diff --git a/packages/viper/package.json b/packages/viper/package.json index 9d6dffdb..da7e622c 100644 --- a/packages/viper/package.json +++ b/packages/viper/package.json @@ -77,6 +77,10 @@ "id": "viper.projectView", "name": "Project" }, + { + "id": "viper.environmentView", + "name": "Environments" + }, { "id": "viper.toolchainView", "name": "Toolchain" @@ -118,6 +122,30 @@ "title": "Open Settings", "category": "Viper" }, + { + "command": "viper.selectEnvironment", + "title": "Select Environment", + "category": "Viper", + "icon": "$(server-environment)" + }, + { + "command": "viper.restartEnvironmentTool", + "title": "Restart VET", + "category": "Viper", + "icon": "$(debug-restart)" + }, + { + "command": "viper.stopEnvironmentTool", + "title": "Stop VET", + "category": "Viper", + "icon": "$(debug-stop)" + }, + { + "command": "viper.showEnvironmentLog", + "title": "Show VET Output", + "category": "Viper", + "icon": "$(output)" + }, { "command": "viper.refreshView", "title": "Refresh", @@ -132,6 +160,18 @@ ], "menus": { "commandPalette": [ + { + "command": "viper.selectEnvironment" + }, + { + "command": "viper.restartEnvironmentTool" + }, + { + "command": "viper.stopEnvironmentTool" + }, + { + "command": "viper.showEnvironmentLog" + }, { "command": "viper.openVpjAsJson", "when": "resourceExtname == .vpj" @@ -154,7 +194,7 @@ "view/title": [ { "command": "viper.refreshView", - "when": "view == viper.projectView || view == viper.toolchainView || view == viper.settingsView", + "when": "view == viper.projectView || view == viper.environmentView || view == viper.toolchainView || view == viper.settingsView", "group": "navigation" } ] @@ -162,6 +202,56 @@ "configuration": { "title": "Viper", "properties": { + "viper.environmentTool.enabled": { + "type": "boolean", + "default": true, + "description": "启用 VET(Viper Environment Tool)常驻扫盘发现 Viper 编译器。", + "scope": "window" + }, + "viper.environmentTool.path": { + "type": "string", + "default": "D:\\Users\\TermiNexus\\Desktop\\TransPyC\\VET\\.tpv_build\\output\\VET.exe", + "description": "VET 可执行文件路径(Viper 自身实现的环境发现工具)。", + "scope": "window" + }, + "viper.environmentTool.scanPaths": { + "type": "array", + "items": { + "type": "string" + }, + "default": [], + "description": "传给 VET 的 --path 扫描目录。留空则让 VET 自行建立常用目录表;一旦填写则完全替换它(不再自动建表)。", + "scope": "window" + }, + "viper.environmentTool.confirmSeconds": { + "type": "number", + "default": 10, + "minimum": 0, + "description": "VET 心跳确认周期(秒,对应 --need-confirm);0 表示关闭确认(不建议:关闭后 VET 不会因超时自杀)。", + "scope": "window" + }, + "viper.environmentTool.confirmTimeoutMs": { + "type": "number", + "default": 5000, + "minimum": 0, + "description": "VET 每轮确认窗口长度(毫秒,对应 --need-confirm-timeout);窗口内没收到 {\"ok\":1} 就会退出。", + "scope": "window" + }, + "viper.environmentTool.scanIntervalMs": { + "type": "number", + "default": 10000, + "minimum": 0, + "maximum": 86400000, + "description": "VET 每轮扫盘间隔(毫秒,对应 --sleep)。", + "scope": "window" + }, + "viper.environmentTool.maxRestarts": { + "type": "number", + "default": 5, + "minimum": 0, + "description": "VET 异常退出后的自动重启次数上限(指数退避,单次最多等 30 秒);超过后通知错误并停止重启。", + "scope": "window" + }, "viper.toolchain.path": { "type": "string", "default": "C:\\v.exe", diff --git a/packages/viper/src/environmentManager.ts b/packages/viper/src/environmentManager.ts new file mode 100644 index 00000000..a094ad5e --- /dev/null +++ b/packages/viper/src/environmentManager.ts @@ -0,0 +1,158 @@ +/* + * environmentManager.ts + * Viper 环境(编译器)清单与选中状态。 + * + * 数据来源是 VET 每轮输出的**全量**快照,所以这里要自己 diff(Added / Removed), + * 并把「选中哪个环境」持久化到扩展状态(globalState)——刻意不写回 .vpj,保持项目文件干净。 + */ + +import { Disposable, Event, EventEmitter, Memento, QuickPickItem, window, commands } from 'vscode'; + +import { ViperEnvironment } from './types'; + +/** 一次快照带来的变化。 */ +export interface EnvironmentChange { + /** 当前全部环境(已排序)。 */ + Environments: ViperEnvironment[]; + /** 本轮新增。 */ + Added: ViperEnvironment[]; + /** 本轮移除。 */ + Removed: ViperEnvironment[]; +} + +export class EnvironmentManager implements Disposable { + private static readonly SelectedIdKey = 'viper.environmentTool.selectedId'; + + private environments: ViperEnvironment[] = []; + private selectedId: string | undefined; + + private readonly _onDidChangeEnvironments = new EventEmitter(); + readonly OnDidChangeEnvironments: Event = this._onDidChangeEnvironments.event; + + private readonly _onDidChangeSelection = new EventEmitter(); + readonly OnDidChangeSelection: Event = this._onDidChangeSelection.event; + + constructor(private readonly globalState: Memento) { + this.selectedId = globalState.get(EnvironmentManager.SelectedIdKey); + } + + /** 应用一轮全量快照,返回 diff 结果。 */ + ApplySnapshot(environments: ViperEnvironment[]): EnvironmentChange { + const previousIds = new Set(this.environments.map((environment) => environment.Id)); + const next = [...environments].sort(CompareEnvironments); + const nextIds = new Set(next.map((environment) => environment.Id)); + + const added = next.filter((environment) => !previousIds.has(environment.Id)); + const removed = this.environments.filter((environment) => !nextIds.has(environment.Id)); + + this.environments = next; + this.ResolveStickySelection(); + + const change: EnvironmentChange = { Environments: next, Added: added, Removed: removed }; + this._onDidChangeEnvironments.fire(change); + return change; + } + + /** + * 选定「本次生效的环境」: + * 1. 优先复用上次用的那个 exe(按 VET 给的 uuid 在快照里找); + * 2. 找不到(第一次用、或那个 exe 已被删/移走)则用发现的第一个; + * 3. VET 一个都没给出时保留上次记录,等它下一轮回来。 + * 结果写回 globalState,作为下一次的「上次用的」。 + */ + private ResolveStickySelection(): void { + const preferred = this.environments.find((environment) => environment.Id === this.selectedId) + ?? this.environments[0]; + + if (!preferred || preferred.Id === this.selectedId) { + return; + } + + this.selectedId = preferred.Id; + void this.globalState.update(EnvironmentManager.SelectedIdKey, preferred.Id); + this._onDidChangeSelection.fire(preferred); + } + + /** 当前全部环境。 */ + GetEnvironments(): ViperEnvironment[] { + return this.environments; + } + + /** 当前选中的环境 id。 */ + GetSelectedId(): string | undefined { + return this.selectedId; + } + + /** 当前选中的环境(若已被 VET 移除则为 undefined)。 */ + GetSelectedEnvironment(): ViperEnvironment | undefined { + if (!this.selectedId) { + return undefined; + } + return this.environments.find((environment) => environment.Id === this.selectedId); + } + + /** + * 选择环境。 + * @param environmentId 传入则直接选中该 id;不传则弹 QuickPick 让用户选。 + */ + async SelectEnvironment(environmentId?: string): Promise { + if (environmentId) { + return this.ApplySelection(environmentId); + } + + const all = this.environments; + if (all.length === 0) { + const picked = await window.showWarningMessage('VET 还没有发现任何 Viper 编译器。', '重启 VET 扫描'); + if (picked === '重启 VET 扫描') { + void commands.executeCommand('viper.restartEnvironmentTool'); + } + return undefined; + } + + const items = all.map((environment) => ({ + label: environment.DisplayName, + description: `${environment.Version} · ${environment.Arch} · ${environment.Source}`, + detail: environment.Executable, + picked: environment.Id === this.selectedId, + Environment: environment, + })); + + const picked = await window.showQuickPick(items, { + title: '选择 Viper 环境', + placeHolder: '选择要使用的 Viper 编译器(仅保存在扩展状态,不写入 .vpj)', + matchOnDescription: true, + matchOnDetail: true, + }); + + return picked ? this.ApplySelection(picked.Environment.Id) : undefined; + } + + dispose(): void { + this._onDidChangeEnvironments.dispose(); + this._onDidChangeSelection.dispose(); + } + + private async ApplySelection(environmentId: string): Promise { + const environment = this.environments.find((item) => item.Id === environmentId); + if (!environment) { + return undefined; + } + + this.selectedId = environmentId; + await this.globalState.update(EnvironmentManager.SelectedIdKey, environmentId); + this._onDidChangeSelection.fire(environment); + return environment; + } +} + +interface EnvironmentQuickPickItem extends QuickPickItem { + Environment: ViperEnvironment; +} + +/** 排序:先按来源分组,再按展示名;保证每轮快照顺序稳定(避免视图抖动)。 */ +function CompareEnvironments(left: ViperEnvironment, right: ViperEnvironment): number { + if (left.Source !== right.Source) { + return left.Source.localeCompare(right.Source); + } + return left.DisplayName.localeCompare(right.DisplayName); +} diff --git a/packages/viper/src/environmentTool.ts b/packages/viper/src/environmentTool.ts new file mode 100644 index 00000000..621946c9 --- /dev/null +++ b/packages/viper/src/environmentTool.ts @@ -0,0 +1,463 @@ +/* + * environmentTool.ts + * VET(Viper Environment Tool)常驻进程客户端。 + * + * VET 是一个用 Viper 自身写的扫盘发现器(见 TransPyC/VET),协议如下: + * VET.exe scan --cache [--sleep <毫秒>] + * [--need-confirm <秒> [--need-confirm-timeout <毫秒>]] [--path ]... + * - stdout:每轮输出一行**完整**的编译器 JSON 数组(所以增量变化要自己 diff) + * - stderr:滚动日志(量很大,原样进输出通道) + * - --need-confirm:每 <秒> 秒一个确认窗口(窗口长度 = --need-confirm-timeout,缺省 5s), + * 窗口内必须从 stdin 收到一行 {"ok":1};否则 VET 在 stdout 输出 {"exit":1,...} 并以退出码 1 终止。 + * + * 本类负责:进程生命周期(自动重启 + 指数退避 + 重启上限)、心跳确认、输出转发、快照解析。 + * + * 输出:VET 的 stdout 与 stderr **原样**转发到 "Vipeye VET" 输出通道,不落盘。 + */ + +import { ChildProcessWithoutNullStreams, spawn } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; +import { commands, Disposable, Event, EventEmitter, OutputChannel, window, workspace } from 'vscode'; + +import { ViperEnvironment } from './types'; + +/** + * VET 进程状态。 + * disabled —— 配置里 viper.environmentTool.enabled = false,扩展不启动它 + * stopped —— 用户手动执行了「停止 VET」命令(与 disabled 区分,便于状态栏给出「点击可重新启动」) + * starting / running / restarting / failed —— 进程生命周期与自动重启 + */ +export type EnvironmentToolState = 'disabled' | 'stopped' | 'starting' | 'running' | 'restarting' | 'failed'; + +/** 进程存活超过该时长即认为这一轮是健康的,重启计数清零。 */ +const HealthyRunMs = 30000; +/** 两次心跳之间的最小间隔,避免同一轮重复发送。 */ +const MinConfirmIntervalMs = 1500; + +export class EnvironmentTool implements Disposable { + private process: ChildProcessWithoutNullStreams | undefined; + private state: EnvironmentToolState = 'disabled'; + private restarts = 0; + private startedAt = 0; + private lastConfirmAt = 0; + private stdoutBuffer = ''; + private stderrBuffer = ''; + private restartTimer: NodeJS.Timeout | undefined; + private watchdogTimer: NodeJS.Timeout | undefined; + private disposed = false; + /** 上次记录过的退出信息,避免状态栏/提示重复报同一件事。 */ + private lastExitSummary = ''; + + private readonly channel: OutputChannel; + + private readonly _onDidChangeEnvironments = new EventEmitter(); + /** 每轮 stdout 快照(全量)。 */ + readonly OnDidChangeEnvironments: Event = this._onDidChangeEnvironments.event; + + private readonly _onDidChangeState = new EventEmitter(); + readonly OnDidChangeState: Event = this._onDidChangeState.event; + + constructor(private readonly storageDir: string, channel: OutputChannel) { + this.channel = channel; + } + + /** 当前进程状态。 */ + GetState(): EnvironmentToolState { + return this.state; + } + + /** 缓存文件路径(交给 VET 自己维护)。 */ + GetCachePath(): string { + return path.join(this.storageDir, 'vet-cache.json'); + } + + /** 当前配置里的 VET 可执行文件路径。 */ + GetExecutablePath(): string { + return this.ReadConfig().exePath; + } + + /** 启动(已启动则先停掉再起)。 */ + Start(): void { + this.StopProcess(); + this.restarts = 0; + this.lastExitSummary = ''; + this.Spawn(); + } + + /** 手动重启(命令入口):重置重启计数,让用户能自己解围。 */ + Restart(): void { + this.AppendLog('[vscode] 手动重启 VET'); + this.Start(); + } + + /** + * 手动停止(命令入口):停掉进程并置为 stopped(区别于配置禁用),不再自动重启。 + * 之后用「重启 VET」即可恢复(Restart → Start 会重新 Spawn)。 + * 说明:不清空已发现的环境与选中项 —— VET 只是发现器,关掉它不影响已经选定的编译器。 + */ + Stop(): void { + this.AppendLog('[vscode] 手动停止 VET'); + this.StopProcess(); + this.restarts = 0; + this.SetState('stopped'); + } + + /** 追加一行日志(进 "Vipeye VET" 输出通道),供扩展其它部分记录与 VET 相关的信息。 */ + AppendLog(line: string): void { + this.channel.appendLine(line); + } + + /** 显示输出通道。 */ + ShowLog(): void { + this.channel.show(true); + } + + dispose(): void { + this.disposed = true; + this.StopProcess(); + this._onDidChangeEnvironments.dispose(); + this._onDidChangeState.dispose(); + this.channel.dispose(); + } + + // ===== 进程生命周期 ===== + + private Spawn(): void { + if (this.disposed) { + return; + } + + const config = this.ReadConfig(); + if (!config.enabled) { + this.SetState('disabled'); + this.AppendLog('[vscode] viper.environmentTool.enabled = false,不启动 VET'); + return; + } + + if (!fs.existsSync(config.exePath)) { + // 路径配错不会自己好,直接失败并提示,不做无意义的重启。 + this.SetState('failed'); + this.AppendLog(`[vscode] 找不到 VET 可执行文件: ${config.exePath}`); + void window + .showErrorMessage( + `VET 可执行文件不存在:${config.exePath}。请检查设置 viper.environmentTool.path。`, + '打开设置' + ) + .then((picked) => { + if (picked === '打开设置') { + void commands.executeCommand('workbench.action.openSettings', 'viper.environmentTool.path'); + } + }); + return; + } + + // VET 要把发现缓存写到该目录;目录不存在时缓存写失败(只告警),会导致每轮都从空缓存重扫。 + try { + fs.mkdirSync(this.storageDir, { recursive: true }); + } catch { + // 建不出目录不阻断启动(VET 仍能运行,只是缓存不可用)。 + } + + this.SetState('starting'); + + const args = this.BuildArgs(config); + this.AppendLog(`[vscode] 启动: ${config.exePath} ${args.join(' ')}`); + + let child: ChildProcessWithoutNullStreams; + try { + child = spawn(config.exePath, args, { windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] }); + } catch (e) { + this.AppendLog(`[vscode] 启动失败: ${String(e)}`); + this.HandleUnexpectedExit(-1, 'spawn-error'); + return; + } + + this.process = child; + this.startedAt = Date.now(); + this.lastConfirmAt = 0; + this.stdoutBuffer = ''; + this.stderrBuffer = ''; + + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + child.stdout.on('data', (chunk: string) => this.HandleStdout(chunk)); + child.stderr.on('data', (chunk: string) => this.HandleStderr(chunk)); + child.on('error', (err: Error) => { + this.AppendLog(`[vscode] 进程错误: ${err.message}`); + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + this.SetState('failed'); + void window.showErrorMessage(`无法启动 VET(${config.exePath}):${err.message}`); + } + }); + child.on('exit', (code, signal) => { + if (this.process === child) { + this.process = undefined; + } + + const aliveMs = Date.now() - this.startedAt; + this.AppendLog(`[vscode] VET 退出: code=${code} signal=${signal ?? '-'} 存活 ${aliveMs} ms`); + + if (this.disposed) { + return; + } + this.HandleUnexpectedExit(code ?? -1, signal ?? 'exit'); + }); + + this.EnsureWatchdog(); + this.SetState('running'); + } + + /** 组装 scan 子命令参数。 */ + private BuildArgs(config: EnvironmentToolConfig): string[] { + const args = ['scan', '--cache', this.GetCachePath(), '--sleep', String(config.scanIntervalMs)]; + + if (config.confirmSeconds > 0) { + args.push('--need-confirm', String(config.confirmSeconds)); + args.push('--need-confirm-timeout', String(config.confirmTimeoutMs)); + } + + // 一旦出现 --path,VET 就不再自动建常用目录表,所以仅在显式配置时追加。 + for (const scanPath of config.scanPaths) { + if (scanPath && scanPath.trim().length > 0) { + args.push('--path', scanPath); + } + } + + return args; + } + + private StopProcess(): void { + if (this.restartTimer) { + clearTimeout(this.restartTimer); + this.restartTimer = undefined; + } + if (this.watchdogTimer) { + clearInterval(this.watchdogTimer); + this.watchdogTimer = undefined; + } + + const child = this.process; + this.process = undefined; + if (!child) { + return; + } + + this.AppendLog('[vscode] 请求停止 VET 进程'); + child.stdout.removeAllListeners(); + child.stderr.removeAllListeners(); + child.removeAllListeners('exit'); + child.kill(); + } + + /** + * 非主动退出:按「重启上限 + 指数退避」处理,并且每次都通知用户。 + * @param code 退出码(-1 表示启动失败等非进程退出情形) + * @param reason 退出原因描述 + */ + private HandleUnexpectedExit(code: number, reason: string): void { + const config = this.ReadConfig(); + const aliveMs = Date.now() - this.startedAt; + if (aliveMs >= HealthyRunMs) { + this.restarts = 0; + } + + const summary = `code=${code} reason=${reason}`; + if (summary !== this.lastExitSummary) { + this.lastExitSummary = summary; + void window + .showErrorMessage(`VET 异常退出(${summary})。`, '查看日志') + .then((picked) => { + if (picked === '查看日志') { + this.ShowLog(); + } + }); + } + + if (this.restarts >= config.maxRestarts) { + this.SetState('failed'); + this.AppendLog( + `[vscode] 已连续异常退出 ${this.restarts} 次,达到上限 ${config.maxRestarts},不再自动重启` + ); + void window + .showErrorMessage( + `VET 已连续异常退出 ${this.restarts} 次(上限 ${config.maxRestarts}),不再自动重启。`, + '重启 VET', + '打开设置' + ) + .then((picked) => { + if (picked === '重启 VET') { + this.Restart(); + } else if (picked === '打开设置') { + void import('vscode').then((vscode) => + vscode.commands.executeCommand('workbench.action.openSettings', 'viper.environmentTool') + ); + } + }); + return; + } + + this.restarts++; + const delay = Math.min(30000, 1000 * 2 ** (this.restarts - 1)); + this.SetState('restarting'); + this.AppendLog(`[vscode] 第 ${this.restarts}/${config.maxRestarts} 次重启,${delay} ms 后重试`); + this.restartTimer = setTimeout(() => { + this.restartTimer = undefined; + this.Spawn(); + }, delay); + } + + // ===== stdout / stderr ===== + + private HandleStdout(chunk: string): void { + this.stdoutBuffer += chunk; + + let newlineIndex = this.stdoutBuffer.indexOf('\n'); + while (newlineIndex >= 0) { + const line = this.stdoutBuffer.slice(0, newlineIndex).trim(); + this.stdoutBuffer = this.stdoutBuffer.slice(newlineIndex + 1); + if (line.length > 0) { + this.HandleStdoutLine(line); + } + newlineIndex = this.stdoutBuffer.indexOf('\n'); + } + } + + private HandleStdoutLine(line: string): void { + // stdout 是数据通道(每轮一行完整 JSON 数组)。原样进 "Vipeye VET",与 stderr 对照排查。 + this.channel.appendLine(`[stdout] ${line}`); + + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + // 非 JSON 的 stdout(例如 VET 的 [OOM] 文本)已在上面原样输出。 + return; + } + + if (Array.isArray(parsed)) { + this._onDidChangeEnvironments.fire(this.ToEnvironments(parsed)); + return; + } + + // {"exit":1, "msg":"timeout/ok!=1"} —— VET 因确认失败而终止。 + const exitInfo = parsed as { exit?: number; msg?: string }; + this.AppendLog(`[vscode] VET 报告退出: exit=${exitInfo.exit ?? '?'} msg=${exitInfo.msg ?? ''}`); + } + + /** 把 VET 输出的 JSON 条目规整为强类型(字段名转 PascalCase,缺字段给空串)。 */ + private ToEnvironments(entries: unknown[]): ViperEnvironment[] { + return entries + .filter((entry): entry is Record => typeof entry === 'object' && entry !== null) + .map((entry) => this.ToEnvironment(entry)) + .filter((environment) => environment.Id.length > 0 || environment.Executable.length > 0); + } + + private ToEnvironment(entry: Record): ViperEnvironment { + return { + Id: String(entry.id ?? ''), + Executable: String(entry.executable ?? ''), + Version: String(entry.version ?? ''), + Arch: String(entry.arch ?? ''), + Source: String(entry.source ?? ''), + DisplayName: String(entry.displayName ?? entry.executable ?? ''), + }; + } + + private HandleStderr(chunk: string): void { + this.stderrBuffer += chunk; + + let newlineIndex = this.stderrBuffer.indexOf('\n'); + while (newlineIndex >= 0) { + const line = this.stderrBuffer.slice(0, newlineIndex).replace(/\r$/, ''); + this.stderrBuffer = this.stderrBuffer.slice(newlineIndex + 1); + if (line.length > 0) { + this.HandleStderrLine(line); + } + newlineIndex = this.stderrBuffer.indexOf('\n'); + } + } + + private HandleStderrLine(line: string): void { + // stderr 全部原样进 "Vipeye VET"(含 [scan] 进度行),不做过滤。 + this.channel.appendLine(line); + + // 每轮结束会提示进入确认窗口,这时才回 {"ok":1}(窗口只有几秒,必须立刻回)。 + if (line.includes('本轮期限') || line.includes('[confirm] 请在')) { + this.SendConfirm(); + } + } + + /** 向 VET 的 stdin 写入一行 {"ok":1}。 */ + private SendConfirm(): void { + const child = this.process; + if (!child || !child.stdin.writable) { + return; + } + // 提示是两行(请在… / 当前… 本轮期限…),间隔很短;去重避免同一窗口重复发送。 + if (Date.now() - this.lastConfirmAt < MinConfirmIntervalMs) { + return; + } + + this.lastConfirmAt = Date.now(); + try { + child.stdin.write('{"ok":1}\n'); + this.AppendLog('[vscode] 已发送心跳 {"ok":1}'); + } catch (e) { + this.AppendLog(`[vscode] 心跳写入失败: ${String(e)}`); + } + } + + /** + * 心跳兜底:万一没识别到确认提示(例如 VET 日志措辞变化), + * 超过「确认周期 + 5 秒」没有发送过就补一次,避免 VET 因超时自杀。 + */ + private EnsureWatchdog(): void { + if (this.watchdogTimer) { + return; + } + + this.watchdogTimer = setInterval(() => { + const config = this.ReadConfig(); + if (config.confirmSeconds <= 0 || !this.process) { + return; + } + if (Date.now() - this.lastConfirmAt > (config.confirmSeconds + 5) * 1000) { + this.SendConfirm(); + } + }, 2000); + } + + // ===== 状态 ===== + + private SetState(next: EnvironmentToolState): void { + if (this.state === next) { + return; + } + this.state = next; + this._onDidChangeState.fire(next); + } + + private ReadConfig(): EnvironmentToolConfig { + const config = workspace.getConfiguration('viper.environmentTool'); + return { + enabled: config.get('enabled', true), + exePath: config.get('path', '').trim(), + scanPaths: config.get('scanPaths', []), + confirmSeconds: config.get('confirmSeconds', 10), + confirmTimeoutMs: config.get('confirmTimeoutMs', 5000), + scanIntervalMs: config.get('scanIntervalMs', 10000), + maxRestarts: Math.max(0, config.get('maxRestarts', 5)), + }; + } +} + +interface EnvironmentToolConfig { + enabled: boolean; + exePath: string; + scanPaths: string[]; + confirmSeconds: number; + confirmTimeoutMs: number; + scanIntervalMs: number; + maxRestarts: number; +} diff --git a/packages/viper/src/extension.ts b/packages/viper/src/extension.ts index eeb7afce..f47e0179 100644 --- a/packages/viper/src/extension.ts +++ b/packages/viper/src/extension.ts @@ -4,10 +4,12 @@ */ import { commands, ExtensionContext, window, workspace } from 'vscode'; +import { EnvironmentManager } from './environmentManager'; +import { EnvironmentTool } from './environmentTool'; import { ProjectManager } from './projectManager'; import { ToolchainManager } from './toolchainManager'; import { ViperViewProvider } from './viperViewProvider'; -import { StatusBarManager } from './statusBar'; +import { EnvironmentStatusBar } from './statusBar'; import { ViperApiImpl } from './api'; import { VpjEditorProvider } from './vpjEditorProvider'; @@ -15,6 +17,12 @@ export async function activate(context: ExtensionContext): Promise const projects = new ProjectManager(); const toolchain = new ToolchainManager(); + // VET(Viper Environment Tool)常驻进程:扫盘发现编译器,每轮输出全量 JSON 快照。 + // stdout 与 stderr 都原样转发到该输出通道(不落盘)。 + const vetChannel = window.createOutputChannel('Vipeye VET'); + const environmentTool = new EnvironmentTool(context.globalStorageUri.fsPath, vetChannel); + const environments = new EnvironmentManager(context.globalState); + // 初始扫描项目文件。 await projects.DiscoverProjects(); @@ -22,19 +30,21 @@ export async function activate(context: ExtensionContext): Promise const watcher = projects.WatchProjectFiles(); context.subscriptions.push(watcher); - // 注册三个树视图。 - const projectView = new ViperViewProvider('viper.projectView', projects, toolchain); - const toolchainView = new ViperViewProvider('viper.toolchainView', projects, toolchain); - const settingsView = new ViperViewProvider('viper.settingsView', projects, toolchain); + // 注册四个树视图。 + const projectView = new ViperViewProvider('viper.projectView', projects, toolchain, environments, environmentTool); + const toolchainView = new ViperViewProvider('viper.toolchainView', projects, toolchain, environments, environmentTool); + const settingsView = new ViperViewProvider('viper.settingsView', projects, toolchain, environments, environmentTool); + const environmentView = new ViperViewProvider('viper.environmentView', projects, toolchain, environments, environmentTool); context.subscriptions.push( window.registerTreeDataProvider('viper.projectView', projectView), window.registerTreeDataProvider('viper.toolchainView', toolchainView), - window.registerTreeDataProvider('viper.settingsView', settingsView) + window.registerTreeDataProvider('viper.settingsView', settingsView), + window.registerTreeDataProvider('viper.environmentView', environmentView) ); - // 状态栏。 - context.subscriptions.push(new StatusBarManager(toolchain)); + // 状态栏:只保留 VET 环境项(编译器位置完全由发现结果决定)。 + context.subscriptions.push(new EnvironmentStatusBar(environments, environmentTool)); // 注册 .vpj 自定义编辑器。 context.subscriptions.push(VpjEditorProvider.Register(context)); @@ -50,6 +60,42 @@ export async function activate(context: ExtensionContext): Promise }) ); + // VET 快照 → 环境清单(自己 diff;VET 每轮都给全量)。 + context.subscriptions.push( + environmentTool.OnDidChangeEnvironments((snapshot) => { + const change = environments.ApplySnapshot(snapshot); + + const summary = [`本轮: 共 ${change.Environments.length} 个`]; + if (change.Added.length > 0) { + summary.push(`+${change.Added.length}`); + } + if (change.Removed.length > 0) { + summary.push(`-${change.Removed.length}`); + } + environmentTool.AppendLog(`[vscode] ${summary.join(' ')}`); + + // 生效环境同步给工具链:有环境就用它(上次用的那个 → 否则发现的第一个), + // 一个都没发现时回退到配置/硬编码的兜底路径。 + const selected = environments.GetSelectedEnvironment(); + if (selected) { + toolchain.ApplyEnvironment(selected.Executable, selected.Version); + } else { + toolchain.ClearEnvironmentOverride(); + } + }), + environments.OnDidChangeEnvironments(() => environmentView.Refresh()), + // 进程状态变化(含手动停止)会影响 Environments 视图的空态文案与状态栏。 + environmentTool.OnDidChangeState(() => environmentView.Refresh()), + environments.OnDidChangeSelection((selected) => { + if (selected) { + toolchain.ApplyEnvironment(selected.Executable, selected.Version); + } else { + toolchain.ClearEnvironmentOverride(); + } + environmentView.Refresh(); + }) + ); + // 命令。 context.subscriptions.push( RegisterCommandSafe(context, 'viper.createProject', async () => { @@ -72,6 +118,19 @@ export async function activate(context: ExtensionContext): Promise projectView.Refresh(); toolchainView.Refresh(); settingsView.Refresh(); + environmentView.Refresh(); + }), + RegisterCommandSafe(context, 'viper.selectEnvironment', async (environmentId?: unknown) => { + await environments.SelectEnvironment(typeof environmentId === 'string' ? environmentId : undefined); + }), + RegisterCommandSafe(context, 'viper.restartEnvironmentTool', () => { + environmentTool.Restart(); + }), + RegisterCommandSafe(context, 'viper.stopEnvironmentTool', () => { + environmentTool.Stop(); + }), + RegisterCommandSafe(context, 'viper.showEnvironmentLog', () => { + environmentTool.ShowLog(); }), RegisterCommandSafe(context, 'viper.openVpjAsJson', async (uri?: import('vscode').Uri) => { const target = uri ?? window.activeTextEditor?.document.uri; @@ -84,15 +143,24 @@ export async function activate(context: ExtensionContext): Promise }) ); - // 监听配置变更,刷新工具链。 + // 监听配置变更,刷新工具链 / 重启 VET。 context.subscriptions.push( workspace.onDidChangeConfiguration((e) => { if (e.affectsConfiguration('viper.toolchain')) { toolchain.Refresh(); } + if (e.affectsConfiguration('viper.environmentTool')) { + environmentTool.Restart(); + } }) ); + // 生命周期:随扩展一起释放(dispose 会停掉 VET 进程并关闭日志流)。 + context.subscriptions.push(environmentTool, environments); + + // 启动 VET 扫盘发现。 + environmentTool.Start(); + // 导出 API。 const api = new ViperApiImpl(projects, toolchain); return api; diff --git a/packages/viper/src/statusBar.ts b/packages/viper/src/statusBar.ts index bfcfb78b..ec38df76 100644 --- a/packages/viper/src/statusBar.ts +++ b/packages/viper/src/statusBar.ts @@ -1,35 +1,91 @@ /* * statusBar.ts - * 右下角状态栏:显示当前 Viper 编译器路径与版本。 + * 右下角状态栏:显示 VET 发现的 Viper 环境与当前生效项。 */ -import { StatusBarAlignment, StatusBarItem, window } from 'vscode'; -import { ToolchainManager } from './toolchainManager'; -import { Disposable } from 'vscode'; +import { Disposable, StatusBarAlignment, StatusBarItem, window } from 'vscode'; +import { EnvironmentManager } from './environmentManager'; +import { EnvironmentTool, EnvironmentToolState } from './environmentTool'; -export class StatusBarManager implements Disposable { +/** + * 环境状态栏:显示 VET 发现/选中了哪个编译器,点击打开环境选择器。 + * 这里只保留这一项:编译器位置完全由 VET 发现结果决定,不再显示硬编码路径。 + */ +export class EnvironmentStatusBar implements Disposable { private readonly item: StatusBarItem; - private readonly toolchain: ToolchainManager; - private readonly disposable: Disposable; + private readonly disposables: Disposable[] = []; - constructor(toolchain: ToolchainManager) { - this.toolchain = toolchain; - this.item = window.createStatusBarItem(StatusBarAlignment.Right, 100); - this.item.command = 'viper.selectToolchain'; + constructor(private readonly environments: EnvironmentManager, private readonly tool: EnvironmentTool) { + this.item = window.createStatusBarItem(StatusBarAlignment.Right, 99); + this.item.command = 'viper.selectEnvironment'; + + this.disposables.push( + environments.OnDidChangeEnvironments(() => this.Update()), + environments.OnDidChangeSelection(() => this.Update()), + tool.OnDidChangeState(() => this.Update()) + ); - this.disposable = toolchain.OnDidChangeToolchain(() => this.Update()); this.Update(); - this.item.show(); } private Update(): void { - const tool = this.toolchain.GetToolchain(); - this.item.text = `$(tools) Viper: ${tool.Path} (${tool.Version})`; - this.item.tooltip = `Viper Toolchain\nPath: ${tool.Path}\nVersion: ${tool.Version}\nSource: ${tool.Source}\nClick to change`; + const state = this.tool.GetState(); + if (state === 'disabled') { + this.item.hide(); + return; + } + + const selected = this.environments.GetSelectedEnvironment(); + const count = this.environments.GetEnvironments().length; + const stateText = DescribeState(state); + + // 手动停止时点击直接把它拉起来(此时选择环境没有意义);其余情况点击打开环境选择器。 + this.item.command = state === 'stopped' ? 'viper.restartEnvironmentTool' : 'viper.selectEnvironment'; + + if (state === 'stopped') { + this.item.text = '$(debug-stop) Viper 环境: 已关闭'; + } else if (selected) { + this.item.text = `$(server-environment) Viper 环境: ${selected.DisplayName}`; + } else if (count > 0) { + this.item.text = `$(server-environment) Viper 环境: 未选择 (${count})`; + } else if (state === 'running' || state === 'starting') { + this.item.text = '$(sync~spin) Viper 环境: 扫描中…'; + } else { + this.item.text = `$(warning) Viper 环境: ${stateText}`; + } + + this.item.tooltip = [ + `VET 状态: ${stateText}`, + `发现环境: ${count}`, + selected ? `当前选中: ${selected.DisplayName}\n${selected.Executable}` : '当前选中: (无)', + `VET: ${this.tool.GetExecutablePath()}`, + '输出: Vipeye VET 输出通道', + state === 'stopped' ? '点击重新启动 VET' : '点击选择环境', + ].join('\n'); + this.item.show(); } dispose(): void { this.item.dispose(); - this.disposable.dispose(); + for (const disposable of this.disposables) { + disposable.dispose(); + } + } +} + +function DescribeState(state: EnvironmentToolState): string { + switch (state) { + case 'running': + return '运行中'; + case 'starting': + return '启动中'; + case 'restarting': + return '重启中'; + case 'stopped': + return '已关闭(手动)'; + case 'failed': + return '已停止(连续失败)'; + default: + return '未启用'; } } diff --git a/packages/viper/src/toolchainManager.ts b/packages/viper/src/toolchainManager.ts index 4ed1688a..d1e89b1b 100644 --- a/packages/viper/src/toolchainManager.ts +++ b/packages/viper/src/toolchainManager.ts @@ -41,6 +41,11 @@ export class ToolchainManager { /** 重新解析当前工具链(配置变更后调用)。 */ Refresh(): void { + // 环境覆盖优先:用户已经用 VET 选了环境时,配置变更不应把它顶掉。 + if (this.current.Source === 'env') { + return; + } + const next = this.ResolveToolchain(); if (next.Path !== this.current.Path || next.Version !== this.current.Version) { this.current = next; @@ -48,6 +53,29 @@ export class ToolchainManager { } } + /** + * 用 VET 选中的环境覆盖当前工具链(仅内存,不写配置、不写 .vpj)。 + * 这样现有的工具链视图/状态栏/API 都能立刻反映「我选的编译器」。 + */ + ApplyEnvironment(path: string, version: string): void { + if (this.current.Source === 'env' && this.current.Path === path && this.current.Version === version) { + return; + } + + this.current = { Path: path, Version: version, Source: 'env' }; + this._onDidChange.fire(this.current); + } + + /** 清除环境覆盖,回到「配置项 → 硬编码」的解析结果。 */ + ClearEnvironmentOverride(): void { + if (this.current.Source !== 'env') { + return; + } + + this.current = this.ResolveToolchain(); + this._onDidChange.fire(this.current); + } + private ResolveToolchain(): ToolchainInfo { const config = workspace.getConfiguration('viper.toolchain'); const configuredPath = config.get('path'); diff --git a/packages/viper/src/types.ts b/packages/viper/src/types.ts index 5638ae11..db28ac94 100644 --- a/packages/viper/src/types.ts +++ b/packages/viper/src/types.ts @@ -32,6 +32,16 @@ export interface ToolchainInfo { Source: 'hardcoded' | 'config' | 'env' | 'path'; } +/** VET(Viper Environment Tool)发现的一个编译器条目。字段名与 VET 的 stdout JSON 一致。 */ +export interface ViperEnvironment { + Id: string; // VET 分配的稳定 id(GUID) + Executable: string; // 编译器可执行文件绝对路径 + Version: string; // 版本字符串(来自 .PRVERSN 段与 --version 前缀) + Arch: string; // 架构,如 x86_64 + Source: string; // 来源分类,如 workspace / global / ... + DisplayName: string; // 展示名,如「Viper 0.0.3-dev.428」 +} + /** 暴露给其他扩展的 API。 */ export interface ViperApi { /** 获取当前工作区的项目配置。 */ @@ -61,5 +71,7 @@ export interface ViperTreeNode { Icon?: string; Children?: ViperTreeNode[]; Command?: string; + /** 命令参数(点击该节点时随命令一起传入)。 */ + CommandArguments?: unknown[]; ContextValue?: string; } diff --git a/packages/viper/src/viperViewProvider.ts b/packages/viper/src/viperViewProvider.ts index f1e21860..6767822d 100644 --- a/packages/viper/src/viperViewProvider.ts +++ b/packages/viper/src/viperViewProvider.ts @@ -1,12 +1,14 @@ /* * viperViewProvider.ts - * 左侧活动栏三个视图的 TreeDataProvider 实现。 + * 左侧活动栏各视图的 TreeDataProvider 实现。 */ import { Event, EventEmitter, ProviderResult, TreeDataProvider, TreeItem, TreeItemCollapsibleState } from 'vscode'; +import { EnvironmentManager } from './environmentManager'; +import { EnvironmentTool } from './environmentTool'; import { ProjectManager } from './projectManager'; import { ToolchainManager } from './toolchainManager'; -import { ViperTreeNode } from './types'; +import { ViperEnvironment, ViperTreeNode } from './types'; /** 通用树视图提供者,按视图 ID 返回不同节点集。 */ export class ViperViewProvider implements TreeDataProvider { @@ -16,7 +18,9 @@ export class ViperViewProvider implements TreeDataProvider { constructor( private readonly viewId: string, private readonly projects: ProjectManager, - private readonly toolchain: ToolchainManager + private readonly toolchain: ToolchainManager, + private readonly environments: EnvironmentManager, + private readonly tool: EnvironmentTool ) {} Refresh(): void { @@ -31,7 +35,7 @@ export class ViperViewProvider implements TreeDataProvider { item.iconPath = element.Icon; } if (element.Command) { - item.command = { command: element.Command, title: element.Label }; + item.command = { command: element.Command, title: element.Label, arguments: element.CommandArguments }; } if (element.ContextValue) { item.contextValue = element.ContextValue; @@ -54,6 +58,8 @@ export class ViperViewProvider implements TreeDataProvider { return this.BuildToolchainNodes(); case 'viper.settingsView': return this.BuildSettingsNodes(); + case 'viper.environmentView': + return this.BuildEnvironmentNodes(); default: return []; } @@ -164,6 +170,62 @@ export class ViperViewProvider implements TreeDataProvider { ]; } + /** 环境视图:按 VET 给出的 source 分组(workspace / global / ...)。 */ + private BuildEnvironmentNodes(): ViperTreeNode[] { + const all = this.environments.GetEnvironments(); + const selectedId = this.environments.GetSelectedId(); + + if (all.length === 0) { + // 手动关闭 VET 时不能显示"扫描中…"(那是误导),改为明确告知并引导重新启动。 + const stopped = this.tool.GetState() === 'stopped'; + return [ + { + Label: stopped ? 'VET 已关闭' : '未发现 Viper 编译器', + Description: stopped ? '点击重新启动' : 'VET 扫描中…', + Tooltip: stopped ? 'VET 已被手动停止,点击重新启动扫描' : '点击可重启 VET 扫描', + Icon: stopped ? '$(debug-stop)' : '$(sync~spin)', + Command: 'viper.restartEnvironmentTool', + }, + ]; + } + + const groups = new Map(); + for (const environment of all) { + const source = environment.Source || 'unknown'; + const bucket = groups.get(source); + if (bucket) { + bucket.push(environment); + } else { + groups.set(source, [environment]); + } + } + + const nodes: ViperTreeNode[] = []; + for (const [source, environments] of groups) { + nodes.push({ + Label: source, + Description: `${environments.length}`, + Tooltip: `${environments.length} 个环境(来源:${source})`, + Icon: '$(folder)', + Children: environments.map((environment) => this.ConvertEnvironmentToNode(environment, selectedId)), + }); + } + + return nodes; + } + + private ConvertEnvironmentToNode(environment: ViperEnvironment, selectedId: string | undefined): ViperTreeNode { + const isSelected = environment.Id === selectedId; + return { + Label: environment.DisplayName, + Description: `${environment.Version} · ${environment.Arch}${isSelected ? ' · 已选中' : ''}`, + Tooltip: `${environment.Executable}\n来源: ${environment.Source}\nID: ${environment.Id}`, + Icon: isSelected ? '$(check)' : '$(server-process)', + Command: 'viper.selectEnvironment', + CommandArguments: [environment.Id], + }; + } + private ToCollapsibleState(children?: ViperTreeNode[]): TreeItemCollapsibleState { return children && children.length > 0 ? TreeItemCollapsibleState.Collapsed : TreeItemCollapsibleState.None; } diff --git a/packages/vscode-vipeye/package.json b/packages/vscode-vipeye/package.json index db99cf9c..dd4cf74f 100644 --- a/packages/vscode-vipeye/package.json +++ b/packages/vscode-vipeye/package.json @@ -116,6 +116,10 @@ "id": "viper.projectView", "name": "Project" }, + { + "id": "viper.environmentView", + "name": "Environments" + }, { "id": "viper.toolchainView", "name": "Toolchain" @@ -267,6 +271,30 @@ "title": "Open Settings", "category": "Viper" }, + { + "command": "viper.selectEnvironment", + "title": "Select Environment", + "category": "Viper", + "icon": "$(server-environment)" + }, + { + "command": "viper.restartEnvironmentTool", + "title": "Restart VET", + "category": "Viper", + "icon": "$(debug-restart)" + }, + { + "command": "viper.stopEnvironmentTool", + "title": "Stop VET", + "category": "Viper", + "icon": "$(debug-stop)" + }, + { + "command": "viper.showEnvironmentLog", + "title": "Show VET Output", + "category": "Viper", + "icon": "$(output)" + }, { "command": "viper.refreshView", "title": "Refresh", @@ -325,6 +353,18 @@ "command": "viper.showFunctionFlowChart", "when": "editorLangId == viper" }, + { + "command": "viper.selectEnvironment" + }, + { + "command": "viper.restartEnvironmentTool" + }, + { + "command": "viper.stopEnvironmentTool" + }, + { + "command": "viper.showEnvironmentLog" + }, { "command": "viper.openVpjAsJson", "when": "resourceExtname == .vpj" @@ -347,7 +387,7 @@ "view/title": [ { "command": "viper.refreshView", - "when": "view == viper.projectView || view == viper.toolchainView || view == viper.settingsView", + "when": "view == viper.projectView || view == viper.environmentView || view == viper.toolchainView || view == viper.settingsView", "group": "navigation" } ] @@ -364,6 +404,56 @@ "type": "object", "title": "Vipeye", "properties": { + "viper.environmentTool.enabled": { + "type": "boolean", + "default": true, + "description": "启用 VET(Viper Environment Tool)常驻扫盘发现 Viper 编译器。", + "scope": "window" + }, + "viper.environmentTool.path": { + "type": "string", + "default": "D:\\Users\\TermiNexus\\Desktop\\TransPyC\\VET\\.tpv_build\\output\\VET.exe", + "description": "VET 可执行文件路径(Viper 自身实现的环境发现工具)。", + "scope": "window" + }, + "viper.environmentTool.scanPaths": { + "type": "array", + "items": { + "type": "string" + }, + "default": [], + "description": "传给 VET 的 --path 扫描目录。留空则让 VET 自行建立常用目录表;一旦填写则完全替换它(不再自动建表)。", + "scope": "window" + }, + "viper.environmentTool.confirmSeconds": { + "type": "number", + "default": 10, + "minimum": 0, + "description": "VET 心跳确认周期(秒,对应 --need-confirm);0 表示关闭确认(不建议:关闭后 VET 不会因超时自杀)。", + "scope": "window" + }, + "viper.environmentTool.confirmTimeoutMs": { + "type": "number", + "default": 5000, + "minimum": 0, + "description": "VET 每轮确认窗口长度(毫秒,对应 --need-confirm-timeout);窗口内没收到 {\"ok\":1} 就会退出。", + "scope": "window" + }, + "viper.environmentTool.scanIntervalMs": { + "type": "number", + "default": 10000, + "minimum": 0, + "maximum": 86400000, + "description": "VET 每轮扫盘间隔(毫秒,对应 --sleep)。", + "scope": "window" + }, + "viper.environmentTool.maxRestarts": { + "type": "number", + "default": 5, + "minimum": 0, + "description": "VET 异常退出后的自动重启次数上限(指数退避,单次最多等 30 秒);超过后通知错误并停止重启。", + "scope": "window" + }, "viper.analysis.autoImportCompletions": { "type": "boolean", "default": true,