feat(viper): 新增「统计项目 LOC」命令与报表 Webview

- 右键 .vpj → Count Project LOC(viper.countLines):递归扫描项目的 source_dir,
  统计文件数 / 总行 / 代码行 / 注释行 / 空行 / 注释率 / 平均行长 / 字符数
- 先打开 Viper LOC Webview 面板再扫描:确定性进度条(先收集文件,再按 N/M 处理
  并显示当前文件),完成后渲染 KPI 卡片、按语言的堆叠条形图(代码/注释/空行,
  以最大语言为基准缩放)、语言筛选(勾选后重算 KPI/图表/表格)与明细表(含合计行)
- 同一份报表以纯文本写入 Viper LOC 输出通道,便于复制
- 计入本扩展注册的语言扩展名 .vp / .vpi / .vpnci / .vpj,并按需求支持 .def
  (; 与 // 行注释、/* */ 块注释,跨行块注释按注释计)
- 跳过项目 build_dir、隐藏目录、node_modules、.git、__pycache__,不跟随符号链接
This commit is contained in:
TermiNexus committed 2026-09-27 12:49:25 +08:00
1 parent aa1c84a326
commit b98102b78f
4 files changed
+1193 -1

No files matched your search

+14
View File
@@ -162,6 +162,11 @@
"command": "viper.openVpjAsJson",
"title": "Open .vpj as JSON",
"category": "Viper"
},
{
"command": "viper.countLines",
"title": "Count Project LOC",
"category": "Viper"
}
],
"menus": {
@@ -184,6 +189,10 @@
{
"command": "viper.openVpjAsJson",
"when": "resourceExtname == .vpj"
},
{
"command": "viper.countLines",
"when": "resourceExtname == .vpj"
}
],
"editor/title/context": [
@@ -198,6 +207,11 @@
"command": "viper.openVpjAsJson",
"when": "resourceExtname == .vpj",
"group": "1_open"
},
{
"command": "viper.countLines",
"when": "resourceExtname == .vpj",
"group": "1_open@2"
}
],
"view/title": [
+74 -1
View File
@@ -4,9 +4,11 @@
*/
import * as path from 'path';
import { commands, ExtensionContext, ProgressLocation, window, workspace } from 'vscode';
import { commands, ExtensionContext, OutputChannel, ProgressLocation, Uri, window, workspace } from 'vscode';
import { EnvironmentManager } from './environmentManager';
import { EnvironmentTool } from './environmentTool';
import { CountProjectLoc, FormatLocReport, LocReport } from './lineCounter';
import { LocReportView } from './locReportView';
import { ProjectManager } from './projectManager';
import { ToolchainManager } from './toolchainManager';
import { ViperCheckResult, ViperEnvironment } from './types';
@@ -25,6 +27,10 @@ export async function activate(context: ExtensionContext): Promise<ViperApiImpl>
const environmentTool = new EnvironmentTool(context.globalStorageUri.fsPath, vetChannel);
const environments = new EnvironmentManager(context.globalState);
// LOC 统计输出通道(右键 .vpj → 统计该工程源目录下所有已知语言的行数)。
const locChannel = window.createOutputChannel('Viper LOC');
context.subscriptions.push(locChannel);
// 初始扫描项目文件。
await projects.DiscoverProjects();
@@ -148,6 +154,9 @@ export async function activate(context: ExtensionContext): Promise<ViperApiImpl>
}
// 用默认文本编辑器打开,绕过自定义 webview 编辑器。
await window.showTextDocument(target, { preview: false });
}),
RegisterCommandSafe(context, 'viper.countLines', async (uri?: unknown) => {
await CountProjectLocCommand(context, projects, locChannel, IsUri(uri) ? uri : undefined);
})
);
@@ -382,3 +391,67 @@ function ShowProjectInfoCommand(projects: ProjectManager): void {
channel.appendLine(lines.join('\n'));
channel.show();
}
/** 判断命令参数是否为 Uri(右键菜单传入的第一个参数)。 */
function IsUri(value: unknown): value is Uri {
return !!value && typeof value === 'object' && typeof (value as Uri).fsPath === 'string';
}
/** 取本命令要统计的工程文件:右键传入的 .vpj → 当前活动编辑器(是 .vpj 时)。 */
function ResolveProjectFile(uri?: Uri): Uri | undefined {
const isProjectFile = (candidate: Uri | undefined): boolean =>
!!candidate && path.extname(candidate.fsPath).toLowerCase() === '.vpj';
if (isProjectFile(uri)) {
return uri;
}
const activeUri = window.activeTextEditor?.document.uri;
return isProjectFile(activeUri) ? activeUri : undefined;
}
/**
* 「统计项目 LOC」:右键 .vpj → 统计该工程源目录(source_dir,通常为 App)下
* 所有已知语言(.vp/.vpi/.vpnci/.vpj/.def)的文件数与行数,
* 结果在 Webview 面板里以进度条 + 图表 + 明细表展示(另存一份纯文本到输出通道)。
* 未传文件参数(命令面板)时退回当前工作区已加载的工程。
*/
async function CountProjectLocCommand(
context: ExtensionContext,
projects: ProjectManager,
channel: OutputChannel,
uri?: Uri
): Promise<void> {
const projectFile = ResolveProjectFile(uri);
// 右键的 .vpj 可能不是 DiscoverProjects 留下(每个工作区只保留最后一个)的那个,
// 故按点击的文件重新解析,保证统计的就是被点中的工程。
const project = projectFile ? projects.LoadProject(projectFile) : projects.GetProject();
if (!project) {
void window.showWarningMessage('未找到 Viper 工程:请对 .vpj 文件使用本命令。');
return;
}
const view = LocReportView.Show(context);
view.StartRun(project.Name, project.ProjectFile, project.SourceDir);
let report: LocReport;
try {
report = await CountProjectLoc(project.SourceDir, [project.BuildDir], (progress) => {
view.ReportProgress(progress);
});
} catch (error) {
view.ReportError(`统计失败:${error instanceof Error ? error.message : String(error)}`);
return;
}
// 纯文本一份留档(便于复制),主力展示在 Webview。
channel.clear();
channel.appendLine(FormatLocReport(report, project.Name, project.ProjectFile));
view.ReportResult({ ...report, ProjectName: project.Name, ProjectFile: project.ProjectFile });
if (report.Total.FileCount > 0) {
void window.showInformationMessage(
`LOC:${report.Total.CodeLines} 行代码 / ${report.Total.TotalLines} 行(${report.Total.FileCount} 个文件,注释率 ${(report.Total.CommentRate * 100).toFixed(1)}%)`
);
}
}
+388
View File
@@ -0,0 +1,388 @@
/*
* lineCounter.ts
* 项目 LOC(代码行)统计:递归扫描工程源目录,按「已知语言」汇总文件数与行数。
*
* 只依赖 Node 的 fs/path,不引用 vscode,便于单独复用与测试;
* 展示层(locReportView.ts)负责进度条、图表与报表渲染。
*/
import * as fs from 'fs';
import * as path from 'path';
/** 一种已知语言:扩展名集合 + 注释语法。 */
export interface KnownLanguage {
/** 语言显示名(报表中的一行)。 */
Name: string;
/** 归属该语言的扩展名(小写,含点)。 */
Extensions: string[];
/** 行注释前缀;无行注释时为空数组。 */
LineCommentPrefixes: string[];
/** 块注释定界符;无块注释时留空。 */
BlockComments?: { Start: string; End: string }[];
}
/**
* 已知语言表(统计范围)。
* Viper 系列与 vscode-vipeye 的 languages 贡献点一致(.vp/.vpi/.vpnci,另有 .vpj 工程文件),
* 并按用户要求支持 .def(定义文件):既可能是 MODULE-DEFINITION 的 ';' 行注释,
* 也可能是 C 风格的 '//' 行注释与块注释,故三者都识别。
*/
export const KnownLanguages: KnownLanguage[] = [
{ Name: 'Viper', Extensions: ['.vp'], LineCommentPrefixes: ['#'] },
{ Name: 'Viper Stub', Extensions: ['.vpi'], LineCommentPrefixes: ['#'] },
{ Name: 'Viper Annotation', Extensions: ['.vpnci'], LineCommentPrefixes: ['#'] },
{ Name: 'Viper Project', Extensions: ['.vpj'], LineCommentPrefixes: [] },
{
Name: 'Definition',
Extensions: ['.def'],
LineCommentPrefixes: [';', '//'],
BlockComments: [{ Start: '/*', End: '*/' }],
},
];
/** 扩展名 → 语言(由 KnownLanguages 展开)。 */
const ExtensionToLanguage = new Map<string, KnownLanguage>();
for (const language of KnownLanguages) {
for (const extension of language.Extensions) {
ExtensionToLanguage.set(extension, language);
}
}
/** 固定跳过的目录名:依赖、版本库元数据、字节码缓存。 */
const SkippedDirectoryNames = new Set(['node_modules', '.git', '__pycache__']);
/** 单语言 / 汇总的行数统计。 */
export interface LineCount {
FileCount: number;
TotalLines: number;
CodeLines: number;
CommentLines: number;
BlankLines: number;
/** 字符数(不含换行符),用于算平均行字符数。 */
Chars: number;
}
/** 派生指标。 */
export interface LocMetrics {
/** 注释率 = 注释行 /(代码行 + 注释行);无非空行时为 0。 */
CommentRate: number;
/** 平均行字符数 = 字符数 / 总行数。 */
AverageLineLength: number;
}
/** 报表的一行(语言明细 / 合计)。 */
export interface LocReportRow extends LineCount, LocMetrics {
Language: string;
}
/** 一次 LOC 统计的结果。 */
export interface LocReport {
/** 被扫描的源目录(绝对路径)。 */
SourceDir: string;
/** 扫描时跳过的子目录(相对源目录的路径)。 */
SkippedDirectories: string[];
/** 按语言的汇总(代码行降序,只含确实存在文件的语言)。 */
Languages: LocReportRow[];
/** 全部已知语言的合计。 */
Total: LocReportRow;
}
/** 扫描进度(用于进度条)。 */
export interface LocScanProgress {
/** 已统计完成的文件数。 */
ProcessedFiles: number;
/** 待统计的已知语言文件总数。 */
TotalFiles: number;
/** 当前文件(相对源目录)。 */
CurrentFile: string;
}
/** 行分类:代码 / 注释 / 空行。 */
type LineKind = 'code' | 'comment' | 'blank';
/** 逐行扫描的跨行状态(块注释未闭合时的结束定界符)。 */
interface ScanState {
InBlockCommentEnd: string | undefined;
}
/** 待统计的文件。 */
interface ScannedFile {
FilePath: string;
Language: KnownLanguage;
}
/** 创建全 0 的行数计数。 */
export function CreateEmptyLineCount(): LineCount {
return { FileCount: 0, TotalLines: 0, CodeLines: 0, CommentLines: 0, BlankLines: 0, Chars: 0 };
}
/** 把 source 累加进 target(原地)。 */
export function AccumulateLineCount(target: LineCount, source: LineCount): void {
target.FileCount += source.FileCount;
target.TotalLines += source.TotalLines;
target.CodeLines += source.CodeLines;
target.CommentLines += source.CommentLines;
target.BlankLines += source.BlankLines;
target.Chars += source.Chars;
}
/** 注释率 = 注释行 /(代码行 + 注释行)。 */
export function ComputeCommentRate(count: LineCount): number {
const nonBlankLines = count.CodeLines + count.CommentLines;
return nonBlankLines > 0 ? count.CommentLines / nonBlankLines : 0;
}
/** 平均行字符数 = 字符数 / 总行数。 */
export function ComputeAverageLineLength(count: LineCount): number {
return count.TotalLines > 0 ? count.Chars / count.TotalLines : 0;
}
/** 取文件所属的已知语言;未登记(或没有扩展名)返回 undefined。 */
export function GetKnownLanguage(filePath: string): KnownLanguage | undefined {
const extension = path.extname(filePath).toLowerCase();
if (!extension) {
return undefined;
}
return ExtensionToLanguage.get(extension);
}
/**
* 统计一段文本的行数(纯函数,按语言的注释语法分类)。
* 行尾的最后一个换行不算作额外空行;跨行的块注释正确归为注释。
*/
export function CountTextLines(content: string, language: KnownLanguage): LineCount {
const count = CreateEmptyLineCount();
count.FileCount = 1;
const lines = content.split(/\r\n|\r|\n/);
// "a\n" 切出 ['a', '']:结尾换行不产生额外空行。
if (lines.length > 0 && lines[lines.length - 1] === '') {
lines.pop();
}
const state: ScanState = { InBlockCommentEnd: undefined };
for (const line of lines) {
count.TotalLines += 1;
count.Chars += line.length;
switch (ClassifyLine(line, language, state)) {
case 'code':
count.CodeLines += 1;
break;
case 'comment':
count.CommentLines += 1;
break;
default:
count.BlankLines += 1;
break;
}
}
return count;
}
/** 读取并统计单个文件;读取失败按全 0 计(不中断整次统计)。 */
export async function CountFileLines(filePath: string, language: KnownLanguage): Promise<LineCount> {
try {
const content = await fs.promises.readFile(filePath, 'utf8');
return CountTextLines(content, language);
} catch {
return CreateEmptyLineCount();
}
}
/**
* 统计工程源目录下所有已知语言的行数。
* extraSkipDirectories 传工程构建目录(源目录内的构建产物不参与统计)。
* onProgress 每读完一个文件回调一次(先回调一次 ProcessedFiles = 0 的总数播报)。
*/
export async function CountProjectLoc(
sourceDir: string,
extraSkipDirectories: string[] = [],
onProgress?: (progress: LocScanProgress) => void
): Promise<LocReport> {
const skippedDirectories: string[] = [];
const files: ScannedFile[] = [];
const extraSkipPaths = new Set(extraSkipDirectories.map((directory) => path.resolve(directory)));
// 阶段一:先收集文件清单,得到总数(进度条的分母)。
await CollectKnownFiles(sourceDir, sourceDir, extraSkipPaths, skippedDirectories, files);
const totalFiles = files.length;
onProgress?.({ ProcessedFiles: 0, TotalFiles: totalFiles, CurrentFile: '' });
// 阶段二:逐个统计并汇报进度。
const counts = new Map<string, LineCount>();
for (const [index, file] of files.entries()) {
const current = counts.get(file.Language.Name) ?? CreateEmptyLineCount();
AccumulateLineCount(current, await CountFileLines(file.FilePath, file.Language));
counts.set(file.Language.Name, current);
onProgress?.({
ProcessedFiles: index + 1,
TotalFiles: totalFiles,
CurrentFile: path.relative(sourceDir, file.FilePath),
});
}
// 只保留确实存在文件的语言,按代码行降序。
const languages: LocReportRow[] = [];
for (const language of KnownLanguages) {
const count = counts.get(language.Name);
if (count && count.FileCount > 0) {
languages.push(CreateReportRow(language.Name, count));
}
}
languages.sort((left, right) => right.CodeLines - left.CodeLines);
const total = CreateEmptyLineCount();
for (const entry of languages) {
AccumulateLineCount(total, entry);
}
return {
SourceDir: sourceDir,
SkippedDirectories: skippedDirectories,
Languages: languages,
Total: CreateReportRow('Total', total),
};
}
/** 生成可读报表(等宽对齐,供输出通道展示)。 */
export function FormatLocReport(report: LocReport, projectName: string, projectFile: string): string {
const columnWidth = 12;
const nameWidth = Math.max(
18,
'Language'.length,
'Total'.length,
...report.Languages.map((entry) => entry.Language.length)
);
const formatRow = (name: string, count: LineCount & LocMetrics): string =>
name.padEnd(nameWidth) +
String(count.FileCount).padStart(columnWidth) +
String(count.TotalLines).padStart(columnWidth) +
String(count.CodeLines).padStart(columnWidth) +
String(count.CommentLines).padStart(columnWidth) +
String(count.BlankLines).padStart(columnWidth) +
`${(count.CommentRate * 100).toFixed(1)}%`.padStart(columnWidth) +
count.AverageLineLength.toFixed(1).padStart(columnWidth);
const header =
'Language'.padEnd(nameWidth) +
'Files'.padStart(columnWidth) +
'Total'.padStart(columnWidth) +
'Code'.padStart(columnWidth) +
'Comment'.padStart(columnWidth) +
'Blank'.padStart(columnWidth) +
'Comment%'.padStart(columnWidth) +
'AvgLen'.padStart(columnWidth);
const lines: string[] = [
'Viper LOC 统计',
`项目: ${projectName}`,
`工程文件: ${projectFile}`,
`源目录: ${report.SourceDir}`,
`跳过目录: ${report.SkippedDirectories.length > 0 ? report.SkippedDirectories.join(', ') : '(none)'}`,
'',
header,
'-'.repeat(header.length),
...report.Languages.map((entry) => formatRow(entry.Language, entry)),
'-'.repeat(header.length),
formatRow('Total', report.Total),
];
return lines.join('\n');
}
/** 由行数计数构造带派生指标的报表行。 */
function CreateReportRow(language: string, count: LineCount): LocReportRow {
return {
Language: language,
...count,
CommentRate: ComputeCommentRate(count),
AverageLineLength: ComputeAverageLineLength(count),
};
}
/** 递归收集源目录下所有已知语言的文件(跳过目录与符号链接)。 */
async function CollectKnownFiles(
directory: string,
sourceDir: string,
extraSkipPaths: Set<string>,
skippedDirectories: string[],
files: ScannedFile[]
): Promise<void> {
let entries: fs.Dirent[];
try {
entries = await fs.promises.readdir(directory, { withFileTypes: true });
} catch {
// 无权限 / 被占用:跳过该目录,不中断整次统计。
return;
}
for (const entry of entries) {
const entryPath = path.join(directory, entry.name);
if (entry.isDirectory()) {
if (ShouldSkipDirectory(entry.name, entryPath, extraSkipPaths)) {
skippedDirectories.push(path.relative(sourceDir, entryPath) || entry.name);
continue;
}
await CollectKnownFiles(entryPath, sourceDir, extraSkipPaths, skippedDirectories, files);
continue;
}
// 符号链接一律跳过:避免环与重复计数。
if (!entry.isFile()) {
continue;
}
const language = GetKnownLanguage(entryPath);
if (language) {
files.push({ FilePath: entryPath, Language: language });
}
}
}
/** 目录是否不参与统计:依赖/元数据目录、隐藏目录(含 .tpv_build 这类构建产物)、工程构建目录。 */
function ShouldSkipDirectory(name: string, entryPath: string, extraSkipPaths: Set<string>): boolean {
return SkippedDirectoryNames.has(name) || name.startsWith('.') || extraSkipPaths.has(entryPath);
}
/** 判断单行的类别;块注释状态在 state 上跨行传递。 */
function ClassifyLine(text: string, language: KnownLanguage, state: ScanState): LineKind {
let rest = text;
let sawComment = false;
for (;;) {
if (state.InBlockCommentEnd !== undefined) {
const endIndex = rest.indexOf(state.InBlockCommentEnd);
if (endIndex < 0) {
// 整行仍在块注释内。
return 'comment';
}
rest = rest.slice(endIndex + state.InBlockCommentEnd.length);
state.InBlockCommentEnd = undefined;
sawComment = true;
continue;
}
const trimmed = rest.trimStart();
if (trimmed.length === 0) {
return sawComment ? 'comment' : 'blank';
}
if (language.LineCommentPrefixes.some((prefix) => trimmed.startsWith(prefix))) {
return 'comment';
}
const blockComment = (language.BlockComments ?? []).find((block) => trimmed.startsWith(block.Start));
if (blockComment) {
state.InBlockCommentEnd = blockComment.End;
rest = trimmed.slice(blockComment.Start.length);
sawComment = true;
continue;
}
return 'code';
}
}
+717
View File
@@ -0,0 +1,717 @@
/*
* locReportView.ts
* 「统计项目 LOC」报告面板:统计进度条 + KPI + 语言筛选 + 行数构成图 + 明细表。
*
* 数据由 lineCounter.ts 计算,本文件只负责展示:
* - 面板单例复用(再次统计只刷新同一面板);
* - 骨架 HTML 内联脚本(packages/viper 不在打包器的资源复制清单里,
* 外部脚本要改 rspack 配置才能落地,故用 nonce 内联脚本);
* - 数据经 postMessage 下发,Webview 脚本就绪(ready)前的数据先缓存。
*/
import { randomBytes } from 'crypto';
import { Disposable, ExtensionContext, ViewColumn, WebviewPanel, window } from 'vscode';
import { LocReport, LocScanProgress } from './lineCounter';
/** 报表头部信息(工程名 / 工程文件 / 源目录)。 */
export interface LocReportHeader {
ProjectName: string;
ProjectFile: string;
SourceDir: string;
}
/** 下发给 Webview 的报表数据。 */
export type LocReportPayload = LocReport & LocReportHeader;
/** 宿主 → Webview 的消息。 */
type ReportMessage =
| { type: 'header'; payload: LocReportHeader }
| { type: 'progress'; payload: LocScanProgress }
| { type: 'report'; payload: LocReportPayload }
| { type: 'error'; payload: { message: string } };
/** 两次进度上报之间的最小间隔(毫秒):文件多时不至于刷爆消息通道。 */
const ProgressPostIntervalMs = 40;
export class LocReportView implements Disposable {
private static currentPanel: LocReportView | undefined;
private readonly panel: WebviewPanel;
private readonly listeners: Disposable[] = [];
private scriptReady = false;
private header: LocReportHeader | undefined;
private progress: LocScanProgress | undefined;
private payload: LocReportPayload | undefined;
private lastProgressPostAt = 0;
private constructor(panel: WebviewPanel) {
this.panel = panel;
panel.webview.options = { enableScripts: true };
panel.webview.html = BuildLocReportHtml(CreateNonce());
this.listeners.push(
panel.webview.onDidReceiveMessage((message: { type?: string } | undefined) => {
if (message?.type === 'ready') {
this.scriptReady = true;
// 脚本刚就绪:把已缓存的状态补齐,避免丢失 ready 之前的下发。
this.PostHeader();
this.PostProgressNow();
this.PostReport();
}
}),
panel.onDidDispose(() => this.Dispose())
);
}
/** 打开(或复用)报告面板。 */
static Show(context: ExtensionContext): LocReportView {
if (LocReportView.currentPanel) {
LocReportView.currentPanel.panel.reveal();
return LocReportView.currentPanel;
}
const panel = window.createWebviewPanel(
'viper.locReport',
'Viper LOC',
ViewColumn.Active,
{ enableScripts: true, retainContextWhenHidden: true }
);
const view = new LocReportView(panel);
LocReportView.currentPanel = view;
context.subscriptions.push(view);
return view;
}
/** 开始一轮统计:面板切回进度态并清空上一轮报表。 */
StartRun(projectName: string, projectFile: string, sourceDir: string): void {
this.header = { ProjectName: projectName, ProjectFile: projectFile, SourceDir: sourceDir };
this.progress = { ProcessedFiles: 0, TotalFiles: 0, CurrentFile: '' };
this.payload = undefined;
this.panel.title = `Viper LOC · ${projectName}`;
this.PostHeader();
this.PostProgressNow();
}
/** 上报扫描进度(按间隔节流,但每轮最后一个文件必发)。 */
ReportProgress(progress: LocScanProgress): void {
this.progress = progress;
const isLastFile = progress.TotalFiles > 0 && progress.ProcessedFiles >= progress.TotalFiles;
if (!isLastFile && Date.now() - this.lastProgressPostAt < ProgressPostIntervalMs) {
return;
}
this.PostProgressNow();
}
/** 下发报表。 */
ReportResult(payload: LocReportPayload): void {
this.payload = payload;
this.PostReport();
}
/** 下发错误(源目录不存在等)。 */
ReportError(message: string): void {
this.Post({ type: 'error', payload: { message } });
}
dispose(): void {
this.Dispose();
}
private Dispose(): void {
if (LocReportView.currentPanel === this) {
LocReportView.currentPanel = undefined;
}
for (const listener of this.listeners) {
listener.dispose();
}
this.listeners.length = 0;
this.scriptReady = false;
}
private PostHeader(): void {
if (this.header) {
this.Post({ type: 'header', payload: this.header });
}
}
private PostProgressNow(): void {
this.lastProgressPostAt = Date.now();
if (this.progress) {
this.Post({ type: 'progress', payload: this.progress });
}
}
private PostReport(): void {
if (this.payload) {
this.Post({ type: 'report', payload: this.payload });
}
}
private Post(message: ReportMessage): void {
if (!this.scriptReady) {
return;
}
void this.panel.webview.postMessage(message);
}
}
/** 生成 Webview 脚本用的 nonce。 */
function CreateNonce(): string {
return randomBytes(16).toString('hex');
}
/** 面板骨架:静态 HTML + 内联样式 + 内联脚本(数据随后经 postMessage 下发)。 */
function BuildLocReportHtml(nonce: string): string {
const contentSecurityPolicy = [
`default-src 'none'`,
`style-src 'unsafe-inline'`,
`script-src 'nonce-${nonce}'`,
].join('; ');
return `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta http-equiv="Content-Security-Policy" content="${contentSecurityPolicy}" />
<title>Viper LOC</title>
<style>
:root {
--seg-code: var(--vscode-charts-blue, #3794ff);
--seg-comment: var(--vscode-charts-green, #89d185);
--seg-blank: var(--vscode-charts-orange, #d18616);
--line: var(--vscode-panel-border, rgba(128, 128, 128, 0.35));
}
body {
margin: 0;
padding: 0 0 24px;
color: var(--vscode-foreground);
background: var(--vscode-editor-background);
font-family: var(--vscode-font-family);
font-size: var(--vscode-font-size);
}
header {
padding: 14px 16px 10px;
border-bottom: 1px solid var(--line);
}
h1 {
margin: 0 0 6px;
font-size: 1.35em;
font-weight: 600;
}
.meta {
opacity: 0.75;
line-height: 1.6;
word-break: break-all;
}
section {
padding: 14px 16px 0;
}
.card {
border: 1px solid var(--line);
border-radius: 6px;
padding: 12px 14px;
margin-bottom: 12px;
background: var(--vscode-editorWidget-background, rgba(128, 128, 128, 0.06));
}
.card-title {
font-weight: 600;
margin-bottom: 10px;
display: flex;
align-items: baseline;
gap: 10px;
flex-wrap: wrap;
}
.card-title .hint {
font-weight: 400;
opacity: 0.7;
}
/* 进度条 */
.bar {
height: 8px;
border-radius: 4px;
overflow: hidden;
background: var(--vscode-input-background, rgba(128, 128, 128, 0.2));
}
#progress-fill {
height: 100%;
width: 0;
background: var(--seg-code);
transition: width 90ms linear;
}
#progress-text {
margin-top: 8px;
font-variant-numeric: tabular-nums;
}
#progress-file {
margin-top: 4px;
opacity: 0.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* KPI */
#kpis {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(132px, 1fr));
gap: 10px;
}
.kpi {
border: 1px solid var(--line);
border-radius: 6px;
padding: 8px 12px;
}
.kpi-value {
font-size: 1.5em;
font-weight: 600;
font-variant-numeric: tabular-nums;
line-height: 1.25;
}
.kpi-label {
opacity: 0.7;
margin-top: 2px;
}
/* 语言筛选 */
#filters {
display: flex;
flex-wrap: wrap;
gap: 8px 18px;
}
.filter {
display: inline-flex;
align-items: center;
gap: 6px;
cursor: pointer;
}
.legend {
display: inline-flex;
align-items: center;
gap: 6px;
opacity: 0.85;
}
.swatch {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 2px;
}
.swatch-code { background: var(--seg-code); }
.swatch-comment { background: var(--seg-comment); }
.swatch-blank { background: var(--seg-blank); }
/* 行数构成图 */
.chart-row {
display: grid;
grid-template-columns: minmax(90px, 140px) 1fr minmax(140px, 200px);
align-items: center;
gap: 10px;
padding: 3px 0;
}
.chart-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chart-track {
height: 16px;
border-radius: 3px;
background: var(--vscode-input-background, rgba(128, 128, 128, 0.16));
}
.chart-bar {
display: flex;
height: 100%;
border-radius: 3px;
overflow: hidden;
}
.seg { height: 100%; }
.seg-code { background: var(--seg-code); }
.seg-comment { background: var(--seg-comment); }
.seg-blank { background: var(--seg-blank); }
.chart-value {
font-variant-numeric: tabular-nums;
opacity: 0.85;
text-align: right;
}
/* 明细表 */
table {
width: 100%;
border-collapse: collapse;
font-variant-numeric: tabular-nums;
}
th, td {
padding: 5px 8px;
text-align: left;
border-bottom: 1px solid var(--line);
white-space: nowrap;
}
th.num, td.num { text-align: right; }
thead th {
opacity: 0.8;
font-weight: 600;
border-bottom: 1px solid var(--line);
}
tbody tr:hover { background: var(--vscode-list-hoverBackground, rgba(128, 128, 128, 0.12)); }
tr.total-row { font-weight: 600; }
tr.total-row th, tr.total-row td { border-top: 1px solid var(--line); border-bottom: none; }
.empty-row {
opacity: 0.7;
padding: 6px 0;
}
#empty, #error {
padding: 16px;
line-height: 1.7;
}
#error { color: var(--vscode-errorForeground); white-space: pre-wrap; }
[hidden] { display: none !important; }
</style>
</head>
<body>
<header>
<h1 id="title">Viper LOC 统计</h1>
<div class="meta">
<div id="meta-source"></div>
<div id="meta-file"></div>
<div id="meta-skipped"></div>
</div>
</header>
<section id="progress-card">
<div class="card">
<div class="card-title">正在统计<span class="hint">按已知语言递归扫描源目录</span></div>
<div class="bar"><div id="progress-fill"></div></div>
<div id="progress-text">正在收集文件…</div>
<div id="progress-file"></div>
</div>
</section>
<section id="report" hidden>
<div class="card">
<div class="card-title">概览<span class="hint" id="kpi-scope"></span></div>
<div id="kpis"></div>
</div>
<div class="card">
<div class="card-title">语言筛选<span class="hint">勾选参与统计的语言(概览、图表与表格同步过滤)</span></div>
<div id="filters"></div>
</div>
<div class="card">
<div class="card-title">
行数构成
<span class="legend"><span class="swatch swatch-code"></span>代码</span>
<span class="legend"><span class="swatch swatch-comment"></span>注释</span>
<span class="legend"><span class="swatch swatch-blank"></span>空行</span>
</div>
<div id="chart"></div>
</div>
<div class="card">
<div class="card-title">语言明细</div>
<table>
<thead>
<tr>
<th>语言</th>
<th class="num">文件数</th>
<th class="num">总行数</th>
<th class="num">代码行</th>
<th class="num">注释行</th>
<th class="num">空行</th>
<th class="num">注释率</th>
<th class="num">平均行长</th>
<th class="num">字符数</th>
</tr>
</thead>
<tbody id="table-body"></tbody>
</table>
</div>
</section>
<div id="empty" hidden>源目录下没有已知语言的文件。</div>
<div id="error" hidden></div>
<script nonce="${nonce}">
(function () {
'use strict';
var vscode = acquireVsCodeApi();
var state = { header: null, progress: null, report: null, selected: {} };
function el(id) {
return document.getElementById(id);
}
function setText(id, value) {
el(id).textContent = value === undefined || value === null ? '' : String(value);
}
function num(value) {
return Number(value || 0).toLocaleString('en-US');
}
function pct(value) {
return (value * 100).toFixed(1) + '%';
}
/** 注释率 = 注释行 /(代码行 + 注释行)。 */
function commentRate(count) {
var nonBlank = count.CodeLines + count.CommentLines;
return nonBlank > 0 ? count.CommentLines / nonBlank : 0;
}
/** 平均行字符数 = 字符数 / 总行数。 */
function averageLineLength(count) {
return count.TotalLines > 0 ? count.Chars / count.TotalLines : 0;
}
function emptyCount() {
return { FileCount: 0, TotalLines: 0, CodeLines: 0, CommentLines: 0, BlankLines: 0, Chars: 0 };
}
function sumCounts(rows) {
var total = emptyCount();
rows.forEach(function (row) {
total.FileCount += row.FileCount;
total.TotalLines += row.TotalLines;
total.CodeLines += row.CodeLines;
total.CommentLines += row.CommentLines;
total.BlankLines += row.BlankLines;
total.Chars += row.Chars;
});
return total;
}
function makeNode(tag, className, text) {
var node = document.createElement(tag);
if (className) {
node.className = className;
}
if (text !== undefined) {
node.textContent = text;
}
return node;
}
function setHidden(id, hidden) {
if (hidden) {
el(id).setAttribute('hidden', '');
} else {
el(id).removeAttribute('hidden');
}
}
function renderHeader() {
var header = state.header;
if (!header) {
return;
}
setText('title', 'LOC 统计 · ' + header.ProjectName);
setText('meta-source', '项目 ' + header.ProjectName + ' · 源目录 ' + header.SourceDir);
setText('meta-file', '工程文件 ' + header.ProjectFile);
}
function renderProgress() {
var progress = state.progress || { ProcessedFiles: 0, TotalFiles: 0, CurrentFile: '' };
var ratio = progress.TotalFiles > 0 ? progress.ProcessedFiles / progress.TotalFiles : 0;
el('progress-fill').style.width = (ratio * 100).toFixed(2) + '%';
setText(
'progress-text',
progress.TotalFiles > 0
? '已统计 ' + num(progress.ProcessedFiles) + ' / ' + num(progress.TotalFiles) + ' 个文件(' + pct(ratio) + ')'
: '正在收集文件…'
);
setText('progress-file', progress.CurrentFile || '');
}
function renderKpi(rows) {
var totals = sumCounts(rows);
var cards = [
['文件数', num(totals.FileCount)],
['总行数', num(totals.TotalLines)],
['代码行', num(totals.CodeLines)],
['注释行', num(totals.CommentLines)],
['空行', num(totals.BlankLines)],
['注释率', pct(commentRate(totals))],
['平均行字符数', averageLineLength(totals).toFixed(1)],
['字符总数', num(totals.Chars)]
];
var host = el('kpis');
host.textContent = '';
cards.forEach(function (card) {
var node = makeNode('div', 'kpi');
node.appendChild(makeNode('div', 'kpi-value', card[1]));
node.appendChild(makeNode('div', 'kpi-label', card[0]));
host.appendChild(node);
});
}
function renderFilters(languages) {
var host = el('filters');
host.textContent = '';
languages.forEach(function (row) {
var label = makeNode('label', 'filter');
var input = document.createElement('input');
input.type = 'checkbox';
input.checked = !!state.selected[row.Language];
input.addEventListener('change', function () {
state.selected[row.Language] = input.checked;
renderReport();
});
label.appendChild(input);
label.appendChild(document.createTextNode(row.Language + '(' + num(row.FileCount) + ' 个文件)'));
host.appendChild(label);
});
}
function renderChart(rows) {
var host = el('chart');
host.textContent = '';
if (rows.length === 0) {
host.appendChild(makeNode('div', 'empty-row', '未选择任何语言'));
return;
}
var maxTotal = 1;
rows.forEach(function (row) {
maxTotal = Math.max(maxTotal, row.TotalLines);
});
rows.forEach(function (row) {
var line = makeNode('div', 'chart-row');
line.appendChild(makeNode('div', 'chart-label', row.Language));
var track = makeNode('div', 'chart-track');
var bar = makeNode('div', 'chart-bar');
bar.style.width = ((row.TotalLines / maxTotal) * 100).toFixed(2) + '%';
[
['code', row.CodeLines],
['comment', row.CommentLines],
['blank', row.BlankLines]
].forEach(function (part) {
var segment = makeNode('div', 'seg seg-' + part[0]);
segment.style.width =
row.TotalLines > 0 ? ((part[1] / row.TotalLines) * 100).toFixed(2) + '%' : '0%';
bar.appendChild(segment);
});
track.appendChild(bar);
line.appendChild(track);
line.appendChild(
makeNode('div', 'chart-value', num(row.CodeLines) + ' 代码 / ' + num(row.TotalLines) + ' 行')
);
host.appendChild(line);
});
}
function renderTable(rows, totalRow) {
var host = el('table-body');
host.textContent = '';
var appendRow = function (row, label, isTotal) {
var tr = document.createElement('tr');
if (isTotal) {
tr.className = 'total-row';
}
[
label,
num(row.FileCount),
num(row.TotalLines),
num(row.CodeLines),
num(row.CommentLines),
num(row.BlankLines),
pct(commentRate(row)),
averageLineLength(row).toFixed(1),
num(row.Chars)
].forEach(function (value, index) {
var cell = document.createElement(index === 0 ? 'th' : 'td');
cell.textContent = value;
if (index > 0) {
cell.className = 'num';
}
tr.appendChild(cell);
});
host.appendChild(tr);
};
rows.forEach(function (row) {
appendRow(row, row.Language, false);
});
appendRow(totalRow, '合计', true);
}
function renderReport() {
var report = state.report;
if (!report) {
return;
}
report.Languages.forEach(function (row) {
if (state.selected[row.Language] === undefined) {
state.selected[row.Language] = true;
}
});
var selectedRows = report.Languages.filter(function (row) {
return state.selected[row.Language];
});
setText('meta-skipped', '跳过目录 ' + (report.SkippedDirectories.length > 0 ? report.SkippedDirectories.join('、') : '(无)'));
setText('kpi-scope', selectedRows.length === report.Languages.length ? '全部语言' : '已选 ' + selectedRows.length + ' / ' + report.Languages.length + ' 种语言');
renderKpi(selectedRows);
renderFilters(report.Languages);
renderChart(selectedRows);
renderTable(selectedRows, Object.assign({ Language: 'Total' }, sumCounts(selectedRows)));
setHidden('progress-card', true);
setHidden('report', false);
setHidden('empty', report.Languages.length > 0);
setHidden('error', true);
}
window.addEventListener('message', function (event) {
var message = event.data;
if (!message || !message.type) {
return;
}
if (message.type === 'header') {
state.header = message.payload || null;
state.progress = { ProcessedFiles: 0, TotalFiles: 0, CurrentFile: '' };
state.report = null;
state.selected = {};
renderHeader();
setHidden('progress-card', false);
setHidden('report', true);
setHidden('empty', true);
setHidden('error', true);
renderProgress();
return;
}
if (message.type === 'progress') {
state.progress = message.payload;
renderProgress();
return;
}
if (message.type === 'report') {
state.report = message.payload;
state.selected = {};
renderReport();
return;
}
if (message.type === 'error') {
setText('error', message.payload && message.payload.message ? message.payload.message : '统计失败');
setHidden('progress-card', true);
setHidden('report', true);
setHidden('empty', true);
setHidden('error', false);
}
});
vscode.postMessage({ type: 'ready' });
})();
</script>
</body>
</html>`;
}