From b98102b78fa22ee1d77c04c740765a3cde9fc22e Mon Sep 17 00:00:00 2001 From: TermiNexus Date: Sun, 27 Sep 2026 12:49:25 +0800 Subject: [PATCH] =?UTF-8?q?feat(viper):=20=E6=96=B0=E5=A2=9E=E3=80=8C?= =?UTF-8?q?=E7=BB=9F=E8=AE=A1=E9=A1=B9=E7=9B=AE=20LOC=E3=80=8D=E5=91=BD?= =?UTF-8?q?=E4=BB=A4=E4=B8=8E=E6=8A=A5=E8=A1=A8=20Webview?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 右键 .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__,不跟随符号链接 --- packages/viper/package.json | 14 + packages/viper/src/extension.ts | 75 ++- packages/viper/src/lineCounter.ts | 388 +++++++++++++++ packages/viper/src/locReportView.ts | 717 ++++++++++++++++++++++++++++ 4 files changed, 1193 insertions(+), 1 deletion(-) create mode 100644 packages/viper/src/lineCounter.ts create mode 100644 packages/viper/src/locReportView.ts diff --git a/packages/viper/package.json b/packages/viper/package.json index 3c910fc7..9ac74562 100644 --- a/packages/viper/package.json +++ b/packages/viper/package.json @@ -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": [ diff --git a/packages/viper/src/extension.ts b/packages/viper/src/extension.ts index 0ed75f15..41e9b65c 100644 --- a/packages/viper/src/extension.ts +++ b/packages/viper/src/extension.ts @@ -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 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 } // 用默认文本编辑器打开,绕过自定义 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 { + 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)}%)` + ); + } +} diff --git a/packages/viper/src/lineCounter.ts b/packages/viper/src/lineCounter.ts new file mode 100644 index 00000000..f0107699 --- /dev/null +++ b/packages/viper/src/lineCounter.ts @@ -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(); +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 { + 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 { + 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(); + 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, + skippedDirectories: string[], + files: ScannedFile[] +): Promise { + 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): 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'; + } +} diff --git a/packages/viper/src/locReportView.ts b/packages/viper/src/locReportView.ts new file mode 100644 index 00000000..28b659e2 --- /dev/null +++ b/packages/viper/src/locReportView.ts @@ -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 ` + + + + + Viper LOC + + + +
+

Viper LOC 统计

+
+
+
+
+
+
+ +
+
+
正在统计按已知语言递归扫描源目录
+
+
正在收集文件…
+
+
+
+ + + + + + + + +`; +}