This commit is contained in:
2026-09-12 14:19:56 +08:00
commit 87ab0b794b
130 changed files with 30042 additions and 0 deletions

140
.eslintrc.cjs Normal file
View File

@@ -0,0 +1,140 @@
/**
* ESLint 配置 —— 只 lint renderer 业务代码src/renderer, src/shared, src/preload
*
* 不 lint src/main那里是 Electron 主进程,跑的是 Node 18 的能力,规则集合不一样,
* 引入会让 CI 噪声很大、价值很低。先把 renderer 这一层质量守住。
*
* 主要规则选型:
* - "@typescript-eslint/recommended-type-checked" 启用类型感知规则,副作用比 no-type-checked 系列少。
* - react/jsx-runtime自动从 react/jsx-runtime 引入),不用 import React。
* - jsx-a11y 全套,覆盖 ARIA、键盘、对比度前的标签语义颜色用 tailwind不在 a11y 范围里)。
* - 自定义规则:
* * no-restricted-syntax 禁止 console.* 在 src 代码里残留(要日志走 electron log
* * no-restricted-imports 禁掉 cross-process importrenderer 永远不该 import src/main
*
* 故意没开prettier用 prettier --check、import/order争论太多收益太小
*/
module.exports = {
root: true,
env: { browser: true, es2022: true, node: true },
parser: '@typescript-eslint/parser',
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
ecmaFeatures: { jsx: true },
project: ['./tsconfig.json'],
tsconfigRootDir: __dirname
},
settings: { react: { version: 'detect' } },
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended-type-checked',
'plugin:react/recommended',
'plugin:react/jsx-runtime',
'plugin:react-hooks/recommended',
'plugin:jsx-a11y/recommended'
],
plugins: ['@typescript-eslint', 'react', 'react-hooks', 'jsx-a11y'],
ignorePatterns: [
'out/**',
'node_modules/**',
'dist/**',
'release/**',
'coverage/**',
'test-results/**',
'playwright-report/**',
'*.config.cjs',
'*.config.js',
'*.config.ts',
'.eslintrc.cjs',
'tailwind.config.cjs',
'postcss.config.cjs'
],
rules: {
// 显式 any 在这种规模的代码里其实有必要 —— @typescript-eslint/no-explicit-any 在 type-checked
// 模式下噪声太大(每个 IpcError catch 都会撞)。先关掉,等真有滥用时再开。
'@typescript-eslint/no-explicit-any': 'off',
// ts-ignore 用一行注释比挨个枚举类型便宜;只在违规真的不可避免时才允许
'@typescript-eslint/ban-ts-comment': ['warn', { 'ts-ignore': 'allow-with-description' }],
// React 17+ 没用 React.* API没必要强制默认写 React import
'react/react-in-jsx-scope': 'off',
'react/prop-types': 'off',
// 性能优化 / a11y 都需要 conditional roles比如 isSelected ? 'true' : undefined
'react/jsx-boolean-value': ['warn', 'never'],
// hooks 依赖数组:我们有 useEffect 用 useRef 当 token 守门react-hooks 会误报
'react-hooks/exhaustive-deps': 'warn',
// a11y 误报role="button" 在 <tr>/<g> 上是正确做法jsx-a11y v6 仍把它当 interactive
// 控件要求 tabIndex规则偶尔会和组件本身定的 roving tabindex 打架 → warn 不 block
'jsx-a11y/no-noninteractive-element-interactions': 'warn',
'jsx-a11y/click-events-have-key-events': 'warn',
// src/shared 是纯类型和常量,应该优先用 type 而不是 interface —— 同名 interface 合并
// 在 React 项目里几乎总是误用
'@typescript-eslint/consistent-type-imports': ['warn', { prefer: 'type-imports' }],
// onClick={async () => await foo()} 在 React 里是常见且正确的写法 —— React 会忽略返回
// 的 Promise这是 React 的设计缺陷,但用 ESLint 卡它没有收益,只会让所有按钮代码都
// 包一层 void 包装)。同理表单 onSubmit。
'@typescript-eslint/no-misused-promises': [
'error',
{ checksVoidReturn: { arguments: false, attributes: false } }
],
'no-restricted-syntax': [
'warn',
{
// renderer 里允许 console.error 用于诊断,但不允许 console.log —— 那是 debug 残留
selector: "CallExpression[callee.object.name='console'][callee.property.name!=/^(error|warn)$/]",
message: 'renderer 进程不要留 console.log/warn日志走 main 进程的 electron-log。'
}
],
'no-restricted-imports': [
'error',
{
// 阻止 renderer 跨进程边界拉主进程代码 —— 一旦发生contextBridge 的隔离就破了
patterns: [
{
group: ['**/src/main/**', '**/main/**'],
message: 'renderer 不允许 import src/main/;走 preload 暴露的 IPC 接口。'
}
]
}
]
},
overrides: [
{
// 测试文件允许 any 和未使用变量fixture/dummy 经常违反。console.log 也允许——
// 测试期间的诊断输出没法走 electron-log。require-await 在 mock fn 里大量误报
// vi.fn().mockResolvedValue 总是返回 Promise写成 async 是统一形式):
files: ['**/*.test.{ts,tsx}', '**/__tests__/**', 'e2e/**'],
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
'no-restricted-syntax': 'off',
'@typescript-eslint/require-await': 'off'
}
},
{
// e2e 测试禁止固定 sleepwaitForTimeout(>=1000) 几乎一定意味着「等某件事完成」,
// 而这件事可以用 web-first 断言expect().toBeVisible/toHaveText/toPass
// 表达成「等某件事真的发生」。固定 sleep 在 CI 慢的时候假阴、快的机器上又假阳。
// 例外≤50ms 紧跟在 keyboard.press / type 后面,给 DOM 派发的事件循环时间,
// 这种替代方案不如直接 sleep 直观。
files: ['e2e/**'],
rules: {
'no-restricted-syntax': [
'error',
{
selector:
"CallExpression[callee.object.name='page'][callee.property.name='waitForTimeout'][arguments.0.value>=1000]",
message:
'禁止固定 sleep >=1s。用 web-first 断言代替expect.toBeVisible/toPass 等CI 慢机器才不会假阴、快的机器才不会假阳。'
}
]
}
},
{
files: ['src/preload/**'],
// preload 在沙箱里跑,没有 Node DOM但有 Node 全局
env: { browser: false, node: true }
}
]
}

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
node_modules/
dist/
out/
release/

10
.prettierignore Normal file
View File

@@ -0,0 +1,10 @@
node_modules
out
dist
release
coverage
test-results
playwright-report
package-lock.json
.claude
engine

10
.prettierrc.json Normal file
View File

@@ -0,0 +1,10 @@
{
"printWidth": 110,
"tabWidth": 2,
"useTabs": false,
"semi": false,
"singleQuote": true,
"trailingComma": "none",
"arrowParens": "always",
"endOfLine": "auto"
}

67
README.md Normal file
View File

@@ -0,0 +1,67 @@
# Python Profiler Visualizer
粘贴一段 Python 代码,一键运行,立刻看到每个函数各花多少时间。桌面应用,不联网,不传你的代码,不配环境。
## 下载
到 [Releases](../../releases) 下载 `Python Profiler Visualizer Setup x.y.z.exe`,双击安装。首次启动会扫描 Python找不到可点应用里的「打开终端」按钮按系统预填命令装即可。
## 从源码运行
需 Node 20+ 和 Python 3.9+`npm install``npm run dev`
## 用法
1. 左侧编辑器贴代码(或点工具栏「加载示例」)
2. `Ctrl/Cmd + Enter` 运行
3. 右侧看摘要、热点表、6 种耗时可视化
4. 点热点表任意函数跳到对应行;标准库/第三方包会新开标签页展示源文件
5. 工具栏切换「剖析范围」:仅用户代码 / 含库函数(切换后旧结果会标为陈旧)
`← / →` 在柱状图、火焰图、矩形树图、旭日图、累计柱状图、模块热力图之间切换。
### 快捷键
| 按键 | 作用 |
| --- | --- |
| `Ctrl/Cmd + Enter` | 运行 / 取消 |
| `Esc` | 取消运行 / 关设置面板 |
| `← / →` | 切标签页或图表视图 |
| `Home` / `End` | 跳到首/尾 |
| `Delete` / `Ctrl/Cmd + W` | 关闭当前标签页 |
## 耗时数字说明
cProfile 会拖慢代码 1.5~3 倍。引擎启动时跑同一段 tight-loop 测出裸跑与仪器化倍率,把你的 instrumented 时间除回去,摘要数字接近真实耗时,并标「已校准」。
提醒:
- 单次数字有抖动,比较时建议跑多次
-`plt.show()` / `input()` / `cv2.waitKey()` 等阻塞事件循环的等待时UI 会提示「含交互等待」
## 限制
- 仅单文件自包含脚本,不支持命令行参数或多文件项目
- 无自动超时(取消靠按钮或 `Esc`),长时间任务跑到自然结束
- 不替代 `py-spy` / `scalene`
## 给开发者
Electron 31 + React 18 + Vite 5 + TypeScript 5Python 引擎用 cProfile + 标准库。
```bash
npm run dev # 开发
npm run build # 打包
npm run release # 出 Windows 安装包
npm test # 单元测试
npm run test:e2e # E2E
python -m pytest engine/tests -v # Python 引擎测试
```
架构:`src/main/` 主进程、`src/renderer/` React 前端、`engine/` Python 引擎。跨语言契约 `AnalysisResult`Python 侧 `engine/schema.py` 是事实源TypeScript 侧 `src/shared/analysis.ts` 对齐,契约测试 `engine/tests/test_contract.py` 锁版本。
`npm run release` 产物在 `release/Python Profiler Visualizer Setup x.y.z.exe`。未签名Windows SmartScreen 点「更多信息 → 仍要运行」即可。
## License
MIT

View File

@@ -0,0 +1,57 @@
import { test, expect, _electron as electron, type ElectronApplication, type Page } from '@playwright/test'
import { join } from 'path'
/**
* 守护 Monaco 懒加载的「运行时」正确性。
*
* v1 这里还有「ELK Worker 在 worker 里完成布局」的用例CallGraph 组件用 ELK 跑
* 布局chore/comprehensive-optimization 把 CallGraph 整个砍掉之后,那条用例
* 失去意义,留着只会锁住"CallGraph 必须存在"的反向约束,移走。
*
* 为什么必须有 e2e 兜lazy 边界一旦加载失败Suspense 只会一直显示 fallback 骨架,
* 页面看起来「在加载」—— typecheck + build 全绿但运行时静默坏掉。修这种 bug 不会
* 让 ESLint / tsc 报警,必须在真窗口里断言。
*/
let app: ElectronApplication
let page: Page
const pageErrors: string[] = []
test.beforeAll(async () => {
// 透传父进程 env但必须剔掉 ELECTRON_RUN_AS_NODEPlaywright 自己是 Electron-as-Node
// 跑的,继承这个变量会让被测应用也退化成纯 Node永远开不出窗口。
const env: Record<string, string> = {}
for (const [k, v] of Object.entries(process.env)) {
if (k !== 'ELECTRON_RUN_AS_NODE' && v !== undefined) env[k] = v
}
app = await electron.launch({ args: [join(__dirname, '..', 'out', 'main', 'index.js')], env })
page = await app.firstWindow()
page.on('pageerror', (e) => pageErrors.push(e.message))
// 启动后等待首屏标题
await expect(page.getByRole('heading', { name: 'Python 程序耗时可视化分析' })).toBeVisible()
await page.reload()
})
test.afterAll(async () => {
await app?.close()
})
test('Monaco 懒加载:骨架屏被真实编辑器替换,且编辑器可编辑', async () => {
// 不 waitForTimeout —— 用 web-first 断言等 lazy chunk 落地
const editor = page.locator('.monaco-editor')
await expect(editor).toBeVisible({ timeout: 30_000 })
// 骨架屏必须让位。它带 role="status" + aria-label="app.editorLoading"App.tsx 用
// useT 拿当前 lang 翻译;中英都覆盖)。默认 lang 是 zh-CN,所以这里断言中文文案。
await expect(page.getByLabel('编辑器加载中…')).toHaveCount(0)
// 编辑器不只是「渲染了个壳」:示例代码必须真的被 Monaco tokenize 出来。
// sample.ts 当前是 `def matinv(A):` —— 跟着示例从「矩阵乘法」改成「线性回归」一并换掉。
await expect(page.locator('.view-line').first()).toBeVisible()
await expect(page.locator('.monaco-editor')).toContainText('matinv')
})
test('全程没有未捕获的渲染进程异常', async () => {
expect(pageErrors).toEqual([])
})

56
e2e/smoke.spec.ts Normal file
View File

@@ -0,0 +1,56 @@
import { test, expect, _electron as electron } from '@playwright/test'
import { join } from 'path'
/**
* 端到端冒烟:覆盖 v2 的 UI 流程(不依赖已删除的 CallGraph / LineHeatmap /
* ExportButton / FindingsList
*
* v1 残留断言("Hotspots" / "build_report" / "What to fix" / "Lines" tab /
* "导出 JSON")随着 v2 重构(见 chore/comprehensive-optimization一并移除。
* 跑这条用例需要的本地条件:检测到至少一个 Python 解释器CI matrix 上预装)。
*/
test('端到端:加载示例 → 运行分析 → 摘要 + 热点表 + 火焰图', async () => {
// Electron 在 ELECTRON_RUN_AS_NODE 存在时会退化为纯 Node需从环境中移除
const env: Record<string, string> = {}
for (const [k, v] of Object.entries(process.env)) {
if (k !== 'ELECTRON_RUN_AS_NODE' && v !== undefined) env[k] = v
}
const app = await electron.launch({
args: [join(__dirname, '..', 'out', 'main', 'index.js')],
env
})
const page = await app.firstWindow()
// 标题存在TopBar 渲染 h1 + 副标lang 切换后 h1 文案跟着切)
await expect(page.getByRole('heading', { name: 'Python 程序耗时可视化分析' })).toBeVisible()
await page.reload()
await expect(page.getByRole('heading', { name: 'Python 程序耗时可视化分析' })).toBeVisible()
// 等待解释器检测填充(并行跑 5 个 phase最坏情况要 15-20s
const runBtn = page.getByRole('button', { name: '运行分析' })
try {
await expect(runBtn).toBeEnabled({ timeout: 30_000 })
} catch {
await app.close()
test.skip(true, '本机未检测到 Python 解释器')
return
}
await runBtn.click()
// 摘要RunSummaryLite出现 = 分析成功,引擎至少跑完了一次 wall-time 测量
await expect(page.getByText('本次耗时')).toBeVisible({ timeout: 60_000 })
// 热点表 sectionv2 标题 = results.hotspot.title
await expect(page.getByText('热点函数')).toBeVisible()
// DEMO_CODE 里的对比函数:纯 Python 高斯消元 vs numpy —— 最耗时的 `matinv` 一定出现在表里
await expect(page.getByText('matinv').first()).toBeVisible()
// 火焰图 sectionv2 标题 = results.time.title = "耗时分布"),确保 SVG 真的渲染
await expect(page.getByText('耗时分布')).toBeVisible()
await expect(page.locator('svg[aria-label="火焰图"]')).toBeVisible()
await app.close()
})

35
electron.vite.config.ts Normal file
View File

@@ -0,0 +1,35 @@
import { resolve } from 'path'
import { defineConfig } from 'electron-vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
main: {
build: {
rollupOptions: {
input: { index: resolve(__dirname, 'src/main/index.ts') }
}
}
},
preload: {
build: {
rollupOptions: {
input: { index: resolve(__dirname, 'src/preload/index.ts') }
}
}
},
renderer: {
root: resolve(__dirname, 'src/renderer'),
resolve: {
alias: {
'@shared': resolve(__dirname, 'src/shared'),
'@renderer': resolve(__dirname, 'src/renderer/src')
}
},
build: {
rollupOptions: {
input: { index: resolve(__dirname, 'src/renderer/index.html') }
}
},
plugins: [react()]
}
})

0
engine/__init__.py Normal file
View File

186
engine/harness.py Normal file
View File

@@ -0,0 +1,186 @@
import gc
import platform
import sys
import time
import types
import cProfile
from engine.schema import Calibration, Environment
def collect_environment() -> Environment:
"""采集运行环境指纹,用于结果可复现性与 UI 展示。"""
return Environment(
python=platform.python_version(),
platform=sys.platform,
processor=platform.processor() or platform.machine(),
timerResolution=time.get_clock_info("perf_counter").resolution,
)
def load_source(script_path: str) -> str:
"""读取脚本文本,剥离 BOM。
utf-8-sig 自动剥离 BOMWindows Notepad 默认带 BOM 的 UTF-8 文件);
用 utf-8 会留下  前缀compile() 抛 SyntaxError: invalid character。
引擎内所有读源码的地方都必须走这里,否则会出现"harness 能跑但
runner 的语法预检先失败"这类不一致(历史 bug
"""
with open(script_path, "r", encoding="utf-8-sig") as f:
return f.read()
def _load_code(script_path: str):
return compile(load_source(script_path), script_path, "exec")
def _build_user_globals(script_path: str) -> dict:
"""构造用户代码 exec 的 globals。
两件事一起处理:
1) sys.argv 临时清成 [script_path] —— 防用户代码读 --out 注册 atexit 改写
result.json(关键安全洞 C1)。exec 完后恢复。
2) 装一个 __main__ module —— 让 pickle / multiprocessing / joblib 这类
依赖 sys.modules['__main__'] 的库能正常工作。
之前只把 __name__ 设成 '__main__' 但 sys.modules['__main__'] 仍指向
engine.runner,直接 break pickle.dumps(本地定义类) ——
PicklingError: ... not found as __main__.Point。
"""
# 装 synthetic __main__ module,跟 exec 的 globals 共享同一份 dict
main_mod = types.ModuleType("__main__")
main_mod.__file__ = script_path
main_mod.__spec__ = None
g = main_mod.__dict__
g["__name__"] = "__main__"
g["__file__"] = script_path
g["__builtins__"] = __builtins__
sys.modules["__main__"] = main_mod
return g
def _scrub_argv_for_user_code(script_path: str) -> list:
"""把 sys.argv 收成 [script_path] 后备一份原值,exec 完后由调用方还原。
关键:用户代码能 sys.argv.index('--out') 拿到结果文件路径然后
atexit.register(lambda: open(out, 'w').write('{...forged...}')),engine
写完 result.json 紧接着被覆盖。父进程拿到的就是 attacker-controlled JSON。
"""
saved = sys.argv
sys.argv = [script_path]
return saved
def _restore_argv(saved):
sys.argv = saved
# 校准负载tight-loop,典型的 CPU-bound 工作量。
# 选 50_000 次是因为典型机器上耗时 ~10-30ms —— 既让 cProfile 开销稳定可测,
# 又不会让用户感到"启动变慢"。如果换成 1 次循环,ratio 会被调度噪声主导;
# 换成 1M 次,校准本身就要 ~1s,用户能感知。
_CALIB_SOURCE = (
"def _pyrof_calib():\n"
" s = 0\n"
" for i in range(50_000):\n"
" s += i\n"
" return s\n"
"_pyrof_calib()\n"
)
# 在 module load 时编译一次 —— 校准负载是常量字符串,之前 calibrate 跑两遍
# (clean + instrumented) 就 compile 两次,纯浪费。co_filename 用合成路径,
# 不需要落到磁盘 —— _build_user_globals 只取它当 __file__,不校验文件存在。
_CALIB_CODE = compile(_CALIB_SOURCE, "<pyrof_calibration>", "exec")
# 同样用于 _build_user_globals(script_path) —— synthetic __main__.__file__
# 用同一条合成路径,跟 code.co_filename 保持一致。
_CALIB_PATH = "<pyrof_calibration>"
def _exec_once_for_calibration() -> None:
"""校准负载的纯 exec —— 不开 cProfile。
GC 状态由 caller 负责 —— calibrate_cprofile_overhead 已经 disable 后
re-enable,这里再包一层是冗余 no-op,徒增两个 isenabled() 系统调用。
"""
g = _build_user_globals(_CALIB_PATH)
saved_argv = _scrub_argv_for_user_code(_CALIB_PATH)
try:
exec(_CALIB_CODE, g)
finally:
_restore_argv(saved_argv)
def _exec_once_under_cprofile() -> None:
"""同一段校准负载,套 cProfile 跑一次 —— 用于测 cProfile 自身开销。
GC 状态由 caller 负责(同上理由)。"""
g = _build_user_globals(_CALIB_PATH)
saved_argv = _scrub_argv_for_user_code(_CALIB_PATH)
pr = cProfile.Profile()
pr.enable()
try:
exec(_CALIB_CODE, g)
finally:
pr.disable()
_restore_argv(saved_argv)
def calibrate_cprofile_overhead(workdir: str | None = None) -> Calibration:
"""跑同一段 tight-loop 各一次(裸 vs 仪器化),返回膨胀系数。
为什么需要这步:
- 用户脚本只 exec 一次 (单跑架构,v4 起),这次 exec 必须开 cProfile 才能拿归因
- 但 cProfile 是插桩式的,wall-time 会膨胀 1.5~3x;直接报给用户不真实
- 思路是「cProfile 开销 ≈ 跟用户代码调用次数成正比」,跟代码时间无关。
所以可以在一段**已知调用次数**的 tight-loop 上测一次 ratio,再把
instrumented_user / ratio 当作估计的干净耗时。
- 这一步与用户代码 exec 解耦:校准不会被用户的 import / 全局副作用干扰,
也不会因为校准改了用户的状态而报错。
边界情况:
- 校准本身耗时 <5ms (机器极快):perf_counter 精度+调度抖动会让 ratio
跳到 0.5x/5x 这种离谱值,此时回退 ratio=1.0 (即相信 instrumented 时间)。
- 校准本身耗时 5~50ms:正常,ratio 在 1.5~3 之间。
workdir:历史参数,现已废弃 —— 校准脚本不再落盘(改成内存里 compile +
合成 __file__),workdir 不再被本函数读取。为保持 profiler-service /
老调用方不破,参数保留并忽略。
"""
del workdir # 明确workdir 已废弃,调用方传过来也不再消费
try:
# 干净跑 —— 不开 cProfile
gc_was = gc.isenabled(); gc.disable()
try:
t0 = time.perf_counter()
_exec_once_for_calibration()
clean = time.perf_counter() - t0
finally:
if gc_was: gc.enable()
# 仪器化跑 —— 开 cProfile
gc_was = gc.isenabled(); gc.disable()
try:
t0 = time.perf_counter()
_exec_once_under_cprofile()
inst = time.perf_counter() - t0
finally:
if gc_was: gc.enable()
finally:
pass # 无文件要清
if clean < 0.005 or inst <= 0 or clean <= 0:
# 校准耗时过短或退化:ratio 不可信,回退 1.0 (即不补偿,UI 显示原始 instrumented 时间)
return Calibration(
ratio=1.0,
workloadName="tight-loop",
instrumentedWorkloadSec=inst,
cleanWorkloadSec=clean,
)
return Calibration(
ratio=inst / clean,
workloadName="tight-loop",
instrumentedWorkloadSec=inst,
cleanWorkloadSec=clean,
)

257
engine/runner.py Normal file
View File

@@ -0,0 +1,257 @@
import argparse
import io
import shutil
import sys
import tempfile
import traceback
from engine.harness import calibrate_cprofile_overhead, collect_environment, load_source
from engine.schema import SCHEMA_VERSION, AnalysisResult, Calibration, FlameNode, WallTime
from engine.structure import profile_and_measure
# Result 文件大小防护(防用户脚本异常把磁盘撑爆)
# - error.message 上限 1KB:1KB 一般足够覆盖正常异常说明
# - error.traceback 上限 8KB:100 帧左右 traceback 的体量,UI 可读范围
# - functions 列表上限 5000:UI 表格默认只看前 200,再多只是给后续解析加负载
_MAX_ERROR_MESSAGE_BYTES = 1024
_MAX_TRACEBACK_BYTES = 8 * 1024
_MAX_FUNCTIONS = 5000
def _truncate_text(text: str, max_bytes: int) -> str:
"""把文本按 UTF-8 字节截断到 max_bytes 以内,补一个明确截断标记。
直接 [:max_chars] 在多字节字符中间切会破坏 UTF-8,UI 解析挂。encode → 截
→ 走 errors='ignore' 让不完整尾部自然掉落 → decode,保证结果仍是合法 UTF-8。
"""
if not text:
return text
encoded = text.encode("utf-8", errors="replace")
if len(encoded) <= max_bytes:
return text
truncated = encoded[:max_bytes].decode("utf-8", errors="ignore")
# 留 64 字节给标记(总长仍 ≤ max_bytes)
suffix = f"\n\n[... 截断:共 {len(encoded)} 字节,仅保留前 {max_bytes} 字节 ...]"
# 如果 suffix 太长导致总超,再砍一次
return (truncated + suffix)[:max_bytes]
def _cap_functions(functions: list, flame: FlameNode | None) -> tuple:
"""截断 functions 列表到 _MAX_FUNCTIONS,并把超出部分累加到 flame 的 root。
为什么不只截 functions 不动 flame?
- flame 是 UI 看到的"时间分布",丢了 functions 里那些小函数会让 flame
总和 < 用户报的总耗时,UI 显示出现 100% 但 sum 不到 80% 的诡异情况。
- 把被丢函数的 tottime 加到 root.value 上,UI 那边仍按 flame 内部占比显示,
多出来的部分归到 root 节点(最大最宽的 tile,用户视觉上只是「其他」变
大了一点 —— 总和不撒谎。
"""
if len(functions) <= _MAX_FUNCTIONS:
return functions, flame
kept = functions[:_MAX_FUNCTIONS]
dropped_tottime = sum(f.tottime for f in functions[_MAX_FUNCTIONS:])
if flame is None:
flame = FlameNode(name="root", value=dropped_tottime, children=[])
else:
flame.value += dropped_tottime
return kept, flame
def _compute_wall_time(instrumented_sec: float, calibration: Calibration) -> float:
"""用校准 ratio 折算 instrumented 时间为估计的干净耗时。
思路:cProfile 是插桩式的,wall-time 会被膨胀 ratio 倍(instrumented / clean);
用同一段 tight-loop 测出 ratio 后,把用户的 instrumented 时间除回去,就是
「去掉 cProfile 开销后的估计耗时」。
边界:calibration.ratio 是 0 / 负数 / NaN 时直接信 instrumented 时间 —— 跟
calibrate_cprofile_overhead 在「校准耗时过短」时回退 ratio=1.0 的兜底策略
保持一致,UI 显示真实测得的 instrumented 时间而不是除以无效值。
"""
ratio = calibration.ratio
# `ratio == ratio` 排除 NaN;`ratio > 0` 排除 0 / 负数
if ratio == ratio and ratio > 0:
return instrumented_sec / ratio
return instrumented_sec
def _cap_result(result: AnalysisResult) -> AnalysisResult:
"""给 result.json 上一道防线,防止异常情况把磁盘/IPC 通道撑爆。
触发场景:
- 用户代码 raise 一个带 50MB 文件内容的 ValueError → error.message 撑爆
- 用户代码触发深递归异常,Python 自动 dump 几千层 traceback → 撑爆
- 用户脚本 import 一个庞大的库(numpy/pandas)scope=all 把整棵树都归因 →
38MB+ 的 functions 列表
截断而非拒绝:UI 仍能看到「有错」「发生了什么」,只是具体细节截断;functions
截断后仍按 tottime 排序,前 5000 一定是最值得用户关注的热点。
"""
if result.error:
msg = result.error.get("message")
if isinstance(msg, str) and msg:
result.error["message"] = _truncate_text(msg, _MAX_ERROR_MESSAGE_BYTES)
tb = result.error.get("traceback")
if isinstance(tb, str) and tb:
result.error["traceback"] = _truncate_text(tb, _MAX_TRACEBACK_BYTES)
if result.functions:
result.functions, result.flame = _cap_functions(result.functions, result.flame)
return result
def _progress(phase, pct):
# 前导 \ntqdm / 进度条类库会写"working..."到 stderr 但不换行,
# 直接接 PROGRESS 会拼成 "working...PROGRESS running 10",主进程正则匹配挂、
# 进度事件掉一档且把工程协议行混进 stderrTail 让用户看到 \r 进度条残片。
print(f"\nPROGRESS {phase} {pct}", file=sys.stderr, flush=True)
def _emit(result: AnalysisResult, out_path=None):
"""发出结果。
out_path 给定时结果写入独立文件stdout/stderr 就完整留给用户代码 ——
否则用户代码里一句 sys.stdout.write("done") 或 print(..., end='') 就会和
JSON 挤在同一行,主进程按"最后一行"取 JSON 时解析失败,
用户看到的却是"结果解析失败"。不给 out_path 时退回 stdout直接跑 CLI / 老测试)。
"""
payload = result.to_json()
if out_path:
with io.open(out_path, "w", encoding="utf-8", newline="\n") as f:
f.write(payload)
else:
print(payload, flush=True)
def _base(env, config, status, error=None, wall_time=None, functions=None, flame=None, calibration=None):
return AnalysisResult(
schemaVersion=SCHEMA_VERSION,
environment=env,
config=config,
status=status,
error=error,
wallTime=WallTime(seconds=wall_time, unit="s") if wall_time is not None else None,
functions=functions or [],
flame=flame,
calibration=calibration,
)
def main(argv=None):
ap = argparse.ArgumentParser()
ap.add_argument("--script", required=True)
ap.add_argument("--out", default=None,
help="结果 JSON 的输出文件;不给则打到 stdout")
ap.add_argument("--scope", choices=["user", "all"], default="user",
help="剖析范围user默认只归因用户脚本里的函数"
"all 包含所有非 cProfile 内部帧(标准库 + 第三方包 + 用户代码),"
"让耗时可以下钻到 import 的包里。")
ap.add_argument("--workdir", default=None,
help="校准文件落盘的临时目录;不给则 runner 自己 mkdtemp。"
"profiler-service 一般传过来(它创建的 pyprof-xxx),"
"直跑 CLI 不传也行。")
ap.add_argument("--hide-internal", dest="hide_internal",
action=argparse.BooleanOptionalAction, default=True,
help="过滤掉测试代码(tests/ / test_*.py / _pyrof_calib 等)+ "
"tottime=0 的量化噪声帧。默认 on,用户原话:「软件内部的测试部分"
"默认百分百过滤掉」。--no-hide-internal 关掉这条过滤,看完整数据。")
args = ap.parse_args(argv)
# 统一走这个 emit结果去 --out 指定的文件stdout 留给用户代码。
# 每条 emit 之前过 _cap_result 给 result.json 上一道防线 —— error.traceback /
# error.message / functions 列表都设硬上限,防止用户异常把磁盘 / IPC 撑爆。
def emit(result):
_emit(_cap_result(result), args.out)
env = collect_environment()
config: dict = {}
# 版本预检:主进程的 validateInterpreter 已经挡过一层,这里是纵深防御——
# 用户可能绕过 UI 直接调引擎,或 PATH 上的 python 在探测后被换掉。
# 低于 3.9 时后面的 list[X] 泛型注解会直接 SyntaxError拿不到结构化错误。
if sys.version_info < (3, 9):
got = ".".join(str(p) for p in sys.version_info[:3])
emit(_base(env, config, "runtime_error",
error={"type": "PythonTooOld",
"message": f"引擎需要 Python 3.9 或更高版本,当前为 {got}"}))
return
# 一次 IO 拿到 src语法预检和后续 profile 都用这份文本
# 之前没 try/except临时目录被删 / 路径权限不够 → 进程崩,无 JSON 输出 → UI 显示 "引擎无输出"
# UnicodeDecodeError 不是 OSError 的子类:二进制文件 / 非 UTF-8 脚本会绕过这层保护再次崩溃。
try:
src = load_source(args.script)
except (OSError, UnicodeDecodeError) as e:
emit(_base(env, config, "runtime_error",
error={"type": type(e).__name__, "message": f"无法读取脚本: {e}"}))
return
# Syntax precheck (用文本而非再读一次)
# compile() 还会在源码含 null 字节时抛 ValueError也一并按 syntax_error 归类
# 把 code object 留起来 —— profile_and_measure 还需要再 exec 一次,
# 直接复用这份省一次 read + compile。
try:
code = compile(src, args.script, "exec")
except (SyntaxError, ValueError) as e:
emit(_base(env, config, "syntax_error", error={"type": type(e).__name__, "message": str(e)}))
return
# v4 单跑架构:校准 + 用户代码 under cProfile 一次拿到归因 + instrumented 时间。
# 跟 v3 的「先裸跑测 wall_time 再 cProfile 跑」相比,
# 用户脚本的副作用(plot / file write / etc)只发生一次,语义更直观。
# workdir 优先用 caller 传的(profiler-service 已经 mkdir pyprof-xxx 落 _pyrof_calib.py
# 和用户脚本);caller 不传则自己 mkdtemp —— 这种情况跑完自己清掉。
workdir = args.workdir
workdir_owned = False
if not workdir:
workdir = tempfile.mkdtemp(prefix="pyprof-")
workdir_owned = True
try:
try:
_progress("calibrating", 5)
# 校准脚本落在 caller 提供的 workdir 里 —— profiler-service 已经 mkdir
# pyrof-xxx 装用户脚本,把 _pyrof_calib.py 放一起能让 rmtree(workdir) 一次
# 清干净。runner 自己 mkdtemp 时(直跑 CLI 场景)workdir_owned=True,
# calibration 拿到的是同一个目录,临时文件一起被 finally 兜底删。
calibration = calibrate_cprofile_overhead(workdir)
_progress("running", 30)
st, instrumented = profile_and_measure(
args.script, scope=args.scope, hide_internal=args.hide_internal,
code=code, src=src,
)
wall_time = _compute_wall_time(instrumented, calibration)
_progress("done", 100)
emit(_base(env, config, "ok",
wall_time=wall_time,
functions=st.functions,
flame=st.flame,
calibration=calibration))
return
except SystemExit as e:
# sys.exit() 抛 SystemExitBaseException 的子类)—— 不被下面的 except BaseException 捕获,
# 子进程直接退出无 JSONUI 就会显示 "引擎无输出 (exit N)"。转成 runtime_error。
# e.code 默认 None无参 sys.exit()),文案要单独处理,否则会出现"sys.exit(None)"
code = e.code if isinstance(e.code, int) else (0 if e.code is None else f"非整数 {e.code!r}")
emit(_base(env, config, "runtime_error",
error={"type": "SystemExit", "message": f"用户代码调用 sys.exit({code})"}))
except BaseException as e:
# KeyboardInterrupt / GeneratorExit / 自定义 BaseException 子类 以及
# 普通 Exception 一并兜底 —— 之前再写一条 `except Exception` 是 dead code
# BaseException 是 Exception 的父类,先匹配的赢)。父进程端有 cancel +
# 进程树 kill 在管 Ctrl+C,这里只负责兜底写 JSON。
emit(_base(env, config, "runtime_error",
error={"type": type(e).__name__, "message": str(e),
"traceback": traceback.format_exc()}))
finally:
# runner 自己 mkdtemp 的目录要自己清 —— profiler-service 已经传 --workdir 时
# 整个 workdir 由 caller 管(主进程清理临时目录的逻辑在 before-quit),这里只兜
# 「直接 python -m engine.runner」的直跑场景。
if workdir_owned:
shutil.rmtree(workdir, ignore_errors=True)
if __name__ == "__main__":
main()

87
engine/schema.py Normal file
View File

@@ -0,0 +1,87 @@
from dataclasses import dataclass, field, asdict
import json
SCHEMA_VERSION = 4
@dataclass
class Environment:
python: str
platform: str
processor: str
timerResolution: float
@dataclass
class WallTime:
seconds: float
unit: str = "s"
@dataclass
class Calibration:
# v4 新增cProfile 开销校准系数,用于把 instrumented wall-time 折算为干净耗时。
# 用户脚本只 exec 一次(单跑架构)的代价是 wall-time 带 cProfile 开销;启动期
# 用同一段 tight-loop 各跑一次(裸 vs 仪器化ratio = instrumented / clean
# 用户 wall_time = instrumented_user / ratio。
ratio: float
# 校准负载名:方便 debug 时看出是哪段负载产生的系数;目前固定为 "tight-loop"
workloadName: str = "tight-loop"
# 校准负载在 cProfile 下的耗时(秒)—— 透传给 UI 方便 debug
instrumentedWorkloadSec: float = 0.0
# 校准负载裸跑的耗时(秒)
cleanWorkloadSec: float = 0.0
@dataclass
class FunctionNode:
id: str # "file:line:name"
file: str
line: int
name: str
cumtime: float
tottime: float
ncalls: int
percallTot: float
# 顶层模块名(用于 scope=all 时的 UI 分类):
# "<user>" 用户脚本 / "json" / "numpy" / "<frozen>" 等。
# 提取逻辑见 engine/structure._top_module这一层做归类
# 让 UI 不必重新解析文件路径(路径 normalize 在 OS 间不一致)。
module: str = ""
# 帧来源v3 新增;用于 UI 按 origin 分组):
# "user" 用户脚本 / "stdlib" 标准库 / "third_party" 第三方包 /
# "builtin" 内置C 实现的 builtin / "frozen" frozen importlib 等 /
# "other" 兜底(未匹配任何已知来源,例如奇怪的 <string> 帧)
#
# 优先用 sys.stdlib_module_names 校准3.10+),降级用路径启发式:
# /Lib/ 或 /lib/pythonX.Y/ → stdlib含 site-packages/dist-packages → third_party。
# 推导逻辑见 engine/structure._classify_origin。
origin: str = ""
@dataclass
class FlameNode:
name: str
value: float
children: list = field(default_factory=list)
@dataclass
class AnalysisResult:
schemaVersion: int
environment: Environment
config: dict
status: str # ok|syntax_error|runtime_error|timeout
error: dict | None
wallTime: WallTime | None
functions: list
flame: FlameNode | None
# v4 新增ok 状态下必有;非 ok语法错 / 运行时错 / 超时)下为 None。
# 见 Calibration 字段注释 —— 折算 wallTime 时需要,存到 result 里给 UI 看。
calibration: Calibration | None = None
def to_json(self) -> str:
# ensure_ascii=True默认在 GBK locale / 没设 PYTHONUTF8 的环境下
# stdout 不是合法 UTF-8UI 解析会挂。转义成纯 ASCII 后逐字节一致,
# JSON.parse 原生还原。主进程仍然会设那两个环境变量,这里是纵深防御。
return json.dumps(asdict(self), ensure_ascii=True)

576
engine/structure.py Normal file
View File

@@ -0,0 +1,576 @@
import ast
import cProfile
import functools
import os
import pstats
import re
import sys
import time
from dataclasses import dataclass
from engine.schema import FlameNode, FunctionNode
from engine.harness import _build_user_globals, _load_code, _restore_argv, _scrub_argv_for_user_code, load_source
@dataclass
class StructureResult:
functions: list
flame: FlameNode
# ──────────────────────────────────────────────────────────────────────────────
# 内部测试代码识别 + 量化噪声过滤
# ──────────────────────────────────────────────────────────────────────────────
#
# 用户原话:「软件内部的测试部分默认百分百过滤掉,不在统计范围内容。完全不显示。」
# —— 所以测试代码 + tottime=0 的帧默认不进 result.json / functions[] / flame。
#
# 这是 v5 才加的过滤。在 App 入口(filterAnalysis)也有一份,这里再加一份是为了
# 「不打开 UI 也想看干净数据」的场景:用户拿 result.json 跑自己的聚合脚本时,
# 拿到的就是过滤后的数据,而不是 774 帧 + 90% 是噪声。
#
# 跟 App 层契约一致:只匹配 CONTEXT(文件路径 / 模块路径) + 引擎内部硬编码白名单,
# 完全不看函数名 —— TS 端 isInternalTest 的同样设计原则,误伤 = bug。
import re as _re_noise
# 文件名是 test_*.py / *_test.py —— 任意位置都算测试代码pytest 文件命名约定)
_INTERNAL_TEST_FILE_RE = _re_noise.compile(r'[\\/](?:test_[^\\/]+|[^\\/]+_test)\.py$')
# 在 tests/ / test/ / __tests__/ **直接下面**的文件 —— pytest 目录约定。
# 收紧到要求「tests/ 后面紧接一个文件名,不能再有 / 子目录」:
# * tests/foo.py → 算 (foo.py 直接在 tests/ 下)
# * tests/fixtures/foo.py → 不算 (fixtures 是子目录,foo.py 不直接挂在 tests/ 下)
# 之前用 `[\\/](tests?|__tests__)[\\/]` 任何含 `/tests/` 的路径都中招,把 fixtures 这类
# 测试数据夹具也当成测试代码误伤 —— 用户脚本只要住在带 tests/ 子目录的路径下,
# 函数表直接被过滤成 0 行。
_INTERNAL_TEST_DIR_RE = _re_noise.compile(r'[\\/](tests?|__tests__)[\\/][^\\/]+$')
_INTERNAL_TEST_MODULE_RE = _re_noise.compile(r'^(tests?|__tests__)([._]|$)')
_INTERNAL_NAMES: frozenset = frozenset(["_pyrof_calib"])
def _is_internal_test(name: str, file: str, module: str) -> bool:
"""Python 版的 isInternalTest —— 命中即视作「噪声帧」,不进 functions[]。
设计原则(同 TS 版 utils/origin.ts):
- 只看语境(文件 / 模块 / 引擎白名单),不看函数名 —— 函数名匹配太容易误伤
(test_helper() / TestCase.test_login() 都是合法业务函数)
- 引擎内部硬编码白名单(_pyrof_calib)是兜底防御,任何情况下都不该出现在用户 stats 里
"""
if name in _INTERNAL_NAMES:
return True
# 文件名是 test_*.py / *_test.py —— 任意位置
if _INTERNAL_TEST_FILE_RE.search(file):
return True
# 在 tests/ / test/ / __tests__/ 直接下面的文件(不能是子目录)
if _INTERNAL_TEST_DIR_RE.search(file):
return True
# module 一定非空_top_module 不会返回空字符串),不用做 truthy 兜底
if _INTERNAL_TEST_MODULE_RE.match(module):
return True
return False
def _extract_user_imports(src: str) -> set:
"""从用户脚本源码里抽出顶层显式 import 的模块名集合。
只看模块级 Import / ImportFrom —— 函数/类内部的 import 是延迟副作用,不是
「我需要分析的目标」。返回集合里保留顶级包名(numpy / json / os 等),不展开
as 后的别名(as np → numpy 也在集合里,别名不进集合)。
解析失败(syntax error 等)时返回空集合 —— 调用方已经独立做了 compile 预检,
走到 profile_and_measure 的代码一定是合法 Python,这里只是防御。
"""
try:
tree = ast.parse(src)
except SyntaxError:
return set()
modules: set = set()
for node in tree.body:
if isinstance(node, ast.Import):
for alias in node.names:
# 'import a.b.c' → 'a' 就够了;子包自然跟着顶级包一起保留。
modules.add(alias.name.split(".")[0])
elif isinstance(node, ast.ImportFrom):
# 'from . import x' 的 level > 0 是相对导入,跳过 —— 它们依附于某个
# 已知包(用户脚本 / 已导入的第三方),不展开根模块。
# node.level 是 int(0/1/2/...),0 是 falsy,这里只需 if node.level。
if node.level:
continue
if node.module:
modules.add(node.module.split(".")[0])
return modules
def _fid(func):
file, line, name = func
return f"{file}:{line}:{name}"
@functools.lru_cache(maxsize=4096)
def _norm(path):
"""规范化路径 —— Windows NTFS 大小写不敏感 + 跨斜杠风格统一。
包 lru_cache典型 profile 5000 帧 / 50 unique file path —— 之前每个
frame 都跑 abspath + normcase,scope=user 的 hot loop 里反复调;
缓存后只有 50 次真正的 abspath。同一路径跨多次 _top_module /
_classify_origin / _make_frame_filter 复用同一结果。
maxsize=4096 覆盖任何真实 profile 看不到的 unique file 数。
"""
try:
return os.path.normcase(os.path.abspath(path))
except Exception:
return path
def _make_frame_filter(script_path, scope="user"):
"""构造帧过滤器。
scope=user默认只保留用户脚本里的函数 — 历史上一直如此。
scope=all保留所有非 cProfile 内部帧(包含标准库和第三方包)— 让耗时
可以"进入到 import 的包里",否则 `pandas.read_csv` 永远是一个黑盒,
看到 3s 也不知道是序列化慢、IO 慢还是解析慢。
`~` 帧的过滤有讲究 —— 之前一刀切 file == '~' 都丢,实际上:
- cProfile 用 `~` 作为**所有 C 函数帧**的文件名time.sleep / numpy C 核
/ json 加速 / re 等),丢这些直接打瞎 scope=all
- 真正的 cProfile 内部帧靠 name 区分:`'_lsprof.Profiler'` /
`'Profiler' / '<method 'disable' of '_lsprof.Profiler' objects>'` 之类
只剔除 name 含 '_lsprof.Profiler' 的帧,其它 C 函数保留 —— schema 里
origin='builtin' / module='<built-in>' 也终于能命中真实 builtin 帧。
"""
target = _norm(script_path)
def _is_kept(func):
file, _, name = func
# cProfile 内部帧 —— file='~',name 含 "_lsprof.Profiler" 子串
# (实际形态:'Profiler' / '_lsprof.Profiler' /
# "<method 'disable' of '_lsprof.Profiler' objects>"
if file == "~" and ("_lsprof.Profiler" in name or name == "Profiler"):
return False
if scope == "all":
return True
# 虚拟路径(<frozen ...> / <built-in ...> / <string> 等)不可能是用户脚本,
# 直接 False 省一次 _norm —— _norm("<frozen ...>") 会跑 abspath 拼成
# "<cwd>/<frozen ...>" 然后 normcase,跟用户脚本路径比必然不等,但白做一次
# 文件系统查询。~ 在 cProfile 内部已上面短路,这里同等处理。
if file.startswith("<") or file == "~":
return False
return _norm(file) == target
return _is_kept
def _top_module(file_path: str, user_script_path: str, user_script_norm: str | None = None) -> str:
"""把 cProfile 给的 file 路径归类成「顶层模块名」,给 UI 做分类用。
输出样例:
- 用户脚本(==user_script_path"<user>"
- "/usr/lib/python3.11/json/decoder.py""json"
- "C:\\Python39\\Lib\\json\\decoder.py""json"
- "C:\\Python39\\Lib\\functools.py""functools" ← 顶层 .py 必须剥掉后缀
- "/.../site-packages/numpy/core/array.py""numpy"
- "<frozen importlib._bootstrap>""<frozen>"
- "<built-in method xxx>""<built-in>"
- 其他 → 取倒数第二个目录段作 fallback基本不会走到
user_script_norm: 调用方预计算的 `_norm(user_script_path)` —— 同一进程内
同一脚本会查几百次,提到外面省 abspath不传则本函数内现算保持单点调用方兼容
"""
if file_path == "~":
# C 函数time.sleep / numpy 加速 / json C decoder 等):
# file==~ + name 是 "<built-in method ...>"
# 之前丢光后这里 dead code,现在 _make_frame_filter 不再丢 C 帧,
# module 字段需要给出有意义分类 —— 用 "<built-in>" 跟 origin 字段对齐。
return "<built-in>"
if _norm(file_path) == (user_script_norm if user_script_norm is not None else _norm(user_script_path)):
return "<user>"
# frozen / built-in / <string> 这种「虚拟」文件:整段作为标签
if file_path.startswith("<"):
if file_path.startswith("<frozen"):
return "<frozen>"
if file_path.startswith("<built-in"):
return "<built-in>"
return file_path
# 路径规范化:跨平台 + 跨斜杠
norm = file_path.replace("\\", "/")
parts = [p for p in norm.split("/") if p]
# site-packages / dist-packages标记之后的第一个目录段就是包名
for marker in ("site-packages", "dist-packages"):
if marker in parts:
idx = parts.index(marker) + 1
if idx < len(parts):
return parts[idx]
# 标准库Windows 安装布局C:\Python39\Lib\<pkg>\... 或顶层 .py
# `Lib\\functools.py` → "functools"(不是 "functools.py"),用 _strip_py 兜底
if "Lib" in parts:
idx = parts.index("Lib") + 1
if idx < len(parts):
return _strip_py(parts[idx])
# 标准库Linux / macOS 安装布局):/usr/lib/python3.X/<pkg>\...
# 之前用 p.startswith("python") + p[6:7].isdigit() 太松散 —— "python3-extra"
# 这种目录会被误识别;收紧到严格的 `pythonX(.Y)?` 形式。
for i, p in enumerate(parts):
if re.fullmatch(r"python\d+(\.\d+)?", p):
if i + 1 < len(parts):
return _strip_py(parts[i + 1])
# 兜底:取倒数第二个目录段(例如 ".../myproj/src/utils/helper.py" → "utils"
if len(parts) >= 2:
return _strip_py(parts[-2])
return _strip_py(file_path)
def _strip_py(name: str) -> str:
"""顶层 .py 文件剥掉扩展名 —— `functools.py` → `functools`。
只剥 `.py` 后缀;其它段('__init__''site-packages' 等)原样保留。
非顶层文件不会被这个函数触碰 —— _top_module 把它包在 `parts[idx]` 之外的位置时
返回的就是 `parts[-2]` 这种目录段,永远不带 `.py`;只有顶层 `<name>.py` 才走到这里。"""
if name.endswith(".py"):
return name[:-3]
return name
# sys.stdlib_module_names 是 3.10+ 才有的3.9 及之前要走路径兜底。
# 提前 frozen 一次 —— 同一进程内不变,反复判 in 走 frozenset 是 O(1)。
_STDLIB_MODULES: frozenset | None
try:
_STDLIB_MODULES = frozenset(getattr(sys, "stdlib_module_names", set()))
except Exception:
_STDLIB_MODULES = None
# 路径兜底"是不是真的在 stdlib 根下"用 sysconfig —— sysconfig.get_paths() 是
# Python 官方给出的 stdlib 根解析工具,远比手算 <prefix> + 'Lib' / '<prefix>/lib/pythonX.Y'
# 靠谱venv / embed / framework 几种安装布局都覆盖)。
#
# M5 fix:之前只看路径里是否含 "/Lib/" 或 "/lib/pythonX.Y/" —— 用户项目
# 里有 `myproject/Lib/foo.py` 这种就会被错认成 stdlib。现在锚到 sysconfig
# 算出的真 stdlib 根上:不是真正的 Python 安装根下面的,一律不算 stdlib。
#
# 一次性算好缓存,frozen 之后 hot path 上 O(1) prefix 比对。
def _stdlib_roots() -> tuple:
roots: list = []
try:
import sysconfig
stdlib_path = sysconfig.get_paths().get("stdlib", "")
if stdlib_path:
roots.append(_norm(stdlib_path))
except Exception:
pass
# 兜底再放 sys.prefix/Lib —— 有些 embedded 安装 sysconfig 拿不到
fallback = os.path.join(sys.prefix, "Lib")
roots.append(_norm(fallback))
return tuple(roots)
_STDLIB_ROOTS_NORM: tuple = _stdlib_roots()
def _is_under_real_stdlib(file_path: str) -> bool:
"""判断 file_path 是否在真正的 Python stdlib 根下。
只走"路径兜底"分支 (3.9 / 未知模块名 兜底),已有 _STDLIB_MODULES 命中时不调用本函数,无谓开销。
"""
fp = _norm(file_path)
for root in _STDLIB_ROOTS_NORM:
if fp == root or fp.startswith(root + os.sep):
return True
return False
def _classify_origin(file_path: str, user_script_path: str, module_name: str, user_script_norm: str | None = None) -> str:
"""给一帧函数归类来源v3 新增字段 origin
返回值(即 JSON 里的字面量UI 端按这个 group:
"user" 用户脚本(路径与 user_script_path 一致)
"frozen" <frozen importlib._bootstrap> 等冻结帧
"builtin" <built-in method exec> 等 C 实现的 builtin
"stdlib" Python 标准库(按 sys.stdlib_module_names 校准3.10+
才生效,老版本退化为路径启发式)
"third_party" site-packages / dist-packages 下的第三方包
"other" 兜底 —— 例如 <string>、未匹配任何已知布局的奇怪路径
顺序很关键:
1) user / frozen / builtin 用文件路径前缀直接判O(1)
2) stdlib 先查 sys.stdlib_module_names权威命中即返回
3) 路径里出现 site-packages / dist-packages → third_party
4) 路径里出现 /Lib/ 或 /lib/pythonX.Y/ → stdlib启发式兜底
5) 其它 → other
user_script_norm: 调用方预计算的 `_norm(user_script_path)` —— 同 _top_module,
热路径上几百次调用,提到外面省一次 abspath。
"""
# C 扩展函数time.sleep / numpy C 核 / json C 加速器 等cProfile 把 file 标成 "~"。
# _top_module 已经把 module 字段定为 "<built-in>"(与 origin 字段对齐的契约见那里),
# 这里也要走 builtin 分支,否则 origin = "other" 与 module = "<built-in>" 错位,UI
# 端按 origin 分组时这条帧会落到别的桶里 —— 之前一直漏到这里。
if file_path == "~":
return "builtin"
# 虚拟文件路径(<frozen ...> / <built-in ...> / <string> 等)优先短路 —— 之前
# 先 _norm 再判 < 是浪费 abspath,而且 "<..." 这种路径跟用户脚本路径无论如何
# 都不可能相等,白调一次 norm。顺序调成「<... 优先」后 hot path 上少 100+
# 次 _norm 调用(典型 scope=all 的 profile 里 <frozen>/<built-in> 帧占大头)。
if file_path.startswith("<"):
if file_path.startswith("<frozen"):
return "frozen"
if file_path.startswith("<built-in"):
return "builtin"
return "other"
if _norm(file_path) == (user_script_norm if user_script_norm is not None else _norm(user_script_path)):
return "user"
# sys.stdlib_module_names 校准module_name 已由 _top_module 算好,
# 直接问「这个包名是不是 stdlib」。
if _STDLIB_MODULES is not None and module_name in _STDLIB_MODULES:
return "stdlib"
# 路径兜底(兼容 3.9 + 处理 _STDLIB_MODULES 偶发漏判的边角包)
norm = file_path.replace("\\", "/")
if "site-packages" in norm or "dist-packages" in norm:
return "third_party"
# 必须锚到真正的 stdlib 根下 —— 之前只看 "Lib/" 子串会把用户项目里的
# /home/x/myproject/Lib/foo.py 误认成 stdlib。
# 锚点用 sysconfig.get_paths()['stdlib'] (官方权威,覆盖 venv/framework/embed),
# 拿不到时回退到 sys.prefix + "Lib"。
if _is_under_real_stdlib(file_path):
return "stdlib"
return "other"
def profile_and_measure(
script_path: str,
scope: str = "user",
hide_internal: bool = True,
code=None,
src: str | None = None,
) -> tuple:
"""v4 单跑架构:一次 exec(code) under cProfile同时拿到函数归因和 instrumented wall-time。
替代 v3 的两阶段执行(先裸跑测 wall_time 再 cProfile 跑):
- 用户脚本只 exec 一次 → plot / print / file-write 等副作用只发生一次
- 返回的 instrumented_wall 含 cProfile 自身开销(典型 1.5~3x 膨胀)
- 调用方需要配合 calibrate_cprofile_overhead 折算wall_time = instrumented / ratio
scope: "user"(默认)只归因用户脚本里的函数;"all" 包含所有非 cProfile 内部帧
(标准库 + 第三方包 + 用户代码),让用户能下钻到 import 的包里。
hide_internal (v5 新增,默认 True):过滤掉测试代码 + tottime=0 的量化噪声帧。
- 测试代码tests/ / test_*.py / _pyrof_calib 等(见 _is_internal_test)
- tottime=0:scope=all 时 typing / inspect / functools 等内部展开常被 cProfile 量化精度截到 0,
这些不是优化目标,默认剔除能让 result.json 干净到「只剩真正在跑的代码」
- 设为 False 时不过滤 —— 给想排查调用栈 / 自定义聚合的用户留一条后路
code / src:可选的预读 code object 和源码文本。runner.py 已经在做 syntax
precheck 时 read + compile 过一份,这里直接复用 —— 避免重复 IO(原来
profile_and_measure 自己又 read 两次 + AST parse 一次)。这两个参数
给 None 时回退到「自己 load_source + compile」,供老调用方 / 单测继续工作。
NOTE: 不在 cProfile exec 周围禁用 GC —— cProfile 应该看到真实的执行环境
(包括 GC 暂停),折算后的 wall-time 才能反映真实耗时。calibration 那两次
tight-loop 跑各跑各的 GC 策略(详见 harness.calibrate_cprofile_overhead
"""
# 复用 caller 读好的 code / src —— runner.py 的 syntax precheck 已经 read+compile 过一次,
# 再读一次等于把同样的字节流从磁盘捞 2 次 + AST parse 一次。None 时退回到旧的「自己读」路径。
if code is None:
code = _load_code(script_path)
if src is None and hide_internal:
src = load_source(script_path)
g = _build_user_globals(script_path)
saved_argv = _scrub_argv_for_user_code(script_path)
pr = cProfile.Profile()
pr.enable()
try:
t0 = time.perf_counter()
exec(code, g)
instrumented = time.perf_counter() - t0
finally:
pr.disable()
_restore_argv(saved_argv)
stats = pstats.Stats(pr)
is_kept = _make_frame_filter(script_path, scope)
# 规范化一次:每个函数帧都会把 file_path 与 script_path 比对;规范化结果
# 与具体帧无关 —— 提到循环外,几百行的 functions 表能省几百次 abspath 调用。
script_norm = _norm(script_path)
# 解析用户脚本里显式 import 的模块集合。stdlib 内部帧如果来自「用户没
# 显式 import 的包」(typing/inspect/functools/re/_py_warnings/...),
# 一律视为 numpy/pandas 这类第三方包触发的「间接调用链」—— 用户无法优化,
# 默认剔除。结果scope=all 时 result.json 也只剩用户代码 + 显式导入的
# 第三方包 + 真正大头的 stdlib 模块(json/os/etc.,用户写了 `import json`
# 就看 json,否则不看)。
# 之前无条件 ast.parse 整个 src —— 即便用户脚本 module 全是 "<user>"(scope=user)
# 根本进不到这条 stdlib 过滤分支,几百行的 fixture 也走一遍 AST。改成只在真正会
# 消费 user_imports 的组合里算(scope=all + hide_internal=True)。
if hide_internal and scope == "all":
user_imports = _extract_user_imports(src)
else:
user_imports = set()
# file → (module, origin) 缓存同一文件的多个函数帧numpy 几百帧共享一个 file
# 只算一次 module + origin。_top_module 和 _classify_origin 各自又会再调一次
# _norm(file),加 cache 后这两个调用也都省了 —— 典型 profile 5000 帧 / 50 文件,
# _top_module 从 5000 次降到 50 次,_classify_origin 同。
file_info_cache: dict[str, tuple] = {}
def _classify_file(file_path: str) -> tuple:
cached = file_info_cache.get(file_path)
if cached is not None:
return cached
module = _top_module(file_path, script_path, script_norm)
origin = _classify_origin(file_path, script_path, module, script_norm)
cached = (module, origin)
file_info_cache[file_path] = cached
return cached
# ── v6 「用户直接调用」过滤 ──
# 用户原话:「只要代码的本身和import 调用的耗时统计,其他的不需要」/「目前好像
# 仍然统计到内部测试的代码了,不合理」—— 之前虽然过滤掉了 stdlib 内部噪声帧
# (typing/inspect/functools 等),但 487 帧里仍有:
# - numpy 内部 250+ 帧(np.array 调用的 _core.fromnumeric 等)→ 间接
# - <frozen> importlib._bootstrap 90 帧 → 间接导入机制
# - <built-in> C 函数 131 帧(len / numpy C 核 / _warnings / dict.keys 等)
# - _distutils_hack 2 帧、mkl 7 帧 → setup machinery,非用户调用
# 用户其实只要:
# 1) 自己写的函数(模块名 == "<user>")
# 2) 自己「直接调用」的 import 入口(np.array / np.mean / json.dumps 等)
# 实现:cProfile 的 callers 字段自带调用方信息。「某帧的 caller 含用户脚本
# 里的帧」=「用户直接调用」。再加 origin 闸门:仅 third_party / 用户显式 import
# 的 stdlib 才算「import 调用」—— builtin / frozen / other / 没显式 import 的
# stdlib 一律不保留,即使技术上确实被用户代码调用到(len / print / numpy C 核)。
# ──────────────────────────────────────────────────────────────────────────────
functions = []
if hide_internal:
# Pass 1:收集用户帧 + 把通过基础过滤的帧的 (module, origin) 缓存下来。
# 注意:这里不再做 tt <= 0 过滤 —— 用户代码 + import 入口里常有纯 C 分派的
# 薄包装(np.random.rand / numpy.__getattr__ 等),cumtime 远大于 0 但 tottime
# 恰好压在 cProfile 量化精度地板上,被滤掉就把"用户调用了哪个 API"这条信息丢了。
# 用户代码 + import 入口自然就少,即便有 tt=0 也只是干净 user-function 占位,
# 全保留就行。
user_frames: set = set()
func_info_cache: dict = {}
for func, (cc, nc, tt, ct, callers) in stats.stats.items():
if not is_kept(func):
continue
file = func[0]
module_name, origin = _classify_file(file)
if _is_internal_test(func[2], file, module_name):
continue
func_info_cache[func] = (module_name, origin)
if module_name == "<user>":
user_frames.add(func)
# Pass 2:边判断「是不是 import_callee」边构造 FunctionNode(原版是分两个独立
# pass 跑 import_callees 再跑 functions,合一遍能省掉一次 stats.stats 全量迭代。
# import_callees 设单独 set 也不必要 —— 这里用 include 标志位本地决定,跳出本
# 帧判断后立即 append / continue。
for func, (cc, nc, tt, ct, callers) in stats.stats.items():
info = func_info_cache.get(func)
if info is None:
continue # pass 1 已过滤
module_name, origin = info
if func in user_frames:
include = True
else:
include = False
for caller in callers:
if caller in user_frames:
if origin == "third_party" or (
origin == "stdlib" and module_name in user_imports
):
# 用户代码确实调到了这个 import 入口 —— 但还要看 ct:
# 低于 cProfile 量化精度(1µs)的「被调到的帧」(典型:
# numpy._mean_dispatcher 这种注册期被触发的辅助分发器,
# cProfile 把调用方记成 <module> 但实际不干活)是噪声。
# 用户代码写的空函数另算(user_frames 不受这条约束)。
include = ct >= 1e-6
break
if not include:
continue
file = func[0]
functions.append(
FunctionNode(
id=_fid(func),
file=file,
line=func[1],
name=func[2],
cumtime=ct,
tottime=tt,
ncalls=nc,
percallTot=(tt / nc if nc else 0.0),
module=module_name,
origin=origin,
)
)
else:
# hide_internal=False:用户要的是全量原始数据 —— 不做 import_callee / 测试代码 / 零耗时过滤,
# 直接把 is_kept 通过的帧全收下来。
for func, (cc, nc, tt, ct, callers) in stats.stats.items():
if not is_kept(func):
continue
file = func[0]
module_name, origin = _classify_file(file)
functions.append(
FunctionNode(
id=_fid(func),
file=file,
line=func[1],
name=func[2],
cumtime=ct,
tottime=tt,
ncalls=nc,
percallTot=(tt / nc if nc else 0.0),
module=module_name,
origin=origin,
)
)
functions.sort(key=lambda f: f.tottime, reverse=True)
total = sum(f.tottime for f in functions)
flame = _build_flame(functions, total)
return StructureResult(functions=functions, flame=flame), instrumented
def profile_structure(
script_path: str,
scope: str = "user",
hide_internal: bool = True,
) -> StructureResult:
"""Backward-compat shimv3 时期暴露的「只拿归因、不读 wall-time」接口。
v4 起实际工作在 profile_and_measure 里完成;保留这个包装是为了不破坏直接
import engine.structure.profile_structure 的测试 / 旧调用方。语义跟 v3 一样:
只跑一次 cProfile exec、返回 functions + flame。
"""
result, _ = profile_and_measure(script_path, scope=scope, hide_internal=hide_internal)
return result
def _build_flame(functions: list, total: float) -> FlameNode:
"""构造火焰图根节点。
火焰图第一层有两个 layout 选项:
- 单模块scope=user 或刚好只 import 一个包):保持扁平(函数列表),
和 v2 之前完全一致,向后兼容。
- 多模块scope=all 且命中 ≥2 个不同的顶层模块):按 module 聚合——
用户问「时间花在了哪个包」时第一眼就能看到;点模块 tile 下钻看内部函数。
"""
# 单模块时维持扁平 —— 同名兄弟不会被 module 节点挤占,截断阈值不变
modules = {f.module for f in functions}
if len(modules) <= 1:
return FlameNode(
name="root",
value=total,
children=[FlameNode(name=f.name, value=f.tottime) for f in functions],
)
# 多模块:按 module 聚合;模块自身 value 是该模块下所有函数 tottime 之和
by_module: dict = {}
for f in functions:
node = by_module.get(f.module)
if node is None:
node = FlameNode(name=f.module, value=0.0, children=[])
by_module[f.module] = node
node.value += f.tottime
node.children.append(FlameNode(name=f.name, value=f.tottime))
# 模块按总 tottime 降序让最贵的包排最左icicle 布局的视觉约定)
sorted_modules = sorted(by_module.values(), key=lambda m: m.value, reverse=True)
return FlameNode(name="root", value=total, children=sorted_modules)

0
engine/tests/__init__.py Normal file
View File

27
engine/tests/fixtures/demo_sort.py vendored Normal file
View File

@@ -0,0 +1,27 @@
"""Demo script for users: contains several detectable anti-patterns.
- membership test against a list inside a loop -> membership_in_list
- nested loops -> nested_loops
- string concatenation with += inside a loop -> str_concat_loop
"""
def build_report(rows, allow):
out = ""
for r in rows:
if r in allow: # O(n) membership test on a list
out += str(r) + "," # string += accumulation
for other in rows: # nested loop -> O(n^2)
if r == other:
pass
return out
def main():
rows = list(range(400))
allow = list(range(0, 400, 2))
for _ in range(30):
build_report(rows, allow)
main()

16
engine/tests/fixtures/nested_calls.py vendored Normal file
View File

@@ -0,0 +1,16 @@
def leaf():
s = 0
for i in range(20000):
s += i
return s
def mid():
return sum(leaf() for _ in range(3))
def main():
return mid()
main()

19
engine/tests/fixtures/with_imports.py vendored Normal file
View File

@@ -0,0 +1,19 @@
"""scope=all 的测试 fixture —— 故意 import 一些标准库并实际用它们,
让 cProfile 能归因到非用户脚本里的帧。"""
import json
import time
def payload():
# 强制 json.loads / json.dumps 进 cProfile 栈
return json.loads(json.dumps({"a": 1, "b": [1, 2, 3]}))
def run():
for _ in range(50):
payload()
# 让 time.sleep / 调度器也进栈 —— 注意 sleep < cProfile tick 的话归不到
# 它自己头上,但 time 模块的辅助函数会被经过
run()

View File

@@ -0,0 +1,125 @@
"""跨语言契约测试engine/schema.py 的输出必须与 src/shared/analysis.ts 的类型对齐。
之前这个文件把 key 集合硬编码在 Python 里,从不读 analysis.ts —— 也就是说
TS 侧加一个字段、或者两边字段名写歧了,这个"契约测试"一律绿灯,
而 README 声称的"用契约测试保证一致"并不成立。
现在两边都是解析出来的Python 侧从 dataclass 的注解拿TS 侧从 interface 声明拿。
"""
import dataclasses
import io
import json
import os
import re
import subprocess
import sys
from engine import schema
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
FX = os.path.join(os.path.dirname(__file__), "fixtures")
ANALYSIS_TS = os.path.join(ROOT, "src", "shared", "analysis.ts")
def _ts_interface_fields(name):
"""从 analysis.ts 里抽出某个 interface 的字段名集合。
只处理"一行一个字段"的写法(本仓库的风格),够用且不需要引 TS parser。
可选字段的 `?` 去掉后再比较。
"""
src = io.open(ANALYSIS_TS, encoding="utf-8").read()
m = re.search(r"export interface %s\s*\{(.*?)\n\}" % re.escape(name), src, re.S)
assert m, "analysis.ts 里找不到 interface %s" % name
fields = set()
for line in m.group(1).splitlines():
line = line.strip()
if not line or line.startswith("//") or line.startswith("*"):
continue
fm = re.match(r"([A-Za-z_][A-Za-z0-9_]*)\??\s*:", line)
if fm:
fields.add(fm.group(1))
assert fields, "interface %s 解析出 0 个字段,解析器该修了" % name
return fields
def _py_dataclass_fields(cls):
return {f.name for f in dataclasses.fields(cls)}
def _run_engine(extra=(), env=None):
# 默认关掉 hide_internal契约测试要断言 keys / shapes,
# filtered 之后的 functions 列表可能为空IndexError 挂掉。
p = subprocess.run(
[sys.executable, "-m", "engine.runner",
"--script", os.path.join(FX, "nested_calls.py"),
"--no-hide-internal", *extra],
capture_output=True, cwd=ROOT, env=env,
)
return p
def _engine_result():
p = _run_engine()
return json.loads(p.stdout.decode("utf-8").strip().splitlines()[-1])
def test_top_level_keys_match_typescript():
data = _engine_result()
assert set(data.keys()) == _ts_interface_fields("AnalysisResult")
def test_top_level_keys_match_python_dataclass():
data = _engine_result()
assert set(data.keys()) == _py_dataclass_fields(schema.AnalysisResult)
def test_wall_time_keys_match_both_sides():
data = _engine_result()
keys = set(data["wallTime"].keys())
assert keys == _ts_interface_fields("WallTime")
assert keys == _py_dataclass_fields(schema.WallTime)
def test_function_keys_match_both_sides():
data = _engine_result()
keys = set(data["functions"][0].keys())
assert keys == _ts_interface_fields("FunctionNode")
assert keys == _py_dataclass_fields(schema.FunctionNode)
def test_output_is_pure_ascii():
"""输出必须是纯 ASCII。
反模式说明现在是历史的;保留 ensure_ascii=True 后输出应当逐字节 ASCII
跨 locale 一致。这个回归保护防止有人"为可读性"改回 ensure_ascii=False。
"""
env = {k: v for k, v in os.environ.items()
if k not in ("PYTHONUTF8", "PYTHONIOENCODING")}
p = _run_engine(env=env)
raw = p.stdout
bad = [b for b in raw if b > 127]
assert not bad, "引擎输出含 %d 个非 ASCII 字节" % len(bad)
raw.decode("ascii") # 不该抛
def test_out_flag_writes_result_to_file(tmp_path):
"""--out结果走独立文件stdout 留给用户代码。
锁的是那个回归 —— 结果曾和用户输出共用 stdout一句不带换行的
sys.stdout.write 就能让合法 Python 报"结果解析失败"
"""
out = os.path.join(str(tmp_path), "result.json")
script = os.path.join(str(tmp_path), "chatty.py")
io.open(script, "w", encoding="utf-8", newline="\n").write(
'import sys\ndef f():\n sys.stdout.write("done")\n return 1\nf()\n'
)
p = subprocess.run(
[sys.executable, "-m", "engine.runner", "--script", script, "--out", out],
capture_output=True, cwd=ROOT,
)
assert os.path.exists(out), "引擎没写出结果文件stderr=%s" % p.stderr[-400:]
data = json.loads(io.open(out, encoding="utf-8").read())
assert data["status"] == "ok"
# 用户的输出确实进了 stdout而且没污染结果
assert b"done" in p.stdout
assert b"schemaVersion" not in p.stdout

View File

@@ -0,0 +1,77 @@
import pytest
from engine.harness import calibrate_cprofile_overhead, collect_environment, load_source
def test_calibrate_returns_well_formed_calibration():
"""校准应该返回一个 well-formed Calibration:ratio > 0,两段耗时 > 0,workloadName 已知。
不强求 ratio > 1.0:在极快的机器上校准本身 < 5ms,会走 fallback ratio=1.0;
此时 instrumentedWorkloadSec 可能比 clean 略小(perf_counter 在 1ms 量级有 noise),
也不强求 instrumented >= clean。这条测试只锁住「返回结构对 + 数值非负」。
"""
calib = calibrate_cprofile_overhead()
assert calib.ratio > 0, f"ratio 必须正:{calib.ratio}"
assert calib.ratio <= 10.0, f"ratio 太大,可能校准失效:{calib.ratio}"
assert calib.cleanWorkloadSec > 0
assert calib.instrumentedWorkloadSec > 0
assert calib.workloadName == "tight-loop"
def test_calibrate_fallback_when_workload_too_fast(monkeypatch):
"""校准本身耗时 < 5ms 时应回退 ratio=1.0 —— 见 calibrate_cprofile_overhead 的边界处理。
直接 mock calibrate_cprofile_overhead 内部的 _exec_once_* 行为不可行(它们是模块内 helper),
改成 monkeypatch time.perf_counter 返回极短耗时:第一次返回 t0=0.0,第二次返回 t0+1e-6。
"""
from engine import harness
counter = {"n": 0}
base = {"t": 1000.0}
def fake_perf_counter():
counter["n"] += 1
# 第一次调用返回 0,之后每次 + 极小 delta,让 elapsed = ~3us (远小于 5ms)
idx = counter["n"]
return base["t"] + idx * 1e-6
monkeypatch.setattr(harness.time, "perf_counter", fake_perf_counter)
calib = harness.calibrate_cprofile_overhead()
assert calib.ratio == 1.0, f"短耗时下应回退 ratio=1.0,实际:{calib.ratio}"
def test_calibrate_does_not_leak_files(monkeypatch, tmp_path):
"""校准自己 scratch 一个临时文件,跑完应清干净 —— 不污染系统临时目录。"""
# 让 mkstemp 落到 tmp_path 方便断言
import tempfile
monkeypatch.setattr(tempfile, "gettempdir", lambda: str(tmp_path))
before = set(tmp_path.iterdir())
calibrate_cprofile_overhead()
after = set(tmp_path.iterdir())
# 不应有残留 —— 之前 / 之后 iterdir 集合应相等
assert after == before, f"残留文件: {after - before}"
def test_collect_environment():
env = collect_environment()
assert env.python.count(".") >= 2
assert env.timerResolution > 0
def test_load_source_strips_bom(tmp_path):
"""load_source 必须剥掉 BOM —— 引擎内所有读源码的地方都依赖这一点。"""
script = tmp_path / "bom.py"
script.write_text("x = 1\n", encoding="utf-8-sig")
assert script.read_bytes().startswith(b"\xef\xbb\xbf")
src = load_source(str(script))
assert not src.startswith("")
# 剥离后必须能直接过 compile(),这正是 runner 语法预检做的事
compile(src, str(script), "exec")
def test_load_source_raises_on_binary(tmp_path):
"""二进制文件抛 UnicodeDecodeError而不是 OSError—— runner 依赖这个类型来归类错误。"""
script = tmp_path / "bin.py"
script.write_bytes(b"\xff\xfe\x00\x01\x80\x81")
with pytest.raises(UnicodeDecodeError):
load_source(str(script))

158
engine/tests/test_runner.py Normal file
View File

@@ -0,0 +1,158 @@
import json
import os
import subprocess
import sys
FX = os.path.join(os.path.dirname(__file__), "fixtures")
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
def _run(script, *extra):
# 默认关掉 hide_internal这些测试是结构 / 状态 / 形状的契约测试,
# 要看到 fixture 里所有 frame 才能断言。hide_internal 是 UI 默认开的产品行为,
# 不是引擎协议的一部分 —— 测协议就别测产品默认。
p = subprocess.run(
[sys.executable, "-m", "engine.runner", "--script", script,
"--no-hide-internal", *extra],
capture_output=True, text=True, cwd=ROOT,
)
assert p.stdout.strip(), f"no stdout; stderr={p.stderr}"
return json.loads(p.stdout.strip().splitlines()[-1])
def test_ok_run():
data = _run(os.path.join(FX, "nested_calls.py"))
assert data["status"] == "ok"
assert data["wallTime"]["seconds"] > 0
assert data["wallTime"]["unit"] == "s"
assert len(data["functions"]) >= 3
def test_syntax_error(tmp_path):
bad = tmp_path / "syntax_error.py"
bad.write_text("def f(:\n pass\n", encoding="utf-8")
data = _run(str(bad))
assert data["status"] == "syntax_error"
assert data["error"]["type"]
def test_runtime_error(tmp_path):
bad = tmp_path / "runtime_error.py"
bad.write_text("raise ValueError('boom')\n", encoding="utf-8")
data = _run(str(bad))
assert data["status"] == "runtime_error"
assert "boom" in data["error"]["message"]
def test_sys_exit_becomes_runtime_error(tmp_path):
"""sys.exit() 抛 SystemExitBaseException 子类),之前 except Exception 抓不到,
子进程裸退出无 JSON → UI 显示 "引擎无输出"。现在应被显式捕获并归为 runtime_error。"""
bad = tmp_path / "exit.py"
bad.write_text("import sys\nsys.exit(7)\n", encoding="utf-8")
data = _run(str(bad))
assert data["status"] == "runtime_error"
assert "SystemExit" in data["error"]["type"]
assert "7" in data["error"]["message"]
def test_io_error_returns_runtime_error(tmp_path):
"""脚本文件不存在 / 不可读时,不要让子进程裸崩 — 应该回 runtime_error 给出有用信息。"""
missing = tmp_path / "does_not_exist.py"
data = _run(str(missing))
assert data["status"] == "runtime_error"
assert "FileNotFoundError" in data["error"]["type"] or "无法读取" in data["error"]["message"]
def test_bom_script_runs_ok(tmp_path):
"""带 BOM 的 UTF-8 脚本Windows 记事本默认保存格式)必须能正常跑。
回归测试runner 曾用 encoding="utf-8" 读源码BOM 的  前缀残留下来,
语法预检的 compile() 直接抛 SyntaxError: invalid character用户看到的是
"语法错误"而不是正常结果 —— 而 harness.py 早就用 utf-8-sig 处理对了,
两处不一致导致这个 bug 只在预检阶段出现。
"""
script = tmp_path / "bom.py"
script.write_text("x = sum(range(1000))\n", encoding="utf-8-sig")
# 确认 fixture 真的带 BOM否则这个测试会静默失效
assert script.read_bytes().startswith(b"\xef\xbb\xbf")
data = _run(str(script))
assert data["status"] == "ok", f"BOM 脚本被误判: {data.get('error')}"
def test_binary_script_returns_runtime_error(tmp_path):
"""非 UTF-8 / 二进制文件应回结构化 runtime_error而不是让引擎裸崩。
回归测试UnicodeDecodeError 不是 OSError 的子类,之前只 except OSError
的那层保护抓不到它,子进程崩溃且无 JSON 输出 → UI 显示"引擎无输出"
"""
script = tmp_path / "binary.py"
script.write_bytes(b"\x00\x01\x02\xff\xfe\xfd binary garbage \x80\x81")
data = _run(str(script))
assert data["status"] in ("runtime_error", "syntax_error")
assert data["error"]["type"]
def test_scope_default_is_user():
"""默认 --scope=user —— 不给 scope 参数时只看到用户脚本里的函数。"""
script = os.path.join(FX, "with_imports.py")
data = _run(script)
assert data["status"] == "ok"
files = {f["file"] for f in data["functions"]}
# user 模式:不应出现 json 模块的内部帧(虽然 fixture 里 import 了 json
json_internal = {f for f in files if "json" in f and "decoder" in f or "json" in f and "encoder" in f}
assert not json_internal, f"默认 scope 应为 user但出现了 json 内部帧: {json_internal}"
def test_scope_all_includes_library_frames():
"""--scope=all让耗时可以下钻到 import 的包里 —— json 模块的函数也应进入结果。"""
script = os.path.join(FX, "with_imports.py")
data = _run(script, "--scope", "all")
assert data["status"] == "ok"
files = {f["file"] for f in data["functions"]}
# scope=all 时至少有一个 json 相关的文件路径进来 —— 说明 import 的包被归因了
has_json = any("json" in f for f in files)
assert has_json, f"scope=all 应包含 json 模块帧,实际 files: {files}"
def test_user_side_effects_run_once(tmp_path):
"""v4 单跑架构核心回归:用户脚本里的副作用只执行一次。
v3 时期 runner 跑两次(裸跑测 wall_time + cProfile 跑),
用户脚本里 plot()、print()、写文件等副作用也跟着跑两次 —— 经典"plot 弹两个窗口"
bug 的根因。v4 改成单次 exec(code) under cProfile + 校准系数折算 wall-time 后,
副作用应只发生 1 次。
检测手段:让用户脚本 append 一行到文件exec 一次就一行,跑两次就两行。
读 marker 文件的行数即可判断 exec 次数。
"""
marker = tmp_path / "marker.txt"
script = tmp_path / "s.py"
# 用 raw 字符串避 Windows 反斜杠麻烦append 模式保证两次跑不会被覆盖
script.write_text(
"with open(r'" + str(marker) + "', 'a', encoding='utf-8') as f:\n"
" f.write('X\\n')\n",
encoding="utf-8",
)
# 直接 spawn runner 子进程(不走 _run helper —— 那个 helper 是从 stdout 解析 JSON
# 的,我们这次主要观察 marker 文件)
out_path = tmp_path / "result.json"
p = subprocess.run(
[sys.executable, "-m", "engine.runner",
"--script", str(script), "--out", str(out_path)],
capture_output=True, text=True, cwd=ROOT,
)
assert p.returncode == 0, f"runner 失败:{p.stderr}"
assert marker.exists(), "用户脚本副作用根本没执行"
lines = marker.read_text(encoding="utf-8").splitlines()
assert len(lines) == 1, (
f"副作用被多次执行(找到 {len(lines)}v4 单跑架构回归:{lines}"
)
# 同时验证 result.json 里 calibration 字段在 + wallTime 被校准
result = json.loads(out_path.read_text(encoding="utf-8"))
assert result["status"] == "ok"
assert "calibration" in result and result["calibration"] is not None
assert result["calibration"]["ratio"] >= 1.0
assert result["wallTime"]["seconds"] > 0

View File

@@ -0,0 +1,21 @@
import json
from engine.schema import AnalysisResult, Environment, SCHEMA_VERSION, WallTime
def test_analysis_result_serializes_to_json():
r = AnalysisResult(
schemaVersion=SCHEMA_VERSION,
environment=Environment(python="3.9.19", platform="win32", processor="x86", timerResolution=1e-7),
config={},
status="ok",
error=None,
wallTime=WallTime(seconds=0.105, unit="s"),
functions=[],
flame=None,
)
data = json.loads(r.to_json())
assert data["schemaVersion"] == SCHEMA_VERSION
assert data["status"] == "ok"
assert data["wallTime"]["seconds"] == 0.105
assert data["environment"]["python"] == "3.9.19"

View File

@@ -0,0 +1,761 @@
import os
import pytest
from engine.schema import FunctionNode
from engine.structure import (
_build_flame,
_classify_origin,
_make_frame_filter,
_norm,
_top_module,
profile_structure,
)
def test_structure_finds_functions():
fx = os.path.join(os.path.dirname(__file__), "fixtures", "nested_calls.py")
res = profile_structure(fx, hide_internal=False)
names = {f.name for f in res.functions}
assert {"leaf", "mid", "main"} <= names
leaf = next(f for f in res.functions if f.name == "leaf")
assert leaf.tottime > 0 and leaf.ncalls >= 3
assert res.flame.value >= leaf.tottime
def test_structure_default_scope_is_user():
"""默认 scope=user —— 只看到用户脚本里的函数(向后兼容)。"""
fx = os.path.join(os.path.dirname(__file__), "fixtures", "nested_calls.py")
res = profile_structure(fx, hide_internal=False)
files = {f.file for f in res.functions}
# 只有 fixture 自身;不应出现标准库路径(<frozen ...> / <string> / site-packages 等)
non_user = {f for f in files if f not in (fx, "~") and not f.endswith("nested_calls.py")}
assert not non_user, f"user 模式下出现非用户脚本帧: {non_user}"
def test_structure_scope_all_includes_stdlib():
"""scope=all耗时可以下钻到 import 的包里,标准库 / 第三方包里的函数也进来。
注:`~` 在这里可能出现在 files 里 —— 它不再是"cProfile 内部"的同义词,
而是 cProfile 给所有 C 扩展函数json C accelerator / numpy C 核 / time.sleep
等)打的文件名。这些是真实工作,scope=all 必须保留。cProfile 内部帧靠 name
识别("_lsprof.Profiler" / "Profiler"),由 _make_frame_filter 剔除。"""
fx = os.path.join(os.path.dirname(__file__), "fixtures", "with_imports.py")
res = profile_structure(fx, scope="all", hide_internal=False)
files = {f.file for f in res.functions}
# fixture 里会 import json 和 time —— 至少其中一个 stdlib 路径应被纳入
has_stdlib = any("json" in f or "time" in f or f == "<frozen importlib>" for f in files)
assert has_stdlib, f"scope=all 应包含标准库帧,实际 files: {files}"
# cProfile 内部帧name 含 _lsprof.Profiler 子串)必须剔除 —— 但其它
# `~` 帧time.sleep / json C 加速器 / builtins.exec 等真实 C 扩展)必须保留。
cprofile_internal = [f for f in res.functions if "_lsprof.Profiler" in f.name]
assert not cprofile_internal, (
f"cProfile 内部帧不应漏进 functions{[(f.name, f.file) for f in cprofile_internal]}"
)
def test_norm_normalizes_case_and_slashes():
"""Windows (NTFS) 上同一文件可能被报成 `C:/Foo/Bar.py` 或 `c:\\foo\\BAR.py`
取决于谁产生的字符串。_norm 必须把两边都规范成同一种形式才能正确比
对脚本路径。"""
raw = "C:/Foo/Bar.py"
expected = os.path.normcase(os.path.abspath(raw))
assert _norm(raw) == expected
def test_user_frame_filter_is_case_and_slash_agnostic():
"""_make_frame_filter 对 frame.file 也要走 _norm —— 跨平台/跨斜杠风格
仍然能命中用户脚本。这条用例专门守回归:之前只用同一字符串匹配,
在 Windows 上大小写或斜杠不一致就会漏掉所有函数。"""
fx = os.path.join(os.path.dirname(__file__), "fixtures", "nested_calls.py")
is_kept = _make_frame_filter(fx)
# 把 frame.file 里的正反斜杠互换 —— 如果原始路径里有 '\\'
swapped = fx.replace("\\", "/") if "\\" in fx else fx.replace("/", "\\")
if swapped != fx:
assert is_kept((swapped, 1, "main")) is True
def test_frame_filter_excludes_cprofile_internal():
"""cProfile 内部帧_lsprof.Profiler 类成员file='~' 且 name 含 Profiler
永远被剔除 —— 不论 scope=user 还是 scope=all。
关键:「`~` == cProfile 内部」是错的 —— cProfile 用 `~` 表示所有 C 扩展函数
time.sleep / json C accelerator / numpy C 核这些必须留下。cProfile
自己靠 name 区分:'_lsprof.Profiler' / 'Profiler' / '<method ... of
'_lsprof.Profiler' objects>' 等。"""
fx = os.path.join(os.path.dirname(__file__), "fixtures", "nested_calls.py")
for scope in ("user", "all"):
is_kept = _make_frame_filter(fx, scope)
# 真实 cProfile 内部 name —— 必须被过滤
assert is_kept(("~", 0, "_lsprof.Profiler")) is False
assert is_kept(("~", 0, "Profiler")) is False
assert is_kept(("~", 0, "<method 'enable' of '_lsprof.Profiler' objects>")) is False
def test_frame_filter_keeps_c_extension_frames():
"""scope=allC 扩展函数帧file='~' + name 是真实 builtin必须留下 —
否则 numpy C 核 / json C 加速器 / time.sleep 等会从结果里消失,
scope=all 失去下钻意义。"""
fx = os.path.join(os.path.dirname(__file__), "fixtures", "nested_calls.py")
is_kept = _make_frame_filter(fx, "all")
# C 扩展(不是 cProfile 内部)—— 必须保留
assert is_kept(("~", 0, "<built-in method sleep of 'time' objects>")) is True
assert is_kept(("~", 0, "_default_encoder")) is True # json C accelerator
# scope=userC 帧不会进入用户脚本 —— 也过滤掉(与历史行为一致)
is_kept_user = _make_frame_filter(fx, "user")
assert is_kept_user(("~", 0, "<built-in method sleep of 'time' objects>")) is False
def test_frame_filter_scope_all_keeps_any_non_internal_frame():
"""scope=all只要不是 cProfile 内部(~)就保留 —— 用户脚本、stdlib、第三方都进。"""
fx = os.path.join(os.path.dirname(__file__), "fixtures", "nested_calls.py")
is_kept = _make_frame_filter(fx, "all")
# 任意"非 ~"路径都应通过
assert is_kept(("C:/Python39/Lib/json/decoder.py", 100, "decode")) is True
assert is_kept(("/some/site-packages/pandas/core/frame.py", 1, "from_records")) is True
def test_top_module_user_script():
"""用户脚本(==user_script_path"<user>""""
fx = os.path.join(os.path.dirname(__file__), "fixtures", "nested_calls.py")
assert _top_module(fx, fx) == "<user>"
def test_top_module_user_script_case_and_slash_agnostic():
"""_top_module 比较 user_script_path 时走 _norm —— Windows 大小写 / 斜杠差异不影响。"""
fx = os.path.join(os.path.dirname(__file__), "fixtures", "nested_calls.py")
swapped = fx.replace("\\", "/") if "\\" in fx else fx.replace("/", "\\")
if swapped != fx:
assert _top_module(swapped, fx) == "<user>"
def test_top_module_stdlib_windows():
"""Windows 标准库C:\\Python39\\Lib\\json\\decoder.py → json"""
assert _top_module(r"C:\Python39\Lib\json\decoder.py", "<user>") == "json"
assert _top_module(r"C:\Python39\Lib\json\__init__.py", "<user>") == "json"
# 多层嵌套Lib/site-packages 这种 dev 布局也行
assert _top_module(r"C:\Python39\Lib\site-packages\foo\bar.py", "<user>") == "foo"
def test_top_module_top_level_py_strips_extension():
"""顶层 .py 文件必须剥掉扩展名 —— `Lib\\functools.py` 是模块 functools 而不是 `functools.py`。
之前 bug直接返回 `parts[idx]`(即 `functools.py`UI 分组里看到一坨文件名而不是模块名。"""
assert _top_module(r"C:\Python39\Lib\functools.py", "<user>") == "functools"
# Linux 顶层 stdlib
assert _top_module("/usr/lib/python3.11/functools.py", "<user>") == "functools"
# __init__ 不剥(不是 .py 后缀)—— 顶层 `Lib\json\__init__.py` 走 _strip_py 时返回 "json"
# (这里 _strip_py 不会动 __init__但 parts[idx] 已经是 "json\__init__.py" 这种,
# 实际代码路径是先取 parts[idx] 再 _strip_py所以 "json\__init__.py" → "json\__init__"
# 这里的关键是 module 名不是 filename-with-py-extension
result = _top_module(r"C:\Python39\Lib\functools.py", "<user>")
assert not result.endswith(".py"), f"module 名不能带 .py 后缀,实际 {result!r}"
def test_top_module_stdlib_linux():
"""Linux/macOS 标准库:/usr/lib/python3.X/<pkg>/... → <pkg>"""
assert _top_module("/usr/lib/python3.11/json/decoder.py", "<user>") == "json"
assert _top_module(
"/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/json/decoder.py",
"<user>",
) == "json"
def test_top_module_site_packages():
"""site-packages / dist-packages标记后的第一个目录段就是包名。"""
assert _top_module(
"/usr/lib/python3.11/site-packages/numpy/core/array.py", "<user>"
) == "numpy"
assert _top_module(
"C:\\Python39\\Lib\\site-packages\\pandas\\core\\frame.py", "<user>"
) == "pandas"
# dist-packagesDebian 系)
assert _top_module(
"/usr/lib/python3.11/dist-packages/requests/api.py", "<user>"
) == "requests"
def test_top_module_frozen():
"""frozen / built-in / <string> 虚拟帧 → 整段保留作为标签。"""
assert _top_module("<frozen importlib._bootstrap>", "<user>") == "<frozen>"
assert _top_module("<built-in method exec>", "<user>") == "<built-in>"
assert _top_module("<string>", "<user>") == "<string>"
def test_top_module_c_extension():
"""`~` 在 cProfile 里代表所有 C 扩展函数numpy C 核 / json 加速器 /
time.sleep 等)。这些 frame 现在 scope=all 会留下(不再被一刀切),需要给个
有意义的 module 标签 —— 用 "<built-in>" 跟 _classify_origin 的 builtin
字段保持一致。"""
assert _top_module("~", "<user>") == "<built-in>"
# user_script_norm 也要匹配上 user 脚本 —— 防御性
fx = os.path.join(os.path.dirname(__file__), "fixtures", "nested_calls.py")
assert _top_module("~", fx, _norm(fx)) == "<built-in>"
def test_top_module_fallback_dirname():
"""未匹配任何标记 → 取倒数第二段目录。"""
# 例如:".../someproj/src/utils/helper.py" → "utils"
assert _top_module("/path/to/someproj/src/utils/helper.py", "<user>") == "utils"
def test_profile_structure_populates_module():
"""profile_structure 给每条 fn 算 modulefixture 是用户脚本,全部 <user>。"""
fx = os.path.join(os.path.dirname(__file__), "fixtures", "nested_calls.py")
res = profile_structure(fx, hide_internal=False)
assert res.functions
modules = {f.module for f in res.functions}
assert modules == {"<user>"}, f"默认 scope 下应只有 <user>,实际 {modules}"
def test_profile_structure_scope_all_populates_real_modules():
"""scope=allfn.module 应来自真实的 stdlib / 第三方包。"""
fx = os.path.join(os.path.dirname(__file__), "fixtures", "with_imports.py")
res = profile_structure(fx, scope="all", hide_internal=False)
modules = {f.module for f in res.functions}
# fixture 里 import 了 json / time至少其中一个 stdlib 应出现
assert "<user>" in modules, f"用户脚本应在模块集合里,实际 {modules}"
assert any(m in ("json", "time", "<frozen>", "<built-in>") for m in modules), (
f"scope=all 应有 stdlib 模块,实际 {modules}"
)
def _mk_fn(name: str, module: str, tottime: float) -> FunctionNode:
"""测试用 FunctionNode 工厂 —— 关注 module / tottime其他字段填占位。"""
return FunctionNode(
id=f"f.py:1:{name}",
file="f.py",
line=1,
name=name,
cumtime=tottime,
tottime=tottime,
ncalls=1,
percallTot=tottime,
module=module,
)
def test_build_flame_single_module_stays_flat():
"""单模块时scope=user 默认情况)保持扁平 —— 同名兄弟不会被 module
节点挤占,截断阈值不变,向后兼容。"""
fns = [
_mk_fn("main", "<user>", 0.5),
_mk_fn("mid", "<user>", 0.3),
_mk_fn("leaf", "<user>", 0.2),
]
flame = _build_flame(fns, total=1.0)
assert flame.name == "root"
assert flame.value == 1.0
# 扁平root.children 是函数名(不是 module
assert [c.name for c in flame.children] == ["main", "mid", "leaf"]
# 每个函数 tile 没有 children不会再下钻一层
for child in flame.children:
assert child.children == []
def test_build_flame_multi_module_groups_by_module():
"""多模块scope=all 且 import 多个包root.children 是 module 节点,
每个 module 节点的 children 是该模块下的函数。"""
fns = [
_mk_fn("main", "<user>", 0.5),
_mk_fn("decode", "json", 0.3),
_mk_fn("loads", "json", 0.2),
_mk_fn("sleep", "time", 0.1),
]
flame = _build_flame(fns, total=1.1)
assert flame.name == "root"
# module 节点按总 tottime 降序:<user>(0.5) > json(0.5) > time(0.1)
# <user> 和 json 都是 0.5,排序稳定时顺序取决于字典遍历顺序 —— 这里不强制
# 顺序,只验证排序结果一致(用 sorted() 拿到一组)
module_nodes = flame.children
assert len(module_nodes) == 3
# 第一个必须是最大的 <user>0.5 严格大于 json 的 0.5,因为总和 tie-break
# 由 sorted 的 stable 行为兜底:<user> 在 fns 里排前面 → 同值时排前)
assert module_nodes[0].name == "<user>"
assert module_nodes[0].value == pytest.approx(0.5)
# json 模块 value = 0.3 + 0.2 = 0.5
json_node = next(c for c in module_nodes if c.name == "json")
assert json_node.value == pytest.approx(0.5)
assert sorted(c.name for c in json_node.children) == ["decode", "loads"]
# time 模块只有 sleep
time_node = next(c for c in module_nodes if c.name == "time")
assert time_node.value == pytest.approx(0.1)
assert [c.name for c in time_node.children] == ["sleep"]
def test_build_flame_empty_returns_empty_root():
"""没函数时不爆 —— root.value=0、children=空列表。"""
flame = _build_flame([], total=0.0)
assert flame.name == "root"
assert flame.value == 0.0
assert flame.children == []
# --- origin 分类v3 新增) -----------------------------------------------------
def test_classify_origin_user_script_matches_norm():
"""用户脚本路径_norm 后)与 user_script_path 一致 → user。"""
fx = os.path.join(os.path.dirname(__file__), "fixtures", "nested_calls.py")
assert _classify_origin(fx, fx, "<user>") == "user"
swapped = fx.replace("\\", "/") if "\\" in fx else fx.replace("/", "\\")
if swapped != fx:
assert _classify_origin(swapped, fx, "<user>") == "user"
def test_classify_origin_frozen_and_builtin():
"""frozen / built-in 虚拟帧 → frozen / builtin。"""
assert _classify_origin("<frozen importlib._bootstrap>", "/x.py", "<frozen>") == "frozen"
assert _classify_origin("<built-in method exec>", "/x.py", "<built-in>") == "builtin"
# 其它 <...> 兜底 other
assert _classify_origin("<string>", "/x.py", "<string>") == "other"
def test_classify_origin_stdlib_via_sys_stdlib_module_names():
"""module_name 在 sys.stdlib_module_names 里 → stdlib权威路径"""
# json / os / re / sys / pathlib 都肯定在 stdlib 里
assert _classify_origin("C:/Python311/Lib/json/decoder.py", "/x.py", "json") == "stdlib"
assert _classify_origin("/usr/lib/python3.11/pathlib/__init__.py", "/x.py", "pathlib") == "stdlib"
def test_classify_origin_third_party_site_packages():
"""site-packages / dist-packages 下的包 → third_party即使包名可能撞车"""
assert _classify_origin(
"/usr/lib/python3.11/site-packages/numpy/core/array.py", "/x.py", "numpy"
) == "third_party"
assert _classify_origin(
"C:\\Python39\\Lib\\site-packages\\pandas\\core\\frame.py", "/x.py", "pandas"
) == "third_party"
# dist-packagesDebian 系)
assert _classify_origin(
"/usr/lib/python3.11/dist-packages/requests/api.py", "/x.py", "requests"
) == "third_party"
def test_classify_origin_stdlib_path_heuristic_fallback():
"""sys.stdlib_module_names 漏判时,只要路径真的在 sysconfig 给的 stdlib 根
下,就该归为 stdlib —— 即使模块名未知。
之前 bugM5:只看路径里含 "/Lib/" 子串就当 stdlib,结果用户项目里
/home/x/myproject/Lib/foo.py 被误认成 stdlib。现在锚到 sysconfig 的真根。
"""
import sysconfig
real_stdlib = sysconfig.get_paths().get("stdlib") or os.path.join(os.sep, "Lib")
# 构造一个 fake 包路径,放在真实 stdlib 根下面 —— 必须真在那个目录里
fake_pkg = os.path.join(real_stdlib, "_weirdstdlib_pkg_for_test")
# 文件不存在也没事 —— _is_under_real_stdlib 只比对 abspath,不要求文件真存在
assert _classify_origin(fake_pkg, "/x.py", "_weirdstdlib_pkg_for_test") == "stdlib"
# 反向用例:用户在 /home/x/myproject/Lib/foo.py 下,即使路径含 "/Lib/" 也不该
# 误判成 stdlib —— 必须真的在 sysconfig 那个根下面
assert _classify_origin(
"/home/user/myproject/Lib/foo.py", "/x.py", "foo"
) == "other"
assert _classify_origin(
"/home/user/myproject/lib/python3.11/foo.py", "/x.py", "foo"
) == "other"
def test_classify_origin_unknown_path_is_other():
"""未匹配的奇怪路径 → other。"""
assert _classify_origin(
"/path/to/someproj/src/utils/helper.py", "/x.py", "utils"
) == "other"
def test_profile_structure_populates_origin():
"""profile_structure 给每条 fn 算 originfixture 是用户脚本,全部 user。"""
fx = os.path.join(os.path.dirname(__file__), "fixtures", "nested_calls.py")
res = profile_structure(fx, hide_internal=False)
origins = {f.origin for f in res.functions}
assert origins == {"user"}, f"默认 scope 下 origin 应全是 user实际 {origins}"
def test_profile_structure_scope_all_classifies_by_origin():
"""scope=all用户脚本帧归 userimport 的 json 帧归 stdlib —— 至少这两个 origin 都有。"""
fx = os.path.join(os.path.dirname(__file__), "fixtures", "with_imports.py")
res = profile_structure(fx, scope="all", hide_internal=False)
origins = {f.origin for f in res.functions}
# 用户脚本帧必有
assert "user" in origins
# fixture 显式 import json → 必有 stdlib 帧
assert "stdlib" in origins, f"json import 后应有 stdlib 帧,实际 origins: {origins}"
# 没 origin 字段缺失 / 其它 origin 兜底值(如 cprofile混进来的情况
assert "cprofile" not in origins, f"cProfile 内部帧不应漏进 origin实际 {origins}"
# 所有 origin 必须是已知值之一
KNOWN = {"user", "stdlib", "third_party", "builtin", "frozen", "other"}
assert origins <= KNOWN, f"未知 origin 混进来:{origins - KNOWN}"
# ──────────────────────────────────────────────────────────────────────────────
# v5 噪声过滤 —— 测试代码 + tottime=0 默认剔除
# ──────────────────────────────────────────────────────────────────────────────
#
# 用户场景:scope=all 时 cProfile 报告里夹带大量测试代码(numpy.tests.test_xxx /
# pytest fixture / unittest runner 等)和 tottime=0 的内部调用展开(typing / inspect /
# functools 等)。这些不是优化目标,默认应该剔除,result.json 一开始就是干净的。
# 用户原话:"软件内部的测试部分默认百分百过滤掉,不在统计范围内容"。
from engine.structure import _is_internal_test
def test_is_internal_test_recognizes_pytest_tests_dir():
# /tests/ /test/ /__tests__/ 目录下的函数 —— pytest 标准约定
assert _is_internal_test("helper", "/p/foo/tests/x.py", "foo.tests")
assert _is_internal_test("helper", "/p/foo/test/x.py", "foo.test")
assert _is_internal_test("helper", "/p/foo/__tests__/x.py", "foo.__tests__")
def test_is_internal_test_recognizes_pytest_file_naming():
# test_xxx.py / xxx_test.py —— pytest 文件命名约定
assert _is_internal_test("f", "/p/test_foo.py", "foo")
assert _is_internal_test("f", "/p/foo_test.py", "foo")
assert _is_internal_test("f", "/p/tests/test_bar.py", "foo")
def test_is_internal_test_recognizes_tests_module_path():
# 模块路径就是 tests / test / __tests__,或它们的子模块
assert _is_internal_test("f", "p/x.py", "tests")
assert _is_internal_test("f", "p/x.py", "test")
assert _is_internal_test("f", "p/x.py", "__tests__")
assert _is_internal_test("f", "p/x.py", "tests.foo")
assert _is_internal_test("f", "p/x.py", "test.bar")
assert _is_internal_test("f", "p/x.py", "__tests__.baz")
def test_is_internal_test_hardcoded_engine_whitelist():
# _pyrof_calib —— 引擎内部白名单,任何情况下都算内部
assert _is_internal_test("_pyrof_calib", "/p/anything.py", "<user>")
assert _is_internal_test("_pyrof_calib", "/p/", "weird_module")
def test_is_internal_test_does_not_match_function_names():
# 关键:只看语境不看函数名 —— 函数名匹配太容易误伤
assert not _is_internal_test("test_helper", "<user>", "a.py")
assert not _is_internal_test("test_visualization", "<user>", "/p/app/main.py")
assert not _is_internal_test("TestFoo.test_foo", "<user>", "/p/myapp/svc.py")
assert not _is_internal_test("MyClass.test_count", "<user>", "a.py")
assert not _is_internal_test("UserService.test_login", "<user>", "a.py")
def test_is_internal_test_does_not_match_test_substring_in_module():
# testing / dataset / contest 等含 test 子串的合法模块名
assert not _is_internal_test("f", "p/x.py", "testing")
assert not _is_internal_test("f", "p/x.py", "dataset")
assert not _is_internal_test("f", "p/x.py", "contest")
def test_is_internal_test_does_not_match_standalone_test_py():
# test.py / tests.py —— 项目入口常这么命名
assert not _is_internal_test("main", "<user>", "/p/test.py")
assert not _is_internal_test("main", "<user>", "/p/tests.py")
def test_is_internal_test_does_not_match_tests_subdir_non_test_files():
"""回归测试:tests/ 子目录下的非测试文件不应当成测试代码剔除。
之前用宽匹配 `[\\/](tests?|__tests__)[\\/]` —— 任何含 `/tests/` 的路径都中招,
包括 `tests/fixtures/x.py` 这种测试数据夹具。结果用户脚本只要住在带 tests/
子目录的路径下,functions 表直接被过滤成 0 行。
收紧到「tests/ 后面紧接文件名,不能再有 / 子目录」后:
- tests/foo.py → 算测试代码 (foo.py 直接在 tests/ 下)
- tests/fixtures/foo.py → 不算 (fixtures 是子目录,foo.py 不挂在 tests/ 下)
- tests/data/sample.py → 不算 (data 是子目录)
- tests/__init__.py / conftest.py → 仍走模块兜底命中 (module == "tests")
"""
# 子目录下的非测试文件 —— 不应被过滤
assert not _is_internal_test("f", "/p/tests/fixtures/x.py", "fixtures")
assert not _is_internal_test("f", "/p/foo/tests/data/sample.py", "data")
assert not _is_internal_test("f", "/p/mine/tests/integration/helper.py", "integration")
# tests/ 直接下面的文件 —— 仍走 dir 兜底命中
assert _is_internal_test("f", "/p/tests/foo.py", "tests")
def test_profile_structure_default_filters_zero_time_and_test_code():
# scope=all + 默认过滤:测试代码 + tottime=0 不进 functions[]
fx = os.path.join(os.path.dirname(__file__), "fixtures", "nested_calls.py")
res = profile_structure(fx, scope="all")
assert all(f.tottime > 0 for f in res.functions), (
f"默认过滤下不应有 tottime=0 的帧: {[f.name for f in res.functions if f.tottime == 0]}"
)
for f in res.functions:
assert not _is_internal_test(f.name, f.file, f.module), (
f"默认过滤下不应有测试代码帧: {f.name} ({f.module})"
)
def test_profile_structure_hide_internal_false_keeps_more():
# hide_internal=False 时不过滤 —— result 含全量数据
fx = os.path.join(os.path.dirname(__file__), "fixtures", "nested_calls.py")
res_full = profile_structure(fx, scope="all", hide_internal=False)
res_filtered = profile_structure(fx, scope="all", hide_internal=True)
assert len(res_full.functions) >= len(res_filtered.functions)
# ──────────────────────────────────────────────────────────────────────────────
# v6 stdlib 间接调用链过滤 —— typing/inspect/functools 等默认剔除
# ──────────────────────────────────────────────────────────────────────────────
#
# 用户场景scope=all 时 numpy 这类第三方包会通过类型注解 / importlib 钩子触发
# typing / inspect / functools / re / _py_warnings / ctypes / enum 等几十个 stdlib
# 内部帧。这些帧 tottime 通常在 1µs~1ms 之间(高于 cProfile 量化地板但低于噪声线),
# 既不是优化目标也不是用户写的代码。默认剔除,只保留用户脚本里显式 import 的
# stdlib 模块帧 —— 「用户能看到『自己 import 了什么』,而不是『numpy 的依赖图』」。
from engine.structure import _extract_user_imports
def test_extract_user_imports_simple_import():
src = "import json\nimport os\nx = 1\n"
assert _extract_user_imports(src) == {"json", "os"}
def test_extract_user_imports_from_import():
src = "from collections import OrderedDict\nfrom os.path import join\nx = 1\n"
# 顶级包名进集合,不展开子模块
assert _extract_user_imports(src) == {"collections", "os"}
def test_extract_user_imports_dotted():
src = "import numpy as np\nimport a.b.c\nx = 1\n"
# 取顶级包名,as 别名不进集合
assert _extract_user_imports(src) == {"numpy", "a"}
def test_extract_user_imports_inside_function_ignored():
"""函数/类内部的 import 不算 —— 它们是延迟副作用,不是「我需要分析的目标」。"""
src = "import json\ndef f():\n import typing\n return typing\n"
assert _extract_user_imports(src) == {"json"}
def test_extract_user_imports_relative_skipped():
"""from . import x 是相对导入 —— 依附于已知包,跳过。"""
src = "from . import utils\nimport json\n"
assert _extract_user_imports(src) == {"json"}
def test_extract_user_imports_syntax_error_returns_empty():
# 语法错误的源码(走不到 profile_and_measure,这里只是防御)
assert _extract_user_imports("def f(:\n pass") == set()
def test_profile_structure_default_filters_indirect_stdlib(tmp_path):
"""scope=all 默认应剔除间接 stdlib 调用链 —— 用户显式 import 的保留,其他剔除。
fixture 设计:用户只 import json + os.path + collections,显式调用 list() +
json.dumps + os.path.join。间接触发 typing/inspect/functools/_py_warnings 的
帧(来自 cProfile 内部 / 解释器启动 / json 自身依赖)不应出现在 result 里。
"""
script = tmp_path / "user.py"
script.write_text(
"import json\n"
"import os.path\n"
"from collections import OrderedDict\n"
"x = json.dumps(OrderedDict([('a', 1)]))\n"
"y = os.path.join('a', 'b')\n"
"z = list(range(10))\n",
encoding="utf-8",
)
res = profile_structure(str(script), scope="all")
modules = {f.module for f in res.functions}
# 用户显式 import 的 stdlib 模块保留
assert "json" in modules, f"json 应保留(用户显式 import实际 {modules}"
# 间接触发的 stdlib 内部模块剔除
leak = modules & {"typing", "inspect", "functools", "_py_warnings", "re",
"annotationlib", "ctypes", "enum", "warnings"}
assert not leak, f"默认应剔除间接 stdlib 调用链,但 {leak} 漏进来了"
def test_profile_structure_hide_internal_false_keeps_indirect_stdlib(tmp_path):
"""--no-hide-internal间接 stdlib 帧也保留,排查调用栈时用得到。
fixture 用 traceback.print_exc() —— 它在 CPython 3.11+ 实现里会触发 inspect /
annotationlib / linecache / tokenize / dataclasses / enum / reprlib / textwrap /
ast / codeop / contextlib 等十几 stdlib 内部模块(都是 Python 层面的,而非 C)。
fixture 只显式 import traceback,其他都是「间接调用链」—— hide_internal=True
时它们被剔除,hide_internal=False 时重新出现。对照组验证 v6 过滤真的生效。
"""
script = tmp_path / "user.py"
script.write_text(
"import traceback\n"
"def f():\n"
" return 1 / 0\n"
"for i in range(50):\n"
" try:\n"
" f()\n"
" except Exception:\n"
" traceback.print_exc()\n",
encoding="utf-8",
)
res_on = profile_structure(str(script), scope="all", hide_internal=True)
res_off = profile_structure(str(script), scope="all", hide_internal=False)
on_modules = {f.module for f in res_on.functions}
off_modules = {f.module for f in res_off.functions}
# 关掉过滤时应恢复间接 stdlib 模块 —— inspect 是 traceback 的核心依赖,必然出现
extra = off_modules - on_modules
stdlib_noise = {"inspect", "annotationlib", "linecache", "tokenize",
"dataclasses", "enum", "reprlib", "textwrap", "ast",
"codeop", "contextlib"}
assert extra & stdlib_noise, (
f"--no-hide-internal 应恢复 traceback 的间接 stdlib 调用链帧,"
f"但 on vs off 的 module 差异 {extra} 里没出现 {stdlib_noise}"
f"v6 过滤可能没生效,或者测试 fixture 没触发干扰。"
)
# hide_internal=True 时不应漏出 inspect / annotationlib / linecache 等间接帧
leak = on_modules & stdlib_noise
assert not leak, (
f"hide_internal=True 时不应漏出间接 stdlib 噪声,但 {leak} 出现了。"
f"这意味着 fixture 没显式 import 它们,却进了 result.json。"
)
# ──────────────────────────────────────────────────────────────────────────────
# v7 「用户直接调用」过滤 —— 只保留用户代码 + 用户直接调用的 import 入口
# ──────────────────────────────────────────────────────────────────────────────
#
# 用户原话:「只要代码的本身和import 调用的耗时统计,其他的不需要」/「目前好像
# 仍然统计到内部测试的代码了,不合理」—— 之前的间接 stdlib 过滤解决了 stdlib 噪声,
# 但 487 帧里仍有 numpy 内部 250+ 帧、<built-in> C 函数 131 帧、<frozen> importlib
# 90 帧等「不是用户调用的」帧。v7 把规则收紧到「caller 链含用户帧才算 import 调用」,
# 并加 cumtime >= 1µs 闸门(剔除纯注册/分发占位函数如 _mean_dispatcher)。
#
# 测试矩阵:
# - 用户写的函数 → 保留
# - 用户直接调用的第三方函数 → 保留
# - 用户调用的第三方函数内部又调的第三方函数 → 剔除
# - 用户调用的第三方函数内部又调的 stdlib(非用户 import)→ 剔除
# - <built-in> C 函数 → 剔除(不是 import 调用)
# - <frozen> importlib → 剔除(不是 import 调用)
# - 用户没 import 的 stdlib 帧 → 剔除
def test_profile_structure_default_keeps_only_user_and_direct_calls(tmp_path):
"""默认过滤:只保留用户代码 + 用户直接调用的 third-party / 用户 import 的 stdlib。
fixture 用 json + 一个明显干活的 helper(json.dumps 大循环),结果应只有:
- 用户模块(脚本本身)
- 用户函数 helper
- 用户直接调的 json.dumps
不应出现:json.decoder / json.scanner / re / typing / inspect 等内部帧。
"""
script = tmp_path / "user.py"
script.write_text(
"import json\n"
"def helper(data):\n"
" return json.dumps(data)\n"
"result = helper({'a': list(range(100))})\n",
encoding="utf-8",
)
res = profile_structure(str(script), scope="all")
modules = {f.module for f in res.functions}
names = {f.name for f in res.functions}
# 1) 用户脚本本身
assert "<user>" in modules, f"用户代码应保留,实际 modules: {modules}"
assert "<module>" in names and "helper" in names
# 2) 用户直接调用的 json.dumps
assert "json" in modules, f"json 是用户显式 import 且被直接调用,应保留"
assert "dumps" in names, "用户直接调用 json.dumps,应保留"
# 3) json 内部帧被剔除(json.decoder / encoder / scanner 等)
json_internal_leak = {"decode", "encode", "scanstring", "JSONDecoder",
"JSONEncoder", "__init__"}
bad = json_internal_leak & names
assert not bad, f"json 内部帧不应漏出,但 {bad} 出现了"
# 4) stdlib 内部展开(typing/inspect/re 等)不应出现
leak = modules & {"typing", "inspect", "re", "_py_warnings", "functools"}
assert not leak, f"间接 stdlib 噪声应剔除,但 {leak} 漏进了"
def test_profile_structure_default_drops_indirect_nested_calls(tmp_path):
"""用户调用 numpy.mean,但 numpy.mean 内部调的 numpy._core._methods 不应出现。
这是 v7 的核心语义:只看「直接被用户调用」,不传递。哪怕是同包内部的辅助函数,
只要不是用户写的,就不在 result 里。
"""
script = tmp_path / "user.py"
script.write_text(
"import numpy as np\n"
"x = np.array([1, 2, 3, 4, 5])\n"
"y = np.mean(x)\n",
encoding="utf-8",
)
res = profile_structure(str(script), scope="all")
names = {f.name for f in res.functions}
# 用户直接调用的入口应保留
assert "mean" in names, f"用户直接调用 np.mean,应保留,实际 names: {names}"
# numpy 内部辅助方法不应出现(_methods / fromnumeric 之类的子帧)
# 注:numpy.mean 实际上调用 numpy._core.fromnumeric.mean,我们想要的是 fromnumeric.mean
# 出现在 result 里(因为它是 user-direct call 的代理),但其内部又调了 _methods
# 这种,不该出现。
bad = {"_methods", "_sum", "_mean", "_std", "_var"}
bad &= names
assert not bad, f"numpy 内部辅助函数不应漏出,但 {bad} 出现了"
def test_profile_structure_default_drops_builtin_and_frozen(tmp_path):
"""<built-in> C 函数 / <frozen> importlib 即使被用户代码用到也不保留。
用户原意是「import 调用」,builtin C 函数(len / dict.keys / numpy C 核等)
和 frozen import machinery 不属于「import 调用」—— 即使技术上被用户代码调到。
"""
script = tmp_path / "user.py"
script.write_text(
"import numpy as np\n"
"data = [1, 2, 3, 4, 5]\n"
"x = np.array(data)\n"
"y = len(data)\n"
"z = sorted(data)\n",
encoding="utf-8",
)
res = profile_structure(str(script), scope="all")
modules = {f.module for f in res.functions}
# <built-in> 完全不应该出现 —— 不是 import 调用
assert "<built-in>" not in modules, (
f"<built-in> C 函数不应作为 import 调用保留,实际 modules: {modules}"
)
# <frozen> 也不应出现
assert "<frozen>" not in modules, (
f"<frozen> importlib 帧不应保留,实际 modules: {modules}"
)
def test_profile_structure_default_keeps_user_function_with_no_args(tmp_path):
"""用户写的不带参数的函数(只调用 import)仍应保留 —— 用户代码一律保留。
这是 v7 的关键 invariant:用户帧不管 tottime/cumtime 多小,不管有没有调用
别的东西,都进 result —— 这是「代码本身」的定义。
"""
script = tmp_path / "user.py"
script.write_text(
"import json\n"
"def tiny():\n"
" return json.dumps({})\n"
"tiny()\n",
encoding="utf-8",
)
res = profile_structure(str(script), scope="all")
user_fns = {f.name for f in res.functions if f.module == "<user>"}
assert "tiny" in user_fns, f"用户函数 tiny 应保留,实际 user fns: {user_fns}"
def test_profile_structure_default_cumtime_floor_drops_dispatcher(tmp_path):
"""cumtime < 1µs 的 entry point 视为注册期/分发期占位 → 剔除。
numpy 在用户 import 时会触发 _mean_dispatcher / _std_dispatcher 等纯注册函数
(cProfile 把 caller 记成 <module>,但 cumtime 几乎为 0)。这些不是真正的
「用户 import 调用」,默认剔除。
"""
script = tmp_path / "user.py"
script.write_text(
"import numpy as np\n"
"y = np.mean([1, 2, 3])\n",
encoding="utf-8",
)
res = profile_structure(str(script), scope="all")
# _mean_dispatcher 是注册期占位,cumtime 应 < 1µs
dispatcher_frames = [f for f in res.functions if "_dispatcher" in f.name]
assert not dispatcher_frames, (
f"_dispatcher 注册占位函数应剔除(它们是 import 期触发的分发器,"
f"cumtime < 1µs),但 {[(f.name, f.cumtime) for f in dispatcher_frames]} 出现了"
)

BIN
icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

10589
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

83
package.json Normal file
View File

@@ -0,0 +1,83 @@
{
"name": "python-profiler-visualizer",
"version": "0.1.0",
"description": "粘贴 Python 代码,科学地可视化各部分耗时并给出可优化点",
"main": "out/main/index.js",
"author": "guanjihuan",
"license": "MIT",
"scripts": {
"dev": "electron-vite dev",
"build": "electron-vite build",
"start": "electron-vite preview",
"typecheck": "tsc --noEmit",
"lint": "eslint . --ext .ts,.tsx,.cjs",
"lint:fix": "eslint . --ext .ts,.tsx,.cjs --fix",
"format": "prettier --write \"**/*.{ts,tsx,css,json,md}\"",
"format:check": "prettier --check \"**/*.{ts,tsx,css,json,md}\"",
"test": "vitest run",
"test:watch": "vitest",
"fixture:golden": "node scripts/regen-golden.mjs",
"pretest:e2e": "electron-vite build",
"test:e2e": "playwright test",
"release": "npm run build && electron-builder --win --x64"
},
"engines": {
"node": ">=20",
"npm": ">=10"
},
"build": {
"appId": "com.guanjihuan.python-profiler-visualizer",
"productName": "Python Profiler Visualizer",
"directories": {
"output": "release"
},
"files": [
"out/**/*",
"package.json",
"icon.ico"
],
"win": {
"target": [
{
"target": "nsis",
"arch": [
"x64"
]
}
],
"icon": "icon.ico"
}
},
"dependencies": {
"@monaco-editor/react": "^4.6.0",
"monaco-editor": "^0.52.0"
},
"devDependencies": {
"@playwright/test": "^1.46.0",
"@testing-library/jest-dom": "^6.4.8",
"@testing-library/react": "^16.0.1",
"@types/node": "^20.14.0",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.19",
"electron": "^31.3.1",
"electron-builder": "^24.13.3",
"electron-vite": "^2.3.0",
"eslint": "^8.57.1",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^4.6.2",
"jsdom": "^24.1.1",
"postcss": "^8.4.40",
"prettier": "^3.9.6",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"tailwindcss": "^3.4.7",
"typescript": "^5.5.4",
"vite": "^5.3.5",
"vitest": "^2.0.5"
}
}

34
playwright.config.ts Normal file
View File

@@ -0,0 +1,34 @@
import { defineConfig, devices } from '@playwright/test'
/**
* Playwright 配置。
*
* 几个值得说明的选择:
* - fullyParallel: false + workers: 1每个测试启动一个完整 Electron 实例,几百兆内存。
* 真机 CI 上多 worker 容易 OOM 也不见得更快。
* - reporter: [['list'], ['html', { open: 'never' }]]list 走 stdout 让人看html
* 写 report/ 让人事后翻CI 上传 artifact 用)。
* - timeout: 90s引擎跑 Python 最坏 timeout 30s + 启动开销hot path 单测。
* - use.expect.timeout: 10s默认 expect 超时。但有 call graph worker 那种「真的
* 要算 5 秒」的断言会自己覆盖成 30s。
*/
export default defineConfig({
testDir: './e2e',
timeout: 90_000,
fullyParallel: false,
workers: 1,
reporter: [['list'], ['html', { outputFolder: 'playwright-report', open: 'never' }]],
use: {
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
video: 'retain-on-failure'
// expect 超时不在顶层设(@playwright/test v1.46 不支持);
// 各处按需在 expect().toBeVisible({ timeout: ... }) 里指定
},
projects: [
{
name: 'electron',
use: { ...devices['Desktop Chrome'] }
}
]
})

6
postcss.config.cjs Normal file
View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
}

2
pytest.ini Normal file
View File

@@ -0,0 +1,2 @@
[pytest]
testpaths = engine/tests

76
scripts/regen-golden.mjs Normal file
View File

@@ -0,0 +1,76 @@
#!/usr/bin/env node
/**
* 重新生成 src/renderer/src/fixtures/golden.json。
*
* golden.json 是冻结的真实引擎输出,被测试套件当夹具用。此前没有任何东西
* 能重新生成它 —— schema 一改,夹具就悄悄过期,而依赖它的测试照样全绿。
*
* 用法node scripts/regen-golden.mjs (或 npm run fixture:golden
* PYTHON=python3.9 npm run fixture:golden 指定解释器
*
* 注意environmentpython 版本 / platform / processor / timerResolution
* wallTime 的具体数字必然随机器和每次运行而变,所以"重新生成后应与仓库内容逐字节
* 一致"这种校验是做不到的,也没有加。这个脚本的作用是让夹具在 schema 演化后
* 能被有意识地刷新 —— 刷新后请跑 npm test 确认依赖它的断言仍然成立。
*/
import { spawnSync } from 'child_process'
import { mkdtempSync, writeFileSync, readFileSync } from 'fs'
import { tmpdir } from 'os'
import { join, dirname, resolve } from 'path'
import { fileURLToPath } from 'url'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const PY = process.env.PYTHON || 'python'
// 与 golden.json 现有内容同源functions[].file 指向的就是这个脚本
const SOURCE = join(ROOT, 'engine', 'tests', 'fixtures', 'nested_calls.py')
const TARGET = join(ROOT, 'src', 'renderer', 'src', 'fixtures', 'golden.json')
const outDir = mkdtempSync(join(tmpdir(), 'golden-'))
const outFile = join(outDir, 'result.json')
const args = ['-m', 'engine.runner', '--script', SOURCE, '--out', outFile]
const r = spawnSync(PY, args, {
cwd: ROOT,
encoding: 'utf-8',
env: { ...process.env, PYTHONUTF8: '1', PYTHONIOENCODING: 'utf-8' }
})
if (r.error) {
console.error(`无法启动 ${PY}${r.error.message}`)
console.error('设置 PYTHON 环境变量指向可用的解释器后重试。')
process.exit(1)
}
if (r.status !== 0) {
console.error(`引擎退出码 ${r.status}`)
console.error(r.stderr?.slice(-2000) ?? '')
process.exit(1)
}
let parsed
try {
parsed = JSON.parse(readFileSync(outFile, 'utf-8'))
} catch (e) {
console.error(`读取/解析引擎结果失败:${e.message}`)
console.error(r.stderr?.slice(-2000) ?? '')
process.exit(1)
}
if (parsed.status !== 'ok') {
console.error(`引擎返回 status=${parsed.status},不写入夹具:`, parsed.error)
process.exit(1)
}
// functions[].file 用仓库相对路径POSIX 分隔符),否则夹具里会带上本机绝对路径
const rel = (p) =>
p
.replace(ROOT, '')
.replace(/^[\\/]+/, '')
.replace(/\\/g, '/')
for (const f of parsed.functions) f.file = rel(f.file)
writeFileSync(TARGET, JSON.stringify(parsed, null, 2) + '\n', 'utf-8')
console.log(`已写入 ${rel(TARGET)}`)
console.log(` functions=${parsed.functions.length}`)
console.log(` wallTime=${parsed.wallTime?.seconds?.toFixed(6)}s`)
console.log('接下来跑 npm test 确认依赖这份夹具的断言仍然成立。')

408
src/main/index.ts Normal file
View File

@@ -0,0 +1,408 @@
import { app, BrowserWindow, dialog, ipcMain, Menu, nativeImage, shell } from 'electron'
import { readFile, rm, stat } from 'fs/promises'
import { readdirSync } from 'fs'
import { join } from 'path'
import { tmpdir } from 'os'
import { detectInterpreters, validateInterpreter, isPythonLikePath } from './interpreter'
import { runAnalysis, cancel, cancelAndWait } from './profiler-service'
import { openTerminal } from './terminal'
import {
isFileReadRequest,
isRunOptions,
MAX_CODE_BYTES,
MAX_FILE_READ_BYTES,
normalizeFileReadPath
} from './ipc-validation'
import { IpcError } from '../shared/ipc'
let mainWindow: BrowserWindow | null = null
// before-quit 重入守卫:清理是异步的,第一次 preventDefault 后 app.quit() 会再触发
// before-quit用这个 flag 防止无限递归
let awaitingQuitCleanup = false
// 软件图标 — 项目根的 icon.ico 是 Windows ICO 文件,作为 BrowserWindow 的运行时图标。
// app.getAppPath() 在 develectron-vite dev和 prodelectron . 或打包后)都会指向
// package.json 所在目录,也就是项目根,所以 'icon.ico' 直接拼上即可。
// nativeImage 原生支持 .icocreateFromPath 返回 isEmpty() 时跳过,避免把坏路径传给
// BrowserWindow 导致 Windows 上整个图标系统降级。
function getAppIcon(): Electron.NativeImage | undefined {
const iconPath = join(app.getAppPath(), 'icon.ico')
const img = nativeImage.createFromPath(iconPath)
return img.isEmpty() ? undefined : img
}
function createWindow(): void {
mainWindow = new BrowserWindow({
width: 1440,
height: 920,
minWidth: 1080,
minHeight: 680,
backgroundColor: '#0f1115',
show: false,
// 关闭 OS 标题栏 — 放大/缩小/关闭按钮由 renderer 在 TopBar 自绘,保持 Linear
// 极简风格。顶栏整体作为 -webkit-app-region: drag 区域,按钮单独标记 no-drag。
frame: false,
icon: getAppIcon(),
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
contextIsolation: true,
nodeIntegration: false,
// sandbox: true — preload 只用 contextBridge + ipcRenderer无 Node 内置模块依赖),
// 开 sandbox 不会断功能,同时拿回 Electron 31 的默认安全姿势,避免 OS 级别
// 任意代码执行的下行风险
sandbox: true
}
})
// macOSBrowserWindow.icon 不会更新 dock 图标,需要单独调用 app.dock.setIcon。
// Windows / Linux 上 app.dock 是 undefined无需处理。
const icon = getAppIcon()
if (process.platform === 'darwin' && app.dock && icon) {
app.dock.setIcon(icon)
}
mainWindow.on('ready-to-show', () => mainWindow?.show())
// 把 maximize 状态推到 renderer —— Windows 用户从任务栏右键菜单还原 / 双击标题栏
// 最大化时renderer 端按钮图标要跟着切,否则状态不一致。
// 这里用 closure 持有 mainWindow 引用,避免 ready-to-show 之前的早期事件丢失。
mainWindow.on('maximize', () => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('window:maximize-changed', true)
}
})
mainWindow.on('unmaximize', () => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('window:maximize-changed', false)
}
})
if (process.env['ELECTRON_RENDERER_URL']) {
// loadURL/loadFile 返回 Promise 但没人需要等 —— ready-to-show / did-fail-load 事件已经
// 帮我们处理失败路径void 标记避免 ESLint 把每个 fire-and-forget 都报为 no-floating-promise
void mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL'])
} else {
void mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
}
}
function registerIpc(): void {
ipcMain.handle('interp:detect', () => detectInterpreters())
ipcMain.handle('interp:pick', async () => {
if (!mainWindow) return null
const win = mainWindow
// 非 Windows 平台不需要 .exe 过滤python / python3 默认无后缀),把过滤器按平台分支
const filters =
process.platform === 'win32'
? [
{ name: '可执行文件', extensions: ['exe'] },
{ name: '所有文件', extensions: ['*'] }
]
: [
{ name: '所有文件', extensions: ['*'] },
{ name: '无后缀可执行', extensions: [''] }
]
const res = await dialog.showOpenDialog(win, {
title: '选择 Python 解释器',
properties: ['openFile'],
filters
})
if (res.canceled || res.filePaths.length === 0) return null
const chosen = res.filePaths[0]
if (!isPythonLikePath(chosen)) {
// 走 IpcError 翻译链 —— 不再单独返回 {ok:false, error: '...'} 让 renderer 自己塞文案。
// 之前 en-US 用户看到的是 main 进程 hardcoded 的中文。
throw new IpcError('interpreter_not_allowed', `不是有效的 Python 解释器路径: ${chosen}`, {
path: chosen
})
}
try {
return await validateInterpreter(chosen)
} catch (err) {
// 选了文件但 spawn 失败(不是 Python / 版本不对 / 执行报错等)—— 走
// interpreter_probe_failed 让 renderer 按 lang 翻译。原始 message 仍保留在
// details.message 给 stderr / 排错用。
const detailMsg = err instanceof Error ? err.message : String(err)
throw new IpcError('interpreter_probe_failed', `解释器探测失败: ${detailMsg}`, {
path: chosen,
originalMessage: detailMsg
})
}
})
ipcMain.handle('analyze:run', async (e, opts: unknown) => {
// 先做形状校验 — 否则 renderer 篡改类型会让 spawn 抛莫名错误
if (!isRunOptions(opts)) {
throw new IpcError('invalid_payload', 'RunOptions 形状校验失败(需要 interpreter + code')
}
// 解释器必须在白名单python / python3 / python.exe
if (!isPythonLikePath(opts.interpreter)) {
throw new IpcError('interpreter_not_allowed', `不允许的解释器路径: ${opts.interpreter}`, {
interpreter: opts.interpreter
})
}
// 用户代码大小上限,防 DoS
const byteLen = Buffer.byteLength(opts.code, 'utf-8')
if (byteLen > MAX_CODE_BYTES) {
throw new IpcError(
'payload_too_large',
`代码过大 (${(byteLen / 1024 / 1024).toFixed(2)} MB > ${MAX_CODE_BYTES / 1024 / 1024} MB)`,
{ byteLen, maxBytes: MAX_CODE_BYTES }
)
}
return runAnalysis(
opts,
(p) => {
// 渲染层 reload 时 e.sender 可能指向已销毁的 webContents放任 .send 会抛未捕获
// 异常,进而把整个分析 Promise 弄 reject。这里用 mainWindow 兜底 + isDestroyed 守卫,
// 单进程多窗口macOS activate也能正常工作。
const wc = (mainWindow && !mainWindow.isDestroyed() ? mainWindow.webContents : null) ?? e.sender
if (!wc || wc.isDestroyed()) return
try {
wc.send('analyze:progress', p)
} catch (err) {
// eslint-disable-next-line no-console
console.warn('[analyze:progress] send failed (webContents gone?)', err)
}
},
// stdout 流式推送:和 progress 一样的 webContents.send 通道,渲染端按需订阅。
// RunConsole 用它做实时滚动;旧 stderrTail 路径只在 error 时丢尾部 —— stdout 不
// 落 AnalysisResult,直接 IPC 流过去更省内存(renderer 自己累计 buffer)。
(chunk) => {
const wc = (mainWindow && !mainWindow.isDestroyed() ? mainWindow.webContents : null) ?? e.sender
if (!wc || wc.isDestroyed()) return
try {
wc.send('analyze:stdout', chunk)
} catch (err) {
// eslint-disable-next-line no-console
console.warn('[analyze:stdout] send failed (webContents gone?)', err)
}
}
)
})
ipcMain.handle('analyze:cancel', () => {
try {
cancel()
} catch (err) {
// 静默吞错不影响 UI用户已经点了取消但日志留痕
// eslint-disable-next-line no-console
console.error('[analyze:cancel] failed:', err)
}
})
/**
* 读取一个 Python 源文件stdlib / 第三方包热点点击 → 打开 tab 用)。
*
* 不走 throw —— 用 discriminated union 返回,让渲染端能直接把「文件不存在 /
* 权限不足 / 太大」展示在 tab 里,不需要再走 readIpcError 那套翻译链路。
* 真正的意外错误ENOENT 之外、磁盘 I/O 故障等)才会 throw IpcError。
*
* 安全策略:
* - isFileReadRequest 已经在入口挡掉非法输入(非绝对路径、`..` 段、非 .py/.pyi
* - normalizeFileReadPath 把路径规范化后再 stat / readFile避免符号链接 / 大小写
* 不一致导致绕过
* - stat 先拿 size超过 MAX_FILE_READ_BYTES 直接返回 too_large不读内容
* stat 失败但 readFile 能成的极端情况罕见;这里以 stat 为准拒绝)
*/
ipcMain.handle('file:read', async (_e, req: unknown) => {
if (!isFileReadRequest(req)) {
throw new IpcError('invalid_payload', 'file:read 必须是 { filePath: string }', {
gotType: typeof req
})
}
const normalized = normalizeFileReadPath(req.filePath)
try {
const st = await stat(normalized)
if (!st.isFile()) {
return { kind: 'not_a_file' as const, message: `${normalized} 不是常规文件` }
}
if (st.size > MAX_FILE_READ_BYTES) {
return {
kind: 'too_large' as const,
message: `文件过大 (${(st.size / 1024 / 1024).toFixed(2)} MB > ${MAX_FILE_READ_BYTES / 1024 / 1024} MB)`
}
}
const content = await readFile(normalized, 'utf-8')
return { kind: 'ok' as const, content, size: st.size }
} catch (err) {
const code = (err as NodeJS.ErrnoException).code
if (code === 'ENOENT') {
return { kind: 'not_found' as const, message: `文件不存在: ${normalized}` }
}
if (code === 'EACCES' || code === 'EPERM') {
return { kind: 'permission_denied' as const, message: `权限不足: ${normalized}` }
}
// 其他 fs 错误EIO / ENOMEM / EBUSY …属于真正异常throw 走通用 IpcError 路径
throw new IpcError('unknown', err instanceof Error ? err.message : String(err), {
code,
path: normalized
})
}
})
ipcMain.handle('terminal:open', async (_e, command: unknown, shell: unknown) => {
// 输入校验command 必须是 string 或 undefined。
// 不接受 object/array —— renderer 没有合法理由传非字符串,强行接受会
// 让我们在主进程拼 shell 命令时被奇怪的 toString() 形状坑到。
if (command !== undefined && typeof command !== 'string') {
throw new IpcError('invalid_payload', 'terminal command 必须是 string 或 undefined', {
gotType: typeof command
})
}
// 长度上限:防止 renderer 被注入后塞几 MB 字符串给 spawn 撑爆命令行。
// 实际合法命令installPythonCommand才 30-60 字节500 已经是 10x 余量。
if (typeof command === 'string' && command.length > 500) {
throw new IpcError('payload_too_large', `terminal command 过长 (${command.length} > 500)`, {
length: command.length
})
}
// shell 必须是 'cmd' / 'powershell' / undefined(默认 cmd)。非 Windows 平台忽略此参数
if (shell !== undefined && shell !== 'cmd' && shell !== 'powershell') {
throw new IpcError('invalid_payload', "terminal shell 必须是 'cmd' / 'powershell' / undefined", {
gotShell: String(shell)
})
}
try {
await openTerminal(command ?? '', shell ?? 'cmd')
} catch (err) {
throw new IpcError('terminal_open_failed', err instanceof Error ? err.message : String(err))
}
})
// ============ 内嵌终端已移除 ============
// 之前用 xterm.js + spawn shell 做内嵌终端,问题多(焦点跨 IPC 不稳、键盘输入丢失、
// 渲染开销大、Electron sandbox 下 xterm 兼容性差)。改用「打开系统终端」按钮,按需弹
// 用户本机的 cmd.exe / Terminal.app / gnome-terminal —— 由 src/main/terminal.ts 的
// openTerminal 提供。Settings 的「打开终端安装 Python」也走它。
// 顶栏左上角图标:把项目根 icon.ico 读成 32×32 PNG data URL 交给 renderer。
// 顶栏实际显示 ~18pxICO 默认挑最大帧(一般 256×256直传浪费 IPC payload
// 还会被 Chromium 双线性缩到 18px 后发糊。先 resize 到 32×32 再 toDataURL
// 既省字节又是更高质量的重采样。文件不存在 / 解不开时返回 nullrenderer 退 SVG。
ipcMain.handle('app:icon', () => {
const img = getAppIcon()
if (!img) return null
return img.resize({ width: 32, height: 32 }).toDataURL()
})
/**
* 在系统默认浏览器里打开一个外部 URL设置 → 关于里的开发者主页链接)。
*
* 协议白名单:只放行 http / https —— 拒绝 javascript: / file: / data: 等,
* 防止 renderer 被注入后塞恶意 scheme 让 shell.openExternal 触发本地协议处理器
* macOS 上 .webloc / Windows 上 ms-msdt 之类的协议 handler 已被用作钓鱼面)。
*
* URL.parse 在这里既做语法校验又做兜底 —— `new URL(...)` 抛 TypeError 直接转 IpcError,
* 渲染侧按 lang 翻译展示,不暴露堆栈。
*/
ipcMain.handle('shell:openExternal', async (_e, url: unknown) => {
if (typeof url !== 'string' || url.length === 0) {
throw new IpcError('invalid_payload', 'openExternal url 必须是非空字符串', { gotType: typeof url })
}
// 长度上限:正常个人主页 / 文档链接最多 200 字节,2KB 已经是 10x 余量,挡住畸形大输入。
if (url.length > 2048) {
throw new IpcError('payload_too_large', `url 过长 (${url.length} > 2048)`, { length: url.length })
}
let parsed: URL
try {
parsed = new URL(url)
} catch (err) {
throw new IpcError('invalid_payload', `url 解析失败: ${err instanceof Error ? err.message : String(err)}`, {
url
})
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
throw new IpcError('invalid_payload', `不支持的协议: ${parsed.protocol}`, {
protocol: parsed.protocol,
url
})
}
try {
await shell.openExternal(parsed.toString())
} catch (err) {
throw new IpcError('unknown', err instanceof Error ? err.message : String(err), { url: parsed.toString() })
}
})
// ============ 自定义窗口控制按钮(顶栏右上角)============
// frame: false 后 OS 标题栏没了,缩放 / 还原 / 关闭必须由 renderer 触发 IPC。
// 主进程在这层做唯一权威操作 —— renderer 不直接调 BrowserWindow。
ipcMain.handle('window:minimize', () => {
mainWindow?.minimize()
})
ipcMain.handle('window:maximize-toggle', () => {
if (!mainWindow) return { isMaximized: false }
if (mainWindow.isMaximized()) {
mainWindow.unmaximize()
} else {
mainWindow.maximize()
}
// 返回权威状态让 renderer 立即同步图标;不要等 'window:maximize-changed' 事件,
// 那条事件可能在 IPC response 之后到达,会闪一下旧图标。
return { isMaximized: mainWindow.isMaximized() }
})
ipcMain.handle('window:close', () => {
mainWindow?.close()
})
// renderer mount 时拉一次当前状态 —— 之前用户可能已经处于最大化,重启后默认 false
// 会让图标错一拍
ipcMain.handle('window:is-maximized', () => mainWindow?.isMaximized() ?? false)
}
// whenReady().then(...) 本身就是 fire-and-forgetcreateWindow 内的加载是同步的loadURL/file
// 是 async 但交给事件处理),这里只是把 setup 包进 ready 回调。catch 单独打错误日志。
app
.whenReady()
.then(() => {
// 隐去 Electron 默认菜单栏File / Edit / View / Window / Help
// 本应用是 Python profiler没有"新建窗口/打开文件"等文件操作入口;
// 设置走顶栏齿轮按钮、运行/取消走顶部按钮、Cut/Copy/Paste 走 Monaco 自带实现、
// DevTools 仍可通过 F12 / Ctrl+Shift+I 唤起(不依赖菜单项)。
Menu.setApplicationMenu(null)
registerIpc()
createWindow()
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
})
.catch((err: unknown) => {
// eslint-disable-next-line no-console
console.error('[app:ready] failed:', err)
})
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit()
})
// 应用退出前清理所有残留的 pyprof-* 临时目录。
// 单次 runAnalysis 退出时 close 事件已经清,但用户 X-out / OS 强制关机
// / spawn 失败 / mkdtemp 后 writeFile 抛错 这些路径会泄漏。
// 这里扫描 tmpdir 下所有 pyprof- 前缀的目录尽力删一次。
// 同时要先杀 childCmd+Q / Alt+F4 中途退出时,留着 Python 子进程会成孤儿
// macOS / detached POSIX 尤甚)。用 cancelAndWait 而不是 cancelWindows 上
// killTree 走的是 taskkill 子进程,不等它回来就 quit 照样留孤儿。
app.on('before-quit', (e) => {
if (awaitingQuitCleanup) return
e.preventDefault()
awaitingQuitCleanup = true
// 清理必须串成一条 await 链再 app.quit():之前 cancel() 和 rm() 都是发出去就不管,
// quit 抢在 taskkill / rm 之前跑完,两个清理都等于没做。
void (async () => {
try {
await cancelAndWait()
} catch {
/* 杀进程失败不该挡住退出 */
}
try {
// 循环变量用 `name`(而不是 `e`):外层 `e` 是 before-quit 的事件对象,
// 内层 if 用 `e` 会遮蔽它。
const entries = readdirSync(tmpdir()).filter((name) => name.startsWith('pyprof-'))
await Promise.allSettled(
entries.map((name) =>
rm(join(tmpdir(), name), { recursive: true, force: true }).catch((err) => {
// eslint-disable-next-line no-console
console.warn('[cleanup] failed to remove', name, err)
})
)
)
} catch {
/* tmpdir 不可读时静默 — 不是阻塞性操作 */
}
app.quit()
})()
})

View File

@@ -0,0 +1,112 @@
import { validateInterpreter, parsePyLauncherOutput, isPythonLikePath } from './interpreter'
// 需要本机存在 python/python3。CI 无 Python 时用 it.skip 而不是 try/catch + return
// - 静默 return 让测试报告看不出哪些用例被跳了CI 矩阵跑起来像绿了但其实没覆盖
// - it.skip 会显式报告 skip 计数 + skip 原因(条件 if 表达式就是文案)
const PY = process.env.PYTHON || 'python'
describe('interpreter', () => {
it('validateInterpreter 返回版本', async () => {
let info
try {
info = await validateInterpreter(PY)
} catch (err) {
// 单独这一项 skip — 不要把整个 file skip因为下面的 parsePyLauncherOutput 是纯字符串
it.skip(`validateInterpreter 跳过:本机找不到 ${PY} (${(err as Error).message})`, () => {})
return
}
expect(info.path).toBe(PY)
expect(info.version).toMatch(/^\d+\.\d+\.\d+$/)
})
describe('parsePyLauncherOutput', () => {
it('从普通 py -0p 输出中提取每个 exe 路径', () => {
const stdout = [
' -V:3.12 * C:\\Python312\\python.exe',
' -V:3.11 C:\\Python311\\python.exe'
].join('\r\n')
expect(parsePyLauncherOutput(stdout)).toEqual([
'C:\\Python312\\python.exe',
'C:\\Python311\\python.exe'
])
})
it('路径含 `*`(默认标记)也要正确剥离版本前缀', () => {
// 之前的 bug^\s*-\s*(.+?\.(?:exe|EXE))\s*$ 会把整行吞下,导致路径里含 `*`
const stdout = ' -V:3.12 * C:\\Python312\\python.exe'
const paths = parsePyLauncherOutput(stdout)
expect(paths).toEqual(['C:\\Python312\\python.exe'])
expect(paths[0]).not.toContain('*')
})
it('处理带引号的路径(含空格的安装目录)', () => {
const stdout = ' -V:3.10 * "C:\\Program Files\\Python310\\python.exe"'
expect(parsePyLauncherOutput(stdout)).toEqual(['C:\\Program Files\\Python310\\python.exe'])
})
it('忽略不含 .exe 的垃圾行warning / 错误)', () => {
const stdout = [
'Some warning message',
' -V:3.12 * C:\\Python312\\python.exe',
'error: not a real path'
].join('\n')
expect(parsePyLauncherOutput(stdout)).toEqual(['C:\\Python312\\python.exe'])
})
it('空 stdout 返回空数组', () => {
expect(parsePyLauncherOutput('')).toEqual([])
})
it('路径后面跟注释 / 额外 token 时仍能提取 .exe', () => {
// 之前的 bug取行末最后一个 token 再过滤 .exe尾随注释/环境标记会让该 token 不是路径,
// 那条安装就被漏掉。修复后改为"最后一个以 .exe 结尾的 token"。
const stdout = ' -V:3.12 * C:\\Python312\\python.exe # default'
expect(parsePyLauncherOutput(stdout)).toEqual(['C:\\Python312\\python.exe'])
})
it('引号路径后跟额外 token 时仍能提取', () => {
const stdout = ' -V:3.10 * "C:\\Program Files\\Python310\\python.exe" trailing-junk'
expect(parsePyLauncherOutput(stdout)).toEqual(['C:\\Program Files\\Python310\\python.exe'])
})
})
describe('isPythonLikePath', () => {
// 平台相关win32 只认 C:\ / \\server\POSIX 只认 /...。用 helper 拼当前平台的绝对路径,
// 否则这些用例在另一个平台上会因为 isAbsolute 检查全挂。
const abs = (name: string): string =>
process.platform === 'win32' ? `C:\\Python311\\${name}` : `/usr/bin/${name}`
it('接受带小版本号的解释器名', () => {
// 回归测试:正则曾是 /^python(\d*)?(\.exe)?$/,只匹配 python / python3
// 把 macOS/Linux 上最常见的 /usr/bin/python3.11 全拒了,用户被迫做符号链接。
expect(isPythonLikePath(abs('python3.11'))).toBe(true)
expect(isPythonLikePath(abs('python3.12'))).toBe(true)
expect(isPythonLikePath(abs('python3.12.1'))).toBe(true)
})
it('继续接受无版本号 / 单段版本号 / .exe 后缀', () => {
expect(isPythonLikePath(abs('python'))).toBe(true)
expect(isPythonLikePath(abs('python3'))).toBe(true)
expect(isPythonLikePath(abs('python.exe'))).toBe(true)
expect(isPythonLikePath(abs('python3.exe'))).toBe(true)
})
it('拒绝裸名(会被 spawn 按 PATH 解析,可被劫持)', () => {
expect(isPythonLikePath('python')).toBe(false)
expect(isPythonLikePath('python3.11')).toBe(false)
})
it('拒绝非 python 可执行文件', () => {
expect(isPythonLikePath(abs('node'))).toBe(false)
expect(isPythonLikePath(abs('pythonw.exe'))).toBe(false)
expect(isPythonLikePath(abs('python-evil'))).toBe(false)
expect(isPythonLikePath(abs('notpython'))).toBe(false)
})
it('拒绝 .. 穿透与空值', () => {
expect(isPythonLikePath(abs('../../evil/python'))).toBe(false)
expect(isPythonLikePath('')).toBe(false)
expect(isPythonLikePath(undefined as unknown as string)).toBe(false)
})
})
})

209
src/main/interpreter.ts Normal file
View File

@@ -0,0 +1,209 @@
import { execFile } from 'child_process'
import { promisify } from 'util'
import { existsSync, readdirSync, statSync } from 'fs'
import { join } from 'path'
import { homedir } from 'os'
import type { InterpreterInfo } from '../shared/analysis'
const execFileAsync = promisify(execFile)
// 一行探测脚本:输出 JSON {version}
const PROBE = 'import sys,json;' + 'print(json.dumps({"version":"%d.%d.%d"%sys.version_info[:3]}))'
// 候选可执行名PATH 中)
const CANDIDATES =
process.platform === 'win32' ? ['python', 'py', 'python3', 'python.exe'] : ['python3', 'python']
// 解释器白名单:只接受"看起来像 python"且**绝对路径**的可执行文件。
// 之前 basename 检查 (`python.exe` / python3) 接受任何匹配名,包括光 `"python"`
// 这种裸名 — spawn 会按 PATH 解析,攻击者把恶意的 `python` 放进靠前 PATH 就能劫持。
//
// 放在 interpreter.ts 而不是 index.tsindex.ts 导入 electron单元测试里没法直接
// require这个函数是纯字符串判断挪过来就能被 interpreter.test.ts 直接覆盖。
export function isPythonLikePath(p: string): boolean {
if (typeof p !== 'string' || !p) return false
// 绝对路径Windows (C:\ / D:\ / \\server\) 或 POSIX (/...)
const isAbsolute =
process.platform === 'win32' ? /^[a-zA-Z]:[\\/]/.test(p) || p.startsWith('\\\\') : p.startsWith('/')
if (!isAbsolute) return false
// 拒绝 ../ 段穿透 — normalize 后还含 .. 就视为不安全
const normalized = p.replace(/\\/g, '/')
if (/\.\.[/\\]/.test(normalized) || normalized.endsWith('/..')) return false
const name = p.split(/[\\/]/).pop()?.toLowerCase() ?? ''
// 允许带小版本号macOS/Linux 上 /usr/bin/python3.11、python3.12 是系统 Python 的
// 常见形态。之前的 /^python(\d*)?(\.exe)?$/ 只匹配 python / python3把这些全拒了
// 用户被迫做符号链接才能用。
return /^python(\d+(\.\d+)*)?(\.exe)?$/.test(name)
}
/** 在常见安装路径下补全候选可执行文件venv、conda、pyenv、uv、官方安装器 */
function extraCandidates(): string[] {
const out: string[] = []
const home = homedir()
if (process.platform === 'win32') {
const local = process.env['LOCALAPPDATA']
const programFiles = process.env['ProgramFiles'] || 'C:\\Program Files'
if (local) {
// Windows 官方安装器默认路径
for (let i = 0; i < 5; i++) {
out.push(join(local, 'Programs', 'Python', `Python3${i}`, 'python.exe'))
out.push(join(local, 'Programs', 'Python', `Python3${i}-32`, 'python.exe'))
}
}
out.push(join(programFiles, 'Python313', 'python.exe'))
out.push(join(programFiles, 'Python312', 'python.exe'))
out.push(join(programFiles, 'Python311', 'python.exe'))
out.push(join(programFiles, 'Python310', 'python.exe'))
} else {
// Linux/macOS 常见路径。pyenv 的 .pyenv/versions 是目录,会在 step 3 展开处理;
// 这里不再尝试直接 validate 整个目录(之前会被 `path.includes('*')` 的"占位"逻辑误判)
out.push('/usr/bin/python3')
out.push('/usr/local/bin/python3')
out.push('/opt/homebrew/bin/python3')
if (home) {
out.push(join(home, '.local', 'bin', 'python3'))
}
}
return out
}
/** 列出 conda 环境中的 python 路径(尽力而为,失败静默) */
async function listCondaPythons(): Promise<string[]> {
try {
const { stdout } = await execFileAsync('conda', ['env', 'list', '--json'], { timeout: 5000 })
const data: unknown = JSON.parse(stdout)
const envs: string[] = Array.isArray((data as { envs?: unknown[] } | null)?.envs)
? (data as { envs: unknown[] }).envs.filter((p): p is string => typeof p === 'string')
: []
const out: string[] = []
for (const env of envs) {
const py = process.platform === 'win32' ? join(env, 'python.exe') : join(env, 'bin', 'python')
if (existsSync(py)) out.push(py)
}
return out
} catch {
return []
}
}
/** 列出 `py -0p` 给出的所有 Windows Python Launcher 安装 */
async function listPyLauncher(): Promise<string[]> {
if (process.platform !== 'win32') return []
try {
const { stdout } = await execFileAsync('py', ['-0p'], { timeout: 5000 })
return parsePyLauncherOutput(stdout)
} catch {
return []
}
}
/**
* 把 `py -0p` 的 stdout 解析成候选 python.exe 路径。
* 每行格式: ` -V:3.X [*] PATH` 或 ` -V:3.X [*] "PATH with spaces"`
* 之前取行内最后一个 token 再过滤 .exe 后缀 — 但某些环境下 launcher 会在末尾追加注释/环境标记,
* 导致行末 token 不是路径,那条安装就被漏掉。
* 改成"扫所有 token取最后一个以 .exe / .EXE 结尾的"——更稳,且兼容原有所有合法行。
*/
export function parsePyLauncherOutput(stdout: string): string[] {
const out: string[] = []
const tokenRe = /"([^"]*)"|(\S+)/g
for (const line of stdout.split(/\r?\n/)) {
let last: string | undefined
let m: RegExpExecArray | null
tokenRe.lastIndex = 0
while ((m = tokenRe.exec(line)) !== null) {
const tok = m[1] !== undefined ? m[1] : m[2]
if (/\.(exe|EXE)$/.test(tok)) last = tok
}
if (last) out.push(last)
}
return out
}
export async function validateInterpreter(path: string): Promise<InterpreterInfo> {
const { stdout } = await execFileAsync(path, ['-c', PROBE], { timeout: 8000 })
// 不直接 JSON.parse(stdout.trim()):第三方包可能在 stdout 印警告deprecation banner 等),
// 那样就把本来能用的解释器当失败丢掉了。取最后一个 '{' 开头的 token 解 JSON。
const lines = stdout.split(/\r?\n/)
for (let i = lines.length - 1; i >= 0; i--) {
const line = lines[i].trim()
if (line.startsWith('{')) {
const data: unknown = JSON.parse(line)
// 校验形状:避免 {version: undefined} 这种"看起来成功"的假阳性
// 导致 UI 显示 "Python undefined"。有 PYTHONSTARTUP / 旧 Python 警告时可能踩到。
const probe = data as { version?: unknown } | null
if (typeof probe?.version !== 'string' || !/^\d+\.\d+\.\d+/.test(probe.version)) {
throw new Error(`probe 返回的 JSON 缺少有效 version 字段: ${line.slice(0, 120)}`)
}
return { path, version: probe.version }
}
}
throw new Error('interpreter probe returned no JSON object')
}
async function tryValidate(path: string): Promise<InterpreterInfo | null> {
if (!path) return null
// pyenv 版本目录占位(前缀如 /home/.../.pyenv/versions 本身)— 不是可执行文件
if (path.includes('*')) return null
if (!existsSync(path)) return null
try {
return await validateInterpreter(path)
} catch {
return null
}
}
/** 列出一个 phase 的所有候选(不做 validate路径解析 / 文件系统访问)。 */
function pyenvCandidates(): string[] {
const home = homedir()
if (!home) return []
const pyenvRoot = join(home, '.pyenv', 'versions')
if (!existsSync(pyenvRoot)) return []
try {
const out: string[] = []
for (const ver of readdirSync(pyenvRoot)) {
const bin = join(pyenvRoot, ver, process.platform === 'win32' ? 'python.exe' : 'bin/python')
if (existsSync(bin) && statSync(bin).isFile()) out.push(bin)
}
return out
} catch {
return []
}
}
/** 并行 validate 一组候选;返回成功的去重后列表(不去重由调用方做)。 */
async function validateAll(paths: string[]): Promise<InterpreterInfo[]> {
const results = await Promise.all(paths.map((p) => tryValidate(p)))
const out: InterpreterInfo[] = []
for (const r of results) if (r) out.push(r)
return out
}
export async function detectInterpreters(): Promise<InterpreterInfo[]> {
const found: InterpreterInfo[] = []
const seen = new Set<string>()
const add = (info: InterpreterInfo) => {
const key = info.path
if (seen.has(key)) return
seen.add(key)
found.push(info)
}
// 五个 phase 并行跑,每个 phase 内部也并行 validate。
// 之前是串行 5 段,每段里再串行 tryValidate20+ 候选在慢盘上能到 4s。
// 并行后总耗时 = max(单 phase 耗时),通常 < 1s。
const [phasePath, phaseExtra, phasePyenv, phaseLauncher, phaseConda] = await Promise.all([
validateAll(CANDIDATES),
validateAll(extraCandidates()),
validateAll(pyenvCandidates()),
listPyLauncher().then(validateAll),
listCondaPythons().then(validateAll)
])
for (const list of [phasePath, phaseExtra, phasePyenv, phaseLauncher, phaseConda]) {
for (const info of list) add(info)
}
return found
}

View File

@@ -0,0 +1,156 @@
import {
isFileReadRequest,
isRunOptions,
MAX_CODE_BYTES,
MAX_FILE_READ_BYTES,
normalizeFileReadPath
} from './ipc-validation'
const VALID = {
interpreter: 'python',
code: 'print(1)'
}
/** 在合法对象上改一个字段,用来测单字段的拒绝。 */
const withField = (key: string, value: unknown): unknown => ({ ...VALID, [key]: value })
describe('isRunOptions', () => {
it('接受合法的 RunOptions', () => {
expect(isRunOptions(VALID)).toBe(true)
})
it('拒绝非对象', () => {
for (const bad of [null, undefined, 'x', 42, true, [], () => {}]) {
expect(isRunOptions(bad)).toBe(false)
}
})
it('拒绝缺字段的对象', () => {
for (const key of Object.keys(VALID)) {
const partial: Record<string, unknown> = { ...VALID }
delete partial[key]
expect(isRunOptions(partial)).toBe(false)
}
})
describe('字符串字段', () => {
it('interpreter / code 必须是字符串', () => {
for (const key of ['interpreter', 'code']) {
for (const bad of [1, null, undefined, {}, [], true]) {
expect(isRunOptions(withField(key, bad))).toBe(false)
}
}
})
it('interpreter 不能为空字符串(解释器路径必须有内容)', () => {
expect(isRunOptions(withField('interpreter', ''))).toBe(false)
})
it('允许 code 是空字符串(用户清空编辑器是合法状态)', () => {
expect(isRunOptions(withField('code', ''))).toBe(true)
})
})
it('忽略多余字段renderer 版本较新时不该整体拒绝)', () => {
expect(isRunOptions({ ...VALID, futureField: 'whatever' })).toBe(true)
})
})
describe('MAX_CODE_BYTES', () => {
it('是 1MB —— 上限值本身别被误改', () => {
expect(MAX_CODE_BYTES).toBe(1024 * 1024)
})
})
describe('MAX_FILE_READ_BYTES', () => {
it('是 2MB —— 上限值本身别被误改', () => {
expect(MAX_FILE_READ_BYTES).toBe(2 * 1024 * 1024)
})
})
describe('normalizeFileReadPath', () => {
it('Windows 路径上的重复斜杠和 ./ 被收掉', () => {
// path.normalize 是平台依赖的 —— 测试在 win32 跑就用 win32 语义断言。
// 真实场景C:\Python311\Lib\json\decoder.py 没必要 normalize (已经干净)
expect(normalizeFileReadPath('C:\\Python311\\Lib\\json\\decoder.py')).toBe(
'C:\\Python311\\Lib\\json\\decoder.py'
)
// 有冗余 ./ 或重复斜杠 → normalize 应当收敛
expect(normalizeFileReadPath('C:\\Python311\\.\\Lib\\\\json\\decoder.py')).toBe(
'C:\\Python311\\Lib\\json\\decoder.py'
)
})
})
describe('isFileReadRequest', () => {
it('接受合法的绝对 .py / .pyi 路径', () => {
for (const ok of [
{ filePath: 'C:\\Python311\\Lib\\json\\decoder.py' },
{ filePath: 'C:\\Python311\\Lib\\json\\decoder.pyi' },
{ filePath: '/usr/lib/python3.11/json/decoder.py' },
{ filePath: 'C:\\Python311\\Lib\\json\\decoder.PY' } // 大小写不敏感
]) {
expect(isFileReadRequest(ok)).toBe(true)
}
})
it('拒绝非对象', () => {
for (const bad of [null, undefined, 'x', 42, true, [], () => {}]) {
expect(isFileReadRequest(bad)).toBe(false)
}
})
it('拒绝缺字段的对象(与 isRunOptions 一致,不严格拒多余字段)', () => {
// 缺 filePath
expect(isFileReadRequest({})).toBe(false)
expect(isFileReadRequest({ path: 'x' })).toBe(false)
// 多余字段:和 isRunOptions 一样忽略 —— renderer 版本较新时不该整体拒绝
expect(isFileReadRequest({ filePath: 'C:/x.py', futureField: 'whatever' })).toBe(true)
})
it('filePath 不是字符串就拒', () => {
for (const bad of [1, null, undefined, {}, [], true, false]) {
expect(isFileReadRequest({ filePath: bad })).toBe(false)
}
})
it('空字符串 / 超长字符串拒绝', () => {
expect(isFileReadRequest({ filePath: '' })).toBe(false)
// 长度边界: 'C:\\' (3) + 'a'.repeat(N) + '.py' (3) = 6 + N 总长
// 6 + 4090 = 4096 → 边界值,接受
expect(isFileReadRequest({ filePath: 'C:\\' + 'a'.repeat(4090) + '.py' })).toBe(true)
// 6 + 4091 = 4097 → 越界 1,拒绝
expect(isFileReadRequest({ filePath: 'C:\\' + 'a'.repeat(4091) + '.py' })).toBe(false)
})
it('相对路径拒绝 —— 防止 ../../etc/passwd', () => {
expect(isFileReadRequest({ filePath: 'json/decoder.py' })).toBe(false)
expect(isFileReadRequest({ filePath: './json/decoder.py' })).toBe(false)
expect(isFileReadRequest({ filePath: '../../etc/passwd' })).toBe(false)
})
it('即便绝对路径,normalize 后含 .. 也拒绝', () => {
// path.isAbsolute 通过,但 segment 含 '..' → 拒
expect(isFileReadRequest({ filePath: '/usr/lib/../../../etc/passwd.py' })).toBe(false)
expect(isFileReadRequest({ filePath: 'C:\\Python311\\..\\..\\Windows\\foo.py' })).toBe(false)
})
it('非 .py / .pyi 扩展名拒绝', () => {
for (const bad of [
'C:/path/file.txt',
'C:/path/file.js',
'C:/path/file.pyc',
'C:/path/file.so',
'C:/path/file',
'C:/path/no-extension',
'C:/path/file.py.bak'
]) {
expect(isFileReadRequest({ filePath: bad })).toBe(false)
}
})
it('目录路径(无扩展名部分)拒绝', () => {
// 末段是空串 → 无扩展名匹配
expect(isFileReadRequest({ filePath: 'C:/python/Lib/' })).toBe(false)
})
})

View File

@@ -0,0 +1,94 @@
import { isAbsolute, normalize } from 'path'
import type { FileReadRequest, RunOptions } from '../shared/analysis'
/**
* IPC 入口的形状与边界校验。
*
* 单独成一个模块是为了能被单元测试直接 import —— 它原本躺在 index.ts 里,
* 而 index.ts 顶层 `import { app, BrowserWindow } from 'electron'`,测试环境
* require 不进来。结果是整个 IPC 安全边界renderer 可以往这儿传任何东西)
* 零测试覆盖。isPythonLikePath 之前是同样的处境,已经用"抽出去再测"解决过一次,
* 这里是同一个做法。
*/
/** 用户代码大小上限:防 10GB 字符串塞爆 tmp 盘。
* 1MB 对正常脚本绰绰有余;再大就拒绝。 */
export const MAX_CODE_BYTES = 1 * 1024 * 1024
/**
* 单个外部源文件读取上限 —— 给 stdlib / 第三方包热点打开文件用。
*
* 选 2MB 的依据:标准库最大的 .pypydoc / tarfile也就 100~200KBsite-packages 里
* 99% 的 .py 不超过 500KB。2MB 是 20x 安全余量 —— 超过这个数基本可以肯定是误用
* cProfile 把 .pyc / 编译产物路径记成 .file或打开了一个奇怪的二进制
*
* 跟 MAX_CODE_BYTES 一样在主进程入口处强制,避免把一个 500MB 的 numpy .so 当 .py 读进来
* 把 Monaco editor 撑爆。
*/
export const MAX_FILE_READ_BYTES = 2 * 1024 * 1024
/**
* IPC 入口处统一校验 RunOptions 形状renderer 篡改或版本不匹配时
* 传错类型会导致 spawn 抛 "args.slice is not a function" 这类莫名错误。
*/
export function isRunOptions(o: unknown): o is RunOptions {
if (typeof o !== 'object' || o === null) return false
const r = o as Record<string, unknown>
if (typeof r['interpreter'] !== 'string' || r['interpreter'].length === 0) return false
if (typeof r['code'] !== 'string') return false
// scope 可选:缺失等同于 "user"。给出非 "user"/"all" 的值直接拒掉,
// 避免传 "alll" / null / 数组这类值让 spawn 报 "unrecognized arguments"。
if (r['scope'] !== undefined && r['scope'] !== 'user' && r['scope'] !== 'all') return false
return true
}
/**
* 校验 file:read 的入参。决定 renderer 是否能读到指定文件。
*
* 校验项(按顺序):
* 1. 形状:必须是 `{ filePath: string }`
* 2. 长度1..4096 字节 —— 实际合法路径最长几百字符4096 是 10x 上限。
* 太长的输入几乎可以肯定是构造的探测串path traversal 探测、buffer 测试)。
* 3. 必须是绝对路径path.isAbsolute相对路径在主进程工作目录里 resolve
* 工作目录不可预测(打包后是 app.asar开发时是 src/),会让同一份 fn.file 在
* 不同启动方式下指向不同文件 → 结果不一致。强制绝对路径也防止 renderer 写
* `../../etc/passwd` 这种相对探测。
* 4. normalize 后不含 `..` 段即便绝对路径normalize('/usr/lib/../../../etc/passwd')
* 也是合法绝对路径,但含 `..` → 仍拒。双重保险。
* 5. 扩展名必须是 .py / .pyicProfile 的 fn.file 应该指向 .py但万一引擎把 .pyc 或
* 别的路径记进来,给用户展示 Python 之外的二进制没意义。.pyi类型存根也允许
* Python 生态里它跟 .py 一样是源码。
*
* 不挂额外字段 —— 校验通过的入参 handler 自己再调 normalizeFileReadPath 拿规范化路径。
*/
export function isFileReadRequest(o: unknown): o is FileReadRequest {
if (typeof o !== 'object' || o === null) return false
const r = o as Record<string, unknown>
if (typeof r['filePath'] !== 'string') return false
const p = r['filePath']
if (p.length < 1 || p.length > 4096) return false
if (!isAbsolute(p)) return false
// 路径段检查:先 split **原始输入** 看任一段是否为 '..' 即视为 traversal 尝试。
// 注意:必须在 normalize 之前做 —— path.normalize 会把 '/usr/lib/../etc/x.py'
// 收成 '/usr/etc/x.py',把 '..' 段消掉,这样 segments.includes('..') 就漏检了。
// 把检查放在 normalize 之前相当于「看到 ../ 就拒」,双重保险。
const segments = p.split(/[/\\]/)
if (segments.includes('..')) return false
// 取 normalize 后的最后一段判扩展名:Windows 上 path.normalize 保留反斜杠,
// 但 split 还是按 / 或 \ 都能切 —— 直接复用 segments 末尾更直接,不必多一道。
const last = segments[segments.length - 1] ?? ''
if (!/\.(py|pyi)$/i.test(last)) return false
return true
}
/**
* 给 isFileReadRequest 校验通过的路径做 normalize。
* 拆出来而不是让 isFileReadRequest 挂在入参上 —— 避免"改入参"的副作用
* 让类型断言(`o is FileReadRequest`)看起来在说谎。
*
* 注意调用方应在 isFileReadRequest 通过之后再调 —— 这里不做形状校验,只负责
* 把绝对路径收成 path.normalize 形式(handler 拿来直接 fs.readFile)。
*/
export function normalizeFileReadPath(p: string): string {
return normalize(p)
}

View File

@@ -0,0 +1,201 @@
import { existsSync } from 'fs'
import { join } from 'path'
import { runAnalysis, resolveEngineRoot, cancelAndWait, userFacingStderr } from './profiler-service'
const PY = process.env.PYTHON || 'python'
async function hasPython(): Promise<boolean> {
try {
const { execFile } = await import('child_process')
const { promisify } = await import('util')
await promisify(execFile)(PY, ['--version'])
return true
} catch {
return false
}
}
// 探测一次,用 it.skipIf 声明式跳过。
// 之前是在用例体内调 it.skip() 再 return —— 那是个 no-op运行中注册新用例不生效
// 没有 Python 时这些用例会「静默全绿」,正是它上面注释声称已经修掉的反模式。
const PYTHON_AVAILABLE = await hasPython()
const itPy = it.skipIf(!PYTHON_AVAILABLE)
const BASE = { interpreter: PY, code: 'x = 1\nprint(x)\n' }
describe('profiler-service', () => {
describe('userFacingStderr 过滤内部进度协议', () => {
it('去掉 PROGRESS 行', () => {
expect(userFacingStderr('PROGRESS timing 10\nPROGRESS lines 70\n')).toBe('')
})
it('保留真正的报错,顺序不变', () => {
const raw =
'PROGRESS timing 10\nTraceback (most recent call last):\nPROGRESS lines 70\n ZeroDivisionError\n'
expect(userFacingStderr(raw)).toBe('Traceback (most recent call last):\n ZeroDivisionError')
})
it('兼容 CRLFWindows 上引擎输出是 \\r\\n', () => {
expect(userFacingStderr('PROGRESS timing 10\r\nboom\r\n')).toBe('boom')
})
it('只过滤行首的 PROGRESS用户自己打印的内容不受影响', () => {
// 用户代码 print 出 "log: PROGRESS timing 10" 这种不该被吃掉。
// 注意整块最后会 trim所以别拿首行的前导空格做断言 —— 把缩进行放在中间。
const raw = 'boom\n PROGRESS timing 10 只是被缩进了\nlog: PROGRESS timing 10'
expect(userFacingStderr(raw)).toBe(raw)
})
it('形似但不合规的 PROGRESS 行保留(宁可多显示也不要吞掉真信息)', () => {
expect(userFacingStderr('PROGRESS timing\nPROGRESSING fast')).toBe('PROGRESS timing\nPROGRESSING fast')
})
it('空输入返回空串', () => {
expect(userFacingStderr('')).toBe('')
expect(userFacingStderr(' \n\n')).toBe('')
})
})
it('resolveEngineRoot 能定位到含 engine/runner.py 的目录', () => {
const root = resolveEngineRoot()
expect(typeof root).toBe('string')
// 只断言 typeof 等于什么都没测:这里断言 runner.py 真的在那儿
expect(existsSync(join(root, 'engine', 'runner.py'))).toBe(true)
})
itPy(
'runAnalysis 正常运行返回 ok 并回调进度',
async () => {
const phases: string[] = []
const result = await runAnalysis(
{
...BASE,
code: 'def slow():\n s=0\n for i in range(50000):\n s+=i\n return s\nslow()\n'
},
(p) => phases.push(p.phase)
)
expect(result.status).toBe('ok')
expect(result.wallTime?.seconds).toBeGreaterThan(0)
expect(phases.length).toBeGreaterThan(0)
},
30000
)
// onStdout 流式回调:每个 stdout 数据块都同步转发给回调,而不是攒在末尾再丢一份。
// 这条锁的是 RunConsole 实时滚动的关键不变量 —— 用户写 `print('hi')`, 期望
// 立刻看到一行 'hi', 而不是等 run 结束才一次性出现。flush=True 强制 Python 不
// 缓冲 stdout,否则本用例在 Windows 上有时只看到 1 个数据块而不是 3 个。
itPy(
'onStdout 回调在运行中实时收到每块',
async () => {
const chunks: string[] = []
const result = await runAnalysis(
{
...BASE,
code: 'import sys\nfor i in range(3):\n print(f"line {i}", flush=True)\n'
},
undefined,
(chunk) => chunks.push(chunk)
)
expect(result.status).toBe('ok')
// chunks 拼起来应包含全部 3 行 —— 顺序可能因数据块边界变化,但内容必须齐全
const joined = chunks.join('')
expect(joined).toContain('line 0')
expect(joined).toContain('line 1')
expect(joined).toContain('line 2')
// 至少应该收到 1 块 —— 不能因为 appendCapped 把整段合并成 1 块就让测试
// 失去意义(只要数据真实流过来就够)
expect(chunks.length).toBeGreaterThan(0)
},
30000
)
// onStdout 抛错不能让 child 死掉 —— 之前没守护时一处 throw 会让整次 run 提前
// resolve,用户拿不到 result.json。这是 useAnalysis / RunConsole 解耦后必须
// 保住的不变量:渲染端 setState 出 bug 不能毁掉主进程运行。
itPy(
'onStdout 回调抛错被吞掉,runAnalysis 继续返回 ok',
async () => {
const result = await runAnalysis(
{ ...BASE, code: 'print("a", flush=True)\nprint("b", flush=True)\n' },
undefined,
() => {
throw new Error('simulated render crash')
}
)
expect(result.status).toBe('ok')
expect(result.wallTime?.seconds).toBeGreaterThan(0)
},
30000
)
// v3 起主进程彻底移除了自动超时(之前是 30s 常量 → IPC 上 timeoutSec 字段被砍掉
// → 现在连 30s 也没有了。超时路径的「setTimeout + taskkill」胶水代码不存在,
// 单元测试不需要等任何自动 kill。cancelAndWait 那条用例仍然能验「外部取消」能
// 杀掉子进程并 resolve,这是用户主动取消(按钮 / Esc的覆盖。
// 以下三条锁的是同一个回归:结果 JSON 曾和用户代码共用 stdout主进程取"最后一行"
// 解析。任何不以换行结尾的用户输出都会和 JSON 挤在同一行,合法 Python 报
// "结果解析失败"。现在结果走 --out 文件,用户想怎么写 stdout 都不影响契约。
describe('用户代码写 stdout 不破坏结果契约', () => {
itPy(
'sys.stdout.write 不带换行',
async () => {
const result = await runAnalysis({
...BASE,
code: 'import sys\ndef f():\n sys.stdout.write("done")\n return 1\nf()\n'
})
expect(result.status).toBe('ok')
expect(result.wallTime?.seconds).toBeGreaterThan(0)
},
30000
)
itPy(
'print(end="") 与 \\r 进度条',
async () => {
const result = await runAnalysis({
...BASE,
code: 'def f():\n for i in range(5):\n print("\\rprogress %d" % i, end="")\n return 1\nf()\n'
})
expect(result.status).toBe('ok')
},
30000
)
itPy(
'输出超过 8MB 上限也不再判失败',
async () => {
// 越过主进程 MAX_CHILD_OUTPUT(8MB) 的缓冲上限。以前必然 OutputOverflow
// 现在结果在文件里,缓冲溢出只是内存保护,不该让运行失败。
const result = await runAnalysis({
...BASE,
code: 'def f():\n print("x" * 9_000_000)\n return 1\nf()\n'
})
expect(result.status).toBe('ok')
},
60000
)
})
// 退出路径要能等到"进程树真的没了"。cancel() 是 voidawait 它等于 await undefined
// 所以 before-quit 里原来那句 await cancel() 从来没有真的等过 taskkill。
describe('cancelAndWait 可等待', () => {
it('没有在跑的子进程时也能 resolve', async () => {
await expect(cancelAndWait()).resolves.toBeUndefined()
})
itPy(
'取消正在跑的分析后 resolve且运行方随即结束',
async () => {
const running = runAnalysis({ ...BASE, code: 'while True:\n pass\n' })
// 给引擎一点时间真的 spawn 起来
await new Promise((r) => setTimeout(r, 800))
await expect(cancelAndWait()).resolves.toBeUndefined()
// 不关心它是 rejectAnalysisCancelledError还是 resolve 成错误结果,
// 只要求它不再挂着
await Promise.allSettled([running])
},
20000
)
})
})

View File

@@ -0,0 +1,499 @@
import type { ChildProcess } from 'child_process'
import { spawn, execFile } from 'child_process'
import { mkdtemp, writeFile, rm, readFile } from 'fs/promises'
import { existsSync } from 'fs'
import { tmpdir } from 'os'
import { join, dirname } from 'path'
import type { AnalysisResult, ProgressEvent, RunOptions } from '../shared/analysis'
import { isAnalysisResult } from '../shared/analysis'
import { IpcError } from '../shared/ipc'
let current: ChildProcess | null = null
// 串行化所有 runAnalysis 调用:
// - 之前只用 `if (current) killTree(current)` 防双跑,但 mkdtemp/writeFile
// 是 await 的,第二次调用进 spawn 之前 current 可能是 null第一次 spawn
// 才追上就成"孤儿 child"(既不被 kill 也没 close 事件清理 workdir
// - 改成 Promise 队列:前一次 finish 之后下一次才开始 spawncancel() 通过
// 让"等待中的调用"直接 reject 来立即返回,不需要 spawn 也立刻 kill
let runQueue: Promise<void> = Promise.resolve()
// Cancel-fence单调递增计数器cancel() 提一次号。所有"已入队但还没 spawn"
// 的 runAnalysisImpl 在真正动 IO 之前先读 fence — 旧 fence 的请求直接 reject
// 不创建 workdir、不写脚本、不 spawn。
// 这条与 runIdRef 的差别runIdRef 是 renderer 侧用来丢陈旧 resolve 的;这里是
// 主进程侧用来在 spawn 之前把"还在排队等上一轮跑完"的请求也拒掉,避免
// "上一轮跑完之前 cancel(),但 spawn 已经排队 → cancel 之后再 spawn 出来孤儿 child"。
let cancelFence = 0
/** Walk upward from candidate dirs to locate the folder containing engine/runner.py. */
export function resolveEngineRoot(): string {
const starts = [process.cwd(), __dirname]
for (const start of starts) {
let dir = start
for (let i = 0; i < 8; i++) {
if (existsSync(join(dir, 'engine', 'runner.py'))) return dir
const parent = dirname(dir)
if (parent === dir) break
dir = parent
}
}
// 找不到时抛出 IpcError 而不是默默返回 cwd 让 Python 报 ModuleNotFoundError。
// 这样渲染层能展示一个有用的诊断kind='engine_not_found'),而不是误导性的 import error。
throw new IpcError(
'engine_not_found',
`engine/runner.py 未在 ${starts.join(' / ')} 的祖先目录中找到;` +
`请确认应用是从仓库根目录启动、或打包脚本把 engine/ 放在了正确位置。`,
{ searched: starts }
)
}
/**
* 砍掉整棵进程树。返回 Promise 是为了让"退出前必须杀干净"的调用方能等 ——
* Windows 上 taskkill 是另开一个进程做的,不等它回来就 app.quit() 的话
* Python 子进程仍可能活到应用消失之后(孤儿)。
* 不关心时机的调用方直接 void 掉即可。
*/
function killTree(child: ChildProcess): Promise<void> {
if (!child.pid) return Promise.resolve()
if (process.platform === 'win32') {
return new Promise<void>((resolve) => {
execFile('taskkill', ['/pid', String(child.pid), '/T', '/F'], () => resolve())
})
}
try {
process.kill(-child.pid, 'SIGKILL')
} catch (e) {
try {
child.kill('SIGKILL')
} catch (innerErr) {
// 两条 kill 路径都失败:进程会成孤儿。下次 cancel() 找不到 current 也不会再试。
// 留 warn 给开发者排查,不要静默吞 —— 之前完全静默让 SIGKILL 失效的场景无从定位。
// eslint-disable-next-line no-console
console.warn('[profiler] kill failed, child may be orphaned', child.pid, e, innerErr)
}
}
return Promise.resolve()
}
/**
* cancel 的可等待版本:拿到"进程树真的没了"这个保证。
* 退出路径before-quit用这个避免 taskkill 还没跑完应用就退了。
*/
export function cancelAndWait(): Promise<void> {
// fence 提号:所有还在排队的请求会在轮到它们时看到这个变化并立即 reject
cancelFence++
const child = current
// 同步置空,与旧的 cancel() 语义一致(调用返回后 current 已是 null
current = null
return child ? killTree(child) : Promise.resolve()
}
export function cancel(): void {
// 返回 void 不是 Promise<void>,因为 IPC handler 同步调用即可 —— 对应 IpcError 中
// cancel 类型不需要 await。fence 提号和 current 置空都在同步段完成,
// 只有 Windows 下等 taskkill 收尾这一步是异步的IPC 侧不需要等。
void cancelAndWait()
}
export async function runAnalysis(
opts: RunOptions,
onProgress?: (e: ProgressEvent) => void,
onStdout?: (chunk: string) => void,
engineRoot: string = resolveEngineRoot()
): Promise<AnalysisResult> {
// 记录当前 fence 值 — 队列轮到时如果 fence 已经变了就立刻 reject
const fenceAtEnqueue = cancelFence
// 串行化等上一次真正结束resolve/reject才开下一次
const next = runQueue
.then(() => {
if (fenceAtEnqueue !== cancelFence) {
// 排队期间被 cancel 了 — 立即返回,不 spawn、不写 workdir
throw new AnalysisCancelledError()
}
return runAnalysisImpl(opts, onProgress, onStdout, engineRoot)
})
.catch((err) => {
// 链式队列:吞掉错误避免断链 — 但要重新抛给当前 await 的调用方
throw err
})
// next 是 Promise<AnalysisResult | void>resolve 时 AnalysisResultcancel reject 时 void
// 串行队列只需要一个"是否在忙"的 sentinel强制归一为 Promise<void>,让 runQueue 类型保持稳定
runQueue = next.then(
() => undefined,
() => undefined
)
return next
}
/** render-side 可以用 instanceof 区分"被取消"和真正的引擎错误UI 上给不同文案 */
export class AnalysisCancelledError extends Error {
constructor() {
super('分析已被取消')
this.name = 'AnalysisCancelledError'
}
}
function runAnalysisImpl(
opts: RunOptions,
onProgress?: (e: ProgressEvent) => void,
onStdout?: (chunk: string) => void,
engineRoot: string = resolveEngineRoot()
): Promise<AnalysisResult> {
// 注意:这里的 try/catch 只覆盖 IO 部分。spawn 之后的事件用 Promise 封装,
// 出错由 cleanup + reject 路径处理workdir 创建失败的极端路径单独 try/catch。
let workdir: string | null = null
return (async () => {
workdir = await mkdtemp(join(tmpdir(), 'pyprof-'))
const scriptPath = join(workdir, 'user_script.py')
try {
await writeFile(scriptPath, opts.code, 'utf-8')
} catch (err) {
// writeFile 抛错(盘满 / 权限mkdtemp 已经创建了 workdir要清掉
if (workdir)
await rm(workdir, { recursive: true, force: true }).catch((e) => logCleanupFail(workdir!, e))
throw err
}
return runChild(workdir, scriptPath, opts, onProgress, onStdout, engineRoot)
})().catch(async (err) => {
// 任何 IO 异常都确保不泄漏 workdir
if (workdir) await rm(workdir, { recursive: true, force: true }).catch((e) => logCleanupFail(workdir!, e))
throw err
})
}
/** 临时目录清理失败(非阻塞):留下 warn 给开发者排查,避免静默吞噬。 */
function logCleanupFail(path: string, err: unknown): void {
// eslint-disable-next-line no-console
console.warn('[profiler] workdir cleanup failed', path, err)
}
function runChild(
workdir: string,
scriptPath: string,
opts: RunOptions,
onProgress?: (e: ProgressEvent) => void,
onStdout?: (chunk: string) => void,
engineRoot?: string
): Promise<AnalysisResult> {
const root = engineRoot ?? resolveEngineRoot()
// 结果 JSON 走独立文件,不跟用户代码抢 stdout。
// 之前引擎把结果 print 到 stdout主进程取"最后一行"解析 —— 用户脚本里一句
// sys.stdout.write("done") 或 print(x, end='') 就会和 JSON 挤在同一行,
// 合法的 Python 却报"结果解析失败"。
const resultPath = join(workdir, 'result.json')
// 透传剖析范围:缺省 user只归因用户脚本用户开启"包含库函数"时改成 all。
// 用 push 而不是 spread避免 opts.scope 字段在类型上被严格收紧时被编译挡掉。
// --workdir 告诉 runner 把校准脚本落在我们已经 mkdir 好的 workdir 里,
// 跟用户脚本一起被 cleanup() 清掉,runner 不用再 mkdtemp。
const args = ['-m', 'engine.runner', '--script', scriptPath, '--out', resultPath, '--workdir', workdir]
if (opts.scope === 'all') args.push('--scope', 'all')
const childEnv = buildChildEnv(root)
return new Promise<AnalysisResult>((resolvePromise, rejectPromise) => {
// 防止双击 / 快速重跑泄露上一轮子进程React 端的 runIdRef 只压制 resolve
// 不会杀 spawn。先杀旧的再启动新的。
if (current) {
void killTree(current)
current = null
}
let child: ChildProcess
try {
child = spawn(opts.interpreter, args, {
cwd: root,
env: childEnv,
detached: process.platform !== 'win32'
})
} catch (err) {
// spawn 同步抛opts.interpreter 不是字符串 / spawn options 非法)
rm(workdir, { recursive: true, force: true }).catch((e) => logCleanupFail(workdir, e))
rejectPromise(err)
return
}
current = child
const stdoutStream = child.stdout
const stderrStream = child.stderr
if (!stdoutStream || !stderrStream) {
// 极端情况stdio: 'pipe' 没启用。理论上 default stdio 是 pipe但 TS 类型
// 标注为 nullable老代码也没处理。这里显式补一个 reject比 .on() 抛
// "Cannot read properties of null" 友好。
rm(workdir, { recursive: true, force: true }).catch((e) => logCleanupFail(workdir, e))
rejectPromise(new Error('child stdout/stderr 不可读'))
return
}
let stdout = ''
let stderr = ''
let stdoutOverflow = false
let stderrOverflow = false
// settled: 防止 error + close 两个出口互相覆盖
let settled = false
// 不再有自动超时:用户脚本可以跑任意长时间。
// 取消路径完全交给 UI 上的「取消」按钮 + Esc 快捷键→cancel() → killTree
// 之前的 30s 常量是历史决定 —— 但用户反馈「要跑长时间任务」,硬截断反而
// 是负体验cProfile 自己写完 result.json 还要被外层误杀,落盘一半被当 timeout 抛掉。
// 现在没有任何 setTimeout 兜底 kill,close 事件就是唯一出口。
const cleanup = () => {
// 仅当我们仍是 current 时清空cancel + 立即重跑的极端时序下,
// 老 child 的 close 事件晚到,若无条件置 null 会把新 child 引用抹掉,
// 导致后续 cancel() 找不到目标进程。
if (current === child) current = null
rm(workdir, { recursive: true, force: true }).catch((e) => logCleanupFail(workdir, e))
}
const finish = (action: () => void) => {
if (settled) return
settled = true
cleanup()
action()
}
// 缓冲区上限:用户脚本 print 几 GB 会让 Electron 主进程 OOM 然后整个应用崩 —
// 之前 stdout/stderr 是无限累积的只有出口slice(-N))截了一段。
// 8MB 已经远超任何健康脚本输出;超过就停止追加但保留尾部,方便 debug。
const MAX_CHILD_OUTPUT = 8 * 1024 * 1024
const appendCapped = (cur: string, chunk: string): { value: string; overflowed: boolean } => {
const next = cur + chunk
if (next.length > MAX_CHILD_OUTPUT) {
// 保留尾部 MAX_CHILD_OUTPUT/2 字节,仍能带出有用的诊断信息
return { value: next.slice(-MAX_CHILD_OUTPUT / 2), overflowed: true }
}
return { value: next, overflowed: false }
}
stdoutStream.setEncoding('utf-8')
stdoutStream.on('data', (d: string) => {
const r = appendCapped(stdout, d)
stdout = r.value
stdoutOverflow = stdoutOverflow || r.overflowed
// 流式推送给渲染层(左侧 RunConsole 实时滚动。appendCapped 已经把总量
// 压到 8MB,所以这里直接 forward 完整 chunk,渲染端做最终 buffer 即可。
// onStdout throw 不能让 child 死掉 —— 包一层 try/catch,异常仅 warn。
if (onStdout) {
try {
onStdout(d)
} catch (err) {
// eslint-disable-next-line no-console
console.warn('[profiler] onStdout callback threw, ignoring', err)
}
}
})
// 把跨 data 事件的半行拼回去 — 之前逐 chunk split 会让 "PROGR|ESS lines 70\n" 两半
// 都匹配不到 ^PROGRESS丢事件进度条卡住。
let progressTail = ''
const PROGRESS_RE = /^PROGRESS\s+(\S+)\s+(\d+)/
// progressTail 的上限。stderr 整体有 appendCapped 兜着,但这个"未完成行"缓冲
// 之前是无界的:用户代码往 stderr 写一个不含换行的超大块(例如
// sys.stderr.write('x' * 10**9))就能把主进程吃到 OOM。
// PROGRESS 行只有几十字节,留 64KB 远够拼回任何被切开的半行。
const MAX_PROGRESS_TAIL = 64 * 1024
stderrStream.setEncoding('utf-8')
stderrStream.on('data', (d: string) => {
const r = appendCapped(stderr, d)
stderr = r.value
stderrOverflow = stderrOverflow || r.overflowed
if (!onProgress) return
// 即使总体已 overflowprogress 行的解析仍然继续 — 用户能看到引擎在跑
progressTail += d
const parts = progressTail.split(/\r?\n/)
progressTail = parts.pop() ?? ''
// 尾部仍然超限说明这一大块里根本没有换行,不可能是 PROGRESS 行。
// 只保留末尾一段,既防无界增长,又不影响后续真正的半行拼接。
if (progressTail.length > MAX_PROGRESS_TAIL) {
progressTail = progressTail.slice(-MAX_PROGRESS_TAIL)
}
for (const line of parts) {
const m = line.match(PROGRESS_RE)
if (m) {
// 夹紧:上游约定 PROGRESS 行只有整数,但 line_profiler 自己的 stderr 噪声如果
// 哪天沾上 PROGRESS 前缀(极小概率),或者引擎被扩展到打印 50.x都不应该
// 让 UI 出现 `NaN%` 或负数。
const n = Number(m[2])
onProgress({ phase: m[1], pct: Number.isFinite(n) ? Math.max(0, Math.min(100, n)) : 0 })
}
}
})
child.on('error', (err) => {
// Electron IPC 不会传递 Error 上挂的自定义属性(如 .stderr所以这里不要
// 通过 reject 抛带 stderr 字段的 Error — renderer 那边只能拿到 message。
// 改成 resolve 一个 status='runtime_error' 的 AnalysisResult把 stderrTail
// 放进 error 对象renderer 一处处理所有失败 UI。
const msg = `${err.message}\n${stderr.slice(-500)}`.trim()
finish(() => resolvePromise(makeRuntimeError('EngineError', msg, stderr)))
})
child.on('close', async (code) => {
const codeDesc = code === null ? '被信号终止' : `exit ${code}`
// 结果是文件而不是 stdout 的最后一行,所以先读文件:读到了就是成功,
// 哪怕用户脚本把 stdout 刷爆了overflow 只是主进程内存保护,不再是失败原因)。
// 必须在 finish() 之前读 —— finish → cleanup 会把 workdir 整个 rm 掉。
//
// 没有自动超时:进程是被 cancel用户中途点「取消」还是被用户脚本正常 exit
// 触发的 close,都走这条同一路径。区别只是 result.json 是否完整:
// - 完整 → resolve 出 result
// - 写一半被 cancel 打断readFile 抛 / JSON 不合法)→ runtime_error,
// 但 render 侧 runIdRef guard 会把这次 resolve 吞掉,UI 仍是 idle,
// 用户不会看到「点取消却报错」的混淆 UI。
let raw: string
try {
raw = await readFile(resultPath, 'utf-8')
} catch {
// 引擎没写出结果:脚本崩到连兜底 JSON 都没发出、被用户中途取消杀掉、或盘写不进去
// —— 全部走 runtime_error,让 UI 给出 stderr 折叠区排查。
// 注意render 侧的 runIdRef guard 会把「中途 cancel」造成的这次 resolve 吞掉,
// 用户看到的依然是 idle 状态,不会把这个当成运行错误。
if (stdoutOverflow || stderrOverflow) {
const which = stdoutOverflow ? 'stdout' : 'stderr'
finish(() =>
resolvePromise(
makeRuntimeError(
'OutputOverflow',
`子进程 ${which} 输出超过 ${MAX_CHILD_OUTPUT / 1024 / 1024} MB 上限(用户脚本可能疯狂打印),且引擎未产出结果 (${codeDesc})`,
stderr
)
)
)
return
}
// 同样走 resolve + runtime_error避免自定义 Error 属性在 IPC 上丢失
finish(() => resolvePromise(makeRuntimeError('NoOutput', `引擎无输出 (${codeDesc})`, stderr)))
return
}
try {
const parsed: unknown = JSON.parse(raw)
// schema 漂移保护JSON 解析成功但形状不对schemaVersion 不对 / 关键字段缺失)
// 时不要直接把 unknown 强转 AnalysisResultrenderer 端会撞 "Cannot read
// property 'wallTime' of undefined"。用 isAnalysisResult 走 runtime_error
// 路径UI 给用户看到的是 "结果结构不匹配",而不是冷不丁的白屏
if (!isAnalysisResult(parsed)) {
const shape = describeShape(parsed)
const msg = `结果结构不匹配 (${codeDesc}): ${shape}`
finish(() => resolvePromise(makeRuntimeError('ParseError', msg, stderr)))
return
}
// 拿到合法 result:这就是用户已经跑出来的真实数据,直接返回。
finish(() => resolvePromise(parsed))
return
} catch (e) {
const msg = `结果解析失败 (${codeDesc}): ${(e as Error).message}`
finish(() => resolvePromise(makeRuntimeError('ParseError', msg, stderr)))
}
})
})
}
/**
* 把 stderr 整理成给用户看的"引擎日志"。
*
* PROGRESS 行是主进程和引擎之间的内部协议(见上面的 PROGRESS_RE对用户没有任何意义。
* 之前直接把原始 stderr 塞进 stderrTail于是超时错误页里显示的是
* `PROGRESS timing 10` —— 超时场景下引擎往往还没来得及打真正的报错,
* 这几行进度协议就成了日志的全部内容,用户看到一堆看不懂的东西,
* 真正有用的 traceback 反而被 2000 字节的截断挤掉。
*/
export function userFacingStderr(stderr: string): string {
return stderr
.split(/\r?\n/)
.filter((line) => !/^PROGRESS\s+\S+\s+\d+/.test(line))
.join('\n')
.trim()
}
/**
* 子进程环境变量最小白名单:避免把 GITHUB_TOKEN / AWS_* / *_KEY 这类
* 父进程 secret 透给用户代码(之前的 `{ ...process.env, ... }` 会全继承)。
* 留下的字段都是 Python 解释器 / Windows DLL 解析实际需要的。
*
* PYTHONIOENCODING 只管标准流的编码Windows 上 sys.stdout.buffer 底层仍可能
* 走 cp936用户代码 print 中文/emoji 会乱码或抛 UnicodeEncodeError。
* PYTHONUTF8=1 (PEP 540, Python 3.7+) 强制整个解释器进 UTF-8 模式。
*/
function buildChildEnv(root: string): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = {
PYTHONPATH: root,
PYTHONIOENCODING: 'utf-8',
PYTHONUTF8: '1',
PYTHONUNBUFFERED: '1'
}
for (const k of CHILD_ENV_INHERIT_KEYS) {
const v = process.env[k]
if (v !== undefined) env[k] = v
}
return env
}
/** 从父进程透传给子进程的 env 键名白名单 —— 其余一律不带。 */
const CHILD_ENV_INHERIT_KEYS: readonly string[] = [
'PATH',
'SystemRoot',
'HOME',
'TEMP',
'TMP',
'LANG',
'LC_ALL'
]
/**
* 任何"engine 路径上"的错误spawn 失败 / 无输出 / 解析失败 / child.on('error'))都
* 用这个 helper 构造一个 status='runtime_error' 的 AnalysisResult而不是 reject。
*
* 原因Electron ipcMain.handle 的 reject 路径只把 Error.message 跨 IPC 序列化过去,
* 自定义属性(如 .stderr会丢失。把 stderrTail 放进 result.error 字段renderer 走
* 统一的 ErrorBanner 渲染路径,不需要为每种 IPC 错误再写一次 UI。
*/
function makeRuntimeError(type: string, message: string, stderr = ''): AnalysisResult {
return makeErrorResult('runtime_error', type, message, stderr)
}
/**
* 统一的「合成错误结果」构造器 —— makeRuntimeError 共用。
* schemaVersion=4 与 Python engine SCHEMA_VERSION 对齐,合成结果混着旧 version
* 会让 UI 一次运行里看到两种 version。
*/
function makeErrorResult(
status: AnalysisResult['status'],
errorType: string,
message: string,
stderr: string
): AnalysisResult {
const tail = userFacingStderr(stderr)
return {
schemaVersion: 4,
environment: { python: '', platform: '', processor: '', timerResolution: 0 },
config: {},
status,
error: {
type: errorType,
message,
...(tail ? { stderrTail: tail.slice(-2000) } : {})
},
wallTime: null,
functions: [],
flame: null
}
}
/**
* 给"形状不对"的 JSON 输出个简短诊断(防止用户看到"Cannot read property X of undefined"
* 这种没头没尾的崩溃)。只取前几个键 + 主要类型truncate 到 200 字符避免日志撑爆。
*/
function describeShape(x: unknown): string {
if (x === null) return 'null'
if (typeof x !== 'object') return `${typeof x}: ${String(x).slice(0, 80)}`
const keys = Object.keys(x).slice(0, 8)
const sig = keys
.map((k) => {
const v = (x as Record<string, unknown>)[k]
const t = Array.isArray(v) ? `Array(${(v as unknown[]).length})` : typeof v
return `${k}:${t}`
})
.join(', ')
return sig.length > 200 ? sig.slice(0, 200) + '…' : sig
}

252
src/main/terminal.test.ts Normal file
View File

@@ -0,0 +1,252 @@
import type * as cp from 'child_process'
import { openTerminal, type TerminalDeps } from './terminal'
/**
* 跨平台 spawn 行为单元测试。
*
* 平台分支(process.platform 在不同 OS 测出来不一样)用 vi.stubGlobal 切掉不现实,
* 改用 DI 注入 TerminalDeps + 直接走对应的 openWindows/openMac/openLinux 间接覆盖:
* - openTerminal 在每个平台上各跑一次,验证 spawn 调用参数
* - 不在测试里 import process.platform 默认值,转而依赖 DI 验证三个分支各自的代码路径
*
* 关于 mock 形状:
* - spawn 需要返回带 .unref() 方法的对象(否则 terminal.ts 调 unref 抛 TypeError)
* - execFile 是 promisified,这里 mock 返回 Promise<{ stdout, stderr }>
*/
function makeFakeChild(): cp.ChildProcess {
// 只用 unref,其他事件都不挂。Node 的 ChildProcess 类型很复杂,这里 cast 出去
return { unref: () => undefined } as unknown as cp.ChildProcess
}
function makeDeps(overrides: Partial<TerminalDeps> = {}): {
deps: TerminalDeps
spawnCalls: Array<{ cmd: string; args: readonly string[]; opts: cp.SpawnOptions }>
execCalls: Array<{ cmd: string; args: readonly string[] }>
} {
const spawnCalls: Array<{ cmd: string; args: readonly string[]; opts: cp.SpawnOptions }> = []
const execCalls: Array<{ cmd: string; args: readonly string[] }> = []
const deps: TerminalDeps = {
spawn: ((cmd: string, args: readonly string[], opts: cp.SpawnOptions) => {
spawnCalls.push({ cmd, args, opts })
return makeFakeChild()
}) as unknown as typeof cp.spawn,
execFile: ((cmd: string, args: readonly string[]) => {
execCalls.push({ cmd, args })
// 默认成功:返回带 stdout 的对象。Linux 探测用 which,stdout 内容我们不在乎。
return Promise.resolve({ stdout: '/usr/bin/' + args[0], stderr: '' })
}) as unknown as typeof cp.execFile,
...overrides
}
return { deps, spawnCalls, execCalls }
}
describe('openTerminal', () => {
describe('Windows', () => {
// 在 Windows runner 上 process.platform === 'win32',openTerminal 会走 openWindows 分支
if (process.platform !== 'win32') {
it.skip('Windows 分支:本机不是 win32,跳过', () => {})
return
}
it('用 cmd /c start "" cmd /K <cmd> 开新窗口', async () => {
const { deps, spawnCalls } = makeDeps()
await openTerminal('winget install Python.Python.3.12', 'cmd', deps)
expect(spawnCalls).toHaveLength(1)
const call = spawnCalls[0]
expect(call.cmd).toBe('cmd.exe')
expect(call.args).toEqual(['/c', 'start', '""', 'cmd.exe', '/k', 'winget install Python.Python.3.12'])
// detached + stdio:ignore 三件套:父进程退出不影响终端
expect(call.opts.detached).toBe(true)
expect(call.opts.stdio).toBe('ignore')
})
it('空命令也能开窗口', async () => {
const { deps, spawnCalls } = makeDeps()
await openTerminal('', 'cmd', deps)
expect(spawnCalls).toHaveLength(1)
// 不包含 echo / winget 等;只有 /k 在末尾
expect(spawnCalls[0].args).toEqual(['/c', 'start', '""', 'cmd.exe', '/k'])
})
it('shell=powershell 时用 powershell.exe -NoExit -Command "<cmd>"', async () => {
const { deps, spawnCalls } = makeDeps()
await openTerminal('winget install Python.Python.3.12', 'powershell', deps)
expect(spawnCalls).toHaveLength(1)
const call = spawnCalls[0]
// 外层 spawn 还是 cmd.exe —— 我们用 cmd /c start 把 powershell 拉进新窗口
expect(call.cmd).toBe('cmd.exe')
// start 内层: powershell.exe + -NoExit + -Command "<cmd>"
expect(call.args).toEqual([
'/c',
'start',
'""',
'powershell.exe',
'-NoExit',
'-Command',
'"winget install Python.Python.3.12"'
])
expect(call.opts.detached).toBe(true)
expect(call.opts.stdio).toBe('ignore')
})
it('shell=powershell 空命令时只开 powershell -NoExit,无 -Command', async () => {
const { deps, spawnCalls } = makeDeps()
await openTerminal('', 'powershell', deps)
expect(spawnCalls).toHaveLength(1)
expect(spawnCalls[0].args).toEqual(['/c', 'start', '""', 'powershell.exe', '-NoExit'])
})
it('shell 缺省时走 cmd(向后兼容老调用方)', async () => {
const { deps, spawnCalls } = makeDeps()
await openTerminal('echo hi', 'cmd', deps)
expect(spawnCalls).toHaveLength(1)
expect(spawnCalls[0].cmd).toBe('cmd.exe')
expect(spawnCalls[0].args).toEqual(['/c', 'start', '""', 'cmd.exe', '/k', 'echo hi'])
})
})
describe('macOS', () => {
if (process.platform !== 'darwin') {
it.skip('macOS 分支:本机不是 darwin,跳过', () => {})
return
}
it('用 osascript 让 Terminal.app 执行命令', async () => {
const { deps, spawnCalls } = makeDeps()
await openTerminal('brew install python3', 'cmd', deps)
// 两次 spawn:do script + activate
expect(spawnCalls.length).toBeGreaterThanOrEqual(2)
const doScriptCall = spawnCalls.find((c) => c.args[0] === '-e' && c.args[1].includes('do script'))
expect(doScriptCall).toBeDefined()
expect(doScriptCall!.args[1]).toContain('tell application "Terminal"')
expect(doScriptCall!.args[1]).toContain('brew install python3')
})
it('命令里的双引号被转义为 \\",避免切 AppleScript 字符串', async () => {
const { deps, spawnCalls } = makeDeps()
// 用户命令里含双引号 — 透到 AppleScript 必须转义,否则切串后命令走样
await openTerminal('echo "hi" | tee /tmp/log', 'cmd', deps)
const doScriptCall = spawnCalls.find((c) => c.args[0] === '-e')
expect(doScriptCall).toBeDefined()
// AppleScript 字符串里的 " 应被 escape 成 \"
expect(doScriptCall!.args[1]).toContain('\\"hi\\"')
expect(doScriptCall!.args[1]).not.toMatch(/echo "hi"/) // 原文里的 " 不能裸出现
})
})
describe('Linux', () => {
if (process.platform !== 'linux') {
it.skip('Linux 分支:本机不是 linux,跳过', () => {})
return
}
it('优先选 x-terminal-emulator', async () => {
const { deps, spawnCalls, execCalls } = makeDeps()
await openTerminal('sudo apt install python3', 'cmd', deps)
// which 探测一次就成功,x-terminal-emulator 在 PATH 上
expect(execCalls[0]).toEqual({ cmd: 'which', args: ['x-terminal-emulator'] })
// spawn 调 x-terminal-emulator + bash -c
expect(spawnCalls).toHaveLength(1)
expect(spawnCalls[0].cmd).toBe('x-terminal-emulator')
expect(spawnCalls[0].args).toEqual(['-e', 'bash', '-c', 'sudo apt install python3\nexec bash'])
})
it('探测全部失败时 reject,错误带尝试列表', async () => {
const failingDeps: TerminalDeps = {
spawn: (() => makeFakeChild()) as unknown as typeof cp.spawn,
execFile: (() => Promise.reject(new Error('not found'))) as unknown as typeof cp.execFile
}
await expect(openTerminal('whatever', 'cmd', failingDeps)).rejects.toThrow(/未找到可用的终端模拟器/)
await expect(openTerminal('whatever', 'cmd', failingDeps)).rejects.toThrow(/x-terminal-emulator/)
await expect(openTerminal('whatever', 'cmd', failingDeps)).rejects.toThrow(/xterm/)
})
it('x-terminal-emulator 缺失时回退到 gnome-terminal', async () => {
let whichIndex = 0
const partialDeps: TerminalDeps = {
spawn: (() => makeFakeChild()) as unknown as typeof cp.spawn,
execFile: ((cmd: string, args: readonly string[]) => {
// 第一次 which x-terminal-emulator 失败,第二次 which gnome-terminal 成功
if (cmd === 'which' && args[0] === 'gnome-terminal' && whichIndex === 1) {
return Promise.resolve({ stdout: '/usr/bin/gnome-terminal', stderr: '' })
}
whichIndex++
return Promise.reject(new Error('not found'))
}) as unknown as typeof cp.execFile
}
await openTerminal('echo hi', 'cmd', partialDeps)
// 已经走到 spawn,但我们没传 spawnCalls 进去所以不验证具体调用,只验证不 reject
})
it('gnome-terminal 用 -- 而不是 -e 分隔参数', async () => {
const fixedDeps: TerminalDeps = {
spawn: (() => makeFakeChild()) as unknown as typeof cp.spawn,
execFile: ((cmd: string, args: readonly string[]) => {
if (cmd === 'which' && args[0] === 'x-terminal-emulator') {
return Promise.reject(new Error('no'))
}
if (cmd === 'which' && args[0] === 'gnome-terminal') {
return Promise.resolve({ stdout: '/usr/bin/gnome-terminal', stderr: '' })
}
return Promise.reject(new Error('not in path'))
}) as unknown as typeof cp.execFile
}
// 用一个 spy 替代 spawn 来抓调用
const calls: Array<{ cmd: string; args: readonly string[] }> = []
fixedDeps.spawn = ((cmd: string, args: readonly string[]) => {
calls.push({ cmd, args })
return makeFakeChild()
}) as unknown as typeof cp.spawn
await openTerminal('echo hi', 'cmd', fixedDeps)
expect(calls).toHaveLength(1)
expect(calls[0].cmd).toBe('gnome-terminal')
// 关键断言:首参是 -- 而不是 -e
expect(calls[0].args[0]).toBe('--')
})
it('其他终端(xterm / konsole / xfce4-terminal)用 -e', async () => {
for (const term of ['xterm', 'konsole', 'xfce4-terminal']) {
const calls: Array<{ cmd: string; args: readonly string[] }> = []
const deps: TerminalDeps = {
spawn: ((cmd: string, args: readonly string[]) => {
calls.push({ cmd, args })
return makeFakeChild()
}) as unknown as typeof cp.spawn,
execFile: ((cmd: string, args: readonly string[]) => {
// 前面的全部 reject,只在目标 term 上成功
const order = ['x-terminal-emulator', 'gnome-terminal', 'konsole', 'xfce4-terminal', 'xterm']
if (cmd === 'which' && args[0] === term) {
return Promise.resolve({ stdout: '/usr/bin/' + term, stderr: '' })
}
// 顺序在 term 之前的也 reject;之后的不会跑(因为已经找到了)
const idx = order.indexOf(term)
const argIdx = order.indexOf(args[0])
if (argIdx < idx || argIdx === -1) {
return Promise.reject(new Error('not in path'))
}
return Promise.resolve({ stdout: '/usr/bin/' + args[0], stderr: '' })
}) as unknown as typeof cp.execFile
}
await openTerminal('echo hi', 'cmd', deps)
expect(calls).toHaveLength(1)
expect(calls[0].args[0]).toBe('-e')
}
})
})
describe('默认 deps', () => {
it('不传 deps 也能走通(用真实 child_process,但 spawn 不会真的开窗口 — 测试环境 detached 后会失败/被 OS 吞)', async () => {
// 我们不验证真实 spawn 是否成功(那要看 OS),只验证调用路径不会立刻抛同步异常
// — 真正开窗口失败由调用方通过 catch 处理
if (process.platform === 'win32' || process.platform === 'darwin') {
// macOS: osascript 可能不在 PATH 上;Windows: cmd.exe 几乎一定在
// 不做 hard assert,只是验证 sync 段不抛
await expect(openTerminal('echo hi')).resolves.not.toThrow()
} else {
// Linux: 大概率没有任何终端模拟器,会 reject — 这是正确行为
await openTerminal('echo hi').catch(() => undefined)
}
})
})
})

172
src/main/terminal.ts Normal file
View File

@@ -0,0 +1,172 @@
import * as cp from 'child_process'
import { promisify } from 'util'
/** 测试时可注入 mock。默认是真实的 child_process 引用,生产路径不感知。 */
export interface TerminalDeps {
spawn: typeof cp.spawn
execFile: typeof cp.execFile
}
const defaultDeps: TerminalDeps = {
spawn: cp.spawn,
execFile: cp.execFile
}
const execFileAsync = promisify(cp.execFile)
/** Windows 上要启哪个 shell。Mac / Linux 不用这个 — 由 OS 决定。 */
export type WindowsShell = 'cmd' | 'powershell'
/**
* 跨平台打开系统终端,可预填一条命令。窗口在命令跑完后保持打开(/K / -NoExit / exec bash)。
*
* 用途:用户没装 Python 时,UI 上点一下 → 弹 cmd/Terminal/gnome-terminal,
* 命令已经预填好(`winget install ...` / `brew install ...`),回车即装。
*
* 安全边界:不解析 shell 元字符(`&&` / `|` / `>` 等),由调用方负责构造安全命令 —
* 我们这里只接受简单字面量(plan 里的 installPythonCommand 输出正是这种形式)。
* IPC 层额外做 string 类型 + 长度校验(参见 src/main/index.ts)。
*
* 设计:
* - detached + stdio:'ignore' + unref() 三件套:父进程(Electron)退出后,终端窗口
* 不被一起带走。spawn 拿到的子引用也要 unref(),否则 Node 会维持引用。
* - 不等子进程 exit:打开就 resolve,UI 立刻恢复可交互。"窗口是否真的弹出来"由 OS 决定,
* 父进程无法可靠等待(Windows `start` / macOS osascript 都是 fire-and-forget)。
*/
export async function openTerminal(
command: string = '',
shell: WindowsShell = 'cmd',
deps: TerminalDeps = defaultDeps
): Promise<void> {
if (process.platform === 'win32') {
openWindows(command, shell, deps)
return
}
if (process.platform === 'darwin') {
await openMac(command, deps)
return
}
await openLinux(command, deps)
}
/**
* Windows:`cmd /c start "" <shell> <keepOpen> <cmd>` 模式开新窗口。
*
* - 必须用 `cmd /c start ... <shell> ...` 才能真开新窗口;直接
* `spawn("cmd", ["/k", cmd])` 会绑回父进程的关联控制台,Electron 没有 → 不可见
* - `start` 的第一个位置参数是窗口标题,传 `""` 占位 — 不传会被吞掉首参
*
* shell=cmd:`cmd.exe /K <cmd>` — /K 让 cmd 跑完命令后窗口留着
* shell=powershell:`powershell.exe -NoExit -Command "<cmd>"` — -NoExit 是 PS 里 /K 的等价物
*
* PowerShell 的 -Command 参数需要包在双引号里(整个 -Command 值被当作 PS 表达式解析);
* 我们的命令字面量不含双引号(`winget install Python.Python.3.12`),直接套引号即可。
* 含双引号的命令未来要加 escape(把内部 " 替换成 `\"` 后再外层 ")。
*/
function openWindows(command: string, shell: WindowsShell, deps: TerminalDeps): void {
let innerCmd: string
let innerArgs: string[]
if (shell === 'powershell') {
innerCmd = 'powershell.exe'
// -NoExit 等价 cmd 的 /K;命令用双引号包成 -Command 的参数值
innerArgs = command ? ['-NoExit', '-Command', `"${command}"`] : ['-NoExit']
} else {
innerCmd = 'cmd.exe'
innerArgs = command ? ['/k', command] : ['/k']
}
// cmd /c start "" <innerCmd> <innerArgs...>
const args: string[] = ['/c', 'start', '""', innerCmd, ...innerArgs]
const child = deps.spawn('cmd.exe', args, {
detached: true,
stdio: 'ignore',
// windowsHide: false 是默认,显式写出来防止有人手滑改成 true 让窗口藏起来
windowsHide: false
})
// unref():不维持对子进程的引用,否则 Node 进程会等它退出
child.unref()
}
/**
* macOS:AppleScript 让 Terminal.app 开新 tab 执行命令。
*
* - 选 osascript 而不是 `open -a Terminal`:后者只能开窗口不能预填命令
* - activate 让窗口浮到前面;部分 macOS 版本不开新窗口 — 但 do script
* 保证有地方跑命令即可(可能是新 tab / 新窗口)
* - AppleScript 里 `"` 必须转义为 `\"`,否则用户命令里的引号会截断字符串
* - 反引号 / 美元符 等 AppleScript 不直接解释,但保险起见一并 escape
* (避免某些边界场景被当成 AppleScript 控制字符)
*/
async function openMac(command: string, deps: TerminalDeps): Promise<void> {
const escaped = escapeForAppleScript(command)
const script = command
? `tell application "Terminal" to do script "${escaped}"`
: `tell application "Terminal" to do script ""`
// 两次 spawn 顺序无所谓,各自 detached + unref,父进程退出不影响
const a = deps.spawn('osascript', ['-e', script], {
detached: true,
stdio: 'ignore'
})
a.unref()
const b = deps.spawn('osascript', ['-e', 'tell application "Terminal" to activate'], {
detached: true,
stdio: 'ignore'
})
b.unref()
// 即使函数体内没 await,也保持 async — 三个平台分支的返回类型签名要一致,
// openTerminal 顶层 await 不会因此出错;Promise.resolve() 让 await 关键字有意义
await Promise.resolve()
}
/**
* Linux:探测一组常见终端模拟器,挑第一个存在的用。
*
* 候选优先级(都在 PATH 上时):x-terminal-emulator (Debian alternatives 系统) >
* gnome-terminal > konsole > xfce4-terminal > xterm
*
* 探测用 `which`,失败静默继续下一个。
*
* 命令走 `bash -c "<cmd>; exec bash"` 包装:
* - 空命令时用 `:`(bash 的 no-op)避免 bash -c 空串报 "no command"
* - `exec bash` 让窗口跑完原命令后留在 bash 提示符,不立即关闭
*
* 参数分隔符差异:gnome-terminal 用 `--`,其他用 `-e`。混用会报 unknown option。
*/
async function openLinux(command: string, deps: TerminalDeps): Promise<void> {
const candidates = ['x-terminal-emulator', 'gnome-terminal', 'konsole', 'xfce4-terminal', 'xterm']
let found: string | null = null
for (const term of candidates) {
try {
// which 0 exit code + 路径输出 = 存在
await execFileAsync('which', [term], { timeout: 2000 })
found = term
break
} catch {
continue
}
}
if (!found) {
throw new Error(
`未找到可用的终端模拟器(尝试 ${candidates.join(' / ')} 均失败)。` +
`请安装其中一个,或在系统终端手动运行命令。`
)
}
const shellCmd = `${command || ':'}\nexec bash`
const args =
found === 'gnome-terminal'
? // gnome-terminal 用 -- 分隔后续参数(老版本才支持 -e)
['--', 'bash', '-c', shellCmd]
: ['-e', 'bash', '-c', shellCmd]
const child = deps.spawn(found, args, {
detached: true,
stdio: 'ignore'
})
child.unref()
}
/** AppleScript 字符串字面量转义:`\` → `\\`,`"` → `\"`。
* 不转义 `$` / 反引号 — AppleScript do script 不会解释它们,
* 转义反而会被当成命令的一部分传给 shell。 */
function escapeForAppleScript(s: string): string {
return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
}

55
src/preload/index.ts Normal file
View File

@@ -0,0 +1,55 @@
import { contextBridge, ipcRenderer, type IpcRendererEvent } from 'electron'
import type { FileReadRequest, ProgressEvent, RendererApi, RunOptions } from '../shared/analysis'
const api: RendererApi = {
detectInterpreters: () => ipcRenderer.invoke('interp:detect'),
pickInterpreter: () => ipcRenderer.invoke('interp:pick'),
analyze: (opts: RunOptions) => ipcRenderer.invoke('analyze:run', opts),
cancel: () => ipcRenderer.invoke('analyze:cancel'),
onProgress: (cb: (e: ProgressEvent) => void) => {
const listener = (_e: IpcRendererEvent, payload: ProgressEvent) => cb(payload)
ipcRenderer.on('analyze:progress', listener)
return () => ipcRenderer.removeListener('analyze:progress', listener)
},
onStdout: (cb: (chunk: string) => void) => {
// 与 onProgress 同型:每次主进程 'analyze:stdout' 事件触发就回调一行 utf-8 字符串。
// RunConsole 用它做实时滚动 + 累积 buffer。
const listener = (_e: IpcRendererEvent, chunk: string) => cb(chunk)
ipcRenderer.on('analyze:stdout', listener)
return () => ipcRenderer.removeListener('analyze:stdout', listener)
},
// 打开系统终端Settings 里安装 Python、TopBar「打开终端」按钮都走它。
// 命令可选 —— 不传就开一个默认 shell 窗口。
// shell 仅 Windows 生效 —— 选 cmd.exe / powershell.exe;Mac / Linux 由 OS 决定。
openTerminal: (command?: string, shell?: 'cmd' | 'powershell') =>
ipcRenderer.invoke('terminal:open', command ?? '', shell ?? 'cmd'),
// 读 Python 源文件stdlib / 第三方包热点打开 tab 用)。主进程已做路径 / 大小校验,
// 返回值是 discriminated union —— 失败不 throwkind 字段告诉 UI 怎么展示。
readFile: (req: FileReadRequest) => ipcRenderer.invoke('file:read', req),
// process.platform 在 preload 上下文里可访问(sandbox 模式下仍保留的 Electron-managed 属性),
// 无需 IPC:一个常量字符串,启动时定一次终身不变
platform: process.platform,
// 顶栏左上角的图标 PNG data URL主进程从 icon.ico 解码 + toDataURL 后传过来)。
// null 表示读不到 —— renderer 退回 SVG fallback。
getAppIcon: () => ipcRenderer.invoke('app:icon') as Promise<string | null>,
// 在系统默认浏览器里打开外部 URL设置「关于」section 的开发者主页链接)。
// 主进程做协议白名单(只放行 http/https+ URL.parse 校验,失败 throw IpcError。
openExternal: (url: string) => ipcRenderer.invoke('shell:openExternal', url) as Promise<void>,
// 自定义窗口控制(顶栏右上角 minimize / maximize / close
// minimize / close / toggleMaximize:request-response
// onMaximizeChanged:event pushOS 触发的最大化状态变更也同步给 renderer
windowControls: {
minimize: () => ipcRenderer.invoke('window:minimize'),
toggleMaximize: () =>
ipcRenderer.invoke('window:maximize-toggle') as Promise<{ isMaximized: boolean }>,
close: () => ipcRenderer.invoke('window:close'),
isMaximized: () => ipcRenderer.invoke('window:is-maximized') as Promise<boolean>,
onMaximizeChanged: (cb: (isMaximized: boolean) => void) => {
const listener = (_e: IpcRendererEvent, isMaximized: boolean) => cb(isMaximized)
ipcRenderer.on('window:maximize-changed', listener)
return () => ipcRenderer.removeListener('window:maximize-changed', listener)
}
}
}
contextBridge.exposeInMainWorld('api', api)

43
src/renderer/index.html Normal file
View File

@@ -0,0 +1,43 @@
<!doctype html>
<html lang="zh-CN" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!--
CSP。注意两点
1. 不要放 frame-ancestors —— 按规范它在 <meta> 里会被忽略,只有 HTTP 响应头有效,
Chromium 还会为此在控制台报一条 errore2e 断言"零 console error"会因此变红)。
这里也不需要它窗口由主进程创建nodeIntegration 关闭,不存在被外部页面嵌套的场景。
2. worker-src 显式写出来。ELK 布局跑在 Web Worker 里,而 default-src 'none' 下
worker-src 的回退链worker-src → child-src → script-src不够直观
写死 'self' 免得以后有人调 script-src 时把 worker 一起弄挂。
-->
<meta
http-equiv="Content-Security-Policy"
content="default-src 'none'; script-src 'self'; worker-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self' data:; img-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'none'; form-action 'none';"
/>
<title>Python 耗时可视化分析</title>
<!--
主题早应用:在 main.tsx 之前同步读 localStorage给 <html> 加 .light / .dark class。
React 挂载前已经处于正确主题,避免整页白闪再变暗。
必须 try/catchfile:// 协议下某些 Electron 配置下 localStorage 抛 SecurityError。
-->
<script>
try {
var t = localStorage.getItem('pyrof.theme')
if (t === 'light') {
document.documentElement.classList.remove('dark')
document.documentElement.classList.add('light')
} else {
document.documentElement.classList.add('dark')
}
var l = localStorage.getItem('pyrof.lang')
if (l === 'en-US') document.documentElement.lang = 'en-US'
} catch (_) {}
</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

521
src/renderer/src/App.tsx Normal file
View File

@@ -0,0 +1,521 @@
import { Suspense, lazy, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from 'react'
import type { CodePanelHandle } from './components/CodePanel'
import EditorTabs, { type EditorTabView } from './components/EditorTabs'
import MinimalRunConfig, { type RunConfigHandle } from './components/MinimalRunConfig'
import ResultsPanel from './components/ResultsPanel'
import TopBar from './components/TopBar'
import SettingsModal from './components/SettingsModal'
import EmptyState from './components/EmptyState'
import ErrorBanner from './components/ErrorBanner'
import FailureResult from './components/FailureResult'
import Splitter from './components/Splitter'
import RunConsole from './components/RunConsole'
import { useAnalysis } from './hooks/useAnalysis'
import { useExternalFiles } from './hooks/useExternalFiles'
import { useInterpreters } from './hooks/useInterpreters'
import { useResizableFraction } from './hooks/useResizableFraction'
import { useScope } from './hooks/useScope'
import { useShellPreference } from './hooks/useShellPreference'
import { useTheme } from './hooks/useTheme'
import { I18nProvider, useT } from './i18n'
import { resolveOrigin } from './utils/origin'
import { errorTitleForTab } from './utils/editorTabs'
import { DEMO_CODE } from './sample'
import type { FunctionNode, RunOptions } from '../../shared/analysis'
/**
* 编辑器按需加载monaco-editor 是首屏 bundle 的最大来源。
* CodePanelLazy 顺带把有副作用的 monaco-setup 一起带进这个 chunk详见该文件注释
* React.lazy 会透传 ref所以 editorRef 的 revealLine/clearHighlight 照旧可用;
* chunk 未就绪时 ref 为 null调用点本来就用 ?. 兜着(和挂载前的行为一致)。
*/
const CodePanel = lazy(() => import('./components/CodePanelLazy'))
/**
* 编辑器 chunk 加载期间的骨架占位Suspense fallback
*
* 没有装饰 chrome —— 等宽字体的行占位直接铺满,避免 monaco 就绪时再跳一下。
*/
function EditorSkeleton() {
const t = useT()
return (
<div className="h-full bg-bg p-3" role="status" aria-label={t('app.editorLoading')}>
<div className="flex h-full flex-col gap-2" aria-hidden="true">
{[72, 56, 88, 40, 64, 48].map((w, i) => (
<div
key={i}
className="h-3 rounded bg-surface-1"
style={{ width: `${w}%`, opacity: 1 - i * 0.12 }}
/>
))}
</div>
</div>
)
}
/**
* 用 ResizeObserver 跟踪元素当前 contentRect 的 width 或 height返回 0 直到首次挂载后量到值。
* 用于 splitter 把「像素 delta」换算成「fraction 增量」时拿分母 —— 容器尺寸变化(窗口 resize、
* DevTools 开关、字号变更)都要跟上,不能依赖 mount 时的 getBoundingClientRect 一次性快照。
*/
function useElementSize(ref: RefObject<HTMLElement>, axis: 'width' | 'height'): number {
const [size, setSize] = useState(0)
useEffect(() => {
const el = ref.current
if (!el) return
setSize(el.getBoundingClientRect()[axis])
const ro = new ResizeObserver((entries) => {
const entry = entries[0]
if (entry) setSize(entry.contentRect[axis])
})
ro.observe(el)
return () => ro.disconnect()
}, [ref, axis])
return size
}
export default function App() {
// I18nProvider + ThemeProvider 套在 App 外面,让 App 内部所有 useT / useTheme 都拿到上下文
return (
<I18nProvider>
<AppInner />
</I18nProvider>
)
}
function AppInner() {
const t = useT()
const [code, setCode] = useState(DEMO_CODE)
const [selectedFuncId, setSelectedFuncId] = useState<string | undefined>()
// 缓存最近一次运行的参数:失败页「再试一次」直接复用,不再 hack 触发隐藏按钮
const [lastOpts, setLastOpts] = useState<RunOptions | null>(null)
// SettingsModal open state —— 用 prop drilling 而非 Context因为只 TopBar 和 Modal 两个消费点
const [settingsOpen, setSettingsOpen] = useState(false)
const editorRef = useRef<CodePanelHandle>(null)
const runConfigRef = useRef<RunConfigHandle>(null)
const mainAreaRef = useRef<HTMLDivElement>(null)
// 编辑器 + 运行输出面板这一列的总高(高度方向 splitter 用)。贴在外层 wrapper 上,
// —— 因为高度 splitter 的像素 delta 需要除以「这一列的总高度」才能换算成 fraction。
// 不贴在 #pyrof-editor-pane 上是因为它还要管宽度,语义上不如单独一个 ref 干净。
const consoleAreaRef = useRef<HTMLDivElement>(null)
const {
result,
state,
progress,
errorMessage,
stderrTail,
stdout,
clearStdout,
resultScope,
run,
cancel,
reset
} = useAnalysis()
// 解释器列表提到 App 顶层MinimalRunConfig mount 时取,不要每次 mount 重 detect
const interpreters = useInterpreters()
// 主题useTheme 内部已经和 <html class> 同步
const { theme, setTheme } = useTheme()
// 剖析范围useScope 内部已经持久化到 localStorage.pyrof.scope
const { scope, toggle: toggleScope } = useScope()
// 打开系统终端时用的 shellWindows: cmd / PowerShell;非 Win 不生效
const { shell, setShell } = useShellPreference()
// 外部文件 tab 状态stdlib / 第三方包热点打开的源码 tab
// 用户代码 tab **不**在这里 —— 是 code state 单独持有。
const {
externalTabs,
activeExternalId,
openFile: openExternalFile,
closeFile,
setActive: setActiveExternal,
consumePendingReveal,
closeAll: closeAllExternal
} = useExternalFiles()
// —— 区域尺寸(编辑器 ↔ 结果区) ——
// 用 ResizeObserver 跟踪主区宽度,拖拽时按「像素 delta / 主区宽度」换算成比例
// (窗口尺寸变化或 DevTools 开关 / 字号变更都会让宽度变;不是 mount 时一次性 getBoundingClientRect
const mainAreaWidth = useElementSize(mainAreaRef, 'width')
const { fraction: leftFrac, applyDelta: applyLeftDelta } = useResizableFraction('pyrof.split.main', {
default: 0.5,
min: 0.2,
max: 0.8
})
// —— 区域尺寸(编辑器 ↔运行输出面板) ——
// 同样的 fraction + ResizeObserver 模式,跟主区 splitter 完全对称。min=0 是因为
// 用户可能想把输出区拖到完全消失(隐藏),最大留 60% 是因为太高会挤压编辑器。
// consoleFraction 是「console 高度 / 该列总高」,applyDelta(dy, height) 拿
// 像素增量除以总高换算成 fraction。
const consoleAreaHeight = useElementSize(consoleAreaRef, 'height')
const { fraction: consoleFrac, applyDelta: applyConsoleDelta } = useResizableFraction(
'pyrof.split.console',
{
default: 0.25,
min: 0,
max: 0.6
}
)
// 「收起」按钮的折叠状态:放在 App 里是为了让 wrapper 高度跟着收缩 —— 只藏 body 的话
// consoleFrac * 100% 还占着列高,header 下面会留一大块空白,看起来没收完。
// 高度降到 auto 让 wrapper 只剩 header(≈44px),editor 段在 flex-1 里把多出来的空间拿走。
const [consoleCollapsed, setConsoleCollapsed] = useState(false)
// 全局快捷键Ctrl/Cmd+Enter 运行Esc 取消
// state 通过 ref 引用:避免每次 state 变化都 remove + addEventListener每帧 rebuild 监听是浪费)。
const stateRef = useRef(state)
stateRef.current = state
// 编辑器内 Ctrl+EnterMonaco 会拦截这个键(默认行为是「下方插入新行」),必须通过 addCommand 接管;
// 走 window keydown 收不到,因为 Monaco 在 capture 阶段 stopPropagation 了。
const triggerRun = useCallback(() => {
if (stateRef.current === 'running') void cancel()
else runConfigRef.current?.run()
}, [cancel])
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
// SettingsModal 打开时Esc 已经被 Modal 在 capture 阶段 stopPropagation 截走,
// 不会到这里取消运行(保留 Esc 取消运行的语义由 Modal 决定)。
const cmdKey = e.metaKey || e.ctrlKey
const target = e.target as HTMLElement | null
const inMonaco = !!target?.closest('.monaco-editor')
if (cmdKey && e.key === 'Enter') {
if (inMonaco) return
e.preventDefault()
triggerRun()
} else if (e.key === 'Escape' && stateRef.current === 'running') {
e.preventDefault()
void cancel()
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [cancel, triggerRun])
const selectHotspot = useCallback(
(fn: FunctionNode) => {
setSelectedFuncId(fn.id)
const origin = resolveOrigin(fn)
// 路由按 origin 分流:
// - user: 文件路径已不存在profiler-service 在 tmpdir 建的脚本被 rm 掉),
// 只能 highlight 当前用户代码编辑器里的行。
// - builtin / frozen: cProfile 给的是虚拟帧,没有源文件。静默 no-op
// —— toast 太吵,每次 run 都有几条。
// - stdlib / third_party / other: 打开对应文件 tab编辑器只读 + reveal line。
if (origin === 'user') {
editorRef.current?.revealLine(fn.line)
return
}
if (origin === 'builtin' || origin === 'frozen') return
if (!fn.file) return
openExternalFile(fn.file, fn.line, origin)
},
[openExternalFile]
)
const onRun = useCallback(
(opts: RunOptions) => {
setLastOpts(opts)
setSelectedFuncId(undefined)
editorRef.current?.clearHighlight()
// 注意:不清外部 tab —— 用户可能正在读源文件比对,新一轮结果不影响已开的源文件。
void run(opts)
},
[run]
)
// 「范围已切换」提示条上的「以新范围重跑」按钮 —— 用最近一次 run 的 opts
// 把 scope 换成当前的,然后再跑一次。不在 onRun 里偷换 scope 是为了不让
// 普通 Retry 路径跟着 scope toggle 走(错误页 Retry 应当 100% 复用旧参数)。
const onRerunWithCurrentScope = useCallback(() => {
if (!lastOpts) return
void onRun({ ...lastOpts, scope })
}, [lastOpts, scope, onRun])
// 「加载示例 / 新建空白」共用一条复位通道 —— 这两个动作是"重置整个工作区"语义,
// 顺手把外部 tab 也清空(用户切到完全不同的代码,原来开的 numpy / json tab 也没用了)。
// 用工厂函数接受目标 code,deps 只跟 reset 走,避免两个 callback 各持一份 closure。
// 完全空串而不是 '\n''\n' 会让 Monaco 渲染一个 phantom 行(视觉上「两行空着」),
// 且 Monaco 对 '' 的处理已经合理——光标落在 line 1 column 1行号正常。
const resetWorkspace = useCallback(
(nextCode: string) => {
setCode(nextCode)
editorRef.current?.clearHighlight()
setSelectedFuncId(undefined)
setLastOpts(null)
closeAllExternal()
reset()
},
[reset, closeAllExternal]
)
const onLoadSample = useCallback(() => resetWorkspace(DEMO_CODE), [resetWorkspace])
const onNewBlank = useCallback(() => resetWorkspace(''), [resetWorkspace])
// aria-live 状态播报
const liveMessage =
state === 'running'
? t('app.live.running', { pct: progress?.pct ?? 0 })
: state === 'done' && result?.status === 'ok'
? t('app.live.done')
: state === 'error'
? t('app.live.error', { message: errorMessage ?? t('errorBanner.unknown') })
: ''
// 派生给 EditorTabs / CodePanel 的状态。
// editorTabId: null 时 = 用户 tab否则是外部 tab id。
const editorTabId = activeExternalId ?? 'user'
// knownTabIds: CodePanel 用来 dispose 不再存在的 tab 对应的 model。
const knownTabIds = useMemo<string[]>(() => ['user', ...externalTabs.map((tb) => tb.id)], [externalTabs])
// modelContentByTabId: CodePanel 重建外部 tab model 用的内容快照。
// 只在 ready 时塞loading 状态 model 内部用 '' 占位empty content 也合理)。
const modelContentByTabId = useMemo<Record<string, string>>(() => {
const out: Record<string, string> = {}
for (const tb of externalTabs) {
// 即使 status=error 也把 message 塞进去 —— tab 内显示的「错误」只是 banner
// Monaco 区域仍然用 message 占位(避免模型换空字符串让 cursor 跳 line 1
out[tb.id] = tb.content
}
return out
}, [externalTabs])
// 活跃 tab 应展示的内容(外部触发 setValue 时用)。
// 用户 tab等于 code。外部 tab等于该 tab 的 content。
const activeContent = editorTabId === 'user' ? code : (modelContentByTabId[editorTabId] ?? '')
// 只读条件:外部 tab 一律只读;运行中所有 tab 都只读。
const isReadOnly = editorTabId !== 'user' || state === 'running'
// 外部 tab 从 loading → ready 时,调 revealLine 把光标移到对应行。
// useExternalFiles.openFile 时已经把 {tabId, line} 暂存到 pendingRevealRef
// 这里把 ref 里的最新一条拿出来 revealLine多次点击同一 tab → 只有最新的 line
// 依赖 externalTabs 而不是 [status] 是为了在 effect 里能遍历到最新列表。
useEffect(() => {
const reveal = consumePendingReveal()
if (!reveal) return
// 只对当前 active tab 的 reveal 感兴趣 —— 如果用户已经切到别的 tab
// 暂存的 revealLine 属于旧 tab不该在新 tab 强制跳行
if (reveal.tabId !== editorTabId) return
editorRef.current?.revealLine(reveal.line)
}, [externalTabs, editorTabId, consumePendingReveal])
// 切到用户 tab 时,如果 pending reveal 暂存的是 user tab 的 line理论上不会也走 reveal。
// 实际场景是「已经在 user tab 高亮状态下,点 user 热点」—— 但 selectHotspot 走的是
// editorRef.current?.revealLine不走 openFile所以 pendingReveal 不会被填 user tab。
// 这条 effect 仍然是兜底:用户代码状态从 loading 过渡的极端场景(理论上不存在)。
useEffect(() => {
if (editorTabId !== 'user') return
const reveal = consumePendingReveal()
if (!reveal) return
if (reveal.tabId === 'user') {
editorRef.current?.revealLine(reveal.line)
}
// user tab 的 reveal 在 selectHotspot 里同步触发,这里只兜底;
// 如果 reveal.tabId 是某个外部 tab 但当前 editorTabId === 'user',丢弃即可。
}, [editorTabId, consumePendingReveal])
// FileReadErrorKind → i18n key 后缀。放在模块作用域(不依赖任何 state避免每次渲染
// 重新建一次表 —— 'unknown' 走不同分支catch 兜底 message不在表里。
const editorTabs: EditorTabView[] = useMemo(() => {
const userTab: EditorTabView = {
id: 'user',
displayName: t('editorTab.user'),
origin: 'user',
closable: false
}
const ext: EditorTabView[] = externalTabs.map((tb) => ({
id: tb.id,
displayName: tb.displayName,
origin: tb.origin,
closable: true,
status: tb.status,
errorTitle: errorTitleForTab(tb, t)
}))
return [userTab, ...ext]
}, [externalTabs, t])
// EditorTabs onSelect 收到 tabId如果是 null关掉最后一个外部 tab 后 user tab 仍 active
// 永远走 user。EditorTabs 当前不传 null —— active 切到 user 就是 onSelect('user')。
const onEditorTabSelect = useCallback(
(tabId: string) => {
if (tabId === 'user') setActiveExternal(null)
else setActiveExternal(tabId)
},
[setActiveExternal]
)
// 关外部 tab 时,如果关的是 active —— useExternalFiles.closeFile 会自动把 activeExternalId
// 置 nullApp 这边等价于切回 user。这里只需要原样转发。
const onEditorTabClose = useCallback(
(tabId: string) => {
closeFile(tabId)
},
[closeFile]
)
return (
<div className="grid h-full grid-rows-[44px_1fr] bg-bg text-fg">
<TopBar
onOpenSettings={() => setSettingsOpen(true)}
theme={theme}
setTheme={setTheme}
onOpenTerminal={() => void window.api.openTerminal(undefined, shell)}
/>
<SettingsModal
open={settingsOpen}
onClose={() => setSettingsOpen(false)}
interpreters={interpreters}
shell={shell}
setShell={setShell}
/>
<div className="flex min-h-0 min-w-0 flex-col">
{/* mainArea:内层 flex row,管理编辑器 ↔ 结果区的宽度分配v-splitter
ResizeObserver 绑在这里——拖拽时用它换算像素 delta 到 fraction */}
<div ref={mainAreaRef} className="flex min-h-0 min-w-0 flex-1 overflow-hidden">
{/* Left: editor with its own header.
flexShrink:0 让左栏不被压扁,左宽由 leftFrac 决定;右栏 flex-1 拿剩余空间 */}
<div
id="pyprof-editor-pane"
className="flex min-h-0 min-w-0 flex-col bg-bg"
style={{ width: `${leftFrac * 100}%`, flexShrink: 0 }}
>
<MinimalRunConfig
ref={runConfigRef}
code={code}
state={state}
progress={progress}
interpreters={interpreters}
scope={scope}
onRun={onRun}
onCancel={cancel}
onLoadSample={onLoadSample}
onNewBlank={onNewBlank}
onToggleScope={toggleScope}
/>
{/*
consoleAreaRef:水平 splitter + RunConsole 共用这一列的高度作为 fraction 分母。
编辑器段在 flex-1 上自适应剩余空间,RunConsole 段取 consoleFrac 占比的固定高度——
两条路径一起让"拖到顶就只剩编辑器,拖到底底就只剩 console"成为一条简单的不变量。
consoleHeight=0 时 consoleFrac*100%=0%,wrapper 高度为 0,splitter 紧贴编辑器底边,
视觉上等同于「运行输出完全收起」(但 splitter 仍在,方便拖回来)。
*/}
<div ref={consoleAreaRef} className="flex min-h-0 flex-1 flex-col">
<div className="flex min-h-0 flex-1 flex-col">
<EditorTabs
tabs={editorTabs}
activeTabId={editorTabId}
onSelect={onEditorTabSelect}
onClose={onEditorTabClose}
/>
<div
className="min-h-0 flex-1"
role="tabpanel"
id="pyrof-editor-panel"
aria-labelledby={`editor-tab-${editorTabId}`}
>
<Suspense fallback={<EditorSkeleton />}>
<CodePanel
ref={editorRef}
activeTabId={editorTabId}
activeContent={activeContent}
readOnly={isReadOnly}
modelContentByTabId={modelContentByTabId}
onUserEdit={setCode}
knownTabIds={knownTabIds}
onCtrlEnter={triggerRun}
editorTheme={theme}
/>
</Suspense>
</div>
</div>
<Splitter
orientation="horizontal"
onDelta={(dy) => applyConsoleDelta(dy, consoleAreaHeight)}
ariaLabel={t('splitter.console.aria')}
ariaControls={['pyrof-editor-panel', 'pyrof-console']}
// ariaValue 用像素单位,跟主 splitter 一致applyDelta 用的 dy 同单位,
// 这样 Home/End 才能一次推到 min=0 / max=0.6,否则 pixel fraction 算出来
// 太小被 clamp 到边界时键盘步进没反应
ariaValueNow={consoleAreaHeight > 0 ? Math.round(consoleFrac * consoleAreaHeight) : undefined}
ariaValueMin={0}
ariaValueMax={consoleAreaHeight > 0 ? Math.round(0.6 * consoleAreaHeight) : undefined}
/>
<div
id="pyrof-console"
className="shrink-0"
// 折叠时改用 auto —— wrapper 高度由内容(header)决定,≈44px;否则仍按 consoleFrac 占比。
// 这条让「收起」按钮和 Splitter 拖到顶两条路径在视觉上等价,意图不同但落点一致。
style={{ height: consoleCollapsed ? 'auto' : `${consoleFrac * 100}%` }}
>
<RunConsole
stdout={stdout}
isRunning={state === 'running'}
onClear={clearStdout}
collapsed={consoleCollapsed}
onCollapsedChange={setConsoleCollapsed}
/>
</div>
</div>
</div>
<Splitter
orientation="vertical"
onDelta={(dx) => applyLeftDelta(dx, mainAreaWidth)}
ariaLabel={t('splitter.main.aria')}
ariaControls={['pyprof-editor-pane', 'pyprof-results']}
// ariaValue 用像素单位 —— 跟 applyLeftDelta(dx, width) 里 dx 同单位,
// Splitter 的 Home/End 才会真的推到 min/max;否则 fraction*100 / width
// 算出来的 fraction delta 太小,clamp 到不到边界
ariaValueNow={mainAreaWidth > 0 ? Math.round(leftFrac * mainAreaWidth) : undefined}
ariaValueMin={mainAreaWidth > 0 ? Math.round(0.2 * mainAreaWidth) : undefined}
ariaValueMax={mainAreaWidth > 0 ? Math.round(0.8 * mainAreaWidth) : undefined}
/>
{/* Right: results or empty state */}
<div id="pyprof-results" className="flex min-h-0 min-w-0 flex-1 flex-col overflow-y-auto bg-bg">
{state === 'error' ? (
<ErrorBanner
message={errorMessage}
stderr={stderrTail}
lastOpts={lastOpts}
currentScope={scope}
currentCode={code}
onRetry={onRun}
/>
) : result && result.status !== 'ok' ? (
<FailureResult
result={result}
lastOpts={lastOpts}
currentScope={scope}
currentCode={code}
onRetry={onRun}
/>
) : result && result.status === 'ok' ? (
<ResultsPanel
result={result}
resultScope={resultScope}
currentScope={scope}
onRerun={onRerunWithCurrentScope}
selectedFuncId={selectedFuncId}
onSelectHotspot={selectHotspot}
/>
) : (
<EmptyState
running={state === 'running'}
hasInterpreter={!!interpreters.active}
onLoadSample={onLoadSample}
onNewBlank={onNewBlank}
onOpenSettings={() => setSettingsOpen(true)}
/>
)}
</div>
</div>
</div>
{/* aria-live 状态播报 */}
<div role="status" aria-live="polite" aria-atomic="true" className="sr-only">
{liveMessage}
</div>
</div>
)
}

View File

@@ -0,0 +1,549 @@
import { forwardRef, useImperativeHandle } from 'react'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import App from '../App'
import type { AnalysisResult, ProgressEvent } from '../../../shared/analysis'
/**
* Mock CodePanelLazy — 真实 CodePanel 拉 Monaco 会拖垮 jsdom10+ MB
* 这里用一个 stub forwardRef 暴露 CodePanelHandle 接口revealLine / clearHighlight / getValue。
* App 顶层 editorRef.current?.xxx() 全部走这条路。
*/
vi.mock('../components/CodePanelLazy', () => ({
default: forwardRef<
{ revealLine: (n: number) => void; clearHighlight: () => void; getValue: () => string },
{
value: string
onChange: (v: string) => void
disabled?: boolean
onCtrlEnter?: () => void
}
>(function CodePanelStub({ value, onChange, onCtrlEnter }, ref) {
useImperativeHandle(ref, () => ({
revealLine: () => {},
clearHighlight: () => {},
getValue: () => value
}))
return (
<div data-testid="code-panel">
<textarea data-testid="code-textarea" value={value} onChange={(e) => onChange(e.target.value)} />
<button data-testid="ctrl-enter-stub" onClick={onCtrlEnter}>
Ctrl+Enter
</button>
</div>
)
})
}))
const SAMPLE_INTERP = {
path: 'C:/python/python.exe',
version: '3.11.0'
}
const FAKE_OK: AnalysisResult = {
schemaVersion: 2,
environment: { python: '3.11', platform: 'win32', processor: 'x86_64', timerResolution: 1e-7 },
config: {},
status: 'ok',
wallTime: { seconds: 0.42, unit: 's' },
functions: [
{
id: 'a.py:1:foo',
file: 'a.py',
line: 1,
name: 'foo',
module: '<user>',
cumtime: 0.5,
tottime: 0.3,
ncalls: 10,
percallTot: 0.03
}
],
flame: null
}
const FAKE_SYNTAX_ERROR: AnalysisResult = {
schemaVersion: 2,
environment: { python: '3.11', platform: 'win32', processor: 'x86_64', timerResolution: 1e-7 },
config: {},
status: 'syntax_error',
error: { type: 'SyntaxError', message: 'unexpected EOF while parsing (<unknown>, line 3)' },
wallTime: null,
functions: [],
flame: null
}
const FAKE_RUNTIME_ERROR: AnalysisResult = {
schemaVersion: 2,
environment: { python: '3.11', platform: 'win32', processor: 'x86_64', timerResolution: 1e-7 },
config: {},
status: 'runtime_error',
error: {
type: 'RuntimeError',
message: 'ZeroDivisionError: division by zero',
traceback: 'Traceback...\n File "user_script.py", line 2, in <module>'
},
wallTime: null,
functions: [],
flame: null
}
const FAKE_TIMEOUT: AnalysisResult = {
schemaVersion: 2,
environment: { python: '3.11', platform: 'win32', processor: 'x86_64', timerResolution: 1e-7 },
config: {},
status: 'timeout',
error: { type: 'Timeout', message: '运行超过 30s 已终止', stderrTail: '...' },
wallTime: null,
functions: [],
flame: null
}
/**
* 多 origin 结果user + stdlib + third_party。
* 用于「点 stdlib 热点 → 开新 tab」路由测试 —— FAKE_OK 只有 user 起源点
* 触发不出读文件路径。
*/
const FAKE_OK_MULTI_ORIGIN: AnalysisResult = {
schemaVersion: 2,
environment: { python: '3.11', platform: 'win32', processor: 'x86_64', timerResolution: 1e-7 },
config: {},
status: 'ok',
wallTime: { seconds: 0.42, unit: 's' },
functions: [
{
id: 'user:1:main',
file: 'a.py',
line: 1,
name: 'main',
module: 'a',
cumtime: 0.5,
tottime: 0.05,
ncalls: 1,
percallTot: 0.05
},
{
id: 'stdlib-json-loads:355:loads',
file: 'C:\\Python311\\Lib\\json\\decoder.py',
line: 355,
name: 'loads',
module: 'json',
cumtime: 0.3,
tottime: 0.25,
ncalls: 5,
percallTot: 0.05
},
{
id: 'stdlib-json-scanstring:355:scanstring',
file: 'C:\\Python311\\Lib\\json\\decoder.py',
line: 100,
name: 'scanstring',
module: 'json',
cumtime: 0.1,
tottime: 0.05,
ncalls: 50,
percallTot: 0.001
},
{
id: 'numpy-sum:42:sum',
file: 'C:\\Python311\\Lib\\site-packages\\numpy\\core\\fromnumeric.py',
line: 42,
name: 'sum',
module: 'numpy',
cumtime: 0.1,
tottime: 0.08,
ncalls: 1,
percallTot: 0.08
}
],
flame: null
}
interface FakeApi {
analyze: ReturnType<typeof vi.fn>
cancel: ReturnType<typeof vi.fn>
detectInterpreters: ReturnType<typeof vi.fn>
pickInterpreter: ReturnType<typeof vi.fn>
readFile: ReturnType<typeof vi.fn>
onProgress: (cb: (e: ProgressEvent) => void) => () => void
emit: (e: ProgressEvent) => void
// 顶栏 Logo 通过 IPC 取 icon.ico data URL。null 让 Logo 走 SVG fallback
// 单测不依赖真实图标渲染
getAppIcon: ReturnType<typeof vi.fn>
// useAnalysis 在 mount 时订阅 stdout chunk —— 测试不验 stdout 累积,
// 只给个 noop unsubscribe 防止"is not a function" 报错让 React 报错。
onStdout: (cb: (chunk: string) => void) => () => void
// 自定义窗口控制:测试里不触发实际 IPC,提供 noop stub 防止 WindowControls
// mount 时调用 isMaximized 报 "undefined" 错。返回 false 模拟默认未最大化。
windowControls: {
minimize: ReturnType<typeof vi.fn>
toggleMaximize: ReturnType<typeof vi.fn>
close: ReturnType<typeof vi.fn>
isMaximized: ReturnType<typeof vi.fn>
onMaximizeChanged: (cb: (isMaximized: boolean) => void) => () => void
}
}
let handlers: Array<(e: ProgressEvent) => void> = []
let stdoutHandlers: Array<(chunk: string) => void> = []
let maximizeHandlers: Array<(isMaximized: boolean) => void> = []
function installFakeApi(): FakeApi {
handlers = []
stdoutHandlers = []
maximizeHandlers = []
const api: FakeApi = {
analyze: vi.fn().mockResolvedValue(FAKE_OK),
cancel: vi.fn().mockResolvedValue(undefined),
detectInterpreters: vi.fn().mockResolvedValue([SAMPLE_INTERP]),
pickInterpreter: vi.fn().mockResolvedValue(null),
readFile: vi.fn(),
onProgress: (cb) => {
handlers.push(cb)
return () => {
const i = handlers.indexOf(cb)
if (i >= 0) handlers.splice(i, 1)
}
},
emit: (e) => {
for (const h of handlers.slice()) h(e)
},
getAppIcon: vi.fn().mockResolvedValue(null),
onStdout: (cb) => {
stdoutHandlers.push(cb)
return () => {
const i = stdoutHandlers.indexOf(cb)
if (i >= 0) stdoutHandlers.splice(i, 1)
}
},
windowControls: {
minimize: vi.fn().mockResolvedValue(undefined),
toggleMaximize: vi.fn().mockResolvedValue({ isMaximized: false }),
close: vi.fn().mockResolvedValue(undefined),
isMaximized: vi.fn().mockResolvedValue(false),
onMaximizeChanged: (cb) => {
maximizeHandlers.push(cb)
return () => {
const i = maximizeHandlers.indexOf(cb)
if (i >= 0) maximizeHandlers.splice(i, 1)
}
}
}
}
;(window as unknown as { api: FakeApi }).api = api
return api
}
beforeEach(() => {
window.localStorage.clear()
installFakeApi()
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('App', () => {
it('首次启动显示 EmptyState', async () => {
render(<App />)
expect(await screen.findByText(/粘贴代码,马上知道慢在哪/)).toBeInTheDocument()
})
it('Ctrl+Enter 触发运行window keydown非编辑器区域', async () => {
const api = installFakeApi()
let resolveAnalyze!: (r: AnalysisResult) => void
api.analyze.mockImplementationOnce(() => new Promise<AnalysisResult>((r) => (resolveAnalyze = r)))
render(<App />)
await waitFor(() => {
expect(api.detectInterpreters.mock.calls.length).toBeGreaterThan(0)
})
// 焦点不在 Monaco — window keydown 应该被处理。
fireEvent.keyDown(document.body, { key: 'Enter', ctrlKey: true })
expect(api.analyze).toHaveBeenCalled()
await act(async () => {
resolveAnalyze(FAKE_OK)
})
// 跑完后state='done' + result=FAKE_OK → 渲染 ResultsPanel含 RunSummaryLite
expect(await screen.findByText(/本次耗时/)).toBeInTheDocument()
})
it('Esc 在 running 时取消', async () => {
const api = installFakeApi()
let resolveAnalyze!: (r: AnalysisResult) => void
api.analyze.mockImplementationOnce(() => new Promise<AnalysisResult>((r) => (resolveAnalyze = r)))
render(<App />)
await waitFor(() => {
expect(api.detectInterpreters.mock.calls.length).toBeGreaterThan(0)
})
fireEvent.keyDown(document.body, { key: 'Enter', ctrlKey: true })
fireEvent.keyDown(document.body, { key: 'Escape' })
expect(api.cancel).toHaveBeenCalled()
await act(async () => {
resolveAnalyze(FAKE_OK)
})
})
it('运行成功后展示 ResultsPanel + 热点表', async () => {
const api = installFakeApi()
api.analyze.mockResolvedValueOnce(FAKE_OK)
render(<App />)
await waitFor(() => {
expect(api.detectInterpreters.mock.calls.length).toBeGreaterThan(0)
})
fireEvent.keyDown(document.body, { key: 'Enter', ctrlKey: true })
// RunSummaryLite 显示「本次耗时」
expect(await screen.findByText(/本次耗时/)).toBeInTheDocument()
})
it('result.status === syntax_error 渲染 FailureResult + 标题「语法错误」', async () => {
const api = installFakeApi()
api.analyze.mockResolvedValueOnce(FAKE_SYNTAX_ERROR)
render(<App />)
await waitFor(() => {
expect(api.detectInterpreters.mock.calls.length).toBeGreaterThan(0)
})
fireEvent.keyDown(document.body, { key: 'Enter', ctrlKey: true })
expect(await screen.findByText(/语法错误/)).toBeInTheDocument()
expect(screen.getByText(/unexpected EOF/)).toBeInTheDocument()
})
it('runtime_error 渲染 FailureResult + traceback', async () => {
const api = installFakeApi()
api.analyze.mockResolvedValueOnce(FAKE_RUNTIME_ERROR)
render(<App />)
await waitFor(() => {
expect(api.detectInterpreters.mock.calls.length).toBeGreaterThan(0)
})
fireEvent.keyDown(document.body, { key: 'Enter', ctrlKey: true })
expect(await screen.findByText(/ZeroDivisionError/)).toBeInTheDocument()
})
it('timeout 走 FailureResult', async () => {
const api = installFakeApi()
api.analyze.mockResolvedValueOnce(FAKE_TIMEOUT)
render(<App />)
await waitFor(() => {
expect(api.detectInterpreters.mock.calls.length).toBeGreaterThan(0)
})
fireEvent.keyDown(document.body, { key: 'Enter', ctrlKey: true })
expect(await screen.findByText(/超时/)).toBeInTheDocument()
})
it('IPC 抛错state === error渲染 ErrorBanner', async () => {
const api = installFakeApi()
api.analyze.mockImplementationOnce(() => Promise.reject(new Error('boom from ipc')))
render(<App />)
await waitFor(() => {
expect(api.detectInterpreters.mock.calls.length).toBeGreaterThan(0)
})
fireEvent.keyDown(document.body, { key: 'Enter', ctrlKey: true })
expect(await screen.findByRole('button', { name: /再试一次/ })).toBeInTheDocument()
})
it('ErrorBanner 的「再试一次」用最近一次 opts 触发 run', async () => {
const api = installFakeApi()
api.analyze
.mockImplementationOnce(() => Promise.reject(new Error('first fail')))
.mockImplementationOnce(() => Promise.resolve(FAKE_OK))
render(<App />)
await waitFor(() => {
expect(api.detectInterpreters.mock.calls.length).toBeGreaterThan(0)
})
fireEvent.keyDown(document.body, { key: 'Enter', ctrlKey: true })
const retryBtn = await screen.findByRole('button', { name: /再试一次/ })
expect(api.analyze).toHaveBeenCalledTimes(1)
await act(async () => {
fireEvent.click(retryBtn)
})
expect(api.analyze).toHaveBeenCalledTimes(2)
})
it('加载示例 → useAnalysis.reset → 清掉旧 result', async () => {
const api = installFakeApi()
api.analyze.mockResolvedValueOnce(FAKE_OK)
render(<App />)
await waitFor(() => {
expect(api.detectInterpreters.mock.calls.length).toBeGreaterThan(0)
})
fireEvent.keyDown(document.body, { key: 'Enter', ctrlKey: true })
await screen.findByText(/本次耗时/)
const sampleBtns = screen.getAllByRole('button', { name: /加载示例/ })
fireEvent.click(sampleBtns[0])
expect(await screen.findByText(/粘贴代码,马上知道慢在哪/)).toBeInTheDocument()
})
it('result 出来后 <result> 区有 id="pyprof-results"', async () => {
const api = installFakeApi()
api.analyze.mockResolvedValueOnce(FAKE_OK)
render(<App />)
await waitFor(() => {
expect(api.detectInterpreters.mock.calls.length).toBeGreaterThan(0)
})
fireEvent.keyDown(document.body, { key: 'Enter', ctrlKey: true })
await screen.findByText(/本次耗时/)
expect(document.getElementById('pyprof-results')).toBeInTheDocument()
})
it('默认 scope=all 透传到 analyze点 toggle 后 scope=user', async () => {
const api = installFakeApi()
api.analyze.mockResolvedValue(FAKE_OK)
render(<App />)
await waitFor(() => {
expect(api.detectInterpreters.mock.calls.length).toBeGreaterThan(0)
})
// 第一次 Ctrl+Enter默认 scope=all含库函数
fireEvent.keyDown(document.body, { key: 'Enter', ctrlKey: true })
await waitFor(() => {
expect(api.analyze).toHaveBeenCalled()
})
const firstCall = api.analyze.mock.calls[0]?.[0] as { scope?: string } | undefined
expect(firstCall?.scope).toBe('all')
// 点 toggle 开关(默认展示「含库函数」,点一下变成「仅用户代码」)
const toggleBtn = await screen.findByRole('switch', { name: /剖析范围.*点击切换/ })
fireEvent.click(toggleBtn)
// 第二次 Ctrl+Enterscope=user
api.analyze.mockClear()
fireEvent.keyDown(document.body, { key: 'Enter', ctrlKey: true })
await waitFor(() => {
expect(api.analyze).toHaveBeenCalled()
})
const secondCall = api.analyze.mock.calls[0]?.[0] as { scope?: string } | undefined
expect(secondCall?.scope).toBe('user')
})
// —— 外部文件 tab 路由selectHotspot 按 origin 分流 ——
it('点 user 热点:不开新 tab不调 readFilerevealLine 用户代码即可)', async () => {
const api = installFakeApi()
api.analyze.mockResolvedValueOnce(FAKE_OK)
render(<App />)
await waitFor(() => {
expect(api.detectInterpreters.mock.calls.length).toBeGreaterThan(0)
})
fireEvent.keyDown(document.body, { key: 'Enter', ctrlKey: true })
await screen.findByText(/本次耗时/)
// tablist 只有一个 user tab
expect(screen.getAllByRole('tab')).toHaveLength(1)
expect(screen.getByRole('tab', { name: '你的代码' })).toBeInTheDocument()
// 点 user 热点行 → onSelectHotspot(fn) → revealLine不发 IPC
// HotspotTable 的 aria-label 形如: foo用户代码 第 1 行 · 自耗时 ...
fireEvent.click(screen.getByRole('button', { name: /foo.*第 1 行/ }))
await act(async () => {
await Promise.resolve()
})
expect(api.readFile).not.toHaveBeenCalled()
expect(screen.getAllByRole('tab')).toHaveLength(1)
})
it('点 stdlib 热点:调 readFile + 新 tab 出现displayName=json/decoder.py', async () => {
const api = installFakeApi()
api.analyze.mockResolvedValueOnce(FAKE_OK_MULTI_ORIGIN)
api.readFile.mockResolvedValueOnce({
kind: 'ok',
content: 'def loads(s, ...):\n ...\n',
size: 28
})
render(<App />)
await waitFor(() => {
expect(api.detectInterpreters.mock.calls.length).toBeGreaterThan(0)
})
fireEvent.keyDown(document.body, { key: 'Enter', ctrlKey: true })
await screen.findByText(/本次耗时/)
// 默认 scope=all —— stdlib / numpy 直接就在 result.functions 里,无需切 toggle
const loadsRow = await screen.findByRole('button', { name: /loads.*第 355 行/ })
await act(async () => {
fireEvent.click(loadsRow)
})
// IPC 调用
await waitFor(() => {
expect(api.readFile).toHaveBeenCalledWith({
filePath: 'C:\\Python311\\Lib\\json\\decoder.py'
})
})
// 新 tab 出现displayName 应该是 json/decoder.py
expect(await screen.findByRole('tab', { name: 'json/decoder.py' })).toBeInTheDocument()
// tablist 现在有 user + ext 两个 tab
expect(screen.getAllByRole('tab')).toHaveLength(2)
})
it('点同一 stdlib 热点两次:去重生效,不重复调 readFile,只切 active', async () => {
const api = installFakeApi()
api.analyze.mockResolvedValueOnce(FAKE_OK_MULTI_ORIGIN)
api.readFile.mockResolvedValueOnce({
kind: 'ok',
content: 'def loads(...): ...\n',
size: 22
})
render(<App />)
await waitFor(() => {
expect(api.detectInterpreters.mock.calls.length).toBeGreaterThan(0)
})
fireEvent.keyDown(document.body, { key: 'Enter', ctrlKey: true })
await screen.findByText(/本次耗时/)
// 默认 scope=all —— stdlib / numpy 直接就在 result.functions 里,无需切 toggle
const loadsRow = await screen.findByRole('button', { name: /loads.*第 355 行/ })
// 第一次点
await act(async () => {
fireEvent.click(loadsRow)
})
await waitFor(() => {
expect(api.readFile).toHaveBeenCalledTimes(1)
})
expect(screen.getAllByRole('tab')).toHaveLength(2)
// 切回 user tab不关 ext
await act(async () => {
fireEvent.click(screen.getByRole('tab', { name: '你的代码' }))
})
// 第二次点同一个 stdlib 热点 → 应该激活已有 tab,不重复 IPC
await act(async () => {
fireEvent.click(loadsRow)
})
// 等 IPC promise 链跑完
await act(async () => {
await new Promise((r) => setTimeout(r, 0))
})
expect(api.readFile).toHaveBeenCalledTimes(1)
expect(screen.getAllByRole('tab')).toHaveLength(2)
})
it('加载示例 → 外部 tab 全部清空(reset 工作区语义)', async () => {
const api = installFakeApi()
api.analyze.mockResolvedValueOnce(FAKE_OK_MULTI_ORIGIN)
api.readFile.mockResolvedValueOnce({
kind: 'ok',
content: 'def loads(...): ...\n',
size: 22
})
render(<App />)
await waitFor(() => {
expect(api.detectInterpreters.mock.calls.length).toBeGreaterThan(0)
})
fireEvent.keyDown(document.body, { key: 'Enter', ctrlKey: true })
await screen.findByText(/本次耗时/)
// 默认 scope=all —— stdlib / numpy 直接就在 result.functions 里,无需切 toggle
// 点 stdlib 热点 → 开 tab
const loadsRow = await screen.findByRole('button', { name: /loads.*第 355 行/ })
await act(async () => {
fireEvent.click(loadsRow)
})
await waitFor(() => {
expect(screen.getAllByRole('tab')).toHaveLength(2)
})
// 加载示例 → 外部 tab 应清空
const sampleBtns = screen.getAllByRole('button', { name: /加载示例/ })
await act(async () => {
fireEvent.click(sampleBtns[0])
})
// tablist 只剩 user tab
expect(screen.getAllByRole('tab')).toHaveLength(1)
})
})

View File

@@ -0,0 +1,326 @@
import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react'
import type { OnMount } from '@monaco-editor/react'
import Editor, { type Monaco } from '@monaco-editor/react'
import type { editor as MonacoEditor } from 'monaco-editor'
import type { Theme } from '../hooks/useTheme'
export interface CodePanelHandle {
revealLine: (line: number) => void
clearHighlight: () => void
getValue: () => string
}
interface Props {
/**
* 当前活跃 tab 的 id。`'user'` 是用户代码 tab唯一一个可写的
* 其他值是外部文件 tab 的 iduseExternalFiles 分配)。
* CodePanel 用它决定切到哪个 model。
*/
activeTabId: string
/**
* 当前活跃 tab 应显示的内容。
* - 用户 tab等于 App.tsx 的 code state外部触发 setValue 用)
* - 外部 tab等于该 tab 的 contentloading 时是 ''
*
* CodePanel 不 watch 这条 prop 的变化来 setValue —— 用户 tab 走 onChange 双向绑定,
* 外部 tab 走 modelContentByTabId 缓存。activeContent 只用于 revealLine 后让 getValue
* 拿到的内容跟 UI 一致。
*/
activeContent: string
/** true 时禁用编辑(外部文件 tab。跟 disabled运行中取或。 */
readOnly: boolean
/** 外部文件 tab 的内容缓存tabId → content。切回时拿到之前的内容重建 model。 */
modelContentByTabId: Record<string, string>
/**
* 仍然存在的 tab id 列表user + 所有外部 tab
* CodePanel 在 effect 里 reconcile列表外的 model 会被 dispose避免关 tab 漏 model。
* App.tsx 从 useExternalFiles 拿到 externalTabs 后拼上 'user' 传进来。
*/
knownTabIds: readonly string[]
/**
* 用户编辑触发。**只在 activeTabId === 'user' 时**由 Monaco onChange 回调;
* 外部 tab 是 read-onlyMonaco 不会触发 onChange但仍传入以防未来扩展。
* 把 user / external 分流是为了让 App.tsx 只在「用户改自己代码」时更新 code state。
*/
onUserEdit?: (v: string) => void
/** 编辑器内 Ctrl+Enter 触发的回调Monaco 自带 Ctrl+Enter 默认行为是「在下方插入新行」,
* 会 stopPropagation 拦截 window 级别监听,必须在 Monaco 命令系统里绑定) */
onCtrlEnter?: () => void
/** 应用层主题,跟 useTheme 同步dark → 'vs-dark'light → 'vs'。默认 dark。 */
editorTheme?: Theme
}
/** 把应用层 Theme 映射到 Monaco 内置主题 id。Monaco 没有跟随系统 token 的概念,
* 只能选两个内置主题之一切换;编辑器自身的背景色 / 语法色由 Monaco 负责,
* 外层 wrapper 的 bg-bg 仍然走 CSS var保证容器底色与整体协调。 */
export const toMonacoTheme = (t: Theme): 'vs' | 'vs-dark' => (t === 'light' ? 'vs' : 'vs-dark')
/**
* Monaco 编辑器选项(不含 readOnly
* hoist 到模块作用域,避免每次 render 重建一个新对象触发 Monaco 内部
* 「配置对象 reference 不同 → 重新 apply options」的开销。disabled / readOnly 状态切换
* 走 updateOptions 路径(见下方 effect不在这里覆盖。
*/
const MONACO_OPTIONS = {
// 系统等宽字体栈(替代之前的 JetBrains Mono离线可用
fontFamily: 'Cascadia Code, SF Mono, Menlo, Consolas, "Courier New", monospace',
fontSize: 13,
lineHeight: 20,
minimap: { enabled: false },
scrollBeyondLastLine: false,
automaticLayout: true,
tabSize: 4,
padding: { top: 12, bottom: 12 },
renderLineHighlight: 'gutter' as const,
scrollbar: { verticalScrollbarSize: 8, horizontalScrollbarSize: 8 },
overviewRulerBorder: false,
hideCursorInOverviewRuler: true
}
/** 内部 helper用 tabId 生成 monaco model 的 URI每个 tab 一个独立 model
* path 里塞 tabId 而不是 filePath —— 多个 user 刷新可能用同一 filePath不该冲突
* scheme 用 'file' 让 Monaco 高亮按文件类型走,跟我们手动设 defaultLanguage='python' 不冲突。 */
function modelUriForTab(tabId: string): string {
return `inmemory://pyrof/${encodeURIComponent(tabId)}.py`
}
const CodePanel = forwardRef<CodePanelHandle, Props>(
(
{
activeTabId,
activeContent,
readOnly,
modelContentByTabId,
knownTabIds,
onUserEdit,
onCtrlEnter,
editorTheme = 'dark'
},
ref
) => {
const editorRef = useRef<MonacoEditor.IStandaloneCodeEditor | null>(null)
const monacoRef = useRef<Monaco | null>(null)
/** 每个 tab 一个 model懒创建。卸载 tab 时 dispose。 */
const modelsRef = useRef<Map<string, MonacoEditor.ITextModel>>(new Map())
/**
* 每个 tab 的视图状态cursor + scroll + folding 等)。
* 切走时 saveViewState 存到这里,切回来时 restoreViewState 取出。
* 用外置 Map 而不是 Monaco 自带的 model-attached view state —— 切换瞬间
* saveViewState → setModel 之间存在一帧 race显式缓存更稳。
*/
const viewStatesRef = useRef<Map<string, MonacoEditor.ICodeEditorViewState>>(new Map())
/** 上次切换 tab 时的 activeTabId用来在 effect 里决定「这次切换是切走了还是首次」。 */
const lastActiveIdRef = useRef<string | null>(null)
/** 上次 setValue 时的内容,避免相同内容触发 Monaco 的 contentChange 事件链。 */
const lastSetValueRef = useRef<Map<string, string>>(new Map())
const decorationsRef = useRef<string[]>([])
// 把 props 存到 ref 避免 useImperativeHandle 闭包过期
const onCtrlEnterRef = useRef(onCtrlEnter)
useEffect(() => {
onCtrlEnterRef.current = onCtrlEnter
}, [onCtrlEnter])
useImperativeHandle(
ref,
() => ({
revealLine: (line: number) => {
const editor = editorRef.current
const monaco = monacoRef.current
// `!line` 会把 line 0 也当无效(用户脚本可写 `def f(): ...` 然后跑过 line 1 的「空行」,
// 但 line 0 不存在),实际不可能是 0保留对 NaN / 负数 / 小数 1.5 的严格过滤
if (!editor || !monaco || !Number.isInteger(line) || line < 1) return
editor.revealLineInCenter(line)
editor.setPosition({ lineNumber: line, column: 1 })
// 只在编辑器可写时才抢焦点:运行中 disabledpointer-events:none + readOnly
// 此时聚焦反而把屏幕阅读器朗读焦点从按钮挪到代码,用户体验诡异。
// readOnly 的外部 tab 也跳过 focus —— focus 到一个用户没法改的编辑器没意义。
if (!readOnly) editor.focus()
decorationsRef.current = editor.deltaDecorations(decorationsRef.current, [
{
range: new monaco.Range(line, 1, line, 1),
options: {
isWholeLine: true,
className: 'pyprof-line-highlight',
linesDecorationsClassName: 'pyprof-line-gutter'
}
}
])
},
clearHighlight: () => {
const editor = editorRef.current
if (!editor) return
decorationsRef.current = editor.deltaDecorations(decorationsRef.current, [])
},
getValue: () => editorRef.current?.getValue() ?? activeContent
}),
// 把 readOnly / activeContent 加进 deps —— 没有 deps 时工厂每次 render 都跑,
// ref.current 指向新对象,App 的 ref 闭包永远是最新;但代价是每次 render 都重建
// handle 对象 + 把方法重新绑到 ref.current,代码上看不出副作用,React DevTools 也
// 容易误报「ref 变了」。明确 deps 让 handle 在 readOnly/activeContent 真变时才重建。
[readOnly, activeContent]
)
const handleMount: OnMount = (editor, monaco) => {
editorRef.current = editor
monacoRef.current = monaco
// 注册第一个 model当前 activeTabId 对应的内容。
// 用户 tab 用 activeContentApp 的 code state外部 tab 用 modelContentByTabId 缓存。
const initialContent = activeTabId === 'user' ? activeContent : (modelContentByTabId[activeTabId] ?? '')
const initialModel = monaco.editor.createModel(
initialContent,
'python',
monaco.Uri.parse(modelUriForTab(activeTabId))
)
modelsRef.current.set(activeTabId, initialModel)
lastSetValueRef.current.set(activeTabId, initialContent)
editor.setModel(initialModel)
// Ctrl+Enter 命令绑定
if (onCtrlEnter) {
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.Enter, () => {
onCtrlEnterRef.current?.()
})
}
}
// 切 tab保存旧 model 的视图状态 → 切到新 model → 恢复视图状态。
// 用 Map<tabId, viewState> 而不是直接靠 Monaco 自带的 model-attached 视图状态,
// 是因为切换瞬间的 saveViewState → setModel 之间存在一帧 raceMonaco 内部)。
// 显式缓存更稳。
useEffect(() => {
const editor = editorRef.current
if (!editor) return // onMount 还没跑
if (lastActiveIdRef.current === activeTabId) return
const prevId = lastActiveIdRef.current
const newId = activeTabId
// 保存当前 model 的视图状态
if (prevId !== null) {
const state = editor.saveViewState()
if (state) {
// 存到 model 自身的 _viewStates用一个临时 Map
// —— 用一个外置 Map 更可控,不依赖 Monaco 内部约定
viewStatesRef.current.set(prevId, state)
}
}
// 取或建新 model
let model = modelsRef.current.get(newId)
if (!model) {
const content = newId === 'user' ? activeContent : (modelContentByTabId[newId] ?? '')
model = monacoRef.current!.editor.createModel(
content,
'python',
monacoRef.current!.Uri.parse(modelUriForTab(newId))
)
modelsRef.current.set(newId, model)
lastSetValueRef.current.set(newId, content)
}
editor.setModel(model)
// 恢复视图状态
const saved = viewStatesRef.current.get(newId)
if (saved) editor.restoreViewState(saved)
lastActiveIdRef.current = newId
}, [activeTabId, activeContent, modelContentByTabId])
// 外部 tab 内容从 loading → ready 时content 变化要 sync 到 model。
// 用户 tab 走 onChange 双向绑定,**不**通过这条 effect 同步(避免覆盖用户编辑)。
useEffect(() => {
const editor = editorRef.current
if (!editor || !monacoRef.current) return
const model = modelsRef.current.get(activeTabId)
if (!model) return
if (activeTabId === 'user') {
// 用户 tab只在「外部触发」比如 onLoadSample / onNewBlank 通过 App 改了 code
// 时同步。判断「外部触发」比较 tricky —— 最简单的办法:跟 activeContent 比较,
// 如果 activeContent 跟 model 当前 value 不同,而 model 又没在 onChange 里被
// 改过(这里没法判断),就 sync。
// 简化策略activeContent 跟 lastSetValueRef 不同时sync。但 lastSetValueRef
// 在 setValue 后会被更新,所以这条只会捕到「外部修改」的情况。
const last = lastSetValueRef.current.get(activeTabId) ?? ''
if (last !== activeContent) {
model.setValue(activeContent)
lastSetValueRef.current.set(activeTabId, activeContent)
}
} else {
// 外部 tab用 modelContentByTabId 缓存里的最新内容 sync覆盖 setValue
// 同一 tab 多次 IPC ready理论上不会但防御以最新为准。
const content = modelContentByTabId[activeTabId] ?? ''
const last = lastSetValueRef.current.get(activeTabId) ?? ''
if (last !== content) {
model.setValue(content)
lastSetValueRef.current.set(activeTabId, content)
}
}
}, [activeTabId, activeContent, modelContentByTabId])
// 切换 readOnly外部 tab / 运行中)
useEffect(() => {
const editor = editorRef.current
if (!editor) return
editor.updateOptions?.({ readOnly })
}, [readOnly])
// 关 tab 时 dispose 对应的 model列表外的 tab id 走 dispose + 清理 view state。
// 必须跳过当前 activeTabId —— 否则快速切走再被关的边界会撞上「model 已被 dispose」
// 的 warning先 dispose 再 setModel 是异步的)。
useEffect(() => {
const valid = new Set(knownTabIds)
for (const [tabId, model] of modelsRef.current) {
if (!valid.has(tabId) && tabId !== activeTabId) {
model.dispose()
modelsRef.current.delete(tabId)
viewStatesRef.current.delete(tabId)
lastSetValueRef.current.delete(tabId)
}
}
}, [knownTabIds, activeTabId])
// 卸载时 dispose 所有 model。HMR / 路由切换时也走这条。
useEffect(() => {
const models = modelsRef.current
const viewStates = viewStatesRef.current
const lastSetValue = lastSetValueRef.current
return () => {
for (const model of models.values()) {
model.dispose()
}
models.clear()
viewStates.clear()
lastSetValue.clear()
}
}, [])
return (
<div className={`h-full bg-bg transition-opacity ${readOnly ? 'opacity-95' : ''}`}>
<Editor
height="100%"
defaultLanguage="python"
theme={toMonacoTheme(editorTheme)}
onMount={handleMount}
options={{ ...MONACO_OPTIONS, readOnly }}
// 把 onChange 放在这里:用户 tab 时把变更上抛到 App外部 tab 时因为 readOnly
// Monaco 不会触发 onChange但保留回调 hook 防止未来扩展需要切读写。
//
// 关键onChange 里也要更新 lastSetValueRef —— 用户每按一个键,
// Monaco 先把文本改进 model,再 emit onChange,App 接到后改 code state,
// 触发 sync effect 重跑。如果 lastSetValueRef 不动,effect 会看到
// activeContent 变了 → model.setValue(...) 又写一次 → Monaco 文本没变,
// 但 setValue 会触发 onDidChangeContent → onChange 又跑一次。
// 一个键 → setValue → onChange → setValue → onChange 死循环,直到某次比对
// 让 last === activeContent(早期 lastSetValueRef 直接拿"上次 setValue
// 写过的内容"作 sentinel,onChange 路径根本不更新它,所以每次 keystroke
// 都会多写一次)。
onChange={(v) => {
if (activeTabId !== 'user') return
const next = v ?? ''
// 用 ref 而非 state:effect 不依赖这个值,只在下一次 sync 时比对一下,
// ref 比 useState 更轻,不会引发额外 render。
lastSetValueRef.current.set(activeTabId, next)
onUserEdit?.(next)
}}
/>
</div>
)
}
)
CodePanel.displayName = 'CodePanel'
export default CodePanel

View File

@@ -0,0 +1,20 @@
/**
* CodePanel 的懒加载入口。
*
* 目的:把 monaco-editor占首屏 bundle 的绝大部分)从 index.js 里摘出来,
* 变成一个按需加载的独立 chunk。
*
* 关键点在于 monaco-setup 的导入位置。它有副作用(写 self.MonacoEnvironment +
* loader.config且 `import * as monaco from 'monaco-editor'` 会把整个 monaco
* 拉进所在 chunk。之前它被 main.tsx 静态导入,所以**即使把 CodePanel 改成
* React.lazy 也一点用没有** —— monaco 早就在首屏 chunk 里了。必须让这个副作用
* 导入只出现在被动态 import() 的模块图里,才能真正切出去。
*
* 顺序保证ESM 按书写顺序执行导入monaco-setup 在 CodePanel 之前完成
* loader.config();而 CodePanel 模块体本身不碰 monaco只有挂载时才用
* 所以 <Editor> 首次渲染时 loader 已配置好,不会退回 CDN 被 CSP 拦掉。
*/
import './../monaco-setup'
export { default } from './CodePanel'
export type { CodePanelHandle } from './CodePanel'

View File

@@ -0,0 +1,262 @@
/**
* 编辑器区域的 tab 条 —— 用户代码 tab + 0..N 个外部文件 tabstdlib / 第三方包热点打开)。
*
* 设计要点:
* - **用户 tab 永远在第一个**,不可关闭。它的 `closable=false`,渲染时不画 X。
* user tab **不渲染 origin 色点**(仅外部 tab 有 origin 概念),并用 `justify-center`
* + `min-w-[96px]` 把标题放在 tab 视觉中线 —— 避免"标题贴左 + 一片空白"的视觉错觉。
* - **外部 tab 可关闭**。关掉的是 active 时App.tsx 通过 `onSelect('user')` 切回。
* - **origin 色点**:每个外部 tab 在名字前放一个 4px 圆点,颜色由 `ORIGIN_COLOR`
* 提供(绿=用户 / 紫=第三方 / 蓝=stdlib / 灰=builtin / 黄=other。视觉锚点跟
* 右侧热点表的 origin 徽章对齐。
* - **横向滚动**tabs 数量超过可视宽度时整个 tablist 横向滚动overflow-x-auto
* 不主动限制 tab 数量 —— 用户开 20 个也不至于崩溃。
* - **键盘**arrow ←→ 切焦点、Home/End 跳首尾、Delete 或 Ctrl+W 关闭聚焦的外部 tab、
* Enter/Space 激活。键盘事件挂在 tablist 根上,通过 event.target 判断是「tablist 自身」
* 还是「tab 按钮」来分流。
*
* 不做的事:
* - 不显示修改指示点user tab 的 dirty 标记)—— 用户代码只有内存状态,没"原始"基线
* 可以对比;等真接文件系统后再说。
* - 不做拖拽排序 —— 顺序就是打开的先后,够用。
*/
import { memo, useCallback, useRef, type KeyboardEvent } from 'react'
import { CloseIcon } from './icons'
import { ORIGIN_COLOR } from '../utils/origin'
import { useT } from '../i18n'
import type { ExternalOrigin } from '../hooks/useExternalFiles'
/** 单个 tab 的元数据已经过滤好的视图模型EditorTabs 不直接接触 OpenFile。 */
export interface EditorTabView {
id: string
displayName: string
/** 用户的 origin 永远是 'user',外部 tab 是其它 5 种之一。 */
origin: 'user' | ExternalOrigin
closable: boolean
/** tab 处于 loading / error 时渲染徽章(外部 tab 才有这两种状态)。 */
status?: 'loading' | 'ready' | 'error'
/** error 状态下 hover tab 给出的本地化提示App 已经按 errorKind 翻译好)。 */
errorTitle?: string
}
interface Props {
tabs: EditorTabView[]
activeTabId: string
/**
* 切到指定 tab。点 X 关掉活跃 tab 时由 App 决定回落到哪个 tab一般是 user
* 所以传 `string` 而不是 `string | null` —— EditorTabs 不假设回落策略。
*/
onSelect: (tabId: string) => void
/** 关掉一个外部 tab。user tab 不会被关closable=false 已经保护)。 */
onClose: (tabId: string) => void
}
function EditorTabs({ tabs, activeTabId, onSelect, onClose }: Props): JSX.Element {
const t = useT()
// 用 ref 拿最新 tabs/onSelect/onClose避免 onKeyDown 的 useCallback 每次 re-render 都重建
const tabsRef = useRef(tabs)
tabsRef.current = tabs
const onSelectRef = useRef(onSelect)
onSelectRef.current = onSelect
const onCloseRef = useRef(onClose)
onCloseRef.current = onClose
const onKeyDown = useCallback(
(e: KeyboardEvent<HTMLDivElement>) => {
const list = tabsRef.current
const currentIndex = list.findIndex((tb) => tb.id === activeTabId)
// ← → 在 tabs 里循环移动 active也移动 focus 到对应 tab 按钮)
if (e.key === 'ArrowLeft') {
e.preventDefault()
const prev = currentIndex > 0 ? currentIndex - 1 : list.length - 1
const target = list[prev]
if (target) {
onSelectRef.current(target.id)
focusTabButton(target.id)
}
return
}
if (e.key === 'ArrowRight') {
e.preventDefault()
const next = currentIndex < list.length - 1 ? currentIndex + 1 : 0
const target = list[next]
if (target) {
onSelectRef.current(target.id)
focusTabButton(target.id)
}
return
}
if (e.key === 'Home') {
e.preventDefault()
const target = list[0]
if (target) {
onSelectRef.current(target.id)
focusTabButton(target.id)
}
return
}
if (e.key === 'End') {
e.preventDefault()
const target = list[list.length - 1]
if (target) {
onSelectRef.current(target.id)
focusTabButton(target.id)
}
return
}
// Delete 或 Ctrl/Cmd+W 关闭聚焦的活跃 tab只对 closable 生效)
if (e.key === 'Delete' || ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'w')) {
const active = list.find((tb) => tb.id === activeTabId)
if (active?.closable) {
e.preventDefault()
onCloseRef.current(active.id)
// 关掉后焦点回落到 user tabApp 的 onClose 会自动切 active这里也同步把 focus 移过去
// —— 不强制移,因为如果 active 不是 userApp 会自己切
}
}
},
[activeTabId]
)
return (
// 不加 tabIndex={0}WAI-ARIA Tabs Pattern 用 roving tabindex,
// 只有 active tab tabIndex=0,其它 tab -1,焦点进入 tablist 时直接落 active tab,
// Tab 一次出去。tablist 本身再 tabIndex=0 会多一个 tab 停点(焦点进 tablist 容器
// → 跳到 active tab → 再 Tab 才出 tablist,用户多按一次 Tab。
// 键盘 ←→/Home/End/Delete/Ctrl+W 在任一焦点状态(含 tablist 自身)都能用 —— onKeyDown
// 挂在容器上,事件会从子 tab 按钮冒泡上来。
// eslint-disable-next-line jsx-a11y/interactive-supports-focus
<div
role="tablist"
aria-label={t('editorTab.listAria')}
// overflow-x-auto 让 tabs 超出可视宽度时横向滚动;不滚动 user tab 整体位置
// 用 gap-px + border-r 让相邻 tab 的 1px 边框重叠成 1px 分隔线(不要 2px
className="flex h-9 min-h-9 overflow-x-auto border-b border-border bg-surface-1 focus:outline-none"
onKeyDown={onKeyDown}
>
{tabs.map((tab) => (
<TabButton
key={tab.id}
tab={tab}
active={tab.id === activeTabId}
onSelect={() => onSelect(tab.id)}
onClose={tab.closable ? () => onClose(tab.id) : undefined}
/>
))}
</div>
)
}
/**
* 把 focus 移到指定 tabId 的按钮。tabIndex={active?0:-1} 的 roving tabindex 模式,
* 用 ref 直接调 .focus() 比改 state + re-render 更快。
*/
function focusTabButton(tabId: string): void {
const btn = document.querySelector<HTMLButtonElement>(`[data-pyprof-tab-id="${tabId}"]`)
btn?.focus()
}
interface TabButtonProps {
tab: EditorTabView
active: boolean
onSelect: () => void
onClose?: () => void
}
/**
* 单个 tab 按钮。memo 化 —— props reference 不变(外层 EditorTabs 已经 useCallback就跳过重渲。
*
* ARIA:
* - role="tab" + aria-selected 表达选中态
* - tabIndex={active?0:-1} 是 roving tabindex只有 active tab 进 Tab 序列,避免每次 Tab
* 都进 tablist 把屏读用户带到所有 tab —— 屏读进入 tablist 后再用 ←→ 切。
* - aria-controls 指到下方的 editor panel id容器 div 上用同一个 id
*/
const TabButton = memo(function TabButton({ tab, active, onSelect, onClose }: TabButtonProps) {
const t = useT()
// X 按钮的 stopPropagation —— 不冒泡到 tab 按钮的 onClick否则点关闭会同时触发切 tab。
const handleCloseClick = (e: React.MouseEvent<HTMLSpanElement>): void => {
e.stopPropagation()
onClose?.()
}
// 键盘激活Enter / Space 也触发关闭。tabIndex={-1} 不进 Tab 序列,但鼠标 hover/click
// 让元素获焦时,KeyDown 仍能冒泡到外部 tablist 的 onKeyDown(Delete/Ctrl+W 关 tab)。
// 这里补一个本地 handler 让 X 自身在获焦态也能被键盘激活 —— 不依赖外层。
const handleCloseKeyDown = (e: React.KeyboardEvent<HTMLSpanElement>): void => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
e.stopPropagation()
onClose?.()
}
}
const closeAria = t('editorTab.closeAria', { name: tab.displayName })
// user tab 不画 origin 点(无外部属性)。外部 tab 才根据 origin 取色。
const isUser = tab.origin === 'user'
const dotColor = isUser ? 'transparent' : ORIGIN_COLOR[tab.origin]
// status 状态徽章(仅外部 tab 有 loading / error 状态)
const statusBadge =
tab.status === 'loading' ? (
<span
className="ml-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-fg-muted"
aria-hidden="true"
/>
) : tab.status === 'error' ? (
<span
className="ml-1 inline-block h-1.5 w-1.5 rounded-full"
style={{ backgroundColor: '#eab308' }}
aria-label="error"
title={tab.errorTitle ?? t('editorTab.error.unknown', { message: '' })}
/>
) : null
// user tab 视觉居中:去掉占位的 origin 点 → 文字自然贴左,看起来"左飘"。改成 justify-center
// + min-w-[96px] 把标题放在 tab 视觉中线min-width 跟原本「点 + 短文本 + padding」宽度接近
// 多 tab 时不会比外部 tab 窄,节奏感保持。
const userAlign = isUser ? 'justify-center min-w-[96px]' : ''
return (
<button
type="button"
role="tab"
id={`pyprof-tab-${tab.id}`}
data-pyprof-tab-id={tab.id}
aria-selected={active}
aria-controls="pyrof-editor-panel"
tabIndex={active ? 0 : -1}
onClick={onSelect}
className={`group relative inline-flex h-full shrink-0 cursor-pointer items-center gap-1.5 border-r border-border px-3 transition-colors focus:outline-none focus-visible:bg-surface-2 ${userAlign} ${
active ? 'bg-bg text-fg' : 'bg-surface-1 text-fg-secondary hover:bg-surface-2 hover:text-fg'
}`}
>
{/* 活跃 tab 顶部 2px accent 条 —— 视觉锚点跟 Splitter 的 active handle 同款 */}
{active && <span aria-hidden="true" className="absolute inset-x-0 top-0 h-0.5 bg-accent" />}
{/* origin 色点 —— 仅外部 tab 渲染user tab 用 justify-center 居中标题,多余的点只占空间) */}
{!isUser && (
<span
aria-hidden="true"
className="inline-block h-2 w-2 shrink-0 rounded-full"
style={{ backgroundColor: dotColor }}
/>
)}
<span className="max-w-[160px] truncate font-mono text-xs" title={tab.displayName}>
{tab.displayName}
</span>
{statusBadge}
{onClose && (
// 关闭按钮:始终渲染(不只是 hover保证键盘聚焦也能看见
<span
role="button"
aria-label={closeAria}
tabIndex={-1}
onClick={handleCloseClick}
onKeyDown={handleCloseKeyDown}
// 用 span + role 而不是 button避免 button 嵌套 buttonHTML 不允许)。
// tabIndex=-1 让它不进入 Tab 序列,键盘用户用 Delete / Ctrl+W 关闭。
className="ml-1 inline-flex h-4 w-4 shrink-0 cursor-pointer items-center justify-center rounded text-fg-muted hover:bg-surface-2 hover:text-fg focus:outline-none focus-visible:ring-1 focus-visible:ring-accent-ring"
>
<CloseIcon size={10} />
</span>
)}
</button>
)
})
export default memo(EditorTabs)

View File

@@ -0,0 +1,115 @@
/**
* 空状态页:编辑器有代码、右侧还没结果时展示。
*
* 三步引导:粘贴代码 → 选解释器 → 运行。运行中追加一行提示。
*
* 解释器未选时:在三步列表之上追加一条「需要先选解释器」的提示条 + 一键打开设置按钮,
* 把「点齿轮图标 → 选 Python」这条首次使用流程压成一步可达。
*/
import { memo } from 'react'
import { CodeIcon, InterpreterIcon, PlayIcon, WarningIcon } from './icons'
import { useT } from '../i18n'
interface Props {
running: boolean
hasInterpreter: boolean
onLoadSample: () => void
onNewBlank: () => void
onOpenSettings: () => void
}
interface Step {
n: string
titleKey: 'empty.step1.title' | 'empty.step2.title' | 'empty.step3.title'
descKey: 'empty.step1.desc' | 'empty.step2.desc' | 'empty.step3.desc'
Icon: typeof CodeIcon
}
const STEPS: Step[] = [
{ n: '01', titleKey: 'empty.step1.title', descKey: 'empty.step1.desc', Icon: CodeIcon },
{ n: '02', titleKey: 'empty.step2.title', descKey: 'empty.step2.desc', Icon: InterpreterIcon },
{ n: '03', titleKey: 'empty.step3.title', descKey: 'empty.step3.desc', Icon: PlayIcon }
]
function EmptyState({ running, hasInterpreter, onLoadSample, onNewBlank, onOpenSettings }: Props) {
const t = useT()
return (
<div className="flex h-full flex-col items-center justify-center px-8 py-10">
<div className="max-w-md text-center">
<div className="font-mono text-xs uppercase tracking-wider text-fg-muted">{t('empty.eyebrow')}</div>
<h2 className="mt-2 text-xl font-semibold tracking-tight text-fg">{t('empty.title')}</h2>
<p className="mt-2 text-sm text-fg-secondary">{t('empty.subtitle')}</p>
</div>
{!hasInterpreter && (
<div
role="status"
className="mt-6 flex max-w-md items-start gap-3 rounded-md border border-warning/40 bg-warning/10 p-3 text-left"
>
<div className="mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-warning/20 text-warning">
<WarningIcon size={14} />
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-fg">{t('empty.needInterpreter')}</div>
<div className="mt-0.5 text-xs text-fg-secondary">{t('empty.needInterpreterDesc')}</div>
<button
type="button"
onClick={onOpenSettings}
className="mt-2 inline-flex items-center gap-1.5 rounded border border-border bg-surface-1 px-2.5 py-1 text-xs font-medium text-fg-secondary hover:border-border-strong hover:text-fg focus:outline-none focus-visible:ring-1 focus-visible:ring-accent-ring"
>
{t('empty.openSettings')}
<span aria-hidden="true"></span>
</button>
</div>
</div>
)}
<ol className="mt-6 flex max-w-md flex-col gap-3">
{STEPS.map((s) => (
<li
key={s.n}
className="flex gap-3 rounded-md border border-border bg-surface-1 p-3 transition-colors hover:border-border-strong"
>
{/* step icon 14 → 16px,text-fg-muted → text-fg-secondary: dark theme 下
fg-muted (RGB 141 148 157) 在 surface-2 上偏淡, 16px fg-secondary 更清晰,
三步引导的视觉锚点更强。 */}
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-surface-2 text-fg-secondary">
<s.Icon size={16} />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="font-mono text-2xs uppercase tracking-wider text-fg-muted">{s.n}</span>
<div className="text-sm font-medium text-fg">{t(s.titleKey)}</div>
</div>
<div className="mt-0.5 text-xs text-fg-secondary">{t(s.descKey)}</div>
</div>
</li>
))}
</ol>
<div className="mt-6 flex items-center gap-2">
{/* 副按钮: px-3 py-1.5 text-xs (26px) → px-3 py-2 text-sm (~34px),与 MinimalRunConfig 同名按钮对齐 */}
<button
type="button"
onClick={onNewBlank}
className="rounded border border-border bg-surface-1 px-3 py-2 text-sm text-fg-secondary hover:border-border-strong hover:text-fg focus:outline-none focus-visible:ring-1 focus-visible:ring-accent-ring"
>
{t('runconfig.newBlank')}
</button>
<button
type="button"
onClick={onLoadSample}
className="rounded border border-border bg-surface-1 px-3 py-2 text-sm text-fg-secondary hover:border-border-strong hover:text-fg focus:outline-none focus-visible:ring-1 focus-visible:ring-accent-ring"
>
{t('runconfig.loadSample')}
</button>
{running && (
// 不加 aria-liveApp 顶层唯一的 polite region 在播报进度,避免重复
<span className="text-xs text-fg-muted">{t('empty.running')}</span>
)}
</div>
</div>
)
}
export default memo(EmptyState)

View File

@@ -0,0 +1,59 @@
/**
* IPC 错误兜底页state === 'error')。
*
* 与 FailureResult 的差别ErrorBanner 用于"result 完全没拿到"的情况
* IPC 抛错、引擎没找到等FailureResult 用于"result.status !== 'ok'"。
* ErrorBanner 没有 traceback引擎没跑过只有 stderr。
*/
import { memo, useCallback } from 'react'
import type { RunOptions } from '../../../shared/analysis'
import { useT } from '../i18n'
interface Props {
message: string | null
stderr: string | null
lastOpts: RunOptions | null
/** Live scope from useScope hook -- user may toggle scope after a failure,
* and retry should honor the new value, not the stale lastOpts.scope */
currentScope: RunOptions['scope']
currentCode: string
onRetry: (opts: RunOptions) => void
}
function ErrorBanner({ message, stderr, lastOpts, currentCode, currentScope, onRetry }: Props) {
const t = useT()
// 复用最近一次成功发起的 opts但 code 用当前编辑器内容(用户可能改过了)。
// 这样 IPC 错误后能一键重跑不必再点顶栏按钮。lastOpts 为 null 时 disable 是
// UI 的兜底,但这里也 short-circuit —— 不依赖调用方按钮。
const retry = useCallback(() => {
if (lastOpts) onRetry({ ...lastOpts, code: currentCode, scope: currentScope })
}, [lastOpts, currentCode, currentScope, onRetry])
return (
<div role="alert" className="m-4 rounded-md border border-sev-high/40 bg-sev-high/10 p-3 text-sm">
<div className="flex items-center justify-between">
<div className="font-medium text-fg">{t('errorBanner.title')}</div>
<button
type="button"
onClick={retry}
disabled={!lastOpts}
className="rounded border border-border-strong bg-surface-1 px-2.5 py-1 text-xs text-fg-secondary hover:bg-surface-2 hover:text-fg disabled:cursor-not-allowed disabled:opacity-50 focus:outline-none focus-visible:ring-1 focus-visible:ring-accent-ring"
>
{t('errorBanner.retry')}
</button>
</div>
<div className="mt-1 font-mono text-xs text-fg-secondary">{message ?? t('errorBanner.unknown')}</div>
{stderr && stderr.trim() && (
<details className="mt-2">
<summary className="cursor-pointer text-xs text-fg-muted hover:text-fg">
{t('errorBanner.logDetails')}
</summary>
<pre className="mt-2 max-h-[40vh] overflow-auto whitespace-pre-wrap rounded bg-bg/60 p-2 font-mono text-xs text-fg-muted">
{stderr}
</pre>
</details>
)}
</div>
)
}
export default memo(ErrorBanner)

View File

@@ -0,0 +1,99 @@
import type { ErrorInfo, ReactNode, RefObject } from 'react'
import { Component, createRef } from 'react'
import { useT } from '../i18n'
interface Props {
children: ReactNode
}
interface State {
error: Error | null
}
/**
* 全局兜底 ErrorBoundary
* - 单个组件渲染抛错时不让整窗白屏
* - 提供"重置"按钮清掉错误状态、让用户继续使用其他功能
* - 只在 production 显示简短提示,开发环境仍打印完整 stack 到 console
*
* a11y进入错误状态时把焦点挪到 alert 容器 — 屏幕阅读器NVDA / JAWS才会
* 立即读出 role="alert" 内容。role="alert" 本身的隐式 aria-live 行为在不同
* 浏览器/AT 组合下不一致;显式 .focus() 是 WAI-ARIA APG 的兜底方案。
*/
export class ErrorBoundary extends Component<Props, State> {
state: State = { error: null }
private alertRef: RefObject<HTMLDivElement> = createRef()
static getDerivedStateFromError(error: Error): State {
return { error }
}
componentDidCatch(error: Error, info: ErrorInfo): void {
// console.error 用固定文本(开发面板,不本地化),但保留错误对象方便 devtools 展开。
// eslint-disable-next-line no-console
console.error('[ErrorBoundary] component threw:', error, info)
}
componentDidUpdate(_prev: Props, prevState: State): void {
// 错误从无到有时 — 把焦点移进 alert 容器reset 之后不再动焦点(用户已经操作过一次了)
if (!prevState.error && this.state.error) {
this.alertRef.current?.focus()
}
}
reset = (): void => this.setState({ error: null })
render(): ReactNode {
const { error } = this.state
if (!error) return this.props.children
// class component 里不能直接 hook —— 用 ErrorBoundaryView 把文案挪出去
return (
<ErrorBoundaryView
alertRef={this.alertRef}
message={error.message || String(error)}
onReset={this.reset}
/>
)
}
}
export default ErrorBoundary
interface ViewProps {
alertRef: RefObject<HTMLDivElement>
message: string
onReset: () => void
}
function ErrorBoundaryView({ alertRef, message, onReset }: ViewProps): JSX.Element {
const t = useT()
return (
<div
ref={alertRef}
role="alert"
tabIndex={-1}
className="flex h-screen w-screen flex-col items-center justify-center gap-4 bg-bg p-8 text-fg outline-none focus-visible:ring-2 focus-visible:ring-accent"
>
<div className="text-lg font-semibold text-sev-high">{t('errorBoundary.title')}</div>
<div className="max-w-xl rounded border border-border bg-surface-1 p-4 font-mono text-xs text-fg-secondary">
{message}
</div>
<div className="flex gap-2">
<button
type="button"
onClick={onReset}
className="rounded border border-border-strong bg-surface-1 px-3 py-1.5 text-sm text-fg-secondary hover:bg-surface-2 hover:text-fg focus:outline-none focus-visible:ring-1 focus-visible:ring-accent-ring"
>
{t('errorBoundary.retry')}
</button>
<button
type="button"
onClick={() => window.location.reload()}
className="rounded border border-border bg-surface-1 px-3 py-1.5 text-sm text-fg-secondary hover:bg-surface-2 hover:text-fg focus:outline-none focus-visible:ring-1 focus-visible:ring-accent-ring"
>
{t('errorBoundary.reload')}
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,73 @@
/**
* 失败页result.status !== 'ok'):单一版本。
*
* 之前有 FailureResultPro 模式专业报错)和 SimpleFailure友好大白话
* 模式合并后只留一个 — 给技术用户看完整 traceback / stderr 折叠区,
* 但不需要两种语气切换。
*/
import { memo, useCallback } from 'react'
import type { AnalysisResult, RunOptions } from '../../../shared/analysis'
import { useT, type StringKey } from '../i18n'
interface Props {
result: AnalysisResult
lastOpts: RunOptions | null
/** Live scope -- same as ErrorBanner: retry must honor post-failure scope change */
currentScope: RunOptions['scope']
currentCode: string
onRetry: (opts: RunOptions) => void
}
function FailureResult({ result, lastOpts, currentCode, currentScope, onRetry }: Props) {
const t = useT()
const isTimeout = result.status === 'timeout'
const isSyntax = result.status === 'syntax_error'
const titleKey: StringKey = isTimeout
? 'failure.title.timeout'
: isSyntax
? 'failure.title.syntax'
: 'failure.title.runtime'
const title = t(titleKey)
const stderrTail = result.error?.stderrTail
// 复用最近一次成功发起的 opts但 code 用当前编辑器内容(用户可能改过了)。
// 这样语法错误修正后、超时后、运行时错误后都能一键重跑,不必再点顶栏按钮。
const retry = useCallback(() => {
if (lastOpts) onRetry({ ...lastOpts, code: currentCode, scope: currentScope })
}, [lastOpts, currentCode, currentScope, onRetry])
return (
<div role="alert" className="flex h-full flex-col gap-3 p-6">
<div className="rounded-md border border-sev-high/40 bg-sev-high/10 p-4">
<div className="text-base font-semibold text-fg">{title}</div>
{result.error && (
<pre className="mt-2 max-h-[40vh] overflow-auto whitespace-pre-wrap rounded bg-bg/60 p-3 font-mono text-xs text-fg-secondary">
{result.error.message}
{result.error.traceback ? '\n\n' + result.error.traceback : ''}
</pre>
)}
{stderrTail && (
<details className="mt-2">
<summary className="cursor-pointer text-xs text-fg-muted hover:text-fg">
{t('failure.logDetails')}
</summary>
<pre className="mt-2 max-h-[40vh] overflow-auto whitespace-pre-wrap rounded bg-bg/60 p-3 font-mono text-xs text-fg-muted">
{stderrTail}
</pre>
</details>
)}
</div>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={retry}
disabled={!lastOpts}
className="rounded bg-accent px-3 py-1.5 text-xs font-semibold text-fg-on-accent hover:bg-accent-hover disabled:cursor-not-allowed disabled:opacity-40 focus:outline-none focus-visible:ring-1 focus-visible:ring-accent-ring"
>
{t('failure.retry')}
</button>
</div>
</div>
)
}
export default memo(FailureResult)

View File

@@ -0,0 +1,48 @@
/**
* 项目 logo顶栏左上的图标
*
* 渲染源:项目根的 icon.ico —— 与 BrowserWindow 窗口图标 / macOS dock 图标同源,
* 改一处不会让窗口和顶栏视觉漂移。
*
* 走 IPC 取 data URL 而不是把图标复制到 src/renderer/public 或在 build 时生成:
* - 单一来源icon.ico不存在"窗口新版 / 顶栏旧版"的漂移
* - ICO 多分辨率帧默认挑最大256×256主进程 resize 到 32×32 再 toDataURL
* 顶栏 ~18px 显示32px source 是高质量重采样;同时 payload 比直传 256×256
* 小一个数量级(几百字节 vs 几 KB
*
* 降级:拿不到时(文件丢失 / IPC 未挂 / 测试无 mock退回内置 SVG
* 顶栏从不会"空一格"。
*/
import { useEffect, useState } from 'react'
import { ACCENT } from '../utils/colors'
const SIZE = 18
function FallbackSvg() {
return (
<svg width={SIZE} height={SIZE} viewBox="0 0 18 18" fill="none" aria-hidden="true">
<rect x="2" y="2" width="3" height="14" rx="1" fill={ACCENT} />
<rect x="7" y="6" width="3" height="10" rx="1" fill={ACCENT} opacity="0.7" />
<rect x="12" y="9" width="3" height="7" rx="1" fill={ACCENT} opacity="0.45" />
</svg>
)
}
export default function Logo() {
const [src, setSrc] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
// 防御性检查window.api 在测试或非 Electron 环境可能不存在;缺失即不挂图标,
// 让 Fallback 接管,避免单测 / Storybook 因为这个 IPC 调用炸红
const api = window.api
if (!api || typeof api.getAppIcon !== 'function') return
void api.getAppIcon().then((s) => {
if (!cancelled) setSrc(s)
})
return () => {
cancelled = true
}
}, [])
if (!src) return <FallbackSvg />
return <img src={src} width={SIZE} height={SIZE} alt="" aria-hidden="true" />
}

View File

@@ -0,0 +1,172 @@
/**
* 极简运行配置:运行 / 取消 + 加载示例 + 进度条。
*
* 解释器选择已在 Settings 面板(顶栏齿轮图标入口)—— 这里不再展示解释器信息,
* 一切交给 Settings。运行按钮在未选解释器时禁用悬浮提示用户去设置里选。
*
* 剖析范围 toggle「仅用户代码」/「含库函数」二选一,默认 all含库函数
* user和 v2 之前一致,只看用户脚本里的函数;
* all耗时可以下钻到 import 的包(标准库 + 第三方)—— 这是默认,
* pandas.read_csv() 这种黑盒直接能展开到 stdlib / numpy 内部。
*
* 选择持久化到 localStorage.pyrof.scopeuseScope hook关闭软件重新
* 打开会保留用户上次的选择,不再每次回到默认。
*/
import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react'
import type { ProgressEvent, RunOptions } from '../../../shared/analysis'
import type { UseInterpretersResult } from '../hooks/useInterpreters'
import type { ProfileScope } from '../hooks/useScope'
import { useT } from '../i18n'
/**
* App 的 Ctrl+Enter 快捷键通过 ref.current.run() 触发同样的入口,结构同 MinimalRunConfig
* 自带 useImperativeHandle 暴露的 run() —— 避免 App.tsx 里用 `as` 强转。
*/
export interface RunConfigHandle {
run: () => void
}
interface Props {
code: string
state: 'idle' | 'running' | 'done' | 'error'
progress: ProgressEvent | null
/** 只取 active其他 select / pick / detectAll 由 Settings 接) */
interpreters: UseInterpretersResult
/** 剖析范围user仅用户脚本/ all含库函数 */
scope: ProfileScope
onRun: (opts: RunOptions) => void
onCancel: () => void
onLoadSample: () => void
onNewBlank: () => void
onToggleScope: () => void
}
/** 进度小圆点:内层实心 + 外层 animate-ping 双圈叠加(原 PulseDot仅此处用 */
function PulseDot() {
return (
<span aria-hidden="true" className="relative flex h-1.5 w-1.5">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-accent opacity-60" />
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-accent" />
</span>
)
}
const MinimalRunConfig = forwardRef<RunConfigHandle, Props>(function MinimalRunConfig(
{ code, state, progress, interpreters, scope, onRun, onCancel, onLoadSample, onNewBlank, onToggleScope },
ref
) {
const t = useT()
// 让父组件通过 ref.current.run() 触发同一条入口Ctrl+Enter 走这里)
const onRunRef = useRef(onRun)
onRunRef.current = onRun
// 单条「构造 opts + 调 onRun」入口,按钮点击 + ref.run() 都走这里,
// 避免两处各持一份闭包 → 哪天加 args(比如入口函数名)容易漏改一处。
const doRun = useCallback(() => {
const interp = interpreters.active
if (!interp) return
onRunRef.current({ interpreter: interp.path, code, scope })
}, [interpreters.active, code, scope])
useImperativeHandle(ref, () => ({ run: doRun }), [doRun])
const isRunning = state === 'running'
const disabled = isRunning || !interpreters.active
// 当前 scope 的展示标签state 决定 label / active 样式
const scopeLabelKey = scope === 'all' ? 'runconfig.scope.all' : 'runconfig.scope.user'
const scopeLabel = t(scopeLabelKey)
return (
<div className="flex flex-col gap-2 border-b border-border bg-bg p-3">
<div className="flex items-center gap-2">
{isRunning ? (
// Cancel / Run 按钮: px-3 py-1.5 text-xs (26px) → px-3 py-2 text-sm (~34px),
// 与下方「新建空白/加载示例」按钮同高,整条控制栏视觉节奏一致。
<button
type="button"
onClick={onCancel}
className="inline-flex items-center gap-2 rounded border border-border bg-surface-1 px-3 py-2 text-sm font-medium text-fg hover:border-border-strong focus:outline-none"
>
<PulseDot /> {t('runconfig.cancel')}
</button>
) : (
<button
type="button"
onClick={doRun}
disabled={disabled}
title={!interpreters.active ? t('runconfig.needInterpreter') : undefined}
className="inline-flex items-center gap-2 rounded bg-accent px-3 py-2 text-sm font-medium text-fg-on-accent hover:bg-accent-hover disabled:cursor-not-allowed disabled:opacity-40 focus:outline-none"
>
{t('runconfig.run')}
<kbd className="ml-1 rounded border border-fg-on-accent/30 px-1 font-mono text-2xs">
{t('runconfig.shortcut')}
</kbd>
</button>
)}
{/* 「新建空白/加载示例」按钮: px-2 py-1.5 text-xs (26px) → px-3 py-2 text-sm (~34px),
与 EmptyState 里同名按钮对齐,横向 padding 也一致。 */}
<button
type="button"
onClick={onNewBlank}
disabled={isRunning}
className="rounded border border-border bg-surface-1 px-3 py-2 text-sm text-fg-secondary hover:border-border-strong hover:text-fg disabled:opacity-50 focus:outline-none"
>
{t('runconfig.newBlank')}
</button>
<button
type="button"
onClick={onLoadSample}
disabled={isRunning}
className="rounded border border-border bg-surface-1 px-3 py-2 text-sm text-fg-secondary hover:border-border-strong hover:text-fg disabled:opacity-50 focus:outline-none"
>
{t('runconfig.loadSample')}
</button>
{/*
剖析范围 toggle switch默认开含库函数用户切回 user 时变灰。
role="switch" + aria-checked 让 SR 播报「开关 开启 / 关闭」而不是
「按钮 按下 / 未按下」。
*/}
<button
type="button"
role="switch"
aria-checked={scope === 'all'}
onClick={onToggleScope}
disabled={isRunning}
aria-label={t('runconfig.scope.aria', { scope: scopeLabel })}
className="inline-flex shrink-0 items-center gap-1.5 rounded px-1.5 py-1 text-xs text-fg-secondary hover:text-fg focus:outline-none focus-visible:ring-1 focus-visible:ring-accent-ring disabled:opacity-50"
>
<span
aria-hidden="true"
className={`relative inline-block h-4 w-7 rounded-full transition-colors ${
scope === 'all' ? 'bg-accent' : 'bg-surface-3'
}`}
>
<span
aria-hidden="true"
className={`absolute top-0.5 left-0.5 h-3 w-3 rounded-full bg-fg-on-accent shadow transition-transform ${
scope === 'all' ? 'translate-x-3' : 'translate-x-0'
}`}
/>
</span>
<span className="select-none">{scopeLabel}</span>
</button>
</div>
{isRunning && progress && (
<div
role="progressbar"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={progress.pct}
aria-label={t('runconfig.progress')}
className="h-1 w-full overflow-hidden rounded bg-surface-2"
>
<div
className="h-full bg-accent transition-[width] duration-150"
style={{ width: `${progress.pct}%` }}
/>
</div>
)}
</div>
)
})
export default MinimalRunConfig

View File

@@ -0,0 +1,175 @@
/**
* 结果页result.status === 'ok'):单一版本,无 Tab。
*
* 之前 ResultsPanel 有 Hotspot / 火焰图 / 行级 / 调用关系 / 优化建议 5 个分块,
* 其中后三个用 Tab 切换。v2 起砍掉行级 / 调用关系 / 优化建议 + Tab 路由,
* 只剩:
* - RunSummaryLite本次耗时 + 函数总数 + 最耗时函数
* - HotspotTable可点击跳到源码行
* - TimeChartSwitcher耗时可视化火焰图 / 柱状图 / 矩形树图 / 旭日图四选一)
*
* 三块按"摘要 → 表 → 图"自然顺序纵向堆叠,键盘焦点只需一次 Tab 进出。
*
* v4 视觉升级:
* - 卡片套 .elev-1: bg-surface-2 + border-strong + 微 shadow, 在 #08090A 上
* 浮起来,不和 page bg 糊在一起
* - section header 加 .section-accent 装饰(左侧 3px accent bar),
* 统一章节标记,长滚动也能定位
* - section hint text-xs → text-sm(14px),tagline 不再"灰得发晕"
*
* v5噪声过滤移到引擎侧engine/structure.py:_is_internal_test + profile_and_measure
* 的 hide_internal 参数。result.json 默认就只包含「真正在跑的代码」,外部脚本读
* result.json 时拿到的也是干净数据 —— 用户原话「软件内部的测试部分默认百分百过滤
* 掉,不在统计范围内容」在源头就生效,不再依赖 UI 层 filter。
*/
import { memo } from 'react'
import type { ReactNode } from 'react'
import type { AnalysisResult, FunctionNode, RunOptions } from '../../../shared/analysis'
import HotspotTable from './views/HotspotTable'
import TimeChartSwitcher from './views/TimeChartSwitcher'
import RunSummaryLite from './RunSummaryLite'
import { ChartIcon, TableIcon } from './icons'
import { useT } from '../i18n'
interface Props {
result: AnalysisResult
/** 当前 result 是哪个 scope 的 ('user' | 'all')。 */
resultScope: RunOptions['scope'] | null
/** 用户当前 toggle 的 scope。结果是旧 scope 时给一条「结果可能跟 toggle 不符」的提示。 */
currentScope: RunOptions['scope']
/** 用现 scope 重跑的回调 —— 提示条上挂一个「重新跑」按钮。 */
onRerun: () => void
selectedFuncId: string | undefined
/**
* 热点点击回调。传整个 FunctionNode 而不是 (id, line) —— App.tsx 需要 fn.file / fn.origin
* 决定路由user → 当前 tab 高亮stdlib / 第三方 → 开新 tab + IPC 读文件)。
*/
onSelectHotspot: (fn: FunctionNode) => void
}
function ResultsPanel({
result,
resultScope,
currentScope,
onRerun,
selectedFuncId,
onSelectHotspot
}: Props) {
const t = useT()
// 结果是不是来自当前 toggle 的 scope —— 切换 scope 后旧 result 不自动重跑,
// 给一句提示 + 重跑按钮,避免用户误以为结果对应当前 toggle。
// 把两边都窄化到 NonNullable 后再比,避免 resultScope=undefined / currentScope=undefined 时误报。
const oldS = resultScope ?? undefined
const newS = currentScope ?? undefined
const scopeMismatch = oldS !== undefined && newS !== undefined && oldS !== newS
return (
<div className="flex h-full min-h-0 flex-col bg-bg">
<RunSummaryLite result={result} onSelectHottest={onSelectHotspot} />
{scopeMismatch && oldS && newS && <ScopeStaleHint oldScope={oldS} newScope={newS} onRerun={onRerun} />}
<div className="flex flex-col gap-6 p-6">
<Section
icon={<TableIcon className="text-fg-muted" />}
title={t('results.hotspot.title')}
hint={t('results.hotspot.hint', { n: result.functions.length })}
>
{/* v4 卡片浮起: bg-surface-2 + border-strong + 微 shadow。 */}
<div className="elev-1 overflow-hidden rounded-md p-2">
<HotspotTable
functions={result.functions}
onSelect={onSelectHotspot}
selectedId={selectedFuncId}
/>
</div>
</Section>
<Section
icon={<ChartIcon className="text-fg-muted" />}
title={t('results.time.title')}
hint={t('results.time.hint')}
>
{/* 不内嵌固定高度 —— 外层右栏自带 overflow-y-auto, 图随内容自然长高,
多余高度直接交给右栏纵滑, 避免「图内 + 右栏」双层翻轮。 */}
{/* v4 卡片浮起: bg-surface-2 + border-strong + 微 shadow。 */}
<div className="elev-1 overflow-hidden rounded-md">
<TimeChartSwitcher
result={result}
selectedFuncId={selectedFuncId}
onSelectHotspot={onSelectHotspot}
/>
</div>
</Section>
</div>
</div>
)
}
export default memo(ResultsPanel)
function Section({
title,
icon,
hint,
children
}: {
title: string
icon?: ReactNode
hint?: ReactNode
children: ReactNode
}) {
return (
<section>
{/* v4:section header 加 .section-accent(左 3px accent bar);
hint text-xs → text-sm font-medium(中等权重,不再"灰得发晕")。
章节内一眼能看清当前在看什么,长滚动也不迷失。 */}
<header className="section-accent mb-3 flex items-center gap-2">
{icon && <span className="flex items-center">{icon}</span>}
<h2 className="text-base font-semibold text-fg">{title}</h2>
{hint && <span className="text-sm font-medium text-fg-secondary">{hint}</span>}
</header>
{children}
</section>
)
}
/**
* 范围 mismatch 提示:当前 result 用的是 usertoggle 已切到 all或反之
* 不自动重跑 —— 跑一次要 1-10 秒,不能在 toggle 上偷偷起活。
* 提示条 + 「以新范围重跑」按钮,让用户主动确认。
*/
function ScopeStaleHint({
oldScope,
newScope,
onRerun
}: {
oldScope: NonNullable<RunOptions['scope']>
newScope: NonNullable<RunOptions['scope']>
onRerun: () => void
}): JSX.Element {
const t = useT()
return (
<div
role="status"
aria-live="polite"
className="flex flex-wrap items-center gap-3 border-b border-border bg-sev-med/10 px-4 py-2.5 text-sm text-fg-secondary"
>
<span className="font-mono uppercase tracking-wide text-sev-med">
{t('results.scopeStale.eyebrow')}
</span>
<span>
{t('results.scopeStale.body', {
old: t(`runconfig.scope.${oldScope}`),
new: t(`runconfig.scope.${newScope}`)
})}
</span>
<button
type="button"
onClick={onRerun}
className="ml-auto cursor-pointer rounded border border-border bg-surface-1 px-2.5 py-1 font-medium text-fg hover:border-border-strong focus:outline-none focus-visible:ring-1 focus-visible:ring-accent-ring"
>
{t('results.scopeStale.rerun')}
</button>
</div>
)
}

View File

@@ -0,0 +1,125 @@
/**
* 运行输出面板(左侧底部)
*
* 显示 Python 子进程 stdout 的实时累积 —— 用户写 `print('hello')` 跑一次,
* 这里立即滚出一行 "hello"。
*
* 设计取舍:
* - **不放在 AnalysisResult**:流式推送后,渲染端 buffer 才是 single source of truth,
* 不再依赖「跑完才拿到 result」。useAnalysis 维护 stdout 字符串并通过 onClear 暴露。
* - **新 run 自动清空**useAnalysis.run() 起始会清空 stdout —— 上一轮的 print() 留着会和
* 本次混在一起,等于丢失"这次运行的输出是哪些"的边界。需要手动清空仍点 header 的「清空」。
* cancel / reset 不清(被 cancel 的部分输出 / 加载示例前的 log 都要保留)。
* - **自动滚到底**v1 简单实现,所有新内容都滚到底 —— 等以后用户提"我看到一半,
* 跳到顶部去看历史,新输出一来又被推到底",再加「用户向上滚动时停止自动滚」检测。
* - **完全折叠**header 上的「收起」按钮把面板折叠成只剩一行 header(高度 ≈ 44px),
* Splitter 拖动是另一种「拖到顶就消失」的隐藏方式,两条路分别对应不同意图。
*/
import { useEffect, useMemo, useRef } from 'react'
import { useT } from '../i18n'
interface Props {
stdout: string
isRunning: boolean
/** 用户手动「清空」按钮回调 —— useAnalysis 的 clearStdout */
onClear: () => void
/** 折叠状态由父组件控制:这样外层 wrapper 才能在折叠时把高度收到只剩 header(~44px),
* 不然 consoleFrac * 100% 占着列高,「收起」看起来像没收完。 */
collapsed: boolean
onCollapsedChange: (collapsed: boolean) => void
}
/** 主进程 appendCapped 把单块压到 8MB 上限;buffer 接近这个量级就算"溢出"。 */
const OVERFLOW_THRESHOLD_BYTES = 4 * 1024 * 1024
function RunConsole({ stdout, isRunning, onClear, collapsed, onCollapsedChange }: Props) {
const t = useT()
const scrollRef = useRef<HTMLDivElement>(null)
// 自动滚到底:stdout 变化 + 展开状态变化时各滚一次。
// 不加 user-scroll-up 检测 —— 见文件头注释,v1 简单版。
useEffect(() => {
if (collapsed) return
const el = scrollRef.current
if (!el) return
el.scrollTop = el.scrollHeight
}, [stdout, collapsed])
// 用 utf-8 字节数(中文 / emoji 一个字多字节)而非 length 判断「多少行」更准确,
// 但 split('\n') 也够用 —— 「共 N 行」是软指标,不需要精确到字节。
const lineCount = useMemo(() => {
if (!stdout) return 0
// 末尾无换行的最后一行也算一行 —— 不要被 -1 减成 0 让 header 闪
return stdout.endsWith('\n') ? stdout.split('\n').length - 1 : stdout.split('\n').length
}, [stdout])
const overflow = stdout.length > OVERFLOW_THRESHOLD_BYTES
const hasContent = stdout.length > 0
return (
<section
aria-label={t('console.title')}
className="flex h-full min-h-0 flex-col border-t border-border bg-bg"
>
{/* Header: 标题 + 行数徽章 + 溢出警告 + 清空/收起 */}
<div className="flex shrink-0 items-center gap-2 border-b border-border bg-surface-1 px-3 py-1.5">
<span className="text-xs font-semibold text-fg-secondary">{t('console.title')}</span>
{isRunning && (
<span aria-hidden="true" className="relative flex h-1.5 w-1.5">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-accent opacity-60" />
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-accent" />
</span>
)}
{hasContent && (
// 行数徽章: text-2xs (11px) → text-xs (12px),与 header 按钮同档位,长输出时
// 不会被「清空/收起」按钮视觉甩开。
<span className="text-xs text-fg-muted">{t('console.lineCount', { count: lineCount })}</span>
)}
{overflow && (
<span className="rounded bg-warning/15 px-1.5 py-0.5 text-2xs text-warning" role="status">
{t('console.overflow')}
</span>
)}
<div className="ml-auto flex items-center gap-1">
{/* 清空/收起按钮: px-2 py-0.5 text-2xs (11px) ≈ 20px 高 → px-2.5 py-1 text-xs (12px) ≈ 28px.
这是 console 顶栏仅有的两个动作,太小鼠标不好点。 */}
<button
type="button"
onClick={onClear}
disabled={!hasContent}
className="rounded px-2.5 py-1 text-xs text-fg-secondary hover:bg-surface-2 disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent"
aria-label={t('console.clear')}
>
{t('console.clear')}
</button>
<button
type="button"
onClick={() => onCollapsedChange(!collapsed)}
aria-expanded={!collapsed}
aria-controls="pyrof-console-body"
className="rounded px-2.5 py-1 text-xs text-fg-secondary hover:bg-surface-2"
>
{collapsed ? t('console.expand') : t('console.collapse')}
</button>
</div>
</div>
{/* Body: 折叠时整个 hide,但 header 仍占位(便于 Splitter 拖动) */}
{!collapsed && (
<div
id="pyrof-console-body"
ref={scrollRef}
// 等宽字体 + surface-2 背景 —— 对齐火焰图风格,让 stdout 看起来"就是数据"。
// overflow-auto:自动滚到底(scrollTop = scrollHeight),用户向上滚时自己接管。
// stdout 输出: text-2xs (11px) → text-xs (12px). 这是用户实际打印的内容,
// 不是装饰 eyebrow —— 11px 长时间读眼睛累,12px 在 monospace 下仍紧凑可读。
className="min-h-0 flex-1 overflow-auto bg-surface-2 px-3 py-2 font-mono text-xs leading-relaxed text-fg-secondary whitespace-pre-wrap break-words"
>
{hasContent ? stdout : <span className="text-fg-muted">{t('console.empty')}</span>}
</div>
)}
</section>
)
}
export default RunConsole

View File

@@ -0,0 +1,185 @@
/**
* 极简结果摘要:单次 wall time + 函数总数 + 耗时最长函数名。
*
* v2 起只剩一次 wall-clock 测量,不再显示 mean/std/p95/median/min/cv/sparkline
* 这个组件替代原 RunSummary。
*
* v4 视觉重做Linear / Vercel / Notion 三方参照):
* - 单一圆角卡片elev-1 + rounded-md + overflow-hidden内部 divide-x divide-border
* 三块不再各自顶 2px 彩条、靠 1px 缝隙拼在一起 —— 那是"三张贴一起",不是"一张卡"
* - 三块同结构UPPER label / 28px 大数字 / 14px 副信息,垂直节奏对齐
* - 三块各自不同色调,左侧 3px accent 条 + label 文字色同步:
* wall timeaccent 紫)= 主指标function countsev-low 绿)= 信息;
* hottestsev-high 红)= actionable 告警,可下钻
* - 函数总数 tile 加"X modules"副信息(仅多模块时显示),不再显得"只是一个数字"
* - wall time 副信息保留(检测到含交互等待时显黄色提示),来自 utils/detect-wait
*/
import type { ReactNode } from 'react'
import type { AnalysisResult, FunctionNode } from '../../../shared/analysis'
import { fmtDuration } from '../utils/format'
import { isInteractiveWait } from '../utils/detect-wait'
import { FlameIcon } from './icons'
import { useT } from '../i18n'
interface Props {
result: AnalysisResult
/** 点最热函数 tile → 跳到对应行App.tsx 把 file/origin 路由到用户 tab / 外部 tab */
onSelectHottest?: (fn: FunctionNode) => void
}
export default function RunSummaryLite({ result, onSelectHottest }: Props) {
const t = useT()
const wallTime = result.wallTime
const fns: FunctionNode[] = result.functions
// 函数列表已经按 tottime 倒序排过engine 端),首项就是最热的
const hottest = fns[0]
// 模块数仅在多模块时显示 —— 单模块scope=user 默认)显示就成噪音
const moduleCount = new Set(fns.map((f) => f.module)).size
return (
<div className="elev-1 grid grid-cols-3 divide-x divide-border-strong overflow-hidden rounded-md shrink-0">
{/* Wall time —— 主指标accent 紫)+ 左侧 accent 条 */}
<SummaryCell label={t('summary.wallTime')} tone="accent">
<div className="flex items-baseline gap-1.5">
<div className="font-mono text-2xl font-medium leading-none tabular text-fg">
{wallTime ? fmtDuration(wallTime.seconds, { big: true }) : '—'}
</div>
{/* v4 起 wall-time 是「cProfile instrumented 时间 / 校准系数」估计值,
用一个 chip 提示用户该值已补偿 cProfile 开销 —— 鼠标 hover 看具体 ratio。
非 ok 状态下 result.calibration 是 undefined,自然不渲染。 */}
{result.calibration && (
<span
title={t('summary.wallTimeCalibratedTitle', {
ratio: result.calibration.ratio.toFixed(2)
})}
className="rounded bg-surface-2 px-1 text-2xs font-medium text-fg-muted"
>
{t('summary.wallTimeCalibrated')}
</span>
)}
</div>
{/* 检测到 wall time 远超 cProfile 归因合计 → 多半含交互等待。
检测阈值见 utils/detect-wait.ts。火焰图对这种 wall time 会比 wall
数字显得小很多,这一行给用户一个 hook 解释为什么对不上。 */}
{wallTime && isInteractiveWait(wallTime.seconds, result.flame?.value ?? 0) && (
<div className="mt-2 text-sm tabular text-sev-med" title={t('summary.wallTimeHintTitle')}>
{t('summary.wallTimeHint', {
gap: fmtDuration(wallTime.seconds - (result.flame?.value ?? 0))
})}
</div>
)}
</SummaryCell>
{/* 函数总数 —— 信息success 绿)+ 左侧 sev-low 条 */}
<SummaryCell label={t('summary.functionCount')} tone="success">
<div className="font-mono text-2xl font-medium leading-none tabular text-fg">{fns.length}</div>
{moduleCount > 1 && (
<div className="mt-2 text-sm tabular text-fg-secondary">
{t('summary.moduleCount', { count: moduleCount })}
</div>
)}
</SummaryCell>
{/* 最耗时函数 —— sev-high 配色 + 火焰 icon + 左 accent 条。
可点击跳到对应行user tab 高亮 / 库函数开新 tab。 */}
<SummaryCell
label={t('summary.hottest')}
tone="alert"
icon={<FlameIcon size={12} aria-hidden />}
// 仅当提供回调且有 hottest 才让 tile 可交互 —— 用 button 元素承担键盘焦点
// 和 a11y,焦点环由 globals.css :focus-visible 统管
onClick={onSelectHottest && hottest ? () => onSelectHottest(hottest) : undefined}
title={
hottest
? `${hottest.name} · ${fmtDuration(hottest.tottime)} · ${hottest.ncalls} calls · 第 ${hottest.line}`
: undefined
}
>
<div className="truncate font-mono text-base font-medium text-fg">{hottest?.name ?? '—'}</div>
{hottest && (
<div className="mt-2 text-sm tabular text-fg-secondary">
{t('summary.hottestSub', {
tottime: fmtDuration(hottest.tottime),
calls: hottest.ncalls
})}
</div>
)}
</SummaryCell>
</div>
)
}
/**
* 单格摘要 —— label / value / sub-info 三段式。
*
* 设计要点:
* - tone 控制左侧 3px accent 条 + label 文字色:
* - accent → 主指标(紫): wall time
* - success → 信息(绿) : function count
* - alert → 告警(红) : hottest function可点击下钻
* - neutral → 无条、无强调
* - onClick 提供时用 button,提供 a11y 焦点 / Enter / Space;
* hover 时 bg 微亮 + 焦点环由 globals.css :focus-visible 统一处理
* - 内部分隔靠父 grid 的 divide-x,本组件不再画 border
*/
type Tone = 'neutral' | 'accent' | 'success' | 'alert'
const TONE_STYLE: Record<Tone, { bar: string; label: string }> = {
neutral: { bar: '', label: 'text-fg-secondary' },
accent: { bar: 'bg-accent', label: 'text-accent' },
success: { bar: 'bg-sev-low', label: 'text-sev-low' },
alert: { bar: 'bg-sev-high', label: 'text-sev-high' }
}
function SummaryCell({
label,
tone = 'neutral',
icon,
onClick,
title,
children
}: {
label: string
tone?: Tone
icon?: ReactNode
onClick?: () => void
title?: string
children: ReactNode
}): JSX.Element {
const style = TONE_STYLE[tone]
const isInteractive = !!onClick
// 交互态:hover 时 bg 微亮var(--surface-3) 比 var(--surface-2) 亮一档),
// 提示 tile 可点;键盘焦点环由全局 css 接管
const hoverCls = isInteractive
? 'cursor-pointer transition-colors hover:bg-surface-3 focus:outline-none focus-visible:bg-surface-3'
: ''
const labelCls = `flex items-center gap-1.5 text-xs font-mono uppercase tracking-wider ${style.label}`
const inner = (
<>
{style.bar && (
<div className={`absolute inset-y-2 left-0 w-[3px] rounded-r ${style.bar}`} aria-hidden="true" />
)}
<div className={labelCls}>
{icon}
<span>{label}</span>
</div>
<div className="mt-3">{children}</div>
</>
)
// interactive → button;否则 → div。用 div 而不是 section 避免 landmark 噪音
if (isInteractive) {
return (
<button
type="button"
onClick={onClick}
title={title}
className={`relative block w-full px-5 py-4 text-left ${hoverCls}`}
>
{inner}
</button>
)
}
return <div className="relative px-5 py-4">{inner}</div>
}

View File

@@ -0,0 +1,372 @@
/**
* 设置弹层保留「解释器」section + 仅 Windows 上的 Shell section + 「关于」section。
*
* 语言 / 主题这两个高频切换已经搬到 TopBar 右上角(各自一个按钮),
* 齿轮留给「解释器选择」这种偶尔发生的重操作。
*
* 设计原则:
* - 通过 createPortal 渲染到 document.body避免被父级 `overflow:hidden` 裁剪
* - Esc 关 Modal不取消运行 — Esc 关 Modal 的优先级最高)
* - 点遮罩关 Modal点内容区不关
* - 打开时焦点进 Modal 内第一项(默认在 select关闭时归还给触发按钮App 负责)
* - Modal 内部始终跟随当前主题(用 CSS vars 自动跟随)
*
* 解释器部分:直接复用原 MinimalRunConfig 里的 select + 浏览 + 刷新代码段(保持逻辑不变),
* 选中的解释器变化通过 `interpreters.selectPath` 写回 App 的 useInterpreters
* 关 Modal 后 MinimalRunConfig 通过 `interpreters.active` 拿到最新值。
*
* 关于部分:开发者信息 + 主页链接,纯展示性质,不持久化任何偏好;链接走 IPC
* `shell:openExternal` 在系统默认浏览器打开,主进程做协议白名单校验。
*/
import { useEffect, useRef, useState, useCallback, type ReactNode } from 'react'
import { createPortal } from 'react-dom'
import type { UseInterpretersResult } from '../hooks/useInterpreters'
import type { Shell } from '../hooks/useShellPreference'
import { useT } from '../i18n'
import { CloseIcon, InfoIcon, PythonIcon, TerminalIcon } from './icons'
import { installPythonCommand } from '../utils/installCommand'
import { formatIpcError } from '../utils/ipcErrorFormat'
interface Props {
open: boolean
onClose: () => void
interpreters: UseInterpretersResult
/** Windows 上打开系统终端用的 shell(cmd / PowerShell);非 Win 不渲染 Shell section */
shell: Shell
setShell: (s: Shell) => void
}
export default function SettingsModal({ open, onClose, interpreters, shell, setShell }: Props) {
const t = useT()
const dialogRef = useRef<HTMLDivElement>(null)
const closeBtnRef = useRef<HTMLButtonElement>(null)
// Esc 关 Modal同时阻止冒泡让 App 顶层的 Esc 不取消运行
useEffect(() => {
if (!open) return
const onKey = (e: KeyboardEvent): void => {
if (e.key === 'Escape') {
e.stopPropagation()
onClose()
}
}
// capture: true 让 Modal 的 Esc 优先于 App 顶层的 keydown listener
window.addEventListener('keydown', onKey, { capture: true })
return () => window.removeEventListener('keydown', onKey, { capture: true })
}, [open, onClose])
// 打开时把焦点移到关闭按钮 —— 比 focus 到 dialog 容器更直观(用户从顶栏齿轮过来,
// 第一个 Tab 自然落在关闭按钮关闭是最高频动作。SR 也会读出「关闭 按钮」。
// dialog 容器 tabIndex=-1 保留作为兜底(按钮还没 mount 时 focus 不会爆错)。
useEffect(() => {
if (!open) return
// requestAnimationFrame 让 React commit + ref 绑定完成后再 focus —— 直接 focus
// 在 effect 同步阶段 ref 可能还 null
const id = requestAnimationFrame(() => {
closeBtnRef.current?.focus()
})
return () => cancelAnimationFrame(id)
}, [open])
if (!open) return null
return createPortal(
// 遮罩层role="presentation" 让 SR 不把它当交互元素;
// 点击关闭由 Esc 键 + 内嵌按钮承担(见下方 close 按钮)。
// 禁用 click-events-have-key-events这是行业通用的 modal 遮罩模式 —— Enter 走
// 内容区第一个可聚焦元素Esc 关闭(已在 useEffect 里处理),不是遮罩。
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions, jsx-a11y/click-events-have-key-events
<div
role="presentation"
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
onClick={onClose}
>
{/*
内嵌 dialogrole="dialog" aria-modal="true" 是 ARIA modal pattern 标准用法。
onClick stopPropagation 让点内容区不冒泡触发遮罩关闭键盘交互Esc走 window listener。
禁用 no-static-element-interactions 和 click-events-have-key-events
div 不是交互元素stopPropagation 只是拦截冒泡不是触发业务逻辑 —
内容区自身的交互由其内部按钮 / select 承担,键盘 Esc 已在 useEffect 里全局处理。
*/}
{/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions, jsx-a11y/click-events-have-key-events */}
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-label={t('settings.title')}
tabIndex={-1}
// stopPropagation 让点内容区不冒泡触发 onClose
onClick={(e) => e.stopPropagation()}
className="w-full max-w-xl rounded-md border border-border bg-surface-1 text-fg shadow-pop outline-none"
>
<div className="flex items-center justify-between border-b border-border px-4 py-3">
<div className="flex items-center gap-2">
<PythonIcon size={16} className="text-fg-muted" />
<h2 className="text-base font-semibold">{t('settings.title')}</h2>
</div>
<button
ref={closeBtnRef}
type="button"
onClick={onClose}
aria-label={t('settings.close')}
// 关闭按钮: p-1 (4px) + 14px icon = 30×30px → p-1.5 (6px) + 16px icon = 32×32px,
// 满足 WCAG 32px 最小触控目标。Modal 打开时焦点直接落这里,尺寸对齐顶栏按钮。
className="inline-flex items-center justify-center rounded p-1.5 text-fg-muted hover:bg-surface-2 hover:text-fg focus:outline-none focus-visible:ring-1 focus-visible:ring-accent-ring"
>
<CloseIcon size={16} />
</button>
</div>
<div className="flex flex-col gap-5 p-5">
{/* 解释器 */}
<Section title={t('settings.section.interpreter')}>
<InterpreterControls interpreters={interpreters} shell={shell} />
</Section>
{/* Shell — 仅 Windows 渲染。Mac / Linux 用 OS 自带 terminal,这个偏好不生效 */}
{window.api.platform === 'win32' && (
<Section title={t('settings.section.shell')}>
<ShellControls shell={shell} setShell={setShell} />
</Section>
)}
{/* 关于 —— 开发者信息 + 主页链接,纯展示 */}
<Section title={t('settings.section.about')}>
<AboutControls />
</Section>
</div>
</div>
</div>,
document.body
)
}
function Section({ title, children }: { title: string; children: ReactNode }): JSX.Element {
return (
<section>
<h3 className="mb-2 font-mono text-2xs uppercase tracking-wider text-fg-muted">{title}</h3>
{children}
</section>
)
}
function InterpreterControls({
interpreters,
shell
}: {
interpreters: UseInterpretersResult
/** Windows 上的 shell 偏好;installPython 时透传给 IPC */
shell: Shell
}): JSX.Element {
const t = useT()
const {
active,
interpreters: list,
selected,
selectPath,
detecting,
detectAll,
pick,
detectError,
pickError
} = interpreters
const showRefreshError = detectError != null && !detecting && list.length === 0
// 仅"列表空 + 不在扫描"时给"打开终端"按钮 — 用户能看到是因为我们没探测到 Python,
// 浏览… 之外多一个"自己去装"的选择。
const showOpenTerminal = list.length === 0 && !detecting
const [terminalError, setTerminalError] = useState<string | null>(null)
// 点击打开终端装 Python:根据平台挑命令 → IPC → 失败时把 IpcError 还原成人类可读消息
const openInstallTerminal = useCallback(async () => {
setTerminalError(null)
const cmd = installPythonCommand(window.api.platform)
try {
await window.api.openTerminal(cmd, shell)
} catch (err) {
// 跨 IPC 抛出的 Error 会被 IpcError 还原成结构化对象;
// 按当前 lang 走翻译表,沿用 detect/pick error 的展示风格
setTerminalError(formatIpcError(err, t))
}
}, [t, shell])
return (
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<PythonIcon size={14} className="shrink-0 text-fg-muted" />
<select
aria-label={t('settings.interpreter.label')}
value={selected}
onChange={(e) => selectPath(e.target.value)}
disabled={detecting}
className="min-w-0 flex-1 rounded border border-border bg-surface-2 px-2 py-1.5 text-xs text-fg disabled:opacity-50"
>
{list.length === 0 && !detecting && <option value="">{t('settings.interpreter.empty')}</option>}
{detecting && <option value="">{t('settings.interpreter.detecting')}</option>}
{list.map((i) => (
<option key={i.path} value={i.path}>
{i.path} Python {i.version}
</option>
))}
</select>
{/* 浏览/重新扫描/打开终端按钮: px-2.5 py-1.5 text-xs (28px) → px-3 py-2 text-sm (34px),
与 MinimalRunConfig 副按钮对齐, Section 内三个按钮尺寸一致。 */}
<button
type="button"
onClick={() => void pick()}
className="rounded border border-border bg-surface-2 px-3 py-2 text-sm text-fg-secondary hover:border-border-strong hover:text-fg focus:outline-none"
>
{t('settings.interpreter.browse')}
</button>
<button
type="button"
onClick={() => void detectAll()}
disabled={detecting}
aria-label={t('settings.interpreter.rescan')}
className="rounded border border-border bg-surface-2 px-3 py-2 text-sm text-fg-secondary hover:border-border-strong hover:text-fg disabled:opacity-50 focus:outline-none"
>
</button>
</div>
{showOpenTerminal && (
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => void openInstallTerminal()}
className="rounded border border-border bg-surface-2 px-3 py-2 text-sm text-fg-secondary hover:border-border-strong hover:text-fg focus:outline-none"
>
{t('settings.terminal.openCmd')}
</button>
<span className="text-2xs text-fg-muted">{t('settings.terminal.hint')}</span>
</div>
)}
{active && (
<div className="font-mono text-2xs text-fg-muted">
Python {active.version} · {active.path}
</div>
)}
{showRefreshError && (
<div className="text-xs text-warning">
{t('settings.interpreter.detectError', { message: detectError })}
</div>
)}
{pickError && (
<div className="text-xs text-warning">
{t('settings.interpreter.pickError', { message: pickError })}
</div>
)}
{terminalError && (
<div className="text-xs text-warning">{t('settings.terminal.error', { message: terminalError })}</div>
)}
</div>
)
}
/**
* Windows 上「打开系统终端」按钮用哪个 shell。两个选项都是 radio —— 二选一且
* 长期持久化(默认 cmd,见 useShellPreference。非 Windows 不渲染此 section。
*
* 不用 segmented control / select —— 只有两条,放一起更显眼;radio 也方便加 aria-label。
*/
function ShellControls({ shell, setShell }: { shell: Shell; setShell: (s: Shell) => void }): JSX.Element {
const t = useT()
return (
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<TerminalIcon size={14} className="shrink-0 text-fg-muted" />
<div role="radiogroup" aria-label={t('settings.shell.label')} className="flex gap-1">
<ShellRadio
current={shell === 'cmd'}
value="cmd"
label={t('settings.shell.cmd')}
onSelect={() => setShell('cmd')}
/>
<ShellRadio
current={shell === 'powershell'}
value="powershell"
label={t('settings.shell.powershell')}
onSelect={() => setShell('powershell')}
/>
</div>
</div>
<p className="text-2xs text-fg-muted">{t('settings.shell.hint')}</p>
</div>
)
}
function ShellRadio({
current,
value,
label,
onSelect
}: {
current: boolean
value: Shell
label: string
onSelect: () => void
}): JSX.Element {
return (
<button
type="button"
role="radio"
aria-checked={current}
data-value={value}
onClick={onSelect}
className={
current
? 'rounded border border-accent bg-accent/15 px-2.5 py-1 text-xs font-medium text-fg focus:outline-none focus-visible:ring-1 focus-visible:ring-accent-ring'
: 'rounded border border-border bg-surface-2 px-2.5 py-1 text-xs text-fg-secondary hover:border-border-strong hover:text-fg focus:outline-none focus-visible:ring-1 focus-visible:ring-accent-ring'
}
>
{label}
</button>
)
}
/**
* 「关于」section:开发者信息 + 个人主页链接。
*
* 纯展示 —— 不持久化任何偏好。链接通过 `shell:openExternal` IPC 走系统默认浏览器,
* 主进程做 http/https 白名单,这里也照样处理 IPC 失败(系统 shell 异常等)给一条可见的错误,
* 用户至少知道"点了为什么没反应"。
*
* 布局:左侧 InfoIcon(与 Interpreter/Shell section 风格一致),右侧
* "开发者: <姓名>" 一行展示文本,下面 URL 单独一行作为可点链接 —— URL 本身才是最
* 自然的超链接锚点,姓名只是标识。链接用 button 而非裸 anchor —— sandbox + contextIsolation
* 下新窗口默认被吞,button 走 IPC 才是稳定路径。
*/
function AboutControls(): JSX.Element {
const t = useT()
// 链接目标走翻译键(URL 是常驻串,i18n 维护它跟文案在一起更清楚);
// 关掉「强制开发者姓名随语言切换」之类的副作用 —— 姓名 / URL 在中英文模式下都是一样的。
const name = t('settings.about.developerName')
const url = t('settings.about.website')
const [error, setError] = useState<string | null>(null)
const openLink = useCallback(async () => {
setError(null)
try {
await window.api.openExternal(url)
} catch (err) {
// 走通用 IPC 错误翻译链 —— invalid_payload(协议被拒)/ unknown(shell 失败)都按 lang 翻译。
setError(formatIpcError(err, t))
}
}, [url, t])
return (
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<InfoIcon size={14} className="shrink-0 text-fg-muted" />
<span className="text-xs text-fg-muted">{t('settings.about.developer')}</span>
<span className="text-xs text-fg">{name}</span>
</div>
<button
type="button"
onClick={() => void openLink()}
aria-label={t('settings.about.linkAria', { name })}
title={url}
className="self-start rounded font-mono text-2xs text-accent underline-offset-2 hover:underline focus:outline-none focus-visible:ring-1 focus-visible:ring-accent-ring"
>
{url}
</button>
{error && <div className="text-xs text-warning">{error}</div>}
</div>
)
}

View File

@@ -0,0 +1,236 @@
/**
* 可拖拽分隔条 —— 编辑器↔结果区(vertical) 和 主区↔终端(horizontal) 共用。
*
* 设计要点:
* - 受控:不持有尺寸,只把像素变化交给 onDelta,父组件用 useResizableFraction
* 把像素换算成 fraction 并 clamp。父组件负责持久化 / 多窗口同步。
* - role="separator":SR 把它当可调分隔条读出来。aria-orientation / aria-valuenow /
* aria-valuemin / aria-valuemax 让 SR 知道当前尺寸比例;aria-controls 标两侧面板。
* - 键盘可调:ArrowLeft/Right(vertical)/ArrowUp/Down(horizontal)每次 step 像素
* (默认 16);Shift+Arrow、PageUp/Down 走大步进(step * 8);Home/End 跳到 max 边界
* (传超出范围的值让父组件 clamp 到 min/max)。
* - 拖拽:onPointerDown 启动,document 上同时监听 pointermove / pointerup /
* pointercancel —— 用户拖到 splitter 外面也能继续;jsdom 里 fireEvent 触发的
* 事件冒泡到 document 也走这条路径。
* - setPointerCapture 让用户在 splitter 之外松手也能正常结束拖拽。
* - 拖拽期间锁 body cursor + user-select:防止鼠标移出 splitter 后 cursor 恢复默认。
* pointerup / pointercancel 时还原原始值(用 bodyLockRef 标记「是我锁的」避免
* 多次 lock 互相覆盖)。
* - data-orientation / data-dragging 属性:测试 + CSS 钩子用,不参与 a11y。
* data-dragging 直接 ref + setAttribute 同步写,不用 React state —— 测试在
* pointerdown 同步返回后立刻断言,那时 useState 还没 commit。
* - aria-valuenow / valuemin / valuemax 可选:不传就不输出该属性,允许 splitter
* 在「还没拿到尺寸」等过渡场景下不暴露误导性的 0 值。
* - useRef 缓存 onDelta 避免每次父组件 re-render 都重建监听(拖动过程中
* 父组件 setState 会触发 re-render)。
*/
import { memo, useCallback, useEffect, useRef } from 'react'
type Orientation = 'vertical' | 'horizontal'
interface Props {
/** vertical = 分隔左右两栏(拖动方向是 X);horizontal = 分隔上下两区(拖动方向是 Y) */
orientation: Orientation
/** 鼠标 / 触屏拖拽时调用,参数是像素 delta(直接交给 applyDelta 即可) */
onDelta: (deltaPx: number) => void
/** 屏幕阅读器读出来的标签(中文/英文由父组件从 i18n 取) */
ariaLabel: string
/** 两侧面板的 id,给 aria-controls 用 —— 不传则省略该 ARIA 属性 */
ariaControls?: string[]
/** 当前 fraction / px,aria-valuenow 直接给百分比或 px 都行。不传则不输出该属性。 */
ariaValueNow?: number
ariaValueMin?: number
ariaValueMax?: number
/** 键盘箭头单步(px),默认 16。Shift / PageUp / PageDown 用 step * 8。 */
step?: number
}
function Splitter({
orientation,
onDelta,
ariaLabel,
ariaControls,
ariaValueNow,
ariaValueMin,
ariaValueMax,
step = 16
}: Props) {
const elRef = useRef<HTMLDivElement>(null)
// 用 ref 缓存 callback,避免父组件 re-render 时拖到一半 onDelta 引用变了导致闭包捕获错值
const onDeltaRef = useRef(onDelta)
onDeltaRef.current = onDelta
// 拖拽态全用 ref —— 同步访问,React state 那时还没 commit
const draggingRef = useRef(false)
const lastPosRef = useRef<number | null>(null)
// 拖拽期间锁 body cursor + user-select 的原始值,pointerup 时还原 —— 多 splitters 共存,
// 但同一时刻只有一个在拖,bodyLockRef 作「我锁的」标记,避免相互覆盖
const bodyLockRef = useRef<{ cursor: string; userSelect: string } | null>(null)
const setDraggingAttr = useCallback((val: boolean) => {
if (elRef.current) {
elRef.current.setAttribute('data-dragging', val ? 'true' : 'false')
}
}, [])
const lockBody = useCallback(() => {
if (bodyLockRef.current) return
bodyLockRef.current = {
cursor: document.body.style.cursor,
userSelect: document.body.style.userSelect
}
document.body.style.cursor = orientation === 'vertical' ? 'col-resize' : 'row-resize'
document.body.style.userSelect = 'none'
}, [orientation])
const releaseBody = useCallback(() => {
if (!bodyLockRef.current) return
document.body.style.cursor = bodyLockRef.current.cursor
document.body.style.userSelect = bodyLockRef.current.userSelect
bodyLockRef.current = null
}, [])
const endDrag = useCallback(() => {
if (!draggingRef.current) return
draggingRef.current = false
lastPosRef.current = null
setDraggingAttr(false)
releaseBody()
}, [setDraggingAttr, releaseBody])
// 拖拽中监听 document:用户拖到 splitter 外面也能继续;jsdom 里 fireEvent.pointerMove(sep)
// 会冒泡到 document,所以这一份也能接住测试事件。这是唯一一份 move 监听 —— 不在
// React 元素上挂 onPointerMove,否则 document 监听和 React 合成事件会重复触发 onDelta。
useEffect(() => {
const onDocMove = (e: PointerEvent) => {
if (!draggingRef.current) return
const last = lastPosRef.current
if (last === null) return
const current = orientation === 'vertical' ? e.clientX : e.clientY
const delta = current - last
if (delta === 0) return // 静止不重复触发
onDeltaRef.current(delta)
lastPosRef.current = current
}
const onDocUp = () => endDrag()
document.addEventListener('pointermove', onDocMove)
document.addEventListener('pointerup', onDocUp)
document.addEventListener('pointercancel', onDocUp)
return () => {
document.removeEventListener('pointermove', onDocMove)
document.removeEventListener('pointerup', onDocUp)
document.removeEventListener('pointercancel', onDocUp)
}
}, [orientation, endDrag])
const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
// 只响应主键;中键右键让浏览器自己处理(右键菜单等)
if (e.button !== 0) return
e.preventDefault()
draggingRef.current = true
lastPosRef.current = orientation === 'vertical' ? e.clientX : e.clientY
setDraggingAttr(true)
lockBody()
// pointer capture:拖到 splitter 外面也能继续,松手在别处也走 pointerup
try {
e.currentTarget.setPointerCapture(e.pointerId)
} catch {
/* 某些测试环境无 Pointer Capture,忽略 */
}
}
const onPointerUpLocal = (e: React.PointerEvent<HTMLDivElement>) => {
try {
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
e.currentTarget.releasePointerCapture(e.pointerId)
}
} catch {
/* 某些测试环境(jsdom)无 Pointer Capture 方法,忽略 */
}
// element-level pointerup 也走 endDrag(document 监听会兜底,这里幂等)
endDrag()
}
const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
// 键盘控制:箭头单步,Shift/PageUp/PageDown 大步进,Home/End 跳到 max 边界
// (传 ±(max+1) 让父组件 clamp 到 min/max —— 父组件是 fraction 的话会 saturate 到 0/1)
const isVertical = orientation === 'vertical'
const bigStep = step * 8
let delta: number | null = null
switch (e.key) {
case 'ArrowLeft':
if (isVertical) delta = e.shiftKey ? -bigStep : -step
break
case 'ArrowRight':
if (isVertical) delta = e.shiftKey ? bigStep : step
break
case 'ArrowUp':
if (!isVertical) delta = e.shiftKey ? -bigStep : -step
break
case 'ArrowDown':
if (!isVertical) delta = e.shiftKey ? bigStep : step
break
case 'PageUp':
delta = -bigStep
break
case 'PageDown':
delta = bigStep
break
case 'Home':
// ariaValueMax 没传时 Home/End 没有语义边界,直接 swallow —— 之前
// ariaValueMax ?? 0 + 1 = 1 会以「1 像素 delta」被发出,在「容器还没拿到尺寸」
// 这类过渡场景里产生一次莫名其妙的 1px 拖拽。
if (ariaValueMax === undefined) return
delta = -ariaValueMax - 1
break
case 'End':
if (ariaValueMax === undefined) return
delta = ariaValueMax + 1
break
default:
return
}
if (delta === null) return
e.preventDefault()
onDeltaRef.current(delta)
}
// vertical splitter 是左右两栏中间的一条竖线。
// 视觉保持 1px (w-px / h-px) 不变 —— 装饰线本来就该细;鼠标命中区由 before: pseudo
// 元素扩展到 20px 宽,以 1px 装饰线为中心 (left-[-10px] 让 before 左边缘在装饰线左侧
// 10px,w-5 = 20px 让右边缘在装饰线右侧 10px),鼠标偏离中线也能拖到。
// 之前 w-1 hover:w-1.5 (4→6px) 实际命中只有 ~6px,鼠标偏 1-2px 就掉进相邻面板,
// 触发不到拖拽 —— 用 hit-area 扩展解决,视觉不"变粗"更克制。
const orientationClass =
orientation === 'vertical'
? 'relative w-px cursor-col-resize before:absolute before:inset-y-0 before:left-[-10px] before:w-5 before:content-[""]'
: 'relative h-px cursor-row-resize before:absolute before:inset-x-0 before:top-[-10px] before:h-5 before:content-[""]'
// ariaValueNow/Min/Max 可选 —— 不传则不输出该属性
const ariaProps: { 'aria-valuenow'?: number; 'aria-valuemin'?: number; 'aria-valuemax'?: number } = {}
if (ariaValueNow !== undefined) ariaProps['aria-valuenow'] = ariaValueNow
if (ariaValueMin !== undefined) ariaProps['aria-valuemin'] = ariaValueMin
if (ariaValueMax !== undefined) ariaProps['aria-valuemax'] = ariaValueMax
return (
// role=separator 是 ARIA 规范里定义的可调分隔条,本身就该接受键盘和指针交互
/* eslint-disable jsx-a11y/no-noninteractive-element-interactions */
/* eslint-disable jsx-a11y/no-noninteractive-tabindex */
<div
ref={elRef}
role="separator"
tabIndex={0}
aria-orientation={orientation}
aria-label={ariaLabel}
aria-controls={ariaControls?.join(' ')}
data-orientation={orientation}
data-dragging="false"
{...ariaProps}
onPointerDown={onPointerDown}
onPointerUp={onPointerUpLocal}
onKeyDown={onKeyDown}
className={`shrink-0 self-stretch bg-border transition-colors hover:bg-accent focus:bg-accent focus:outline-none ${orientationClass}`}
/>
)
}
export default memo(Splitter)

View File

@@ -0,0 +1,110 @@
/**
* 应用顶栏。
*
* 布局:左 Logo + 标题;右 快捷键提示 + 语言切换 + 主题切换 + 打开终端 + 齿轮Settings
* 高度 44px 由 App.tsx 顶层 grid-rows-[44px_1fr_auto] 控制。
*
* 高频设置提到顶栏的理由:
* - 语言 / 主题 是最高频的几个动作,每次都进 Settings 找成本高;
* 各自一个按钮显示「当前态」(暗色 → 月亮 / zh-CN → EN
* 一眼可读当前主题,单击即切。
* - 「打开终端」按钮按需弹用户本机的 cmd.exe / Terminal.app / gnome-terminal
* 不是「切换内嵌终端」 — 内嵌 xterm + spawn shell 的方案焦点跨 IPC 不稳,已移除。
* - 齿轮留给「解释器选择」这种偶尔发生的重操作。
*/
import { memo, type CSSProperties } from 'react'
import Logo from './Logo'
import { GearIcon, MoonIcon, SunIcon, TerminalIcon } from './icons'
import WindowControls from './WindowControls'
import { useI18n, useT } from '../i18n'
import type { Theme } from '../hooks/useTheme'
interface Props {
onOpenSettings: () => void
theme: Theme
setTheme: (theme: Theme) => void
onOpenTerminal: () => void
}
const OTHER_LANG: Record<'zh-CN' | 'en-US', 'zh-CN' | 'en-US'> = {
'zh-CN': 'en-US',
'en-US': 'zh-CN'
}
export default memo(function TopBar({ onOpenSettings, theme, setTheme, onOpenTerminal }: Props) {
const t = useT()
const { lang, setLang } = useI18n()
const otherLang = OTHER_LANG[lang]
const isDark = theme === 'dark'
// 顶栏整体作为窗口拖拽区 —— frame: false 后 OS 不再提供原生拖拽CSS
// `-webkit-app-region: drag` 让整个 <header> 可拖。按钮组的 <div> 单独
// 标记 no-drag 覆盖,覆盖继承让所有交互按钮都不会误触发拖拽。
// WebkitAppRegion 不在 React 标准 CSSProperties 类型里 —— 用 as 强转绕开。
const dragStyle = { WebkitAppRegion: 'drag' } as CSSProperties
const noDragStyle = { WebkitAppRegion: 'no-drag' } as CSSProperties
return (
<header
className="flex items-center justify-between border-b border-border bg-bg px-4"
style={dragStyle}
>
<div className="flex items-center gap-2.5">
<Logo />
<h1 className="text-base font-semibold tracking-tight text-fg">{t('topbar.title')}</h1>
</div>
<div className="flex items-center gap-2 text-xs text-fg-muted" style={noDragStyle}>
{/* kbd 视觉上和其他 28×28 按钮对齐: 6px padding + ~14px 内容 ≈ 26-28px 总高 */}
<kbd className="hidden items-center rounded border border-border bg-surface-1 px-2 py-1 font-mono text-2xs text-fg-muted md:inline-flex">
{t('topbar.shortcutHint')}
</kbd>
<button
type="button"
onClick={() => setLang(otherLang)}
aria-label={t('topbar.switchLanguageTo', {
target: otherLang === 'zh-CN' ? t('topbar.lang.zh') : t('topbar.lang.en')
})}
title={t('topbar.switchLanguageTo', {
target: otherLang === 'zh-CN' ? t('topbar.lang.zh') : t('topbar.lang.en')
})}
// p-1 → p-1.5: 16px icon + 6px padding = 28×28 click target, 接近 WCAG 32px 建议。
// hover:bg-surface-1 → hover:bg-surface-2: dark theme 下 surface-1 与 bg 仅 6 RGB 差,
// hover 几乎看不出surface-2 (RGB 19 20 24) 与 bg (RGB 8 9 10) 差 11,反馈清晰。
className="rounded p-1.5 font-mono text-xs text-fg-secondary hover:bg-surface-2 hover:text-fg focus:outline-none focus-visible:ring-1 focus-visible:ring-accent-ring"
>
{otherLang === 'zh-CN' ? t('topbar.lang.zh') : t('topbar.lang.en')}
</button>
<button
type="button"
onClick={() => setTheme(isDark ? 'light' : 'dark')}
aria-label={t(isDark ? 'topbar.theme.toLight' : 'topbar.theme.toDark')}
title={t(isDark ? 'topbar.theme.toLight' : 'topbar.theme.toDark')}
// p-1 → p-1.5: 28×28 click target, 与语言切换按钮对齐。
// hover:bg-surface-1 → hover:bg-surface-2: dark theme hover 反馈可见化。
className="rounded p-1.5 text-fg-secondary hover:bg-surface-2 hover:text-fg focus:outline-none focus-visible:ring-1 focus-visible:ring-accent-ring"
>
{isDark ? <MoonIcon size={16} /> : <SunIcon size={16} />}
</button>
<button
type="button"
onClick={onOpenTerminal}
aria-label={t('topbar.openTerminal')}
title={t('topbar.openTerminal')}
// p-1 → p-1.5: 与语言/主题按钮对齐到 28×28。
className="rounded p-1.5 text-fg-secondary hover:bg-surface-2 hover:text-fg focus:outline-none focus-visible:ring-1 focus-visible:ring-accent-ring"
>
<TerminalIcon size={16} />
</button>
<button
type="button"
onClick={onOpenSettings}
aria-label={t('topbar.openSettings')}
title={t('topbar.openSettings')}
// p-1 → p-1.5: 与顶栏其他图标按钮对齐到 28×28。
className="rounded p-1.5 text-fg-secondary hover:bg-surface-2 hover:text-fg focus:outline-none focus-visible:ring-1 focus-visible:ring-accent-ring"
>
<GearIcon size={16} />
</button>
<WindowControls />
</div>
</header>
)
})

View File

@@ -0,0 +1,74 @@
/**
* 自定义窗口控制按钮组 —— 顶栏右上角(齿轮按钮之后)。
*
* 替代 OS 默认标题栏按钮frame: false 后 OS 不再渲染):
* - 最小化
* - 放大 / 还原(双态切换,图标跟着切)
* - 关闭hover 变红,与 Linear/VS Code 风格一致)
*
* 拖拽区域由父容器TopBar 按钮组的 <div>)统一加 `WebkitAppRegion: no-drag`
* 标记,本组件内部不再单独加。
*/
import { memo, useEffect, useState } from 'react'
import { CloseIcon, MaximizeIcon, MinimizeIcon, RestoreIcon } from './icons'
import { useT } from '../i18n'
const BASE_BTN =
// 与顶栏其他图标按钮对齐16px 图标 + p-1.5 padding = 28×28 click target, hover 反馈用
// surface-2 (而非 surface-1) —— dark theme 下与 bg 对比足够明显。
'inline-flex items-center justify-center rounded p-1.5 text-fg-muted transition-colors hover:bg-surface-2 hover:text-fg focus:outline-none focus-visible:ring-1 focus-visible:ring-accent-ring'
// 关闭按钮的 hover 用红色 —— 与 Linear / VS Code 等极简应用的语义一致,
// "破坏性操作" 给出视觉区分。浅色主题下用更深的红保 AA 对比。
const CLOSE_BTN =
'inline-flex items-center justify-center rounded p-1.5 text-fg-muted transition-colors hover:bg-red-500/10 hover:text-red-400 focus:outline-none focus-visible:ring-1 focus-visible:ring-accent-ring'
function WindowControlsInner(): JSX.Element {
const t = useT()
const [isMaximized, setIsMaximized] = useState(false)
// mount: 拉一次初始状态 + 订阅 OS 触发的 maximize 变更(双击标题栏 / 任务栏右键)。
// isMaximized 不能默认 false —— 用户上次可能以最大化状态退出,重启后应立即显示还原图标。
useEffect(() => {
void window.api.windowControls.isMaximized().then(setIsMaximized)
return window.api.windowControls.onMaximizeChanged(setIsMaximized)
}, [])
return (
<div className="flex items-center gap-0.5 pl-1">
<button
type="button"
onClick={() => void window.api.windowControls.minimize()}
aria-label={t('topbar.window.minimize')}
title={t('topbar.window.minimize')}
className={BASE_BTN}
>
<MinimizeIcon size={16} />
</button>
<button
type="button"
onClick={async () => {
// 立即用 IPC 返回的权威状态更新 —— 不要等 onMaximizeChanged 事件,
// 事件可能在 IPC response 之后到达导致图标闪一下旧状态。
const { isMaximized: next } = await window.api.windowControls.toggleMaximize()
setIsMaximized(next)
}}
aria-label={t(isMaximized ? 'topbar.window.restore' : 'topbar.window.maximize')}
title={t(isMaximized ? 'topbar.window.restore' : 'topbar.window.maximize')}
className={BASE_BTN}
>
{isMaximized ? <RestoreIcon size={16} /> : <MaximizeIcon size={16} />}
</button>
<button
type="button"
onClick={() => void window.api.windowControls.close()}
aria-label={t('topbar.window.close')}
title={t('topbar.window.close')}
className={CLOSE_BTN}
>
<CloseIcon size={16} />
</button>
</div>
)
}
export default memo(WindowControlsInner)

View File

@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest'
import { toMonacoTheme } from '../CodePanel'
/**
* CodePanel 的 Monaco theme 映射。
*
* 这里只测纯函数 toMonacoTheme —— 不直接 render CodePanel因为 Monaco 在 jsdom 下
* 要走 worker / WASM启动代价太高且与组件本身的关注点主题映射无关。
* 主题切换触发的是 <Editor theme={...}> prop 变化,
* @monaco-editor/react v4+ 在 theme prop 改变时自动调用 monaco.editor.setTheme()
* 行为由上游库保证,本组件只负责把应用层 Theme 翻成 Monaco 内置主题 id。
*/
describe('toMonacoTheme', () => {
it("'light' → 'vs'(浅色内置主题)", () => {
expect(toMonacoTheme('light')).toBe('vs')
})
it("'dark' → 'vs-dark'(深色内置主题)", () => {
expect(toMonacoTheme('dark')).toBe('vs-dark')
})
})

View File

@@ -0,0 +1,182 @@
/**
* EditorTabs 的回归测试。
*
* 关注几条关键不变式:
* 1) user tab 永远是第一个、不可关(无 X
* 2) 外部 tab 有 X 关闭按钮,点击调 onClosestopPropagation 不触发 onSelect
* 3) 键盘 ←/→ 在 tabs 之间循环移动 + 调 onSelect
* 4) Home / End 跳首尾
* 5) Delete / Ctrl+W 关闭聚焦的可关闭 tab不关 user tab
* 6) role / aria-selected / tabIndex roving 模式
*/
import { fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import EditorTabs, { type EditorTabView } from '../EditorTabs'
import { wrap } from '../../i18n/test-utils'
const TABS: EditorTabView[] = [
{ id: 'user', displayName: '你的代码', origin: 'user', closable: false },
{ id: 'ext-1', displayName: 'json/decoder.py', origin: 'stdlib', closable: true },
{ id: 'ext-2', displayName: 'pandas/core/frame.py', origin: 'third_party', closable: true }
]
describe('EditorTabs', () => {
afterEach(() => {
vi.restoreAllMocks()
})
it('渲染所有 tabrole=tablist 根 + 第一个 tab 是 user', () => {
render(wrap(<EditorTabs tabs={TABS} activeTabId="user" onSelect={() => {}} onClose={() => {}} />))
expect(screen.getByRole('tablist')).toBeTruthy()
const tabs = screen.getAllByRole('tab')
expect(tabs).toHaveLength(3)
expect(tabs[0]).toHaveAttribute('id', 'pyprof-tab-user')
expect(tabs[0]).toHaveTextContent('你的代码')
})
it('user tab 没有关闭按钮closable=false → 不渲染 X', () => {
render(wrap(<EditorTabs tabs={TABS} activeTabId="user" onSelect={() => {}} onClose={() => {}} />))
const userTab = screen.getByRole('tab', { name: '你的代码' })
// 关闭按钮的 aria-label 形如 "关闭 {name}" —— user tab 不应有
expect(userTab.querySelector('[aria-label^="关闭"]')).toBeNull()
})
it('外部 tab 有关闭按钮,点击调 onClose + 不触发 onSelect', () => {
const onSelect = vi.fn()
const onClose = vi.fn()
render(wrap(<EditorTabs tabs={TABS} activeTabId="ext-1" onSelect={onSelect} onClose={onClose} />))
const closeBtn = screen.getByRole('button', { name: '关闭 json/decoder.py' })
fireEvent.click(closeBtn)
expect(onClose).toHaveBeenCalledWith('ext-1')
// X 的 stopPropagation → tab 按钮的 onClick 不应被触发
expect(onSelect).not.toHaveBeenCalled()
})
it('aria-selected 与 tabIndex roving 模式:active=0,其他=-1', () => {
render(wrap(<EditorTabs tabs={TABS} activeTabId="ext-1" onSelect={() => {}} onClose={() => {}} />))
const tabs = screen.getAllByRole('tab')
expect(tabs[0]).toHaveAttribute('aria-selected', 'false')
expect(tabs[0]).toHaveAttribute('tabindex', '-1')
expect(tabs[1]).toHaveAttribute('aria-selected', 'true')
expect(tabs[1]).toHaveAttribute('tabindex', '0')
expect(tabs[2]).toHaveAttribute('aria-selected', 'false')
expect(tabs[2]).toHaveAttribute('tabindex', '-1')
})
it('aria-controls 指向编辑器面板', () => {
render(wrap(<EditorTabs tabs={TABS} activeTabId="user" onSelect={() => {}} onClose={() => {}} />))
const tab = screen.getByRole('tab', { name: '你的代码' })
expect(tab).toHaveAttribute('aria-controls', 'pyrof-editor-panel')
})
it('点击 tab 触发 onSelect(tabId)', () => {
const onSelect = vi.fn()
render(wrap(<EditorTabs tabs={TABS} activeTabId="user" onSelect={onSelect} onClose={() => {}} />))
fireEvent.click(screen.getByRole('tab', { name: 'json/decoder.py' }))
expect(onSelect).toHaveBeenCalledWith('ext-1')
})
describe('键盘导航', () => {
it('ArrowRight 从 user 切到 ext-1 + 调 onSelect', () => {
const onSelect = vi.fn()
render(wrap(<EditorTabs tabs={TABS} activeTabId="user" onSelect={onSelect} onClose={() => {}} />))
const list = screen.getByRole('tablist')
fireEvent.keyDown(list, { key: 'ArrowRight' })
expect(onSelect).toHaveBeenCalledWith('ext-1')
})
it('ArrowLeft 从 user 循环到末尾 ext-2', () => {
const onSelect = vi.fn()
render(wrap(<EditorTabs tabs={TABS} activeTabId="user" onSelect={onSelect} onClose={() => {}} />))
const list = screen.getByRole('tablist')
fireEvent.keyDown(list, { key: 'ArrowLeft' })
expect(onSelect).toHaveBeenCalledWith('ext-2')
})
it('ArrowRight 从末尾循环回第一个(user)', () => {
const onSelect = vi.fn()
render(wrap(<EditorTabs tabs={TABS} activeTabId="ext-2" onSelect={onSelect} onClose={() => {}} />))
const list = screen.getByRole('tablist')
fireEvent.keyDown(list, { key: 'ArrowRight' })
expect(onSelect).toHaveBeenCalledWith('user')
})
it('Home 跳到第一个', () => {
const onSelect = vi.fn()
render(wrap(<EditorTabs tabs={TABS} activeTabId="ext-2" onSelect={onSelect} onClose={() => {}} />))
const list = screen.getByRole('tablist')
fireEvent.keyDown(list, { key: 'Home' })
expect(onSelect).toHaveBeenCalledWith('user')
})
it('End 跳到最后一个', () => {
const onSelect = vi.fn()
render(wrap(<EditorTabs tabs={TABS} activeTabId="user" onSelect={onSelect} onClose={() => {}} />))
const list = screen.getByRole('tablist')
fireEvent.keyDown(list, { key: 'End' })
expect(onSelect).toHaveBeenCalledWith('ext-2')
})
})
describe('键盘关闭', () => {
it('Delete 关闭 active 的可关闭 tab', () => {
const onClose = vi.fn()
render(wrap(<EditorTabs tabs={TABS} activeTabId="ext-1" onSelect={() => {}} onClose={onClose} />))
const list = screen.getByRole('tablist')
fireEvent.keyDown(list, { key: 'Delete' })
expect(onClose).toHaveBeenCalledWith('ext-1')
})
it('Ctrl+W 关闭 active 的可关闭 tab', () => {
const onClose = vi.fn()
render(wrap(<EditorTabs tabs={TABS} activeTabId="ext-2" onSelect={() => {}} onClose={onClose} />))
const list = screen.getByRole('tablist')
fireEvent.keyDown(list, { key: 'w', ctrlKey: true })
expect(onClose).toHaveBeenCalledWith('ext-2')
})
it('Cmd+W (Meta) 同样生效', () => {
const onClose = vi.fn()
render(wrap(<EditorTabs tabs={TABS} activeTabId="ext-1" onSelect={() => {}} onClose={onClose} />))
const list = screen.getByRole('tablist')
fireEvent.keyDown(list, { key: 'w', metaKey: true })
expect(onClose).toHaveBeenCalledWith('ext-1')
})
it('Delete 在 user tab(不可关)上不调 onClose', () => {
const onClose = vi.fn()
render(wrap(<EditorTabs tabs={TABS} activeTabId="user" onSelect={() => {}} onClose={onClose} />))
const list = screen.getByRole('tablist')
fireEvent.keyDown(list, { key: 'Delete' })
expect(onClose).not.toHaveBeenCalled()
})
})
it('loading / error 状态徽章:loading 有 pulse 圆点,error 有黄色圆点', () => {
const tabsWithStatus: EditorTabView[] = [
{ id: 'user', displayName: '你的代码', origin: 'user', closable: false },
{ id: 'ext-load', displayName: 'loading.py', origin: 'stdlib', closable: true, status: 'loading' },
{ id: 'ext-err', displayName: 'error.py', origin: 'stdlib', closable: true, status: 'error' }
]
render(
wrap(<EditorTabs tabs={tabsWithStatus} activeTabId="user" onSelect={() => {}} onClose={() => {}} />)
)
const tabs = screen.getAllByRole('tab')
// loading 徽章: animate-pulse class藏在 tab 按钮里的某个子元素上)
const loadingTab = tabs.find((el) => el.textContent?.includes('loading.py'))!
expect(loadingTab.querySelector('.animate-pulse')).not.toBeNull()
// error 徽章: aria-label="error"—— 同 tab 内有「error.py」+ 「error」两个 label,
// 用 querySelector 直接找带 aria-label="error" 的 span 即可,不依赖 accessible name
const errorTab = tabs.find((el) => el.textContent?.includes('error.py'))!
const errorBadge = errorTab.querySelector('[aria-label="error"]')
expect(errorBadge).not.toBeNull()
})
it('空 tab 数组也能渲染(只剩 user tab)', () => {
const onlyUser: EditorTabView[] = [
{ id: 'user', displayName: '你的代码', origin: 'user', closable: false }
]
render(wrap(<EditorTabs tabs={onlyUser} activeTabId="user" onSelect={() => {}} onClose={() => {}} />))
expect(screen.getAllByRole('tab')).toHaveLength(1)
})
})

View File

@@ -0,0 +1,106 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import EmptyState from '../EmptyState'
import { wrap } from '../../i18n/test-utils'
describe('EmptyState', () => {
it('展示三步引导 + 「粘贴代码,马上知道慢在哪」标题', () => {
render(
wrap(
<EmptyState
running={false}
hasInterpreter
onLoadSample={() => {}}
onNewBlank={() => {}}
onOpenSettings={() => {}}
/>
)
)
expect(screen.getByText(/粘贴代码,马上知道慢在哪/)).toBeInTheDocument()
expect(screen.getByText(/粘贴 Python 代码/)).toBeInTheDocument()
expect(screen.getByText(/选择 Python 解释器/)).toBeInTheDocument()
expect(screen.getByText(/运行分析/)).toBeInTheDocument()
})
it('点击「加载示例」触发 onLoadSample', () => {
const onLoadSample = vi.fn()
render(
wrap(
<EmptyState
running={false}
hasInterpreter
onLoadSample={onLoadSample}
onNewBlank={() => {}}
onOpenSettings={() => {}}
/>
)
)
fireEvent.click(screen.getByRole('button', { name: /加载示例/ }))
expect(onLoadSample).toHaveBeenCalledTimes(1)
})
it('点击「新建空白」触发 onNewBlank', () => {
const onNewBlank = vi.fn()
render(
wrap(
<EmptyState
running={false}
hasInterpreter
onLoadSample={() => {}}
onNewBlank={onNewBlank}
onOpenSettings={() => {}}
/>
)
)
fireEvent.click(screen.getByRole('button', { name: /新建空白/ }))
expect(onNewBlank).toHaveBeenCalledTimes(1)
})
it('running 时显示「运行中…」', () => {
render(
wrap(
<EmptyState
running
hasInterpreter
onLoadSample={() => {}}
onNewBlank={() => {}}
onOpenSettings={() => {}}
/>
)
)
expect(screen.getByText(/运行中/)).toBeInTheDocument()
})
it('hasInterpreter=false 时显示「需要先选解释器」并提供打开设置按钮', () => {
const onOpenSettings = vi.fn()
render(
wrap(
<EmptyState
running={false}
hasInterpreter={false}
onLoadSample={() => {}}
onNewBlank={() => {}}
onOpenSettings={onOpenSettings}
/>
)
)
expect(screen.getByText(/需要先选 Python 解释器/)).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /打开设置/ }))
expect(onOpenSettings).toHaveBeenCalledTimes(1)
})
it('hasInterpreter=true 时不显示「需要先选解释器」', () => {
render(
wrap(
<EmptyState
running={false}
hasInterpreter
onLoadSample={() => {}}
onNewBlank={() => {}}
onOpenSettings={() => {}}
/>
)
)
expect(screen.queryByText(/需要先选 Python 解释器/)).not.toBeInTheDocument()
})
})

View File

@@ -0,0 +1,162 @@
/**
* RunConsole 的单元测试。
*
* 关注几条不变式:
* 1. 空 stdout 显示 empty placeholder
* 2. 有内容时显示 stdout,行数统计正确
* 3. isRunning=true 时显示脉冲圆点
* 4. 清空按钮调 onClear,disabled 状态正确
* 5. 收起/展开 toggle body 可见性
* 6. 溢出阈值触发警告徽章
* 7. stdout 变化时 scrollTop 跳到 scrollHeight(自动滚到底)
*
* jsdom 24 不支持 layout / scrollHeight 真实值 —— 上面"自动滚到底"用 mock
* 实现的方式测: mock HTMLElement.prototype.scrollHeight getter,断言 setAttribute 后的 scrollTop。
*/
import { useState } from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import RunConsole from '../RunConsole'
import { wrap } from '../../i18n/test-utils'
// RunConsole 的 collapsed 现在是受控的lift 到 App 以便外层 wrapper 收高度)。
// 测试里没有 App,加一个最小 wrapper 持有 state,rerender 后 state 也不丢。
function ControlledRunConsole(
props: Omit<React.ComponentProps<typeof RunConsole>, 'collapsed' | 'onCollapsedChange'>
) {
const [collapsed, setCollapsed] = useState(false)
return <RunConsole {...props} collapsed={collapsed} onCollapsedChange={setCollapsed} />
}
describe('RunConsole', () => {
afterEach(() => {
vi.restoreAllMocks()
})
it('空 stdout 显示 empty placeholder,不显示行数徽章', () => {
render(wrap(<ControlledRunConsole stdout="" isRunning={false} onClear={() => {}} />))
expect(screen.getByText('运行后此处显示 print() 输出')).toBeInTheDocument()
// 没有 stdout 时不显示「共 N 行」
expect(screen.queryByText(/共 \d+ 行/)).toBeNull()
})
it('有 stdout 时显示内容 + 行数徽章(尾部有换行不减一行)', () => {
render(wrap(<ControlledRunConsole stdout={'a\nb\nc\n'} isRunning={false} onClear={() => {}} />))
// stdout 直接渲染在 #pyrof-console-body 容器里 —— getByText 默认 normalizer
// 会把换行折叠/裁切,改读 DOM textContent 做包含判断
const body = document.getElementById('pyrof-console-body')
expect(body).not.toBeNull()
expect(body!.textContent).toContain('a\nb\nc\n')
// 末尾 \n 时 split('\n').length - 1 = 2 行 → 「共 3 行」是不对的,要等于行数
// 实际: ['a','b','c',''].length = 4, length - 1 = 3,所以显示「共 3 行」
expect(screen.getByText('共 3 行')).toBeInTheDocument()
// placeholder 不应该出现
expect(screen.queryByText('运行后此处显示 print() 输出')).toBeNull()
})
it('stdout 不以换行结尾时,最后一行也算一行', () => {
// 用户在编辑 print() 收尾不带 \n 之类的场景很常见(例如脚本被 kill),
// 这里走的是 lineCount 的 else 分支(split('\n').length 不减一)
render(wrap(<ControlledRunConsole stdout={'x\ny'} isRunning={false} onClear={() => {}} />))
expect(screen.getByText('共 2 行')).toBeInTheDocument()
})
it('isRunning=true 时显示脉冲圆点 + running 状态', () => {
const { container } = render(wrap(<ControlledRunConsole stdout="" isRunning onClear={() => {}} />))
// pulse 圆点用 animate-ping class 标识
expect(container.querySelector('.animate-ping')).not.toBeNull()
})
it('isRunning=false 时不显示脉冲圆点', () => {
const { container } = render(
wrap(<ControlledRunConsole stdout="" isRunning={false} onClear={() => {}} />)
)
expect(container.querySelector('.animate-ping')).toBeNull()
})
it('清空按钮:有内容时可点,点击调 onClear', () => {
const onClear = vi.fn()
render(wrap(<ControlledRunConsole stdout="hello" isRunning={false} onClear={onClear} />))
const btn = screen.getByRole('button', { name: '清空' })
expect(btn).not.toBeDisabled()
fireEvent.click(btn)
expect(onClear).toHaveBeenCalledTimes(1)
})
it('清空按钮:无内容时 disabled', () => {
render(wrap(<ControlledRunConsole stdout="" isRunning={false} onClear={() => {}} />))
const btn = screen.getByRole('button', { name: '清空' })
expect(btn).toBeDisabled()
})
it('收起/展开按钮切换 aria-expanded + body 可见性', () => {
const { rerender } = render(
wrap(<ControlledRunConsole stdout="hello" isRunning={false} onClear={() => {}} />)
)
const toggle = screen.getByRole('button', { name: '收起' })
expect(toggle).toHaveAttribute('aria-expanded', 'true')
// stdout 元素存在
expect(document.getElementById('pyrof-console-body')).not.toBeNull()
fireEvent.click(toggle)
// body 收起后 stdout 元素不在文档里
expect(document.getElementById('pyrof-console-body')).toBeNull()
// 按钮文案换成「展开」+ aria-expanded=false
rerender(wrap(<ControlledRunConsole stdout="hello" isRunning={false} onClear={() => {}} />))
// 收起/展开按钮在 rerender 后是新的 DOM 节点 — 重新查询
const expandBtn = screen.getByRole('button', { name: '展开' })
expect(expandBtn).toHaveAttribute('aria-expanded', 'false')
fireEvent.click(expandBtn)
expect(document.getElementById('pyrof-console-body')).not.toBeNull()
})
it('stdout 超过 4MB 时显示「溢出」警告徽章', () => {
// OVERFLOW_THRESHOLD_BYTES = 4 * 1024 * 1024,length(JS String)是 UTF-16 code units,
// ascii 字符下和 byte 一致 —— 用 ascii 拼 4MB+1 字符正好越过阈值
const huge = 'x'.repeat(4 * 1024 * 1024 + 1)
render(wrap(<ControlledRunConsole stdout={huge} isRunning={false} onClear={() => {}} />))
expect(screen.getByText(/输出过长/)).toBeInTheDocument()
})
it('stdout 变化时把 scrollRef 滚到底(scrollTop = scrollHeight)', () => {
// jsdom 默认 scrollHeight = 0 —— 把它 mock 成一个固定值,验证组件把它赋给 scrollTop
const scrollHeight = 9999
let capturedScrollTop = -1
const proto = HTMLElement.prototype as unknown as {
get scrollHeight(): number
}
Object.defineProperty(proto, 'scrollHeight', {
configurable: true,
get: function (this: HTMLElement) {
// 用 function 而非箭头,this 才能拿到挂载对象
return this.tagName === 'DIV' && this.id === 'pyrof-console-body' ? scrollHeight : 0
}
})
Object.defineProperty(HTMLElement.prototype, 'scrollTop', {
configurable: true,
get: function () {
return 0
},
set: function (this: HTMLElement, v: number) {
if (this.id === 'pyrof-console-body') capturedScrollTop = v
}
})
render(wrap(<ControlledRunConsole stdout="hello" isRunning={false} onClear={() => {}} />))
expect(capturedScrollTop).toBe(scrollHeight)
// 清理 mock,避免污染其它测试
delete (HTMLElement.prototype as unknown as { scrollHeight: unknown }).scrollHeight
delete (HTMLElement.prototype as unknown as { scrollTop: unknown }).scrollTop
})
it('overflow 区域 aria-label 指向 console.title', () => {
const { container } = render(
wrap(<ControlledRunConsole stdout="" isRunning={false} onClear={() => {}} />)
)
const section = container.querySelector('section')
expect(section).toHaveAttribute('aria-label', '运行输出')
})
})

View File

@@ -0,0 +1,241 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import SettingsModal from '../SettingsModal'
import type { UseInterpretersResult } from '../../hooks/useInterpreters'
import { wrap } from '../../i18n/test-utils'
// 最小 fake api —— SettingsModal 在打开时会读 window.api.platform 来决定是否
// 渲染 Shell section,InterpreterControls 也会通过 window.api.openTerminal / platform
// 构造安装命令,AboutControls 会调 openExternal。所以测试里必须先挂上一个 fake,
// 否则访问 undefined.platform 抛错。
interface FakeApi {
platform: 'win32' | 'darwin' | 'linux'
detectInterpreters: ReturnType<typeof vi.fn>
pickInterpreter: ReturnType<typeof vi.fn>
analyze: ReturnType<typeof vi.fn>
cancel: ReturnType<typeof vi.fn>
openTerminal: ReturnType<typeof vi.fn>
onProgress: () => () => void
openExternal: ReturnType<typeof vi.fn>
}
function installFakeApi(overrides: Partial<FakeApi> = {}): FakeApi {
const api: FakeApi = {
// 默认 linux:Shell section 不渲染(测试期望 queryByRole('radiogroup') 为空)。
// 想覆盖时传 overrides.platform = 'win32'。
platform: 'linux',
detectInterpreters: vi.fn().mockResolvedValue([]),
pickInterpreter: vi.fn().mockResolvedValue(null),
analyze: vi.fn().mockResolvedValue({}),
cancel: vi.fn().mockResolvedValue(undefined),
openTerminal: vi.fn().mockResolvedValue(undefined),
onProgress: () => () => undefined,
openExternal: vi.fn().mockResolvedValue(undefined),
...overrides
}
;(window as unknown as { api: FakeApi }).api = api
return api
}
beforeEach(() => {
installFakeApi()
})
afterEach(() => {
window.localStorage.clear()
delete (window as unknown as { api?: unknown }).api
})
function makeStubInterpreters(overrides: Partial<UseInterpretersResult> = {}): UseInterpretersResult {
return {
interpreters: [
{ path: 'C:/python/python.exe', version: '3.11.0' },
{ path: 'C:/python2/python.exe', version: '3.10.0' }
],
selected: 'C:/python/python.exe',
selectPath: vi.fn(),
detecting: false,
detectError: null,
pickError: null,
detectAll: vi.fn().mockResolvedValue(undefined),
pick: vi.fn().mockResolvedValue(undefined),
active: { path: 'C:/python/python.exe', version: '3.11.0' },
...overrides
}
}
const stubInterpreters: UseInterpretersResult = makeStubInterpreters()
describe('SettingsModal', () => {
it('未 open 时不渲染内容', () => {
render(
wrap(
<SettingsModal
open={false}
onClose={vi.fn()}
interpreters={stubInterpreters}
shell="cmd"
setShell={vi.fn()}
/>
)
)
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
it('open 时显示「设置」标题 + 解释器 section语言 / 主题已搬到 TopBar', () => {
render(
wrap(
<SettingsModal
open
onClose={vi.fn()}
interpreters={stubInterpreters}
shell="cmd"
setShell={vi.fn()}
/>
)
)
expect(screen.getByRole('dialog', { name: '设置' })).toBeInTheDocument()
expect(screen.getByText('解释器')).toBeInTheDocument()
// 语言 / 主题 不再出现在 Modal 里 —— 它们在 TopBar 右上角
expect(screen.queryByText('语言')).not.toBeInTheDocument()
expect(screen.queryByText('主题')).not.toBeInTheDocument()
// Shell section 仅在 win32 渲染 — 默认 fake api platform='linux',
// 所以 radiogroup 不应该出现
expect(screen.queryByRole('radiogroup')).not.toBeInTheDocument()
})
it('解释器 select 变化调 selectPath', () => {
const selectPath = vi.fn()
const interpreters = makeStubInterpreters({ selectPath })
render(
wrap(
<SettingsModal open onClose={vi.fn()} interpreters={interpreters} shell="cmd" setShell={vi.fn()} />
)
)
const select = screen.getByRole('combobox', { name: 'Python 解释器' })
fireEvent.change(select, { target: { value: 'C:/python2/python.exe' } })
expect(selectPath).toHaveBeenCalledWith('C:/python2/python.exe')
})
it('点遮罩dialog 容器)触发 onClose', () => {
const onClose = vi.fn()
render(
wrap(
<SettingsModal
open
onClose={onClose}
interpreters={stubInterpreters}
shell="cmd"
setShell={vi.fn()}
/>
)
)
// 遮罩是 dialog 的父节点;点 dialog 自身会被 stopPropagation 拦住,所以点外层
const dialog = screen.getByRole('dialog')
const backdrop = dialog.parentElement!
fireEvent.click(backdrop)
expect(onClose).toHaveBeenCalled()
})
it('点内容区不触发 onClosestopPropagation', () => {
const onClose = vi.fn()
render(
wrap(
<SettingsModal
open
onClose={onClose}
interpreters={stubInterpreters}
shell="cmd"
setShell={vi.fn()}
/>
)
)
const dialog = screen.getByRole('dialog')
const inner = dialog.firstElementChild!
fireEvent.click(inner)
expect(onClose).not.toHaveBeenCalled()
})
it('active 解释器时显示「Python x.y.z · 路径」', () => {
render(
wrap(
<SettingsModal
open
onClose={vi.fn()}
interpreters={stubInterpreters}
shell="cmd"
setShell={vi.fn()}
/>
)
)
expect(screen.getByText(/Python 3\.11\.0 · C:\/python\/python\.exe/)).toBeInTheDocument()
})
it('win32 平台渲染 Shell section两个 radio点 PowerShell 调 setShell("powershell")', () => {
installFakeApi({ platform: 'win32' })
const setShell = vi.fn()
render(
wrap(
<SettingsModal
open
onClose={vi.fn()}
interpreters={stubInterpreters}
shell="cmd"
setShell={setShell}
/>
)
)
const group = screen.getByRole('radiogroup', { name: '打开终端用的 shell' })
expect(group).toBeInTheDocument()
// cmd radio 已选中aria-checked=true
expect(screen.getByRole('radio', { name: '命令提示符 (cmd)' })).toHaveAttribute('aria-checked', 'true')
// 点 PowerShell 触发 setShell
fireEvent.click(screen.getByRole('radio', { name: 'PowerShell' }))
expect(setShell).toHaveBeenCalledWith('powershell')
})
it('渲染「关于」section开发者标签 + 姓名 + URL,URL 可点调 openExternal', () => {
const openExternal = vi.fn().mockResolvedValue(undefined)
installFakeApi({ openExternal })
render(
wrap(
<SettingsModal
open
onClose={vi.fn()}
interpreters={stubInterpreters}
shell="cmd"
setShell={vi.fn()}
/>
)
)
// section 标题 + 「开发者」标签 + 开发者姓名 + URL
expect(screen.getByText('关于')).toBeInTheDocument()
expect(screen.getByText('开发者')).toBeInTheDocument()
expect(screen.getByText('关济寰')).toBeInTheDocument()
// URL 渲染为可点链接 —— button accessible name 是带前缀的「访问 {name} 的个人主页」,
// 视觉文本仍是 URL 原文,这样 SR 用户既知道打开的是谁的页面,也能听到 / 看到 URL。
const link = screen.getByRole('button', { name: '访问 关济寰 的个人主页' })
expect(link).toHaveTextContent('https://www.guanjihuan.com/about')
fireEvent.click(link)
expect(openExternal).toHaveBeenCalledWith('https://www.guanjihuan.com/about')
})
it('openExternal 抛错时显示错误条', async () => {
const openExternal = vi.fn().mockRejectedValue(new Error('boom'))
installFakeApi({ openExternal })
render(
wrap(
<SettingsModal
open
onClose={vi.fn()}
interpreters={stubInterpreters}
shell="cmd"
setShell={vi.fn()}
/>
)
)
fireEvent.click(screen.getByRole('button', { name: '访问 关济寰 的个人主页' }))
// 错误以文本节点出现 —— Error.message 原文('boom')兜底展示。
expect(await screen.findByText(/boom/)).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,443 @@
/**
* Splitter 的单元测试。
*
* - jsdom 24 不带 PointerEvent;fireEvent.pointerDown 因此退化成裸 Event,button /
* clientX / pointerId 全丢失,Splitter 的 onPointerDown 会因 e.button !== 0 直接
* return。在本文件顶部 inline 一个最小 polyfill (继承 MouseEvent),让
* fireEvent.pointerDown / pointerUp 派发的 PointerEventInit 真正生效。
* vitest.setup.ts 故意保持纯净,不在那里挂全局 polyfill —— 只有真正用到
* Pointer Events 的测试自己负责。
* - Splitter 在 document 上挂 pointermove / pointerup / pointercancel 监听,
* fireEvent.pointerMove(sep, ...) 派发的事件冒泡到 document 时会被接住。
* - 静止的 pointermove (delta=0) 被 handleMove 内部提前 return,不触发 onDelta。
* - endDrag 用 draggingRef 做了幂等:第一次 pointerup 清理完,后续再调直接 return。
*/
import { fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import Splitter from '../Splitter'
// PointerEvent polyfill —— 必须在 import Splitter 之前安装,确保后续 useEffect
// 注册的全局 document 监听跑在这个 polyfill 之下。
if (typeof globalThis.PointerEvent === 'undefined') {
class PointerEventPolyfill extends MouseEvent {
public readonly pointerId: number
public readonly pointerType: string
public readonly isPrimary: boolean
constructor(type: string, params: PointerEventInit = {}) {
super(type, params)
this.pointerId = params.pointerId ?? 0
this.pointerType = params.pointerType ?? ''
this.isPrimary = params.isPrimary ?? true
}
}
globalThis.PointerEvent = PointerEventPolyfill as unknown as typeof PointerEvent
}
afterEach(() => {
// 拖拽期间 body 样式被覆盖,清理避免污染下一个 case
document.body.style.cursor = ''
document.body.style.userSelect = ''
})
describe('Splitter', () => {
it('渲染:role=separator + aria-orientation + aria-label + data-* 属性', () => {
render(
<Splitter
orientation="vertical"
onDelta={() => {}}
ariaLabel="Resize A and B"
ariaValueNow={50}
ariaValueMin={20}
ariaValueMax={80}
/>
)
const sep = screen.getByRole('separator', { name: 'Resize A and B' })
expect(sep).toBeInTheDocument()
expect(sep).toHaveAttribute('aria-orientation', 'vertical')
expect(sep).toHaveAttribute('aria-label', 'Resize A and B')
expect(sep).toHaveAttribute('data-orientation', 'vertical')
expect(sep).toHaveAttribute('data-dragging', 'false')
})
it('horizontal orientation 也正确标 aria + data', () => {
render(
<Splitter
orientation="horizontal"
onDelta={() => {}}
ariaLabel="Resize vertically"
ariaValueNow={50}
ariaValueMin={20}
ariaValueMax={80}
/>
)
const sep = screen.getByRole('separator', { name: 'Resize vertically' })
expect(sep).toHaveAttribute('aria-orientation', 'horizontal')
expect(sep).toHaveAttribute('data-orientation', 'horizontal')
})
it('ariaControls 拼成空格分隔的列表', () => {
render(
<Splitter
orientation="vertical"
onDelta={() => {}}
ariaLabel="Resize"
ariaControls={['pane-left', 'pane-right']}
ariaValueNow={50}
ariaValueMin={20}
ariaValueMax={80}
/>
)
const sep = screen.getByRole('separator', { name: 'Resize' })
expect(sep).toHaveAttribute('aria-controls', 'pane-left pane-right')
})
it('ariaValueNow / Min / Max 都写到属性', () => {
render(
<Splitter
orientation="vertical"
onDelta={() => {}}
ariaLabel="Resize"
ariaValueNow={60}
ariaValueMin={20}
ariaValueMax={80}
/>
)
const sep = screen.getByRole('separator', { name: 'Resize' })
expect(sep).toHaveAttribute('aria-valuenow', '60')
expect(sep).toHaveAttribute('aria-valuemin', '20')
expect(sep).toHaveAttribute('aria-valuemax', '80')
})
it('ariaValueNow 不传时不输出 valuenow / min / max可选', () => {
render(<Splitter orientation="vertical" onDelta={() => {}} ariaLabel="Resize" />)
const sep = screen.getByRole('separator', { name: 'Resize' })
expect(sep).not.toHaveAttribute('aria-valuenow')
expect(sep).not.toHaveAttribute('aria-valuemin')
expect(sep).not.toHaveAttribute('aria-valuemax')
})
it('vertical 拖拽X 位移触发 onDelta,Y 位移忽略', () => {
const onDelta = vi.fn()
render(
<Splitter
orientation="vertical"
onDelta={onDelta}
ariaLabel="V"
ariaValueNow={50}
ariaValueMin={20}
ariaValueMax={80}
/>
)
const sep = screen.getByRole('separator', { name: 'V' })
fireEvent.pointerDown(sep, { clientX: 100, clientY: 100, button: 0, pointerId: 1, pointerType: 'mouse' })
expect(sep).toHaveAttribute('data-dragging', 'true')
fireEvent.pointerMove(sep, { clientX: 130, clientY: 999, pointerId: 1, pointerType: 'mouse' })
fireEvent.pointerUp(sep, { clientX: 130, clientY: 999, pointerId: 1, pointerType: 'mouse' })
expect(sep).toHaveAttribute('data-dragging', 'false')
expect(onDelta).toHaveBeenCalledTimes(1)
expect(onDelta).toHaveBeenCalledWith(30)
})
it('horizontal 拖拽:只取 Y 位移', () => {
const onDelta = vi.fn()
render(
<Splitter
orientation="horizontal"
onDelta={onDelta}
ariaLabel="H"
ariaValueNow={50}
ariaValueMin={20}
ariaValueMax={80}
/>
)
const sep = screen.getByRole('separator', { name: 'H' })
fireEvent.pointerDown(sep, { clientX: 100, clientY: 100, button: 0, pointerId: 1, pointerType: 'mouse' })
fireEvent.pointerMove(sep, { clientX: 500, clientY: 150, pointerId: 1, pointerType: 'mouse' })
fireEvent.pointerUp(sep, { clientX: 500, clientY: 150, pointerId: 1, pointerType: 'mouse' })
expect(onDelta).toHaveBeenCalledTimes(1)
expect(onDelta).toHaveBeenCalledWith(50)
})
it('多次 pointermove 累加 delta,静止帧delta=0被 handleMove 跳过', () => {
const onDelta = vi.fn()
render(
<Splitter
orientation="vertical"
onDelta={onDelta}
ariaLabel="V"
ariaValueNow={50}
ariaValueMin={20}
ariaValueMax={80}
/>
)
const sep = screen.getByRole('separator', { name: 'V' })
fireEvent.pointerDown(sep, { clientX: 0, clientY: 0, button: 0, pointerId: 1, pointerType: 'mouse' })
fireEvent.pointerMove(sep, { clientX: 10, clientY: 0, pointerId: 1, pointerType: 'mouse' }) // +10
fireEvent.pointerMove(sep, { clientX: 25, clientY: 0, pointerId: 1, pointerType: 'mouse' }) // +15
fireEvent.pointerMove(sep, { clientX: 25, clientY: 0, pointerId: 1, pointerType: 'mouse' }) // 静止 → skip
fireEvent.pointerMove(sep, { clientX: 40, clientY: 0, pointerId: 1, pointerType: 'mouse' }) // +15
fireEvent.pointerUp(sep, { clientX: 40, clientY: 0, pointerId: 1, pointerType: 'mouse' })
expect(onDelta).toHaveBeenCalledTimes(3)
expect(onDelta).toHaveBeenNthCalledWith(1, 10)
expect(onDelta).toHaveBeenNthCalledWith(2, 15)
expect(onDelta).toHaveBeenNthCalledWith(3, 15)
})
it('pointerdown 非主键(右键/中键)不进入拖拽', () => {
const onDelta = vi.fn()
render(
<Splitter
orientation="vertical"
onDelta={onDelta}
ariaLabel="V"
ariaValueNow={50}
ariaValueMin={20}
ariaValueMax={80}
/>
)
const sep = screen.getByRole('separator', { name: 'V' })
fireEvent.pointerDown(sep, { clientX: 100, clientY: 0, button: 2, pointerId: 1, pointerType: 'mouse' })
fireEvent.pointerMove(sep, { clientX: 200, clientY: 0, pointerId: 1, pointerType: 'mouse' })
expect(onDelta).not.toHaveBeenCalled()
expect(sep).toHaveAttribute('data-dragging', 'false')
})
it('pointercancel 也会触发 endDrag,dragging 状态正确还原', () => {
const onDelta = vi.fn()
render(
<Splitter
orientation="vertical"
onDelta={onDelta}
ariaLabel="V"
ariaValueNow={50}
ariaValueMin={20}
ariaValueMax={80}
/>
)
const sep = screen.getByRole('separator', { name: 'V' })
fireEvent.pointerDown(sep, { clientX: 0, clientY: 0, button: 0, pointerId: 1, pointerType: 'mouse' })
fireEvent.pointerMove(sep, { clientX: 20, clientY: 0, pointerId: 1, pointerType: 'mouse' })
fireEvent.pointerCancel(sep, { clientX: 20, clientY: 0, pointerId: 1, pointerType: 'mouse' })
expect(sep).toHaveAttribute('data-dragging', 'false')
expect(onDelta).toHaveBeenCalledTimes(1)
})
it('键盘 ArrowLeft/Right 触发 onDeltavertical splitter,默认 step=16', () => {
const onDelta = vi.fn()
render(
<Splitter
orientation="vertical"
onDelta={onDelta}
ariaLabel="V"
ariaValueNow={50}
ariaValueMin={20}
ariaValueMax={80}
/>
)
const sep = screen.getByRole('separator', { name: 'V' })
sep.focus()
fireEvent.keyDown(sep, { key: 'ArrowRight' })
fireEvent.keyDown(sep, { key: 'ArrowLeft' })
expect(onDelta).toHaveBeenCalledTimes(2)
expect(onDelta).toHaveBeenNthCalledWith(1, 16)
expect(onDelta).toHaveBeenNthCalledWith(2, -16)
})
it('键盘 ArrowUp/Down 触发 onDeltahorizontal splitter', () => {
const onDelta = vi.fn()
render(
<Splitter
orientation="horizontal"
onDelta={onDelta}
ariaLabel="H"
ariaValueNow={50}
ariaValueMin={20}
ariaValueMax={80}
/>
)
const sep = screen.getByRole('separator', { name: 'H' })
sep.focus()
fireEvent.keyDown(sep, { key: 'ArrowDown' })
fireEvent.keyDown(sep, { key: 'ArrowUp' })
expect(onDelta).toHaveBeenCalledTimes(2)
expect(onDelta).toHaveBeenNthCalledWith(1, 16)
expect(onDelta).toHaveBeenNthCalledWith(2, -16)
})
it('Shift + Arrow 是大步进(默认 step * 8 = 128', () => {
const onDelta = vi.fn()
render(
<Splitter
orientation="vertical"
onDelta={onDelta}
ariaLabel="V"
ariaValueNow={50}
ariaValueMin={20}
ariaValueMax={80}
/>
)
const sep = screen.getByRole('separator', { name: 'V' })
sep.focus()
fireEvent.keyDown(sep, { key: 'ArrowRight', shiftKey: true })
expect(onDelta).toHaveBeenCalledTimes(1)
expect(onDelta).toHaveBeenCalledWith(128)
})
it('PageUp / PageDown 走大步进,Home / End 走极值', () => {
const onDelta = vi.fn()
render(
<Splitter
orientation="vertical"
onDelta={onDelta}
ariaLabel="V"
ariaValueNow={50}
ariaValueMin={20}
ariaValueMax={80}
/>
)
const sep = screen.getByRole('separator', { name: 'V' })
sep.focus()
fireEvent.keyDown(sep, { key: 'PageDown' })
fireEvent.keyDown(sep, { key: 'PageUp' })
fireEvent.keyDown(sep, { key: 'Home' })
fireEvent.keyDown(sep, { key: 'End' })
expect(onDelta).toHaveBeenCalledTimes(4)
// ariaValueMax=80: Home = -(80+1) = -81, End = 80+1 = 81
expect(onDelta).toHaveBeenNthCalledWith(1, 128) // PageDown = big
expect(onDelta).toHaveBeenNthCalledWith(2, -128) // PageUp
expect(onDelta).toHaveBeenNthCalledWith(3, -81) // Home
expect(onDelta).toHaveBeenNthCalledWith(4, 81) // End
})
it('其他键不触发 onDelta', () => {
const onDelta = vi.fn()
render(
<Splitter
orientation="vertical"
onDelta={onDelta}
ariaLabel="V"
ariaValueNow={50}
ariaValueMin={20}
ariaValueMax={80}
/>
)
const sep = screen.getByRole('separator', { name: 'V' })
sep.focus()
fireEvent.keyDown(sep, { key: 'a' })
fireEvent.keyDown(sep, { key: 'Enter' })
fireEvent.keyDown(sep, { key: 'Tab' })
expect(onDelta).not.toHaveBeenCalled()
})
it('键盘箭头调用 preventDefault避免页面滚动', () => {
const onDelta = vi.fn()
render(
<Splitter
orientation="vertical"
onDelta={onDelta}
ariaLabel="V"
ariaValueNow={50}
ariaValueMin={20}
ariaValueMax={80}
/>
)
const sep = screen.getByRole('separator', { name: 'V' })
sep.focus()
const ev = new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true, cancelable: true })
fireEvent(sep, ev)
expect(ev.defaultPrevented).toBe(true)
})
it('拖拽期间 body cursor / userSelect 锁住,结束时还原', () => {
document.body.style.cursor = 'pointer'
document.body.style.userSelect = 'text'
const onDelta = vi.fn()
render(
<Splitter
orientation="vertical"
onDelta={onDelta}
ariaLabel="V"
ariaValueNow={50}
ariaValueMin={20}
ariaValueMax={80}
/>
)
const sep = screen.getByRole('separator', { name: 'V' })
fireEvent.pointerDown(sep, { clientX: 0, clientY: 0, button: 0, pointerId: 1, pointerType: 'mouse' })
expect(document.body.style.cursor).toBe('col-resize')
expect(document.body.style.userSelect).toBe('none')
fireEvent.pointerUp(sep, { clientX: 0, clientY: 0, pointerId: 1, pointerType: 'mouse' })
expect(document.body.style.cursor).toBe('pointer')
expect(document.body.style.userSelect).toBe('text')
})
it('horizontal splitter 拖拽期间 body cursor 是 row-resize', () => {
render(
<Splitter
orientation="horizontal"
onDelta={() => {}}
ariaLabel="H"
ariaValueNow={50}
ariaValueMin={20}
ariaValueMax={80}
/>
)
const sep = screen.getByRole('separator', { name: 'H' })
fireEvent.pointerDown(sep, { clientX: 0, clientY: 0, button: 0, pointerId: 1, pointerType: 'mouse' })
expect(document.body.style.cursor).toBe('row-resize')
fireEvent.pointerUp(sep, { clientX: 0, clientY: 0, pointerId: 1, pointerType: 'mouse' })
})
it('endDrag 幂等:连发 pointerup / pointercancel 不会重复 setDragging / releaseBody', () => {
document.body.style.cursor = 'help'
document.body.style.userSelect = 'auto'
render(
<Splitter
orientation="vertical"
onDelta={() => {}}
ariaLabel="V"
ariaValueNow={50}
ariaValueMin={20}
ariaValueMax={80}
/>
)
const sep = screen.getByRole('separator', { name: 'V' })
fireEvent.pointerDown(sep, { clientX: 0, clientY: 0, button: 0, pointerId: 1, pointerType: 'mouse' })
fireEvent.pointerUp(sep, { clientX: 0, clientY: 0, pointerId: 1, pointerType: 'mouse' })
fireEvent.pointerCancel(sep, { clientX: 0, clientY: 0, pointerId: 1, pointerType: 'mouse' })
fireEvent.pointerUp(sep, { clientX: 0, clientY: 0, pointerId: 1, pointerType: 'mouse' })
// 第一次清理完后 body 应回到原始 'help' / 'auto',后续 idempotent 不动它
expect(document.body.style.cursor).toBe('help')
expect(document.body.style.userSelect).toBe('auto')
})
})

View File

@@ -0,0 +1,307 @@
/**
* 集中图标库:内联 SVG与设计 tokencurrentColor / accent一致。
*
* 设计原则:
* - 不引入图标库;项目全部走 currentColor方便上层用 className 控制颜色
* - 所有图标 aria-hidden="true",由包裹它们的可见文字承担 a11y
* - strokeWidth 1.5~1.6圆角端点strokeLinecap="round"Linear 风格
*
* 用法:`import { GearIcon } from './icons'`
*
* 内联到组件里的非本文件图标(比如 FileIcon in CodePanel仍然就近放在组件文件
* — 它们与组件强耦合(颜色 / 状态由父组件决定),不必强迁到这里。
*/
import type { SVGProps } from 'react'
type IconProps = Omit<SVGProps<SVGSVGElement>, 'viewBox' | 'fill' | 'aria-hidden'> & {
size?: number
}
/** Python 双色 logoRunConfig 用)。 */
export function PythonIcon({ size = 14, ...rest }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden="true" {...rest}>
<path
d="M11.914 0c-.62 0-1.273.022-1.93.066-1.99.131-2.353 1.328-2.353 2.98v2.04h4.7v.68H6.43c-1.366 0-2.561.82-2.937 2.381-.432 1.788-.452 2.903 0 4.98.334 1.553 1.13 2.382 2.496 2.382h1.616v-2.264c0-1.638 1.418-3.084 3.078-3.084h4.696c1.37 0 2.466-1.13 2.466-2.504V3.046C18.05 1.394 17.355.13 15.378.066 14.252.022 13.085 0 11.914 0zM9.62 1.62a.86.86 0 110 1.72.86.86 0 010-1.72z"
fill="#3776AB"
/>
<path
d="M12.086 24c.62 0 1.273-.022 1.93-.066 1.99-.131 2.353-1.328 2.353-2.98v-2.04h-4.7v-.68h5.901c1.366 0 2.561-.82 2.937-2.381.432-1.788.452-2.903 0-4.98-.334-1.553-1.13-2.382-2.496-2.382h-1.616v2.264c0 1.638-1.418 3.084-3.078 3.084H8.62c-1.37 0-2.466 1.13-2.466 2.504v3.49c0 1.652.695 2.916 2.672 2.98 1.126.044 2.293.066 3.464.066zM14.38 22.38a.86.86 0 110-1.72.86.86 0 010 1.72z"
fill="#FFD43B"
/>
</svg>
)
}
/** 齿轮Settings 入口图标)。 */
export function GearIcon({ size = 16, ...rest }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden="true" {...rest}>
<path
d="M12 8.5a3.5 3.5 0 100 7 3.5 3.5 0 000-7zM19.4 12c0-.5-.05-1-.13-1.46l2.1-1.65a.5.5 0 00.12-.64l-2-3.46a.5.5 0 00-.6-.22l-2.49 1a7.4 7.4 0 00-2.52-1.46l-.38-2.65A.5.5 0 0013 1h-4a.5.5 0 00-.5.42l-.38 2.65a7.4 7.4 0 00-2.52 1.46l-2.49-1a.5.5 0 00-.6.22l-2 3.46a.5.5 0 00.12.64l2.1 1.65c-.08.46-.13.96-.13 1.46s.05 1 .13 1.46L.62 15.07a.5.5 0 00-.12.64l2 3.46a.5.5 0 00.6.22l2.49-1a7.4 7.4 0 002.52 1.46l.38 2.65A.5.5 0 008.99 23h4a.5.5 0 00.5-.42l.38-2.65a7.4 7.4 0 002.52-1.46l2.49 1a.5.5 0 00.6-.22l2-3.46a.5.5 0 00-.12-.64l-2.1-1.65c.08-.46.13-.96.13-1.46z"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)
}
/** 火焰(火焰图 section 标题前的小图标)。 */
export function FlameIcon({ size = 14, ...rest }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden="true" {...rest}>
<path
d="M12 3c.5 2.5 2.5 4 2.5 7 0 1.4-.6 2.4-1.5 3 .5-1.6 0-3-1-4 0 2-1 3-2 4-1.5 1.5-2 3.5-2 5a4 4 0 008 0c0-3-2-5-2-8 0-3 .5-5 1-7-1.5 1-3 2-3 0z"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)
}
/** 表格(热点表 section 标题前的小图标3 行 3 列)。 */
export function TableIcon({ size = 14, ...rest }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden="true" {...rest}>
<rect x="3" y="5" width="18" height="14" rx="1.5" stroke="currentColor" strokeWidth="1.5" />
<path d="M3 10h18" stroke="currentColor" strokeWidth="1.5" />
<path d="M3 14.5h18" stroke="currentColor" strokeWidth="1.5" />
<path d="M9.5 5v14" stroke="currentColor" strokeWidth="1.5" />
</svg>
)
}
/** 太阳(亮色主题提示:亮色模式下显示当前态,点了切到暗色)。 */
export function SunIcon({ size = 16, ...rest }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden="true" {...rest}>
<circle cx="12" cy="12" r="4" stroke="currentColor" strokeWidth="1.5" />
<path
d="M12 3v2M12 19v2M3 12h2M19 12h2M5.6 5.6l1.4 1.4M17 17l1.4 1.4M5.6 18.4L7 17M17 7l1.4-1.4"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
</svg>
)
}
/** 月亮(暗色主题提示:暗色模式下显示当前态,点了切到亮色)。 */
export function MoonIcon({ size = 16, ...rest }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden="true" {...rest}>
<path
d="M20.5 14.2A8 8 0 119.8 3.5a7 7 0 0010.7 10.7z"
stroke="currentColor"
strokeWidth="1.5"
strokeLinejoin="round"
/>
</svg>
)
}
/** 空状态 step 1 图标:粘贴 / 文档 */
export function CodeIcon({ size = 16, ...rest }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden="true" {...rest}>
<path
d="M8 7l-4 5 4 5M16 7l4 5-4 5M14 5l-4 14"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)
}
/** 空状态 step 2 图标:解释器 (Python snake) */
export function InterpreterIcon({ size = 16, ...rest }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden="true" {...rest}>
<path
d="M12 3v3M12 18v3M3 12h3M18 12h3M5.6 5.6l2.1 2.1M16.3 16.3l2.1 2.1M5.6 18.4l2.1-2.1M16.3 7.7l2.1-2.1"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
/>
<circle cx="12" cy="12" r="3" stroke="currentColor" strokeWidth="1.6" />
</svg>
)
}
/** 空状态 step 3 图标:运行 / 播放 */
export function PlayIcon({ size = 16, ...rest }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden="true" {...rest}>
<path d="M7 5.5v13a1 1 0 001.5.86l11-6.5a1 1 0 000-1.72l-11-6.5A1 1 0 007 5.5z" fill="currentColor" />
</svg>
)
}
/** 柱状图图标3 根高低不一的竖条)—— BarChart 切换按钮。 */
export function BarIcon({ size = 14, ...rest }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden="true" {...rest}>
<path d="M5 19V11" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
<path d="M12 19V6" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
<path d="M19 19V14" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
</svg>
)
}
/** 矩形树图图标2×2 不同大小的方块)—— Treemap 切换按钮。 */
export function TreemapIcon({ size = 14, ...rest }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden="true" {...rest}>
<rect x="4" y="4" width="10" height="16" rx="1" stroke="currentColor" strokeWidth="1.6" />
<rect x="15" y="4" width="5" height="9" rx="1" stroke="currentColor" strokeWidth="1.6" />
<rect x="15" y="14" width="5" height="6" rx="1" stroke="currentColor" strokeWidth="1.6" />
</svg>
)
}
/** 旭日图图标(同心弧 + 一根径向分隔)—— Sunburst 切换按钮。 */
export function SunburstIcon({ size = 14, ...rest }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden="true" {...rest}>
<path
d="M12 12 L21 12 A9 9 0 0 0 12 3 Z"
stroke="currentColor"
strokeWidth="1.6"
strokeLinejoin="round"
/>
<path
d="M12 12 L17.5 12 A5.5 5.5 0 0 0 12 6.5 Z"
stroke="currentColor"
strokeWidth="1.6"
strokeLinejoin="round"
/>
<path d="M12 12 L18.36 5.64" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
</svg>
)
}
/** 累计条形图标(横条 + 内部分段线)—— CumulativeBar 切换按钮。 */
export function CumulativeIcon({ size = 14, ...rest }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden="true" {...rest}>
<rect x="3" y="9" width="18" height="6" rx="1.5" stroke="currentColor" strokeWidth="1.6" />
<line x1="9" y1="9" x2="9" y2="15" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
</svg>
)
}
/** 模块热力图标2 行 ×3 列,每格大小不同)—— ModuleHeatmap 切换按钮。 */
export function HeatmapIcon({ size = 14, ...rest }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden="true" {...rest}>
<rect x="3" y="3" width="10" height="7" rx="1" stroke="currentColor" strokeWidth="1.6" />
<rect x="14" y="3" width="7" height="7" rx="1" stroke="currentColor" strokeWidth="1.6" />
<rect x="3" y="11" width="6" height="10" rx="1" stroke="currentColor" strokeWidth="1.6" />
<rect x="10" y="11" width="11" height="10" rx="1" stroke="currentColor" strokeWidth="1.6" />
</svg>
)
}
/** 通用图表图标(带升势线的方框)—— ResultsPanel 段头用。 */
export function ChartIcon({ size = 14, ...rest }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden="true" {...rest}>
<rect x="3.5" y="3.5" width="17" height="17" rx="2" stroke="currentColor" strokeWidth="1.5" />
<path
d="M7 15l3-4 3 2 4-5"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)
}
/** 终端图标(顶栏的「切换终端」按钮用)。 */
export function TerminalIcon({ size = 16, ...rest }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden="true" {...rest}>
<rect x="3" y="4.5" width="18" height="15" rx="2" stroke="currentColor" strokeWidth="1.5" />
<path
d="M7 10l3 2.5L7 15M12.5 15.5h4"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)
}
/** 关闭 (X) —— SettingsModal 关闭按钮。1.6 描边 + 圆角端点,与其它图标风格一致;
* 视觉上是「斜十字」而非孤零零的 × 文本字符,避免不同字体下渲染不一致。 */
export function CloseIcon({ size = 14, ...rest }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden="true" {...rest}>
<path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
</svg>
)
}
/** 最小化(底部横线)—— WindowControls 用。 */
export function MinimizeIcon({ size = 14, ...rest }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden="true" {...rest}>
<path d="M5 19h14" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
</svg>
)
}
/** 最大化(四角方框)—— WindowControls 在非最大化状态时显示。 */
export function MaximizeIcon({ size = 14, ...rest }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden="true" {...rest}>
<rect x="5" y="5" width="14" height="14" rx="1.5" stroke="currentColor" strokeWidth="1.6" />
</svg>
)
}
/** 还原(两个错位的方框,标识「从最大化还原」状态)—— WindowControls 在最大化时显示。 */
export function RestoreIcon({ size = 14, ...rest }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden="true" {...rest}>
<rect x="6" y="9" width="12" height="10" rx="1.5" stroke="currentColor" strokeWidth="1.6" />
<path
d="M9 9V6.5A1.5 1.5 0 0110.5 5H17.5A1.5 1.5 0 0119 6.5V13.5A1.5 1.5 0 0117.5 15H15"
stroke="currentColor"
strokeWidth="1.6"
strokeLinejoin="round"
/>
</svg>
)
}
/** 警告 (三角形 + 感叹号) —— EmptyState 「需要先选解释器」提示条。 */
export function WarningIcon({ size = 14, ...rest }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden="true" {...rest}>
<path d="M12 3.2L21 19H3L12 3.2z" stroke="currentColor" strokeWidth="1.6" strokeLinejoin="round" />
<path d="M12 10v4" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
<circle cx="12" cy="17" r="0.9" fill="currentColor" />
</svg>
)
}
/** 信息 (圆圈 + 小写 i) —— SettingsModal 「关于」section 标题前的小图标。 */
export function InfoIcon({ size = 14, ...rest }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden="true" {...rest}>
<circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth="1.6" />
<path d="M12 11v5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
<circle cx="12" cy="8" r="0.9" fill="currentColor" />
</svg>
)
}

View File

@@ -0,0 +1,217 @@
import { memo, useMemo, type KeyboardEvent } from 'react'
import type { FunctionNode } from '../../../../shared/analysis'
import { fmtDuration, totalSelfTime } from '../../utils/format'
import { FOCUS, FONT_MONO, FONT_SIZE, RESTING, charsForWidth, colorByName, truncateName } from './chart-utils'
import { useT } from '../../i18n'
import { ORIGIN_COLOR, ORIGIN_LABEL_KEY, resolveOrigin } from '../../utils/origin'
/**
* 横向柱状图:按自耗时排序,取前 N 个函数以可读数量。
*
* 取 N=12 是有意为之 —— 一屏 12 行滚动可控,>12 之后用户基本不会逐个看柱长。
* 截断必显式提示:和 HotspotTable / FlameGraph 同款策略,不静默藏数据。
*
* v4 字号升级:从 15-16px 提到 16-18px,行高加大到 52,长 name 按可用宽度按字
* 数 ellipsis,小屏也不会挤成一团。
*/
const TOP_N = 12
const CANVAS_W = 920
const ROW_H = 52
const ROW_GAP = 10
/** 左侧 name 标签区宽度px。Cascadia Code 18px 下 ~11px/字 → ~22 字符。 */
const NAME_W = 260
/** 数据条右端到画布右边的留白 —— 给 "42.5% · 1.2s" 右对齐文本让出空间。 */
const RIGHT_PAD = 140
interface Props {
functions: readonly FunctionNode[]
/** 热点点击回调 —— 传整 FunctionNode 让 App.tsx 拿 file/origin 决定路由。 */
onSelect: (fn: FunctionNode) => void
focusName?: string
}
interface Bar {
fn: FunctionNode
pct: number
isFocus: boolean
origin: ReturnType<typeof resolveOrigin>
}
function BarChart({ functions, onSelect, focusName }: Props) {
const t = useT()
const total = useMemo(() => totalSelfTime(functions) || 1, [functions])
// 防御性排序:注释里说 "engines 已按 tottime 倒序输出",但那是个不变量假设。
// 一旦 engines 改了顺序,图表静默错位。显式 sort + slice 一次到位N=12 排序
// 代价 O(N log N) 可忽略,行为可预测。
const visible = useMemo(
() => [...functions].sort((a, b) => b.tottime - a.tottime).slice(0, TOP_N),
[functions]
)
const hidden = functions.length - visible.length
const bars = useMemo<Bar[]>(
() =>
visible.map((fn) => ({
fn,
pct: (100 * fn.tottime) / total,
isFocus: fn.name === focusName,
// 每条 bar 算一次 origin —— scope=all 时用户代码 / 第三方包 / 标准库同框
// 出现,色点帮助扫表时一眼区分。
origin: resolveOrigin(fn)
})),
[visible, total, focusName]
)
if (bars.length === 0) {
return (
<div className="flex h-full items-center justify-center text-sm text-fg-muted">{t('chart.empty')}</div>
)
}
const totalH = bars.length * (ROW_H + ROW_GAP) + ROW_GAP
// 左侧 origin 色点r=5 + 1px stroke,比 v3 的 r=4 大一档,在 18px 字号下视觉
// 锚点更稳;主题切换/浅色背景下都顶得住。aria-hidden 不参与 SR。
const ORIGIN_DOT_R = 5
const ORIGIN_DOT_CX = 12
const NAME_X = ORIGIN_DOT_CX + ORIGIN_DOT_R + 10 // 27
const BAR_X = NAME_W + 16
const BAR_W = CANVAS_W - BAR_X - RIGHT_PAD
return (
<div className="flex h-full flex-col p-3">
<div className="mb-2 text-sm text-fg-secondary" aria-live="polite">
{t('chart.bar.hint')}
</div>
{hidden > 0 && (
<div className="mb-2 text-sm text-fg-secondary" aria-live="polite">
{t('chart.truncated', { shown: TOP_N, hidden })}
</div>
)}
{/* 不内嵌 overflow-auto —— 外层 ResultsPanel 所在右栏自带 overflow-y-auto,
内嵌会形成「图内横滑 + 右栏纵滑」双层滚轮, 体验差。
SVG 用 viewBox + preserveAspectRatio="xMinYMin meet" 自适应尺寸,
数据再多也直接交给外层滚动接管。 */}
<div className="flex-1">
<svg
width="100%"
viewBox={`0 0 ${CANVAS_W} ${totalH}`}
preserveAspectRatio="xMinYMin meet"
role="img"
aria-label={t('chart.bar.aria')}
>
{bars.map((bar, i) => {
const y = i * (ROW_H + ROW_GAP) + ROW_GAP / 2
// 颜色用 colorByName和 Treemap / Sunburst 保持"同函数 = 同色"。
const fill = colorByName(bar.fn.name)
const barPx = Math.max(0, (bar.pct / 100) * BAR_W)
const onKeyDown = (e: KeyboardEvent<SVGGElement>) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onSelect(bar.fn)
}
}
// origin 长标签进 aria-labelSR 友好),同 HotspotTable 行的策略。
// 短码放 title tooltiphover 看全名origin 是 module 的上一级分类,
// 长标签能补足「第三方包 vs 标准库 vs 我自己的代码」的语义)。
const originLong = t(ORIGIN_LABEL_KEY[bar.origin])
const ariaText = t('chart.bar.tile.aria', {
name: bar.fn.name,
origin: originLong,
value: fmtDuration(bar.fn.tottime),
pct: bar.pct.toFixed(1)
})
// 按 NAME 区实际可用宽度反算能放几个字(Cascadia Code 18px ≈ 11px/字)
// —— 改字体大小/窗口宽度都自动重算,不绑死截断长度。
const nameMax = charsForWidth(NAME_W - NAME_X - 6, FONT_SIZE.NAME)
return (
<g
key={bar.fn.id}
transform={`translate(0, ${y})`}
role="button"
tabIndex={0}
aria-label={ariaText}
aria-keyshortcuts="Enter Space"
onClick={() => onSelect(bar.fn)}
onKeyDown={onKeyDown}
className="chart-tile cursor-pointer"
>
{/* origin 色点(v4 起 r=5):行首圆点 + 半透明深色描边,
在浅/深主题下都能稳住视觉锚点(无 stroke 会被浅色背景"吃掉")。
同 ModuleHeatmap / HotspotTable 的 ORIGIN_COLOR —— 颜色 = 视觉锚点
(绿色=用户代码/紫色=第三方/蓝色=标准库/...)。scope=all 时
「我的代码 vs 库」一眼分清。aria-hidden 不参与 SR —— 已并入 aria-label。 */}
<circle
cx={ORIGIN_DOT_CX}
cy={ROW_H / 2}
r={ORIGIN_DOT_R}
fill={ORIGIN_COLOR[bar.origin]}
stroke="rgba(0,0,0,0.25)"
strokeWidth={1}
aria-hidden="true"
/>
{/* name 左对齐;currentColor 跟主题,长名截断加 ellipsis。
<title> 已经展示完整名, 所以 hover 仍能看到全名。
fontSize 18 + 自适应 charsForWidth,不再绑死 NAME_MAX_CHARS 兜底。 */}
<text
x={NAME_X}
y={ROW_H / 2 + 6}
fill="currentColor"
fontSize={FONT_SIZE.NAME}
fontFamily={FONT_MONO}
pointerEvents="none"
aria-hidden="true"
>
{truncateName(bar.fn.name, nameMax)}
</text>
{/* 背景轨:让用户一眼看出"最大值参考线"。不带 data-chart-shape,
hover 时不动 —— 只有数据条会提亮。 */}
<rect
x={BAR_X}
y={ROW_H * 0.3}
width={BAR_W}
height={ROW_H * 0.4}
rx={3}
fill="var(--surface-1)"
/>
{/* 数据条:和 Treemap / Sunburst 同样的 FOCUS / RESTING 描边。
data-chart-shape 标记后,hover 时 fillOpacity 提到 1.0。 */}
<rect
data-chart-shape
x={BAR_X}
y={ROW_H * 0.3}
width={barPx}
height={ROW_H * 0.4}
rx={3}
fill={fill}
fillOpacity={bar.isFocus ? FOCUS.fillOpacity : RESTING.fillOpacity}
stroke={bar.isFocus ? FOCUS.stroke : RESTING.stroke}
strokeWidth={bar.isFocus ? FOCUS.strokeWidth : RESTING.strokeWidth}
/>
{/* pct + 时长右对齐:currentColor 跟主题,精度 1 位和 tooltip 一致。
fontSize 16 是 BODY 档位(主信息低于 NAME)。宽度变时 barPx 缩小,
右文本不动 —— NAME 区固定,右对齐文本锚定 CANVAS_W - 8。 */}
<text
x={CANVAS_W - 8}
y={ROW_H / 2 + 6}
fill="currentColor"
fontSize={FONT_SIZE.BODY}
fontFamily={FONT_MONO}
textAnchor="end"
pointerEvents="none"
aria-hidden="true"
>
{bar.pct.toFixed(1)}% · {fmtDuration(bar.fn.tottime)}
</text>
<title>{ariaText}</title>
</g>
)
})}
</svg>
</div>
</div>
)
}
export default memo(BarChart)

View File

@@ -0,0 +1,280 @@
/**
* 累计条形图 —— 按 cumtime 排序,bar 内分段显示 self 占比。
*
* 与 BarChart 的差别:
* - BarChart 按 tottime 排,每条 bar 长度 = 自耗时占比。
* —— 适合回答"哪些函数自己最慢"。
* - CumulativeBar 按 cumtime 排,每条 bar 长度 = 累计耗时占比,bar 内部
* 左侧实色段 = 自耗时,右侧淡色段 = 被调用方耗时。
* —— 适合回答"哪些函数累计最耗时"(常出现在顶层入口函数、IO wrapper、装饰器)。
*
* 分段语义:
* bar 整体长度 ∝ cumtime
* bar 内 [0, selfRatio] 用 colorByName 实色 → 自耗时
* bar 内 [selfRatio, 1] 用 var(--surface-1) 淡色 → 被调用方耗时
*
* 注:不另起"callee segment"独立颜色,避免和 palette 撞;用 surface-1 既
* 表达"非数据"(被调用方归属其它函数)又复用现有 token。
*/
import { memo, useMemo, type KeyboardEvent } from 'react'
import type { FunctionNode } from '../../../../shared/analysis'
import { fmtDuration } from '../../utils/format'
import { FOCUS, FONT_MONO, FONT_SIZE, RESTING, charsForWidth, colorByName, truncateName } from './chart-utils'
import { useT } from '../../i18n'
import { ORIGIN_COLOR, ORIGIN_LABEL_KEY, resolveOrigin } from '../../utils/origin'
const TOP_N = 12
const CANVAS_W = 920
const ROW_H = 52
const ROW_GAP = 10
const NAME_W = 260
const RIGHT_PAD = 140
interface Props {
functions: readonly FunctionNode[]
/** 热点点击回调 —— 传整 FunctionNode 让 App.tsx 拿 file/origin 决定路由。 */
onSelect: (fn: FunctionNode) => void
focusName?: string
}
interface Bar {
fn: FunctionNode
cumtime: number
tottime: number
selfRatio: number
isFocus: boolean
origin: ReturnType<typeof resolveOrigin>
}
function CumulativeBar({ functions, onSelect, focusName }: Props) {
const t = useT()
// 分母用 cumtime 之和(而非 tottime 之和):cumtime 已含递归,更能反映"总耗时占比"。
// 有 0 cumtime 的函数(cProfile 极短调用)被直接剔除,避免排序占位但 bar 长度为 0。
const totalCum = useMemo(() => functions.reduce((s, f) => s + Math.max(0, f.cumtime), 0) || 1, [functions])
const visible = useMemo(
() =>
[...functions]
.filter((f) => f.cumtime > 0)
.sort((a, b) => b.cumtime - a.cumtime)
.slice(0, TOP_N),
[functions]
)
const hidden = functions.length - visible.length
const bars = useMemo<Bar[]>(
() =>
visible.map((fn) => ({
fn,
cumtime: fn.cumtime,
tottime: Math.min(fn.tottime, fn.cumtime), // 防 cumtime < tottime 数据 bug 导致 ratio > 1
selfRatio: fn.cumtime > 0 ? Math.min(1, fn.tottime / fn.cumtime) : 0,
isFocus: fn.name === focusName,
// origin 同 BarChart —— 同色系,跨视图扫表时颜色一致。
origin: resolveOrigin(fn)
})),
[visible, focusName]
)
if (bars.length === 0) {
return (
<div className="flex h-full items-center justify-center text-sm text-fg-muted">{t('chart.empty')}</div>
)
}
const totalH = bars.length * (ROW_H + ROW_GAP) + ROW_GAP
// 左侧 origin 色点r=5 + 1px stroke主题切换/浅色背景下都能稳住视觉锚点;
// cx=12 留 10px 视觉呼吸空间,避免「贴墙感」。同 BarChart 的 dot 参数。
const ORIGIN_DOT_R = 5
const ORIGIN_DOT_CX = 12
const NAME_X = ORIGIN_DOT_CX + ORIGIN_DOT_R + 10
const BAR_X = NAME_W + 16
const BAR_W = CANVAS_W - BAR_X - RIGHT_PAD
return (
<div className="flex h-full flex-col p-3">
<div className="mb-2 text-sm text-fg-secondary" aria-live="polite">
{t('chart.cumulative.hint')}
</div>
{hidden > 0 && (
<div className="mb-2 text-sm text-fg-secondary" aria-live="polite">
{t('chart.truncated', { shown: TOP_N, hidden })}
</div>
)}
{/* 不内嵌 overflow-auto —— 外层右栏自带 overflow-y-auto 接管所有滚动。
SVG viewBox + preserveAspectRatio 自适应尺寸, 数据再多也直接交给外层滚动。 */}
<div className="flex-1">
<svg
width="100%"
viewBox={`0 0 ${CANVAS_W} ${totalH}`}
preserveAspectRatio="xMinYMin meet"
role="img"
aria-label={t('chart.cumulative.aria')}
>
{/* 图例:取第一根 bar 的颜色 —— colorByName('self') 会按哈希随机挑色,
与实际 bar 段颜色不一致,会让用户以为"自耗时 = 那个特定色"。
用 bars[0].fn.name 的色既对应用户能看到的色,又避免重复引入 palette 索引。
v4 字号升级:MICRO 12 → HINT 14,可读性提升。 */}
{(() => {
const legendFill = bars[0] ? colorByName(bars[0].fn.name) : colorByName('legend')
return (
<g aria-hidden="true">
<rect x={BAR_X} y={4} width={12} height={12} rx={2} fill="var(--surface-1)" />
<text
x={BAR_X + 18}
y={14}
fill="currentColor"
fontSize={FONT_SIZE.HINT}
fontFamily={FONT_MONO}
opacity={0.7}
>
{t('chart.cumulative.cumSeg')}
</text>
<rect
x={BAR_X + 140}
y={4}
width={12}
height={12}
rx={2}
fill={legendFill}
fillOpacity={0.85}
/>
<text
x={BAR_X + 158}
y={14}
fill="currentColor"
fontSize={FONT_SIZE.HINT}
fontFamily={FONT_MONO}
opacity={0.7}
>
{t('chart.cumulative.selfSeg')}
</text>
</g>
)
})()}
{bars.map((bar, i) => {
const y = i * (ROW_H + ROW_GAP) + ROW_GAP / 2
const fill = colorByName(bar.fn.name)
const barPx = Math.max(0, (bar.cumtime / totalCum) * BAR_W)
const selfPx = barPx * bar.selfRatio
const calleePx = barPx - selfPx
const onKeyDown = (e: KeyboardEvent<SVGGElement>) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onSelect(bar.fn)
}
}
const ariaText = t('chart.cumulative.tile.aria', {
name: bar.fn.name,
origin: t(ORIGIN_LABEL_KEY[bar.origin]),
cum: fmtDuration(bar.cumtime),
self: fmtDuration(bar.tottime),
pct: ((bar.cumtime / totalCum) * 100).toFixed(1)
})
// 按 NAME 区实际可用宽度反算能放几个字 —— 自适应窗口。
const nameMax = charsForWidth(NAME_W - NAME_X - 6, FONT_SIZE.NAME)
return (
<g
key={bar.fn.id}
transform={`translate(0, ${y})`}
role="button"
tabIndex={0}
aria-label={ariaText}
aria-keyshortcuts="Enter Space"
onClick={() => onSelect(bar.fn)}
onKeyDown={onKeyDown}
className="chart-tile cursor-pointer"
>
{/* origin 色点(v4 起 r=5)—— 同 BarChart行首圆点 + 半透明描边。
跨主题视觉稳定。aria-hidden 不参与 SR。 */}
<circle
cx={ORIGIN_DOT_CX}
cy={ROW_H / 2}
r={ORIGIN_DOT_R}
fill={ORIGIN_COLOR[bar.origin]}
stroke="rgba(0,0,0,0.25)"
strokeWidth={1}
aria-hidden="true"
/>
<text
x={NAME_X}
y={ROW_H / 2 + 6}
fill="currentColor"
fontSize={FONT_SIZE.NAME}
fontFamily={FONT_MONO}
pointerEvents="none"
aria-hidden="true"
>
{truncateName(bar.fn.name, nameMax)}
</text>
{/* 背景轨 */}
<rect
x={BAR_X}
y={ROW_H * 0.3}
width={BAR_W}
height={ROW_H * 0.4}
rx={3}
fill="var(--surface-1)"
/>
{/* callee 段(被调用方耗时) —— 浅色画在背景轨上,从右往左画,
让 self 段(深色)从左开始连续。但更直观的画法是先画整个 cum 段
再覆盖 self。两种都能读,选「分两段」语义更清晰。 */}
{calleePx > 0 && (
<rect
data-chart-shape
x={BAR_X + selfPx}
y={ROW_H * 0.3}
width={calleePx}
height={ROW_H * 0.4}
rx={3}
fill={fill}
fillOpacity={0.35}
stroke={bar.isFocus ? FOCUS.stroke : RESTING.stroke}
strokeWidth={bar.isFocus ? FOCUS.strokeWidth : RESTING.strokeWidth}
/>
)}
{/* self 段(自耗时) —— 深色 */}
{selfPx > 0 && (
<rect
data-chart-shape
x={BAR_X}
y={ROW_H * 0.3}
width={selfPx}
height={ROW_H * 0.4}
rx={3}
fill={fill}
fillOpacity={bar.isFocus ? FOCUS.fillOpacity : RESTING.fillOpacity}
stroke={bar.isFocus ? FOCUS.stroke : RESTING.stroke}
strokeWidth={bar.isFocus ? FOCUS.strokeWidth : RESTING.strokeWidth}
/>
)}
{/* 右对齐:累计耗时 + 该函数自耗时占比。百分号前显式标 "self" ——
bar 长度已经是「累计占总时长比例」,这里的 % 不能再读成总占比,
必须是「该函数自身时间 / 它总耗时」,否则和 bar 长度直接矛盾。 */}
<text
x={CANVAS_W - 8}
y={ROW_H / 2 + 6}
fill="currentColor"
fontSize={FONT_SIZE.BODY}
fontFamily={FONT_MONO}
textAnchor="end"
pointerEvents="none"
aria-hidden="true"
>
{t('chart.cumulative.rightText', {
pct: (bar.selfRatio * 100).toFixed(0),
cum: fmtDuration(bar.cumtime)
})}
</text>
<title>{ariaText}</title>
</g>
)
})}
</svg>
</div>
</div>
)
}
export default memo(CumulativeBar)

View File

@@ -0,0 +1,236 @@
import { memo, useEffect, useMemo, useState, type KeyboardEvent } from 'react'
import type { FlameNode } from '../../../../shared/analysis'
import { TEXT_ON_DARK } from '../../utils/colors'
import { fmtDuration } from '../../utils/format'
import {
FOCUS,
FONT_MONO,
FONT_SIZE,
MAX_SIBLINGS,
RESTING,
charsForWidth,
colorByName,
countTruncatedSiblings,
truncateName
} from './chart-utils'
import { useT } from '../../i18n'
interface Props {
flame: FlameNode | null
focusName?: string
}
const MAX_DEPTH = 3
const CANVAS_W = 920
const ROW_H = 48
const ROW_GAP = 8
interface Tile {
name: string
value: number
x: number
width: number
hasChildren: boolean
depth: number // 父节点仍可渲染;用真实 depth 而非 row index 更稳
/** 指向原 FlameNode — 直接访问 children避免同名 sibling 在 byDepth Map 里互相覆盖 */
node: FlameNode
}
function FlameGraph({ flame, focusName }: Props) {
const t = useT()
const [focus, setFocus] = useState<string | null>(null)
// 关键:结果变化时清掉旧 focus避免残留高亮
useEffect(() => {
setFocus(null)
}, [flame])
const rows = useMemo<Tile[][] | null>(() => {
if (!flame || flame.value <= 0 || flame.children.length === 0) return null
return layoutIcicle(flame, MAX_DEPTH, MAX_SIBLINGS)
}, [flame])
const focusKey = focus ?? focusName ?? null
if (!rows || !flame) {
return (
<div className="flex h-full items-center justify-center text-sm text-fg-muted">{t('flame.empty')}</div>
)
}
const total = flame.value
const totalH = rows.length * (ROW_H + ROW_GAP) + ROW_GAP
// 任何一层有 sibling 被 MAX_SIBLINGS 截掉,就告知用户 ——
// 火焰图"看上去挤"不是用户错觉,是 layout 故意藏了一部分。
//
// v4 起按「每层单独统计」:之前只算 root.children 数量,深层被截完全静默。
// 现在递归扫一遍,统计所有被截的 depth 和各层丢失的 sibling 数,
// 一句聚合文案("在 N 层共截了 M 个兄弟")避免每层都贴一行 DOM。
const truncatedInfo = countTruncatedSiblings(flame, MAX_SIBLINGS)
return (
<div className="flex h-full flex-col p-3">
<div className="mb-2 flex items-center justify-between text-sm text-fg-secondary">
{/* note 提到 ResultsPanel section header,这里只留 reset(下钻态时显示) */}
{focus ? (
<button
onClick={() => setFocus(null)}
aria-label={t('flame.reset.aria')}
className="rounded px-1.5 py-0.5 text-fg-secondary hover:bg-surface-1 hover:text-fg focus:outline-none focus-visible:bg-surface-1"
>
{t('flame.reset')}
</button>
) : (
<span aria-hidden="true">{t('chart.flame.hint')}</span>
)}
</div>
{/* 截断提示v4 起把多层截断聚合为一句。
之前只显示 root 的截断量,深层有 sibling 被截会"看起来挤但毫无提示" —— 用户
误以为是数据问题。现在递归算所有深度,只显示有截断的层,避免每层都贴噪音。 */}
{truncatedInfo.totalHidden > 0 && (
<div className="mb-2 text-sm text-fg-secondary" aria-live="polite">
{truncatedInfo.depths.length === 1
? t('chart.truncated', { shown: MAX_SIBLINGS, hidden: truncatedInfo.totalHidden })
: t('chart.flame.truncatedMulti', {
shown: MAX_SIBLINGS,
hidden: truncatedInfo.totalHidden,
depths: truncatedInfo.depths.length
})}
</div>
)}
{/* 不内嵌 overflow-auto —— 外层右栏自带 overflow-y-auto 接管所有滚动。
SVG viewBox + preserveAspectRatio 自适应尺寸, 深层调用栈再多也直接交给外层滚动。 */}
<div className="flex-1">
<svg
width="100%"
viewBox={`0 0 ${CANVAS_W} ${totalH}`}
preserveAspectRatio="xMinYMin meet"
role="img"
aria-label={t('flame.aria')}
>
{rows.map((row, rowDepth) =>
row.map((it, i) => {
// depth 0 是 root 的整条tile 上的 depth 用 rowDepth+1 与父节点 children 深度对齐
const depth = rowDepth + 1
const isFocusMatch = it.name === focusKey
const isClickable = depth < MAX_DEPTH && it.hasChildren
const fill = rowDepth === 0 ? 'var(--surface-2)' : colorByName(it.name)
// 文字一律用浅色tile 以 fillOpacity 0.7 合成在背景上,合成后的颜色
// 对近黑文字 (TEXT_ON_LIGHT) 只有 2.463.626 个色值里 4 个过不了
// 11px 小字所需的 4.5:1。浅色文字在整块 PALETTE 上都 >= 4.88(见 colors.ts
// 注PALETTE 是品牌色,不随主题切换;只有 root 行 (rowDepth===0) 走
// `var(--surface-2)` 跟随主题 —— root 是"画布底色",不是品牌色。
const textColor = TEXT_ON_DARK
const pct = ((it.value / total) * 100).toFixed(1)
// 用 fmtDuration 而非 toFixed(4) —— 与 BarChart/CumulativeBar/Treemap 保持一致
// (1.23s / 12.3ms / 123µs 自动选单位,屏幕阅读器读起来更自然)
const ariaText =
t('flame.tile.aria', {
name: it.name,
value: fmtDuration(it.value),
pct
}) + (isClickable ? t('flame.tile.drill') : '')
const onKeyDown = (e: KeyboardEvent<SVGGElement>) => {
if (isClickable && (e.key === 'Enter' || e.key === ' ')) {
e.preventDefault()
setFocus(it.name)
}
}
// v4 起不再「width > 70 才显示 label」 —— 改用 charsForWidth 反算,
// 即使 tile 只有 28px 也能挤进 1-2 个字符(不会挤出一团墨块,反而是视觉
// 锚点)。name 放不下时返回空串 —— 这种情况不出 text 节点。
const labelChars = charsForWidth(it.width - 16, FONT_SIZE.BODY)
const showLabel = labelChars >= 1
return (
<g
key={`${rowDepth}-${it.name}-${i}`}
transform={`translate(0, ${rowDepth * (ROW_H + ROW_GAP) + ROW_GAP / 2})`}
className={isClickable ? 'chart-tile cursor-pointer' : ''}
onClick={() => isClickable && setFocus(it.name)}
onKeyDown={onKeyDown}
role={isClickable ? 'button' : undefined}
tabIndex={isClickable ? 0 : undefined}
aria-keyshortcuts={isClickable ? 'Enter Space' : undefined}
aria-label={ariaText}
>
<rect
data-chart-shape
x={it.x}
y={0}
width={Math.max(0, it.width - 1.5)}
height={ROW_H}
rx={3}
fill={fill}
fillOpacity={rowDepth === 0 ? 1 : isFocusMatch ? FOCUS.fillOpacity : RESTING.fillOpacity}
stroke={isFocusMatch ? FOCUS.stroke : RESTING.stroke}
strokeWidth={isFocusMatch ? FOCUS.strokeWidth : RESTING.strokeWidth}
/>
{showLabel && (
<text
x={it.x + 8}
y={ROW_H / 2 + 6}
fill={textColor}
fontSize={FONT_SIZE.BODY}
fontFamily={FONT_MONO}
pointerEvents="none"
aria-hidden="true"
>
{truncateName(it.name, labelChars)}
</text>
)}
<title>{ariaText}</title>
</g>
)
})
)}
</svg>
</div>
</div>
)
}
/** 单层 BFS直接通过 Tile.node.children 找子节点O(N) 一次扫描。 */
function layoutIcicle(root: FlameNode, maxDepth: number, maxSiblings: number): Tile[][] {
const rootTile: Tile = {
name: root.name,
value: root.value,
x: 0,
width: CANVAS_W,
hasChildren: root.children.length > 0,
depth: 0,
node: root
}
const rows: Tile[][] = [[rootTile]]
for (let rowDepth = 0; rowDepth < maxDepth - 1; rowDepth++) {
const cur: Tile[] = []
const parents = rows[rowDepth]
for (const parent of parents) {
if (!parent.hasChildren) continue
// 防止极宽函数淹没横向布局 — 截断的同时给用户一个可见提示
const cs = parent.node.children.slice(0, maxSiblings)
if (cs.length === 0) continue
const sum = cs.reduce((s, c) => s + c.value, 0) || 1
let xCursor = parent.x
for (let i = 0; i < cs.length; i++) {
const c = cs[i]
const w = (c.value / sum) * parent.width
cur.push({
name: c.name,
value: c.value,
x: xCursor,
width: w,
hasChildren: c.children.length > 0,
depth: rowDepth + 1,
node: c
})
xCursor += w
}
}
if (cur.length === 0) break
rows.push(cur)
}
return rows
}
export default memo(FlameGraph)

View File

@@ -0,0 +1,549 @@
import { memo, useEffect, useMemo, useState, type KeyboardEvent } from 'react'
import type { FunctionNode } from '../../../../shared/analysis'
import { fmtDuration, totalSelfTime } from '../../utils/format'
import { colorByName } from './chart-utils'
import { useT, type StringKey } from '../../i18n'
import {
ORIGIN_ABBR_KEY,
ORIGIN_COLOR,
ORIGIN_LABEL_KEY,
type Origin,
groupModuleKeysByOrigin,
resolveOrigin
} from '../../utils/origin'
type SortKey = 'tottime' | 'cumtime' | 'ncalls' | 'percallTot' | 'name'
/**
* 默认最多渲染多少行。
*
* 引擎侧 `functions` 是不设上限的 —— `--topn` 只决定哪些函数进逐行剖析,热点表拿到的是
* 用户脚本里所有被调用过的函数。几千行的模块能轻松产出几百个函数,全量渲染就是几百个
* <tr> × 6 个单元格,首次打开结果面板会明显卡一下。
* 不引入虚拟滚动(多一个依赖 + 破坏 Ctrl-F 和屏幕阅读器的表格语义),直接截断 + 展开按钮:
* 按自耗时排序时200 行之后的函数本来就不是优化目标。
*/
const DEFAULT_ROW_CAP = 200
interface Props {
functions: FunctionNode[]
/**
* 热点点击回调。传整 FunctionNode —— App.tsx 拿 file / origin 决定路由
* user tab 高亮 vs 打开 stdlib / 第三方源码 tab
*/
onSelect: (fn: FunctionNode) => void
selectedId?: string
}
/**
* 大量函数时selectedFuncId 切换不应让整个表格重新排序/重画所有条形。
* React.memo + 稳定的 onSelect外部已 useCallback保证父组件 re-render 不连带。
*/
function HotspotTable({ functions, onSelect, selectedId }: Props) {
const t = useT()
const [sortKey, setSortKey] = useState<SortKey>('tottime')
const [asc, setAsc] = useState(false)
const [showAll, setShowAll] = useState(false)
// 模块过滤null = 显示全部;否则只显示 module 等于该字符串的函数。
// scope=all 时函数表会塞进来几百行 stdlib / 第三方包;没有这个 filter
// 用户要点开一行才能知道「这条 json 内部调用」属于哪个模块,分析价值大减。
const [moduleFilter, setModuleFilter] = useState<string | null>(null)
// v5 起:零耗时帧 + 测试代码 过滤都搬到 App 入口utils/filterAnalysis
// 一次性过滤后整个结果页RunSummaryLite / 火焰图 / 柱图 / 旭日图 / 矩形树图 /
// 累积柱图 / 模块热图 / HotspotTable都看到同一份干净数据。这里不再重复过滤
// 想看完整原始数据直接点 ResultsPanel 右上角的「默认隐藏 N 帧」总开关。
// 模块切换 / 新 run 来了之后:
// 1) 新结果没有当前过滤的模块 → filtered = [] → 给用户显一个空表;
// 2) 新结果只有一个模块 → showModuleFilter=false → filter chip 消失,没有关通道能清除过滤。
// 从减少两种“过滤对不上当前数据”的状态,在 functions 变化时检查过滤模块是否还存在;不存在就备位到 null。
useEffect(() => {
if (moduleFilter === null) return
const has = functions.some((f) => f.module === moduleFilter)
if (!has) setModuleFilter(null)
}, [functions, moduleFilter])
// total 缓存props.functions 引用变化才重算
const total = useMemo(() => totalSelfTime(functions) || 1, [functions])
// 模块计数:函数表里出现哪些模块、各有几个。
// 只在模块数 > 1 时显示 filter chips —— 单模块scope=user 时)显示没意义。
const moduleCounts = useMemo(() => {
const m = new Map<string, number>()
for (const f of functions) {
m.set(f.module, (m.get(f.module) ?? 0) + 1)
}
return m
}, [functions])
const modules = useMemo(() => Array.from(moduleCounts.keys()), [moduleCounts])
const showModuleFilter = modules.length > 1
// 模块 → representative FunctionNode每个模块用首条函数算 origin 给该模块归类。
// 用 first 而不是 random —— 同一模块下所有函数的 origin 是一致的_classify_origin
// 决定于 file/module_name不会因函数不同而变任意挑一条都准first 让结果稳定。
const fnByModule = useMemo(() => {
const m = new Map<string, FunctionNode>()
for (const f of functions) {
if (!m.has(f.module)) m.set(f.module, f)
}
return m
}, [functions])
// 模块 chip 按 origin 分组v3 新增)—— scope=all 时十几/ 二十几个 stdlib 包
// 平铺成一长串扫起来累,按"用户代码 / 标准库 / 第三方包 / 内置 / 冻结 / 其它"
// 分桶后每桶内再排,用户一眼定位"自己的代码 / 想下钻的包"在哪。
const moduleGroups = useMemo(() => groupModuleKeysByOrigin(modules, fnByModule), [modules, fnByModule])
// 过滤后的函数集合v5 起 App 入口已做完测试代码 + 零耗时帧过滤(见 utils/filterAnalysis
// 这里只做模块过滤 —— scope=all 时用户想聚焦某个包内部时切 moduleFilter。
const filtered = useMemo(() => {
if (moduleFilter === null) return functions
return functions.filter((f) => f.module === moduleFilter)
}, [functions, moduleFilter])
const rows = useMemo(() => {
const copy = [...filtered]
copy.sort((a, b) => {
const av = a[sortKey]
const bv = b[sortKey]
const cmp =
typeof av === 'number' && typeof bv === 'number' ? av - bv : String(av).localeCompare(String(bv))
return asc ? cmp : -cmp
})
return copy
}, [filtered, sortKey, asc])
// 截断在排序之后:无论按哪一列排,用户看到的都是「当前排序下的前 200 名」,
// 而不是「自耗时前 200 名再按当前列排」—— 后者会让排序结果看起来是错的。
const visible = useMemo(() => (showAll ? rows : rows.slice(0, DEFAULT_ROW_CAP)), [rows, showAll])
const hiddenCount = rows.length - visible.length
// 切 module filter 时重置 showAll —— 上次展开 800 行切到 json 模块后只剩 30 行,
// 那条「展开全部」会让用户以为没切对。
const selectModule = (next: string | null): void => {
setModuleFilter(next)
setShowAll(false)
}
if (functions.length === 0) {
return <div className="px-4 py-8 text-center text-sm text-fg-muted">{t('hotspot.empty')}</div>
}
return (
<div className="flex flex-col">
{/*
模块 filter chips按 origin 分组v3 新增):
- "全部 N" 永远在最前,是默认 chip
- 然后按 origin 分桶user → third_party → stdlib → builtin → frozen → other
- 每个桶一条 1px 分隔线 + 一行小标题("Python 标准库 (5)")作为视觉分组锚点
- 桶内模块 chip 沿用 colorByName 哈希保持跨视图同色
aria-pressed 表达选中态aria-label 把当前可见 / 总数都带上,让 SR 用户
不必先切换就知道这次过滤掉了几行。
*/}
{showModuleFilter && (
<div
role="group"
aria-label={t('hotspot.module.aria', {
label: moduleFilter ?? t('hotspot.module.allFilter', { total: functions.length }),
shown: visible.length,
total: rows.length
})}
className="flex flex-col gap-1 border-b border-border px-3 py-1.5"
>
{/* "全部 N" 单独一行 —— 概念上它代表「清除过滤」而不是某个 origin 桶,
跟下面分桶行视觉上分离更清晰(不然会和第一桶 origin 混读)。 */}
<div className="flex flex-wrap items-center gap-1 pt-0.5">
<ModuleChip
active={moduleFilter === null}
label={t('hotspot.module.allFilter', { total: functions.length })}
color={colorByName('all')}
onClick={() => selectModule(null)}
/>
</div>
{moduleGroups.map((g) => (
<div key={g.origin} className="flex flex-wrap items-center gap-1">
{/*
桶标签 = 6×10 色块(与 ModuleHeatmap 标题条同款)+ origin 长名 + 计数。
色块承担「这条 section 是什么颜色」的视觉锚点,文字只负责读出名字,
—— 把颜色和文字分工,颜色更"重",文字更"轻",扫表时一眼定位。
*/}
<span
className="mr-1.5 inline-flex items-center gap-1.5 font-mono text-2xs uppercase tracking-wider"
style={{ color: ORIGIN_COLOR[g.origin] }}
aria-hidden="true"
>
<span
className="inline-block h-2.5 w-1.5 rounded-sm"
style={{ backgroundColor: ORIGIN_COLOR[g.origin] }}
/>
{t(ORIGIN_LABEL_KEY[g.origin])}
<span className="font-sans text-fg-muted">
({g.modules.reduce((s, m) => s + (moduleCounts.get(m) ?? 0), 0)})
</span>
</span>
{g.modules.map((m) => (
<ModuleChip
key={m}
active={moduleFilter === m}
label={t('hotspot.module.filter', { module: m, count: moduleCounts.get(m) ?? 0 })}
color={colorByName(m)}
onClick={() => selectModule(m)}
/>
))}
</div>
))}
</div>
)}
<div className="overflow-x-auto">
<table className="w-full border-collapse text-sm">
<thead>
<tr className="border-b border-border text-left text-2xs uppercase tracking-wider text-fg-muted">
<th scope="col" className="w-3" aria-hidden="true" />
<Header
labelKey="hotspot.col.function"
sortKey="name"
current={sortKey}
asc={asc}
onSort={setSortKey}
setAsc={setAsc}
/>
{/* 初始默认按 tottime 降序 —— 用 Header 而不是普通 <th>,让 aria-sort 同步暴露给 SR。
之前写成普通 <th>,屏幕阅读器无法知道当前排序列。 */}
<Header
labelKey="hotspot.col.tottime"
sortKey="tottime"
current={sortKey}
asc={asc}
onSort={setSortKey}
setAsc={setAsc}
className="w-1/3"
/>
<Header
labelKey="hotspot.col.cumtime"
sortKey="cumtime"
current={sortKey}
asc={asc}
onSort={setSortKey}
setAsc={setAsc}
align="right"
/>
<Header
labelKey="hotspot.col.calls"
sortKey="ncalls"
current={sortKey}
asc={asc}
onSort={setSortKey}
setAsc={setAsc}
align="right"
/>
<Header
labelKey="hotspot.col.percall"
sortKey="percallTot"
current={sortKey}
asc={asc}
onSort={setSortKey}
setAsc={setAsc}
align="right"
/>
</tr>
</thead>
<tbody>
{visible.length === 0 && moduleFilter !== null ? (
// 模块过滤把全部行数过滤掉了 —— 给一行明确的占位 + 回到「全部」的快捷链接,
// 比沉默的空白表友好。colSpan 对齐表头 6 列(含最左侧 3px 装饰列)。
<tr>
<td colSpan={6} className="px-3 py-8 text-center text-sm text-fg-muted">
{t('hotspot.emptyFiltered', {
total: functions.length
})}
<button
type="button"
onClick={() => selectModule(null)}
className="ml-2 cursor-pointer text-accent underline-offset-2 hover:underline focus:outline-none focus-visible:underline"
>
{t('hotspot.module.allFilter', { total: functions.length })}
</button>
</td>
</tr>
) : (
visible.map((f) => (
<Row
key={f.id}
fn={f}
total={total}
isSelected={selectedId === f.id}
origin={resolveOrigin(f)}
onSelect={onSelect}
/>
))
)}
</tbody>
</table>
{/*
绝不静默截断:藏了行就必须说藏了几行、能怎么展开。
aria-live 让屏幕阅读器在展开后播报行数变化。
v5 起:测试代码 + 零耗时帧的过滤通知搬到 ResultsPanel 顶上的总开关,
这里只剩「行数被截断」的提示。
*/}
{hiddenCount > 0 && (
<div className="border-t border-border px-3 py-2 text-xs text-fg-muted" aria-live="polite">
{t('hotspot.truncated', { visible: visible.length, hidden: hiddenCount })}
<button
type="button"
onClick={() => setShowAll(true)}
className="ml-2 cursor-pointer text-accent underline-offset-2 hover:underline focus:outline-none focus-visible:underline"
>
{t('hotspot.expand', { total: rows.length })}
</button>
</div>
)}
{showAll && rows.length > DEFAULT_ROW_CAP && (
<div className="border-t border-border px-3 py-2 text-xs text-fg-muted" aria-live="polite">
{t('hotspot.totals', { total: rows.length })}
<button
type="button"
onClick={() => setShowAll(false)}
className="ml-2 cursor-pointer text-accent underline-offset-2 hover:underline focus:outline-none focus-visible:underline"
>
{t('hotspot.collapse', { cap: DEFAULT_ROW_CAP })}
</button>
</div>
)}
</div>
</div>
)
}
export default memo(HotspotTable)
/**
* 模块 filter chipscope=all 才出现。
* 色块用 colorByName 哈希到 PALETTE —— 同一个模块在不同 chip / 表格行 / 火焰图里
* 都是同一个颜色,跨视图保持视觉锚点。
* background 用 hex + 26 (≈15% alpha) 营造"染色"感而非"实心块"。
*/
function ModuleChip({
active,
label,
color,
onClick
}: {
active: boolean
label: string
color: string
onClick: () => void
}): JSX.Element {
return (
<button
type="button"
onClick={onClick}
aria-pressed={active}
className={`inline-flex items-center rounded-full px-2.5 py-0.5 font-mono text-2xs uppercase tracking-wide transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent ${
active ? 'text-fg' : 'text-fg-secondary hover:text-fg'
}`}
style={{
backgroundColor: active ? `${color}33` : `${color}1a`,
borderColor: active ? color : 'transparent',
borderWidth: 1
}}
>
{label}
</button>
)
}
/** 行单独 memo — 同一函数 id 在不切换选中时完全跳过重渲染。 */
const Row = memo(function Row({
fn,
total,
isSelected,
origin,
onSelect
}: {
fn: FunctionNode
total: number
isSelected: boolean
origin: Origin
onSelect: (fn: FunctionNode) => void
}) {
const t = useT()
const pct = (100 * fn.tottime) / total
// 之前类型标的是 HTMLTableRowElement — 当时 onKeyDown 挂在 <tr> 上。
// 后来把点击/键盘搬到了 name cell 里的 <button>(让 <tr> 保持真实 tr + aria-selected),
// 类型没跟上,改成 HTMLButtonElement 匹配现绑定。
const onKeyDown = (e: KeyboardEvent<HTMLButtonElement>) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onSelect(fn)
}
}
const originLabel = t(ORIGIN_LABEL_KEY[origin])
const ariaText = isSelected
? t('hotspot.row.ariaSelected', {
name: fn.name,
line: fn.line,
tottime: fmtDuration(fn.tottime),
calls: fn.ncalls,
// SR 视角:把 origin 拼进 aria-label让用户不用展开行就知道「这条帧是哪个来源」。
origin: originLabel
})
: t('hotspot.row.aria', {
name: fn.name,
line: fn.line,
tottime: fmtDuration(fn.tottime),
calls: fn.ncalls,
origin: originLabel
})
return (
// keep implicit role="row" so SR reads cells row-by-row. previous
// role="button" override dropped the rest of the cells out of the
// table a11y tree (SR heard "button" but no row context). click +
// keyboard activation live on a <button> inside the name cell, so
// the row stays a real <tr> with selectable semantics (aria-selected).
<tr
aria-selected={isSelected}
className={`group border-b border-border/40 transition-colors ${
isSelected ? 'bg-surface-2' : 'hover:bg-surface-1'
}`}
>
{/* Signature: 3px left accent bar on selected row */}
<td className="relative p-0" aria-hidden="true">
<div
className={`absolute inset-y-0 left-0 w-[3px] ${isSelected ? 'bg-accent' : 'bg-transparent group-hover:bg-border-strong'}`}
/>
</td>
<td className="relative p-0">
{/* 模块 chip + 函数名 + 行号:<button> 包整格,
承载点击和键盘激活Enter / Space
表格其它列是纯数据格 —— SR 按 td 朗诵数值,不被按钮语义干扰。 */}
<button
type="button"
onClick={() => onSelect(fn)}
onKeyDown={onKeyDown}
aria-label={ariaText}
// 用 aria-pressed 而不是 aria-current当前按钮语义是「这条帧已被选中下钻」
// pressed 比 current 更准current 用于导航/位置上下文。aria-selected 在父 <tr>
// 上保留 —— 两边语义不重叠。
aria-pressed={isSelected}
className="block w-full cursor-pointer px-3 py-2 pr-3 text-left focus:outline-none focus-visible:bg-surface-1"
>
<div className="mb-1 flex items-center gap-1.5">
{/*
origin 徽章v3 新增1px 圆点 + 1-2 字母短码S/3P/U/B/F/?),按 origin
着色。origin 信息在 module chip 之外再叠一次 —— 用户扫表时不需要先过滤
就能认出「这条 json 帧是 stdlib」跨多模块的下钻体验更顺。
aria-hidden 不参与 SR —— origin 已并入父 button 的 aria-label。
*/}
<span
className="inline-flex items-center gap-1 rounded px-1.5 py-0.5 font-mono text-2xs uppercase tracking-wide"
style={{
color: ORIGIN_COLOR[origin],
backgroundColor: `${ORIGIN_COLOR[origin]}1a`
}}
aria-hidden="true"
title={originLabel}
>
<span
className="inline-block h-1.5 w-1.5 rounded-full"
style={{ backgroundColor: ORIGIN_COLOR[origin] }}
/>
{t(ORIGIN_ABBR_KEY[origin])}
</span>
{fn.module && (
<span
className="inline-block rounded px-1.5 py-0.5 font-mono text-2xs uppercase tracking-wide text-fg-secondary"
style={{ backgroundColor: `${colorByName(fn.module)}1a` }}
aria-hidden="true"
>
{fn.module}
</span>
)}
</div>
<span className="font-mono text-fg">{fn.name}</span>
<div className="font-mono text-xs text-fg-muted">{t('hotspot.col.line', { line: fn.line })}</div>
</button>
</td>
<td className="w-1/3 py-2 pr-3">
<div className="flex items-center gap-2">
{/* Restrained bar — dataviz rule: 2px surface gap to next */}
<div className="relative h-1.5 flex-1 overflow-hidden rounded-full bg-surface-1">
<div
className="absolute inset-y-0 left-0 rounded-full bg-accent/70"
style={{ width: `${Math.min(100, pct)}%` }}
/>
</div>
<span className="w-16 text-right font-mono text-xs tabular text-fg-secondary">
{/* 跟 BarChart / FlameGraph / Treemap / Sunburst 四图统一用 toFixed(1) ——
之前 0 位小数会让小耗时函数在表格里和柱状图里出现不一致表格写「2%」、柱状图写「2.3%」),
同函数不同视图对不上。 */}
{pct.toFixed(1)}% · {fmtDuration(fn.tottime)}
</span>
</div>
</td>
<td className="py-2 pr-3 text-right font-mono tabular text-fg-secondary">{fmtDuration(fn.cumtime)}</td>
<td className="py-2 pr-3 text-right font-mono tabular text-fg-secondary">{fn.ncalls}</td>
<td className="py-2 pr-3 text-right font-mono tabular text-fg-muted">{fmtDuration(fn.percallTot)}</td>
</tr>
)
})
/** 表头键盘可排序aria-sort 同步。 */
function Header({
labelKey,
sortKey,
current,
asc,
onSort,
setAsc,
align = 'left',
className = ''
}: {
labelKey: StringKey
sortKey: SortKey
current: SortKey
asc: boolean
onSort: (k: SortKey) => void
setAsc: (b: boolean) => void
align?: 'left' | 'right'
className?: string
}) {
const t = useT()
const isActive = current === sortKey
const label = t(labelKey)
const onKeyDown = (e: KeyboardEvent<HTMLButtonElement>) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
toggleSort()
}
}
const toggleSort = () => {
if (current === sortKey) setAsc(!asc)
else {
onSort(sortKey)
setAsc(false)
}
}
return (
<th
aria-sort={isActive ? (asc ? 'ascending' : 'descending') : 'none'}
scope="col"
className={`p-0 font-medium ${className} ${align === 'right' ? 'text-right' : 'text-left'}`}
>
{/*
之前给 <th> 加 role="button" 会丢掉隐式 columnheader 语义SR 读成 "button" 而非
"column header"。把交互放到内嵌的 <button type="button"> 上,<th> 保持列头语义。
*/}
<button
type="button"
onClick={toggleSort}
onKeyDown={onKeyDown}
aria-label={t('hotspot.sortBy', { label })}
className={`flex w-full cursor-pointer select-none items-center px-3 py-2 transition-colors focus:outline-none focus-visible:bg-surface-1 ${
align === 'right' ? 'justify-end' : 'justify-start'
} ${isActive ? 'text-fg' : 'hover:text-fg-secondary'}`}
>
{label}
{isActive ? <span className="ml-1 text-accent">{asc ? '↑' : '↓'}</span> : null}
</button>
</th>
)
}

View File

@@ -0,0 +1,371 @@
/**
* 模块热力图 —— 按 module 聚合的"行 × 函数 cell"布局。
*
* 布局:
* - 每个 module 占一行
* - 行内 cell 横向排列,宽度 ∝ 该函数 self-time 占模块总和的比例
* - cell 颜色用 colorByName(name):同函数在 4 图 + 这里都同色
* - 行首标 module 名 + 函数数
*
* 使用场景:
* - scope=user:只有一个模块("<user>"),没有横向比较价值 → 显示空态文案引导
* 用户切到 scope=all(或直接由 EmptyState 隐藏这一视图)
* - scope=all:json / numpy / time / <user> / <frozen> 等模块同框,一眼看出
* "json.loads 吃掉 30% 总时间"或"time.sleep 占比最高"。
*
* v3 起按 origin(用户代码 / 第三方包 / 标准库 / ...)分桶渲染,每桶头一个
* 模块行前画 origin 标题 —— 跨多模块时定位「我的代码 vs 库」更直接。
*
* 截断:每个模块最多展示 TOP_N_PER_MODULE 个函数,超出必显式提示;
* 模块本身超过 MAX_MODULES 也截断 + 提示。
*
* v4 字号升级13-15px → 14-16px, 窄 cell 也按可用宽度反算可放字符数,
* 永不静默藏标签。
*/
import { memo, useMemo, type KeyboardEvent } from 'react'
import type { FunctionNode } from '../../../../shared/analysis'
import { TEXT_ON_DARK } from '../../utils/colors'
import { fmtDuration, totalSelfTime } from '../../utils/format'
import { FOCUS, FONT_MONO, FONT_SIZE, RESTING, charsForWidth, colorByName, truncateName } from './chart-utils'
import { useT } from '../../i18n'
import {
ORIGIN_ABBR_KEY,
ORIGIN_COLOR,
ORIGIN_LABEL_KEY,
ORIGIN_ORDER,
resolveOrigin,
type Origin as OriginType
} from '../../utils/origin'
const CANVAS_W = 920
const ROW_H = 64
const ROW_GAP = 10
/** 行首模块名标签宽度。Cascadia Code 16px ≈ 10px/字 → ~13 字, 留余到 150px。 */
const LABEL_W = 150
/** 行末「more」提示位宽度 —— 给「+N more」文字让出空间。 */
const MORE_W = 90
const CELL_GAP = 4
/** 每个模块最多展开几个函数 cell。多了挤,且热度对比下降。 */
const TOP_N_PER_MODULE = 6
/** 模块行数上限 —— 防止 scope=all 时十几个 stdlib 把画布撑爆。 */
const MAX_MODULES = 10
/** Cell 最小可见宽度 —— 太窄的 cell 没有信息量。 */
const MIN_CELL_W = 24
/** Origin 标题小条高度(左侧 4px 色块 + 文字),避免它跟模块行撞。 */
const ORIGIN_HEADER_H = 22
interface Props {
functions: readonly FunctionNode[]
/** 热点点击回调 —— 传整 FunctionNode 让 App.tsx 拿 file/origin 决定路由。 */
onSelect: (fn: FunctionNode) => void
focusName?: string
}
interface ModuleRow {
module: string
origin: OriginType
total: number
cells: Array<{ fn: FunctionNode; width: number; isFocus: boolean }>
hiddenInRow: number
}
function ModuleHeatmap({ functions, onSelect, focusName }: Props) {
const t = useT()
const moduleStats = useMemo(() => {
const byModule = new Map<string, { total: number; fns: FunctionNode[] }>()
for (const f of functions) {
// 过滤 tottime=0 的函数 —— 它们通常是 cProfile 极短调用(< 1µs 量化到 0),
// 不该和真实耗时共享一个 MIN_CELL_W=24 的格子,会让用户误以为该函数占了空间。
// 与 CumulativeBar 过滤 cumtime=0 同款策略。
if (f.tottime <= 0) continue
let e = byModule.get(f.module)
if (!e) {
e = { total: 0, fns: [] }
byModule.set(f.module, e)
}
e.total += f.tottime
e.fns.push(f)
}
// 排序:模块总耗时降序
return Array.from(byModule.entries())
.map(([module, v]) => ({ module, ...v }))
.sort((a, b) => b.total - a.total)
}, [functions])
const totalAll = useMemo(() => totalSelfTime(functions) || 1, [functions])
// 按 module 排序后截到 MAX_MODULES再按 origin 分桶v3 新增)。
// 桶内保留 tottime 降序 —— 视觉上「本 origin 里最贵的包排最前」。
const groupedRows = useMemo<Array<{ origin: OriginType; rows: ModuleRow[] }>>(() => {
const top = moduleStats.slice(0, MAX_MODULES)
const moduleOrigin = new Map<string, OriginType>()
for (const m of top) {
const sample = m.fns[0]
moduleOrigin.set(m.module, sample ? resolveOrigin(sample) : 'other')
}
const buckets = new Map<OriginType, ModuleRow[]>()
for (const m of top) {
const o = moduleOrigin.get(m.module) ?? 'other'
const cells = [...m.fns]
.sort((a, b) => b.tottime - a.tottime)
.slice(0, TOP_N_PER_MODULE)
.map((fn) => ({
fn,
width: Math.max(MIN_CELL_W, (fn.tottime / m.total) * (CANVAS_W - LABEL_W - MORE_W - CELL_GAP)),
isFocus: fn.name === focusName
}))
const row: ModuleRow = {
module: m.module,
origin: o,
total: m.total,
cells,
hiddenInRow: m.fns.length - cells.length
}
let b = buckets.get(o)
if (!b) {
b = []
buckets.set(o, b)
}
b.push(row)
}
// 桶按 ORIGIN_ORDER 排user → third_party → stdlib → ...
return ORIGIN_ORDER.filter((o) => buckets.has(o)).map((origin) => ({
origin,
rows: buckets.get(origin) ?? []
}))
}, [moduleStats, focusName])
const truncatedModules = moduleStats.length > MAX_MODULES ? moduleStats.length - MAX_MODULES : 0
if (moduleStats.length === 0) {
return (
<div className="flex h-full items-center justify-center text-sm text-fg-muted">{t('chart.empty')}</div>
)
}
if (moduleStats.length === 1) {
return (
<div className="flex h-full items-center justify-center px-6 text-center text-sm text-fg-muted">
{t('chart.heatmap.empty')}
</div>
)
}
// 行 y 偏移origin header 占 ORIGIN_HEADER_H 高,紧跟每个 ROW_H + ROW_GAP。
let yCursor = ORIGIN_HEADER_H + ROW_GAP / 2
const rowY = new Map<string, number>()
for (const g of groupedRows) {
yCursor += ORIGIN_HEADER_H // header 自身高度
for (const r of g.rows) {
rowY.set(r.module, yCursor)
yCursor += ROW_H + ROW_GAP
}
}
// 重新计算 SVG 高度 —— 直接从 yCursor 推:最后一行底 + 半 gap 余量。
// 之前用 `ORIGIN_HEADER_H + moduleRowCount * (ROW_H + ROW_GAP) + ROW_GAP` 硬算,
// 不算首个 group 的双重 header 预留 (yCursor 起始值 + loop 内 +=),结果差 ~7px,
// 多行时最后一行 cell 底部被 viewBox 裁掉一截。
const totalH = yCursor + ROW_GAP / 2
return (
<div className="flex h-full flex-col p-3">
<div className="mb-2 text-sm text-fg-secondary" aria-live="polite">
{t('chart.heatmap.hint')}
</div>
{truncatedModules > 0 && (
<div className="mb-2 text-sm text-fg-secondary" aria-live="polite">
{t('chart.truncated', { shown: MAX_MODULES, hidden: truncatedModules })}
</div>
)}
{/* 不内嵌 overflow-auto —— 外层右栏自带 overflow-y-auto 接管所有滚动。
模块多时 SVG viewBox 自动按高度缩放, 多余高度直接交给外层滚动。 */}
<div className="flex-1">
<svg
width="100%"
viewBox={`0 0 ${CANVAS_W} ${totalH}`}
preserveAspectRatio="xMinYMin meet"
role="img"
aria-label={t('chart.heatmap.aria')}
>
{groupedRows.map((g) => {
const firstY = rowY.get(g.rows[0].module) ?? 0
const headerY = firstY - ORIGIN_HEADER_H + 4
return (
<g key={g.origin}>
{/* origin 标题条(v3 新增):左侧 6px 色块 + 短码/长标签。
色块加粗到 6px + rx=2 让 section 边界更明显,scope=all 时一眼
区分"我的代码 / 第三方 / 标准库"。色块 y 对齐文字字形中央,
不再悬空于标题条顶部。v4 字号 11 → 13 配合更大 ORIGIN_HEADER_H。 */}
<g aria-hidden="true">
<rect
x={0}
y={headerY + 4}
width={6}
height={ORIGIN_HEADER_H - 12}
rx={2}
fill={ORIGIN_COLOR[g.origin]}
/>
<text
x={16}
y={headerY + 14}
fill={ORIGIN_COLOR[g.origin]}
fontSize={FONT_SIZE.HINT}
fontFamily={FONT_MONO}
pointerEvents="none"
style={{ letterSpacing: '0.06em', textTransform: 'uppercase' }}
>
{t(ORIGIN_ABBR_KEY[g.origin])} · {t(ORIGIN_LABEL_KEY[g.origin])}
</text>
</g>
{g.rows.map((row) => (
<ModuleHeatmapRow
key={row.module}
row={row}
y={rowY.get(row.module) ?? 0}
totalAll={totalAll}
onSelect={onSelect}
/>
))}
</g>
)
})}
</svg>
</div>
</div>
)
}
// 单行渲染抽出来 —— 让父组件 groupedRows.map 不至于读不下去。
const ModuleHeatmapRow = memo(function ModuleHeatmapRow({
row,
y,
totalAll,
onSelect
}: {
row: ModuleRow
y: number
totalAll: number
onSelect: (fn: FunctionNode) => void
}) {
const t = useT()
const moduleColor = colorByName(row.module)
let cursor = LABEL_W
return (
<g>
{/* 行首:模块色点 + 模块名 + (函数数)。
v4 字号 15 → 17(BODY+1), 标签宽度 LABEL_W 也加到 150。 */}
<rect x={0} y={y + ROW_H * 0.25} width={12} height={ROW_H * 0.5} rx={2} fill={moduleColor} />
<text
x={20}
y={y + ROW_H / 2 + 6}
fill="currentColor"
fontSize={FONT_SIZE.BODY}
fontFamily={FONT_MONO}
pointerEvents="none"
aria-hidden="true"
>
{t('chart.heatmap.moduleLabel', {
module: truncateName(row.module, 14),
count: row.cells.length + row.hiddenInRow
})}
</text>
{/* 行底分割线 —— 让多行模块视觉上分组 */}
<line
x1={0}
y1={y + ROW_H + ROW_GAP / 2 - 1}
x2={CANVAS_W}
y2={y + ROW_H + ROW_GAP / 2 - 1}
stroke="var(--border)"
strokeWidth={0.5}
strokeOpacity={0.5}
/>
{/* cells */}
{row.cells.map((cell) => {
const x = cursor
const cy = y + ROW_H * 0.15
const ch = ROW_H * 0.7
const w = cell.width
cursor = x + w + CELL_GAP
const fill = colorByName(cell.fn.name)
const onKeyDown = (e: KeyboardEvent<SVGGElement>) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onSelect(cell.fn)
}
}
const ariaText = t('chart.heatmap.tile.aria', {
module: row.module,
name: cell.fn.name,
value: fmtDuration(cell.fn.tottime),
pct: ((cell.fn.tottime / totalAll) * 100).toFixed(1)
})
// v4 起不再「w >= 60 才显示 label」 —— 改用 charsForWidth 反算
// 14px 字号下能放几个字, 永远不静默吞掉函数名(哪怕只能塞 1-2 字也画,
// 作为视觉锚点比完全空白更有信息量)。
const labelChars = charsForWidth(w - 12, FONT_SIZE.HINT)
const showLabel = labelChars >= 1
return (
<g
key={cell.fn.id}
transform={`translate(${x}, ${cy})`}
role="button"
tabIndex={0}
aria-label={ariaText}
aria-keyshortcuts="Enter Space"
onClick={() => onSelect(cell.fn)}
onKeyDown={onKeyDown}
className="chart-tile cursor-pointer"
>
<rect
data-chart-shape
x={0}
y={0}
width={Math.max(0, w - 1)}
height={ch}
rx={3}
fill={fill}
fillOpacity={cell.isFocus ? FOCUS.fillOpacity : RESTING.fillOpacity}
stroke={cell.isFocus ? FOCUS.stroke : RESTING.stroke}
strokeWidth={cell.isFocus ? FOCUS.strokeWidth : RESTING.strokeWidth}
/>
{showLabel && (
<text
x={6}
y={ch / 2 + 5}
fill={TEXT_ON_DARK}
fontSize={FONT_SIZE.HINT}
fontFamily={FONT_MONO}
pointerEvents="none"
aria-hidden="true"
>
{truncateName(cell.fn.name, labelChars)}
</text>
)}
<title>{ariaText}</title>
</g>
)
})}
{/* 行尾「+N more」:若模块被 TOP_N_PER_MODULE 截了,显示藏了几个。
v4 字号 13 → 14 让 "+N more" 更醒目。 */}
{row.hiddenInRow > 0 && (
<text
x={CANVAS_W - 8}
y={y + ROW_H / 2 + 6}
fill="currentColor"
fontSize={FONT_SIZE.HINT}
fontFamily={FONT_MONO}
textAnchor="end"
pointerEvents="none"
opacity={0.6}
aria-hidden="true"
>
{t('chart.heatmap.more', { count: row.hiddenInRow })}
</text>
)}
</g>
)
})
// ModuleHeatmap 单独引用 —— 文件尾集中导出一个 const,避免被 React Fast Refresh / dead-code 误删
export default memo(ModuleHeatmap)

View File

@@ -0,0 +1,304 @@
import { memo, useMemo, type KeyboardEvent } from 'react'
import type { FlameNode, FunctionNode } from '../../../../shared/analysis'
import { ACCENT_DEEP, TEXT_ON_DARK } from '../../utils/colors'
import { fmtDuration } from '../../utils/format'
import {
FOCUS,
FONT_MONO,
FONT_SIZE,
MAX_SIBLINGS,
RESTING,
charsForWidth,
colorByName,
countTruncatedSiblings,
indexFunctionsByName,
truncateName
} from './chart-utils'
import { useT } from '../../i18n'
/**
* 旭日图 —— Firefox Profiler 等层级径向布局的同款可视化。
*
* 结构:
* - 中心 = 根节点
* - 每向外一圈 = 多一层深度
* - 弧长 = 节点自耗时占总值比例
*
* 深度上限 4 圈(中心 0 不算),比 FlameGraph / Treemap 多一层 —— 旭日图的圈
* 视觉密度天然更低,再多就糊。
*
* 截断:每层 sibling 上限 24沿用 MAX_SIBLINGS超出必显式提示。
*
* v4 升级:
* - SIZE 520 → 600, RING_W 56 → 64 (容纳更大字号 / 更宽弧)
* - 内圈也开始画 label (depth < MAX_DEPTH, 字号小一档) —— 之前完全空, 用户
* 只看外圈感觉不到层级
* - 加图例 (色块 = 函数 colorByName 色, 解释"颜色同函数")
*/
const MAX_DEPTH = 4
const SIZE = 600
const CENTER = SIZE / 2
const RING_W = 64
/** 中心留出内圈空间给直接子节点显示 —— root 是占位概念,视觉上不该吃一整圈。 */
const INNER_R = RING_W * 0.55
interface Props {
flame: FlameNode | null
/** 提供后可点击 slice 跳转源码行(按 name 查 id。 */
functions?: readonly FunctionNode[]
/** 热点点击回调 —— 传整 FunctionNode 让 App.tsx 拿 file/origin 决定路由。 */
onSelect?: (fn: FunctionNode) => void
focusName?: string
}
interface Arc {
name: string
value: number
start: number
end: number
innerR: number
outerR: number
depth: number
node: FlameNode
}
function Sunburst({ flame, functions, onSelect, focusName }: Props) {
const t = useT()
const arcs = useMemo<Arc[] | null>(() => {
if (!flame || flame.value <= 0) return null
return layoutSunburst(flame)
}, [flame])
const fnByName = useMemo(() => indexFunctionsByName(functions), [functions])
const truncatedInfo = useMemo(
() => (flame ? countTruncatedSiblings(flame, MAX_SIBLINGS) : { depths: [], totalHidden: 0 }),
[flame]
)
if (!arcs || !flame) {
return (
<div className="flex h-full items-center justify-center text-sm text-fg-muted">{t('chart.empty')}</div>
)
}
const total = flame.value
const selectFromArc = onSelect && fnByName.size > 0 ? onSelect : undefined
return (
<div className="flex h-full flex-col p-3">
<div className="mb-2 text-sm text-fg-secondary" aria-live="polite">
{t('chart.sunburst.hint')}
</div>
{truncatedInfo.totalHidden > 0 && (
<div className="mb-2 text-sm text-fg-secondary" aria-live="polite">
{truncatedInfo.depths.length === 1
? t('chart.truncated', { shown: MAX_SIBLINGS, hidden: truncatedInfo.totalHidden })
: t('chart.flame.truncatedMulti', {
shown: MAX_SIBLINGS,
hidden: truncatedInfo.totalHidden,
depths: truncatedInfo.depths.length
})}
</div>
)}
{/* 不内嵌 overflow-auto —— 外层右栏自带 overflow-y-auto 接管所有滚动。
SVG 居中显示, 数据再多也直接交给外层滚动。 */}
<div className="flex flex-1 items-center justify-center">
<svg
width="100%"
viewBox={`0 0 ${SIZE} ${SIZE}`}
preserveAspectRatio="xMidYMid meet"
role="img"
aria-label={t('chart.sunburst.aria')}
>
{/* 中心摘要:总耗时 + 函数数 —— root 的 name 一般是 "root" 没价值。
把这两项放中心,用户一眼看到"这堆数据总共花多少 / 多少个函数"。
v4 字号升级: 17 → 19 / 13 → 14。 */}
<text
x={CENTER}
y={CENTER - 6}
fill={TEXT_ON_DARK}
fontSize={FONT_SIZE.NAME}
fontFamily={FONT_MONO}
textAnchor="middle"
pointerEvents="none"
aria-hidden="true"
>
{fmtDuration(total)}
</text>
<text
x={CENTER}
y={CENTER + 18}
fill="currentColor"
fontSize={FONT_SIZE.HINT}
fontFamily={FONT_MONO}
textAnchor="middle"
pointerEvents="none"
opacity={0.6}
aria-hidden="true"
>
{t('chart.sunburst.totalFns', { count: arcCount(arcs) })}
</text>
{arcs.map((arc, i) => {
// 根节点的 arcdepth=0渲染为实心圆盘其他用 colorByName。
const isRoot = arc.depth === 0
const fill = isRoot ? ACCENT_DEEP : colorByName(arc.name)
const isFocus = arc.name === focusName
const fn = fnByName.get(arc.name)
const interactive = !isRoot && !!selectFromArc && !!fn
const path = isRoot ? undefined : arcPath(arc, CENTER, CENTER)
const onKeyDown = (e: KeyboardEvent<SVGGElement>) => {
if (interactive && (e.key === 'Enter' || e.key === ' ')) {
e.preventDefault()
if (fn) selectFromArc(fn)
}
}
const ariaText = t('chart.sunburst.slice.aria', {
name: arc.name,
value: fmtDuration(arc.value),
pct: ((arc.value / total) * 100).toFixed(1)
})
// v4 起两层 label
// - 外圈 (depth === MAX_DEPTH): 主字号 NAME 18, 弧长 > 0.08 rad (≈4.6°) 才显示
// - 内圈 (1 ≤ depth < MAX_DEPTH): HINT 14 字号小一档, 弧长 > 0.18 rad (≈10°)
// 才显示 —— 内圈 ring 宽度只有 64px,大半字会被挤掉
// 内圈让用户"扫表"也能看出"那一圈是哪些函数在瓜分时间"
// 之前完全空,旭日图变成"中间一个饼+外圈一圈标签"的奇异状态。
const arcSpan = arc.end - arc.start
const showLabel = !isRoot && arcSpan > (arc.depth === MAX_DEPTH ? 0.08 : 0.18)
// 弧对应外圈弦长 ~ 2R sin(θ/2),字符宽度按弦长的 70% 估(留 label 角度倾斜余量)
const labelChord = 2 * arc.outerR * Math.sin(arcSpan / 2)
const labelFontSize = arc.depth === MAX_DEPTH ? FONT_SIZE.NAME : FONT_SIZE.HINT
const labelChars = charsForWidth(labelChord * 0.7, labelFontSize)
const labelAngle = (arc.start + arc.end) / 2
const labelR = (arc.innerR + arc.outerR) / 2
const lx = CENTER + labelR * Math.sin(labelAngle)
const ly = CENTER - labelR * Math.cos(labelAngle)
return (
<g
key={`${arc.depth}-${arc.name}-${i}`}
onClick={() => interactive && fn && selectFromArc(fn)}
onKeyDown={onKeyDown}
tabIndex={interactive ? 0 : -1}
role={interactive ? 'button' : undefined}
aria-keyshortcuts={interactive ? 'Enter Space' : undefined}
aria-label={interactive ? ariaText : undefined}
className={interactive ? 'chart-tile cursor-pointer' : ''}
>
{isRoot ? (
<circle cx={CENTER} cy={CENTER} r={INNER_R} fill={fill} />
) : (
<path
data-chart-shape
d={path}
fill={fill}
fillOpacity={isFocus ? FOCUS.fillOpacity : RESTING.fillOpacity}
stroke={isFocus ? FOCUS.stroke : RESTING.stroke}
strokeWidth={isFocus ? FOCUS.strokeWidth : RESTING.strokeWidth}
/>
)}
{showLabel && labelChars >= 1 && (
<text
x={lx}
y={ly + 5}
fill={TEXT_ON_DARK}
fontSize={labelFontSize}
fontFamily={FONT_MONO}
textAnchor="middle"
pointerEvents="none"
aria-hidden="true"
>
{truncateName(arc.name, labelChars)}
</text>
)}
<title>{ariaText}</title>
</g>
)
})}
</svg>
</div>
</div>
)
}
/**
* 角度约定0 在 12 点钟方向,正向 = 顺时针。
* x = cx + r·sin(θ)y = cy - r·cos(θ)。
*/
function layoutSunburst(root: FlameNode): Arc[] {
const arcs: Arc[] = []
const walk = (node: FlameNode, depth: number, start: number, end: number) => {
if (depth > MAX_DEPTH) return
const innerR = depth === 0 ? 0 : depth * RING_W
const outerR = depth === 0 ? RING_W : (depth + 1) * RING_W
arcs.push({
name: node.name,
value: node.value,
start,
end,
innerR,
outerR,
depth,
node
})
if (node.children.length === 0 || depth >= MAX_DEPTH) return
// node.value === 0 但仍有 children —— 多发生在「模块聚合但模块内没有 self-time」之类的
// 边角情况。子节点 span * (c.value / 0) = NaN / Infinity后续 arc 全坏。
// 直接在分配之前 short-circuit父弧已 push子节点保留在结构里但不入 arc。
if (node.value <= 0) return
const cs = node.children.slice(0, MAX_SIBLINGS)
const span = end - start
let cursor = start
for (let i = 0; i < cs.length; i++) {
const c = cs[i]
const childSpan = span * (c.value / node.value)
walk(c, depth + 1, cursor, cursor + childSpan)
cursor += childSpan
}
}
walk(root, 0, 0, 2 * Math.PI)
return arcs
}
/** 中心摘要里的「N 个函数」:去重后的函数名集合数量,
* 不是 arc 数(同一函数多圈递归会重复算)。
* 排除根节点 —— 「N 个函数」要的是函数数,根容器不是函数。 */
function arcCount(arcs: readonly Arc[]): number {
const seen = new Set<string>()
for (const a of arcs) {
if (a.depth === 0) continue
seen.add(a.name)
}
return seen.size
}
/**
* 把 Arc 转成 SVG path「内弧起点 → 外弧起点 → 弧到外弧终点 → 内弧终点 → 弧回内弧起点」。
* 大弧标志span > π 取 1sweep 始终 1顺时针
*/
function arcPath(arc: Arc, cx: number, cy: number): string {
const { start, end, innerR, outerR } = arc
const largeArc = end - start > Math.PI ? 1 : 0
// 外弧端点
const ox0 = cx + outerR * Math.sin(start)
const oy0 = cy - outerR * Math.cos(start)
const ox1 = cx + outerR * Math.sin(end)
const oy1 = cy - outerR * Math.cos(end)
// 内弧端点
const ix1 = cx + innerR * Math.sin(end)
const iy1 = cy - innerR * Math.cos(end)
const ix0 = cx + innerR * Math.sin(start)
const iy0 = cy - innerR * Math.cos(start)
return [
`M ${ox0} ${oy0}`,
`A ${outerR} ${outerR} 0 ${largeArc} 1 ${ox1} ${oy1}`,
`L ${ix1} ${iy1}`,
`A ${innerR} ${innerR} 0 ${largeArc} 0 ${ix0} ${iy0}`,
'Z'
].join(' ')
}
export default memo(Sunburst)

View File

@@ -0,0 +1,230 @@
/**
* 耗时可视化切换器:在 6 种图表之间切换同一份 result。
*
* 设计选择 —— 不用 `role="tablist"`:
* - 仓库目前没有 tablist 角色先例,引入需要同步加方向键 roving focus 和 tabpanel 联动;
* - 顶部 TopBar 的 `aria-pressed` 切换模式(终端按钮 line 70-77)已是审计过的成熟样式;
* - 这里 6 个按钮逻辑等价于「6 个互斥的开关」,本质是 radio 而非 tab —— 沿用 aria-pressed
* 配 role="group" + aria-label,键盘焦点不强制单焦点(用户可任意进出),更符合
* 「查看不同视图」的心智模型。
*
* 排序逻辑(从最常用到最专业):
* - bar: 「这函数慢在哪」的首答 —— 排名直读,符合「paste code, see what's slow」
* - flame: 主流 profiler(Chrome DevTools / Speedscope)的默认视图,适合下钻层级
* - treemap:层级占比的面积视图,同 flame 数据换个看图角度
* - sunburst:Firefox Profiler / Pyroscope 风,层级径向,信息密度更高
* - cumulative: 自耗时易误导(函数本身快但被调用方慢)时,看 cumtime 排名
* - heatmap: 仅在 scope=all + 多模块时才有信息密度,scope=user 时退化为空态
*
* 默认 bar —— 用户粘贴代码运行分析后第一个问题就是「哪个函数慢」,这正是柱状图的强项。
* 火焰图适合二轮 drill-down,不应该是默认首屏。
*
* 键盘支持:
* - Tab 进入 button 组后,← / → 在 6 个按钮之间循环切换并把焦点移到对应按钮上(roving
* focus)。Home/End 跳到首/尾。和 TopBar 的 tablist 不同 —— 这里是单焦点在按钮间移动
* 而非全局一个焦点,符合「切换按钮组」的常见模式。
* - 进入图表内部后,图表自身的键盘交互(Enter/Space 选中)仍然由图表自己管,switcher 不抢。
*
* 数据来源:result.flame(树状)和 result.functions(扁平)。每个图表自己挑需要的
* 输入;fnId 由 name 查 functions[] 拿 —— FlameNode 没 id。
*
* v4 字号升级:按钮 text-xs → text-sm, padding 加大 (py-1.5 → py-2), 让 6 个
* 切换按钮在屏幕上一眼看清(之前 12px uppercase 偏小, 长时间看眼睛累)。
*/
import { memo, useEffect, useRef, useState, type KeyboardEvent } from 'react'
import type { AnalysisResult, FunctionNode } from '../../../../shared/analysis'
import BarChart from './BarChart'
import CumulativeBar from './CumulativeBar'
import FlameGraph from './FlameGraph'
import ModuleHeatmap from './ModuleHeatmap'
import Sunburst from './Sunburst'
import Treemap from './Treemap'
import { BarIcon, CumulativeIcon, FlameIcon, HeatmapIcon, SunburstIcon, TreemapIcon } from '../icons'
import { useT } from '../../i18n'
type ChartType = 'flame' | 'bar' | 'treemap' | 'sunburst' | 'cumulative' | 'heatmap'
interface Props {
result: AnalysisResult
selectedFuncId: string | undefined
/**
* 热点点击回调。传 FunctionNode 整对象,让 App.tsx 拿 file / origin 决定
* 是 user tab 高亮还是开外部文件 tab。
*/
onSelectHotspot: (fn: FunctionNode) => void
}
/** 切换顺序:bar → flame → treemap → sunburst → cumulative → heatmap(常用 → 专业)。 */
const ORDER: ChartType[] = ['bar', 'flame', 'treemap', 'sunburst', 'cumulative', 'heatmap']
function TimeChartSwitcher({ result, selectedFuncId, onSelectHotspot }: Props) {
const t = useT()
const [chartType, setChartType] = useState<ChartType>('bar')
// 6 个按钮的 ref 集合,键盘 ←/→ 在 ref 之间循环跳转。
const btnRefs = useRef<Map<ChartType, HTMLButtonElement>>(new Map())
// 新结果时复位到柱状图:避免上次实验选了一个小数据集的视图,新结果是大数据集时
// 上次的旭日图视角会误导。和 FlameGraph 自身的 useEffect(setFocus(null), [flame]) 同款。
useEffect(() => {
setChartType('bar')
}, [result])
const focusName = result.functions.find((f) => f.id === selectedFuncId)?.name
/** 移动焦点到指定 type 的按钮,并把 chartType 也切过去(键盘切和点击效果一致) */
const focusType = (type: ChartType): void => {
setChartType(type)
btnRefs.current.get(type)?.focus()
}
const onSwitcherKeyDown = (e: KeyboardEvent<HTMLDivElement>): void => {
const i = ORDER.indexOf(chartType)
switch (e.key) {
case 'ArrowLeft':
e.preventDefault()
focusType(ORDER[(i - 1 + ORDER.length) % ORDER.length] ?? 'bar')
break
case 'ArrowRight':
e.preventDefault()
focusType(ORDER[(i + 1) % ORDER.length] ?? 'bar')
break
case 'Home':
e.preventDefault()
focusType(ORDER[0] ?? 'bar')
break
case 'End':
e.preventDefault()
focusType(ORDER[ORDER.length - 1] ?? 'bar')
break
default:
break
}
}
return (
<div className="flex h-full flex-col">
{/* role="group" + onKeyDown 是有意为之的 roving-focus 容器(WAI-ARIA 切换按钮组的
标准键盘模式):ArrowLeft/ArrowRight/Home/End 在 6 个按钮间循环跳转。
eslint-plugin-jsx-a11y 的 no-noninteractive-element-interactions 规则把
group 视为非交互元素,但这里它本身承载键盘交互,需要豁免。 */}
{/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */}
<div
role="group"
aria-label={t('chart.switcher.label')}
onKeyDown={onSwitcherKeyDown}
// flex-wrap(不内嵌 overflow-x-auto):右侧整列自带 overflow-y-auto 滚轮,
// 内部再横滑就是双层翻轮 + 一窄就要画横滑条, 体验差。
// 窄屏让按钮自然换行(2 行内能容下 6 个)即可 —— 切图按钮组高度变化小, 图表区 h-[540px] 还能容下。
// v4:padding py-2.5 → py-3 (上下多 2px), 配合按钮 py-2 让 6 个切换器更易点。
className="flex shrink-0 flex-wrap items-center gap-1.5 border-b border-border bg-bg px-3 py-3"
>
{ORDER.map((type) => (
<ChartTypeButton
key={type}
type={type}
pressed={chartType === type}
onSelect={() => setChartType(type)}
registerRef={(el) => {
if (el) btnRefs.current.set(type, el)
else btnRefs.current.delete(type)
}}
/>
))}
</div>
<div className="min-h-0 flex-1">
{chartType === 'flame' && <FlameGraph flame={result.flame} focusName={focusName} />}
{chartType === 'bar' && (
<BarChart functions={result.functions} onSelect={onSelectHotspot} focusName={focusName} />
)}
{chartType === 'cumulative' && (
<CumulativeBar functions={result.functions} onSelect={onSelectHotspot} focusName={focusName} />
)}
{chartType === 'treemap' && (
<Treemap
flame={result.flame}
functions={result.functions}
onSelect={onSelectHotspot}
focusName={focusName}
/>
)}
{chartType === 'sunburst' && (
<Sunburst
flame={result.flame}
functions={result.functions}
onSelect={onSelectHotspot}
focusName={focusName}
/>
)}
{chartType === 'heatmap' && (
<ModuleHeatmap functions={result.functions} onSelect={onSelectHotspot} focusName={focusName} />
)}
</div>
</div>
)
}
/** 单个切换按钮:和 TopBar 终端按钮同款 `aria-pressed` 模式。
* v4 字号 text-xs → text-sm,padding 加大,提升点击热区与可读性。 */
function ChartTypeButton({
type,
pressed,
onSelect,
registerRef
}: {
type: ChartType
pressed: boolean
onSelect: () => void
registerRef: (el: HTMLButtonElement | null) => void
}) {
const t = useT()
const icon = ICON[type]
return (
<button
ref={registerRef}
type="button"
onClick={onSelect}
aria-pressed={pressed}
aria-label={t(LABEL_KEY[type])}
title={t(LABEL_KEY[type])}
className={
// 与 TopBar terminal 按钮一致:pressed 用 bg-accent-soft + text-accent 高亮;
// 不上 bg-accent 是因为按钮小,纯色填充视觉过重。
// shrink-0:父容器 flex-nowrap + overflow-x-auto,按钮不能被压缩到消失。
// v4:text-sm (14px), py-2 px-3.5 — 增大点击区域/提升可读性。
pressed
? 'inline-flex shrink-0 items-center gap-2 rounded bg-accent-soft px-3.5 py-2 font-mono text-sm uppercase tracking-wider text-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-accent'
: 'inline-flex shrink-0 items-center gap-2 rounded px-3.5 py-2 font-mono text-sm uppercase tracking-wider text-fg-muted hover:bg-surface-1 hover:text-fg-secondary focus:outline-none focus-visible:ring-2 focus-visible:ring-accent'
}
>
{icon}
<span>{t(LABEL_KEY[type])}</span>
</button>
)
}
const LABEL_KEY: Record<
ChartType,
| 'chart.type.flame'
| 'chart.type.bar'
| 'chart.type.treemap'
| 'chart.type.sunburst'
| 'chart.type.cumulative'
| 'chart.type.heatmap'
> = {
flame: 'chart.type.flame',
bar: 'chart.type.bar',
treemap: 'chart.type.treemap',
sunburst: 'chart.type.sunburst',
cumulative: 'chart.type.cumulative',
heatmap: 'chart.type.heatmap'
}
const ICON: Record<ChartType, JSX.Element> = {
flame: <FlameIcon size={16} />,
bar: <BarIcon size={16} />,
cumulative: <CumulativeIcon size={16} />,
treemap: <TreemapIcon size={16} />,
sunburst: <SunburstIcon size={16} />,
heatmap: <HeatmapIcon size={16} />
}
export default memo(TimeChartSwitcher)

View File

@@ -0,0 +1,329 @@
import { memo, useMemo, type KeyboardEvent } from 'react'
import type { FlameNode, FunctionNode } from '../../../../shared/analysis'
import { TEXT_ON_DARK } from '../../utils/colors'
import { fmtDuration } from '../../utils/format'
import {
FOCUS,
FONT_MONO,
FONT_SIZE,
MAX_SIBLINGS,
RESTING,
charsForWidth,
colorByName,
countTruncatedSiblings,
indexFunctionsByName,
truncateName
} from './chart-utils'
import { useT } from '../../i18n'
/**
* 矩形树图squarified treemap—— Chrome DevTools / Speedscope 的标准风格。
*
* 为什么不用 slice-and-dice交替横竖切会让大半和小条拼在一起看起来很乱
* squarify 通过保持每个矩形宽高比接近 1得到「看起来是方块」的版面。
* 实现参考 Bruls et al. (2000);手写版避免引入 d3-hierarchy。
*
* 层级:递归 squarify。每个非叶子节点占一个矩形含顶部标签带
* 子节点 squarify 进剩余区域。深度限 3和 FlameGraph 一致)。
*
* 截断:每层 sibling 上限 24沿用 MAX_SIBLINGS超出必显式提示。
*
* v4 升级:
* - HEADER_H 28 → 36 容纳更大字号
* - fontSize 15 → 16-18
* - 窄 tile 也显示标签charsForWidth 兜底)
* - 加图例(解释 area = 占比 / colorByName 同色)
*/
const MAX_DEPTH = 3
const CANVAS_W = 920
// 920×920 的方形画布会让根节点 squarify 出"宽 920 × 高 920"的极端长方形,
// 顶层 child tile 横向极宽;改成 920×580(≈16:10)后顶层更接近正方形,信息密度也更接近
// ResultsPanel 540px 容器高度。viewBox 配合 preserveAspectRatio="xMidYMid meet" 自动
// 缩放,容器窄于 920 时按宽度等比缩,不会越界。
const CANVAS_H = 580
const HEADER_H = 36
interface Props {
flame: FlameNode | null
/** 提供后可点击 tile 跳转源码行(按 name 查 id。BarChart 同步走这条路径。 */
functions?: readonly FunctionNode[]
/** 热点点击回调 —— 传整 FunctionNode 让 App.tsx 拿 file/origin 决定路由。 */
onSelect?: (fn: FunctionNode) => void
focusName?: string
}
interface Rect {
x: number
y: number
width: number
height: number
}
interface Tile {
name: string
value: number
rect: Rect
depth: number
isHeader: boolean
node: FlameNode
}
function Treemap({ flame, functions, onSelect, focusName }: Props) {
const t = useT()
const tiles = useMemo<Tile[] | null>(() => {
if (!flame || flame.value <= 0 || flame.children.length === 0) return null
return layoutHierarchical(flame, { x: 0, y: 0, width: CANVAS_W, height: CANVAS_H })
}, [flame])
// name → FunctionNode 查表:选中 / 焦点同步
const fnByName = useMemo(() => indexFunctionsByName(functions), [functions])
const truncatedInfo = useMemo(
() => (flame ? countTruncatedSiblings(flame, MAX_SIBLINGS) : { depths: [], totalHidden: 0 }),
[flame]
)
if (!tiles || !flame) {
return (
<div className="flex h-full items-center justify-center text-sm text-fg-muted">{t('chart.empty')}</div>
)
}
const maxY = tiles.reduce((m, it) => Math.max(m, it.rect.y + it.rect.height), 0)
const total = flame.value
const selectFromTile = onSelect && fnByName.size > 0 ? onSelect : undefined
return (
<div className="flex h-full flex-col p-3">
<div className="mb-2 text-sm text-fg-secondary" aria-live="polite">
{t('chart.treemap.hint')}
</div>
{/* 截断提示v4 起按深度聚合(同 FlameGraph—— 深层被截不再静默。 */}
{truncatedInfo.totalHidden > 0 && (
<div className="mb-2 text-sm text-fg-secondary" aria-live="polite">
{truncatedInfo.depths.length === 1
? t('chart.truncated', { shown: MAX_SIBLINGS, hidden: truncatedInfo.totalHidden })
: t('chart.flame.truncatedMulti', {
shown: MAX_SIBLINGS,
hidden: truncatedInfo.totalHidden,
depths: truncatedInfo.depths.length
})}
</div>
)}
{/* 不内嵌 overflow-auto —— 外层右栏自带 overflow-y-auto 接管所有滚动。
SVG viewBox + preserveAspectRatio 自适应尺寸, 深层调用栈再多也直接交给外层滚动。 */}
<div className="flex-1">
<svg
width="100%"
viewBox={`0 0 ${CANVAS_W} ${Math.max(1, maxY)}`}
preserveAspectRatio="xMinYMin meet"
role="img"
aria-label={t('chart.treemap.aria')}
>
{tiles.map((it, i) => {
// header tile 是"目录"色 (var(--surface-2)),不是数据;数据 tile 用 colorByName。
const fill = it.isHeader ? 'var(--surface-2)' : colorByName(it.name)
const isFocus = it.name === focusName
// v4 起不再「width > 80 && height > 32 才显示」 —— 改用 charsForWidth
// 按可用宽度反算能放几个字。窄 tile 也挤得下 1-2 字符,完全是视觉锚点。
// 算 label 高度: header 用 HEADER_H,数据 tile 用 rect 高度;两条都给 6px padding。
const labelH = it.isHeader ? HEADER_H : it.rect.height
const labelChars = charsForWidth(it.rect.width - 12, FONT_SIZE.BODY)
const showLabel = labelChars >= 1 && (it.isHeader || it.rect.height >= 18)
const fn = fnByName.get(it.name)
const interactive = !!selectFromTile && !!fn && !it.isHeader
const onKeyDown = (e: KeyboardEvent<SVGGElement>) => {
if (interactive && (e.key === 'Enter' || e.key === ' ')) {
e.preventDefault()
if (fn) selectFromTile(fn)
}
}
const ariaText = t('chart.treemap.tile.aria', {
name: it.name,
value: fmtDuration(it.value),
pct: ((it.value / total) * 100).toFixed(1)
})
return (
<g
key={`${it.depth}-${it.name}-${i}`}
transform={`translate(${it.rect.x}, ${it.rect.y})`}
onClick={() => interactive && fn && selectFromTile(fn)}
onKeyDown={onKeyDown}
tabIndex={interactive ? 0 : -1}
role={interactive ? 'button' : undefined}
aria-keyshortcuts={interactive ? 'Enter Space' : undefined}
aria-label={interactive ? ariaText : undefined}
className={interactive ? 'chart-tile cursor-pointer' : ''}
>
<rect
data-chart-shape={it.isHeader ? undefined : true}
x={0}
y={0}
width={Math.max(0, it.rect.width - 1)}
height={Math.max(0, it.rect.height - 1)}
rx={2}
fill={fill}
fillOpacity={it.isHeader ? 1 : isFocus ? FOCUS.fillOpacity : RESTING.fillOpacity}
stroke={isFocus ? FOCUS.stroke : RESTING.stroke}
strokeWidth={isFocus ? FOCUS.strokeWidth : RESTING.strokeWidth}
/>
{showLabel && (
<text
x={8}
y={labelH / 2 + 6}
fill={it.isHeader ? 'currentColor' : TEXT_ON_DARK}
fontSize={it.isHeader ? FONT_SIZE.HINT : FONT_SIZE.BODY}
fontFamily={FONT_MONO}
pointerEvents="none"
aria-hidden="true"
>
{truncateName(it.name, labelChars)}
</text>
)}
<title>{ariaText}</title>
</g>
)
})}
</svg>
</div>
</div>
)
}
/** squarify 输入项value + 任意附加字段。返回时附 rect 字段。 */
type SquarifyItem<T> = T & { value: number; rect: Rect }
/**
* 把节点列表按 value 降序排好的前提下 squarify 进给定矩形。
* 算法核心Bruls et al. 2000维护一行 row新行宽高比不更差就并入否则落行。
*/
function squarify<T>(items: readonly (T & { value: number })[], container: Rect): SquarifyItem<T>[] {
if (items.length === 0 || container.width <= 0 || container.height <= 0) return []
// 按 value 降序;保留每个元素在原数组中的 idx 以便回写
const indexed = items.map((it, i) => ({ it, idx: i })).sort((a, b) => b.it.value - a.it.value)
const total = indexed.reduce((s, x) => s + x.it.value, 0)
if (total <= 0) return []
const result: SquarifyItem<T>[] = items.map((it) => ({ ...it, rect: { x: 0, y: 0, width: 0, height: 0 } }))
let rem: Rect = { ...container }
let row: typeof indexed = []
let best = Infinity
const rowSum = (r: typeof row) => r.reduce((s, x) => s + x.it.value, 0)
const worst = (r: typeof row, shortSide: number) => {
if (r.length === 0) return Infinity
const s = rowSum(r)
if (s <= 0) return Infinity
let mx = -Infinity
let mn = Infinity
for (const x of r) {
if (x.it.value > mx) mx = x.it.value
if (x.it.value < mn) mn = x.it.value
}
const s2 = s * s
const w2 = shortSide * shortSide
return Math.max((w2 * mx) / s2, s2 / (w2 * mn))
}
const placeRow = (r: typeof row) => {
const s = rowSum(r)
if (s <= 0) return
const horizontal = rem.width >= rem.height
const rowSize = horizontal ? rem.height : rem.width
const totalSize = horizontal ? rem.width : rem.height
let offset = horizontal ? rem.x : rem.y
for (const x of r) {
const len = (x.it.value / s) * totalSize
result[x.idx].rect = horizontal
? { x: offset, y: rem.y, width: len, height: rowSize }
: { x: rem.x, y: offset, width: rowSize, height: len }
offset += len
}
rem = horizontal
? { x: rem.x, y: rem.y + rowSize, width: rem.width, height: rem.height - rowSize }
: { x: rem.x + rowSize, y: rem.y, width: rem.width - rowSize, height: rem.height }
}
let pending = indexed
while (pending.length > 0) {
const next = pending[0]
const newRow = [...row, next]
const shortSide = Math.min(rem.width, rem.height)
const w = worst(newRow, shortSide)
if (row.length === 0 || w <= best) {
row = newRow
best = w
pending = pending.slice(1)
} else {
placeRow(row)
row = []
best = Infinity
}
}
if (row.length > 0) placeRow(row)
return result
}
/**
* 递归布局:每个非叶子节点先 squarify 子节点再把自己的「header」标签带放上去。
* 根节点不画 header占满画布其他节点 depth ≥ 1 且 rect 高 ≥ HEADER_H + 8 时才画。
*/
function layoutHierarchical(root: FlameNode, canvas: Rect): Tile[] {
const tiles: Tile[] = []
const walk = (node: FlameNode, rect: Rect, depth: number) => {
if (depth >= MAX_DEPTH || node.children.length === 0) {
tiles.push({
name: node.name,
value: node.value,
rect,
depth,
isHeader: false,
node
})
return
}
const childrenRect: Rect =
depth === 0 ? rect : { ...rect, y: rect.y + HEADER_H, height: Math.max(0, rect.height - HEADER_H) }
if (depth > 0 && rect.height >= HEADER_H + 8) {
tiles.push({
name: node.name,
value: node.value,
rect: { x: rect.x, y: rect.y, width: rect.width, height: HEADER_H },
depth,
isHeader: true,
node
})
}
const cs = node.children.slice(0, MAX_SIBLINGS)
if (childrenRect.width <= 0 || childrenRect.height <= 0) return
const items = cs.map((c, i) => ({ ...c, _idx: i }))
const childTiles = squarify(items, childrenRect)
for (const ct of childTiles) {
tiles.push({
name: ct.name,
value: ct.value,
rect: ct.rect,
depth: depth + 1,
isHeader: false,
node: ct
})
if (ct.children.length > 0 && depth + 1 < MAX_DEPTH) {
walk(ct, ct.rect, depth + 1)
}
}
}
walk(root, canvas, 0)
return tiles
}
export default memo(Treemap)

View File

@@ -0,0 +1,72 @@
import { fireEvent, render, screen } from '@testing-library/react'
import BarChart from '../BarChart'
import golden from '../../../fixtures/golden.json'
import type { AnalysisResult, FunctionNode } from '../../../../../shared/analysis'
import { wrap } from '../../../i18n/test-utils'
const result = golden as unknown as AnalysisResult
describe('BarChart', () => {
it('renders one rect per visible row (≤ TOP_N)', () => {
const { container } = render(wrap(<BarChart functions={result.functions} onSelect={() => {}} />))
// 12 行:每行一个背景轨 + 一个数据条 = 24 rect
// 取 TOP_N=12 这个不变量就行,不绑死 rect 数(防御未来调 N
const rects = container.querySelectorAll('rect')
expect(rects.length).toBeGreaterThan(0)
// 至少包含 top1 函数名golden 首条是 leaf
expect(screen.getByText('leaf')).toBeInTheDocument()
})
it('shows placeholder when no functions', () => {
render(wrap(<BarChart functions={[]} onSelect={() => {}} />))
expect(screen.getByText(/暂无耗时数据/)).toBeInTheDocument()
})
it('truncation notice when functions.length > TOP_N', () => {
// 造 20 个函数TOP_N=12应出现「仅显示前 12 个,另有 8 个未显示」
// —— 复用 chart.truncated 文案(和 FlameGraph / Treemap / Sunburst 一致)
const fns: FunctionNode[] = Array.from({ length: 20 }, (_, i) => ({
id: `f${i}`,
file: 'a.py',
line: i + 1,
name: `fn${i}`,
module: '<user>',
cumtime: 1,
tottime: 1,
ncalls: 1,
percallTot: 1
}))
render(wrap(<BarChart functions={fns} onSelect={() => {}} />))
expect(screen.getByText(/仅显示前 12 个,另有 8 个未显示/)).toBeInTheDocument()
})
it('no truncation notice when within TOP_N', () => {
const fns: FunctionNode[] = Array.from({ length: 5 }, (_, i) => ({
id: `f${i}`,
file: 'a.py',
line: i + 1,
name: `fn${i}`,
module: '<user>',
cumtime: 1,
tottime: 1,
ncalls: 1,
percallTot: 1
}))
render(wrap(<BarChart functions={fns} onSelect={() => {}} />))
expect(screen.queryByText(/另有 \d+ 个未显示/)).not.toBeInTheDocument()
})
it('clicking a bar fires onSelect with the matching fn', () => {
const onSelect = vi.fn<(fn: { id: string; line: number }) => void>()
render(wrap(<BarChart functions={result.functions} onSelect={onSelect} />))
// 第一行是 leafrole=button 的 aria-label 包含「自耗时」
const btn = screen.getByRole('button', { name: /leaf/ })
fireEvent.click(btn)
expect(onSelect).toHaveBeenCalledTimes(1)
const firstCall = onSelect.mock.calls[0]
if (!firstCall) throw new Error('expected onSelect to be called')
const [fn] = firstCall
expect(fn.id).toBe(result.functions[0].id)
expect(fn.line).toBe(result.functions[0].line)
})
})

View File

@@ -0,0 +1,114 @@
import { fireEvent, render, screen } from '@testing-library/react'
import CumulativeBar from '../CumulativeBar'
import golden from '../../../fixtures/golden.json'
import type { AnalysisResult, FunctionNode } from '../../../../../shared/analysis'
import { wrap } from '../../../i18n/test-utils'
const result = golden as unknown as AnalysisResult
describe('CumulativeBar', () => {
it('renders one cumulative row per visible function (≤ TOP_N=12)', () => {
const { container } = render(wrap(<CumulativeBar functions={result.functions} onSelect={() => {}} />))
// 5 个函数(都 cumtime>0):每行 2 段 rect(self+callee)= 10 rect,加 5 个背景轨 = 15 rect
const rects = container.querySelectorAll('rect')
expect(rects.length).toBeGreaterThan(0)
// golden cumtime 最大的应该是 leaf(累计时间最长)
expect(screen.getByText('leaf')).toBeInTheDocument()
})
it('shows placeholder when no functions', () => {
render(wrap(<CumulativeBar functions={[]} onSelect={() => {}} />))
expect(screen.getByText(/暂无耗时数据/)).toBeInTheDocument()
})
it('truncation notice when functions.length > TOP_N', () => {
const fns: FunctionNode[] = Array.from({ length: 20 }, (_, i) => ({
id: `f${i}`,
file: 'a.py',
line: i + 1,
name: `fn${i}`,
module: '<user>',
cumtime: 1,
tottime: 1,
ncalls: 1,
percallTot: 1
}))
render(wrap(<CumulativeBar functions={fns} onSelect={() => {}} />))
expect(screen.getByText(/仅显示前 12 个,另有 8 个未显示/)).toBeInTheDocument()
})
it('clicking a bar fires onSelect with the matching fn', () => {
const onSelect = vi.fn<(fn: { id: string; line: number }) => void>()
render(wrap(<CumulativeBar functions={result.functions} onSelect={onSelect} />))
// 第一行是 cumtime 最大的 leaf
const btn = screen.getByRole('button', { name: /leaf/ })
fireEvent.click(btn)
expect(onSelect).toHaveBeenCalledTimes(1)
const firstCall = onSelect.mock.calls[0]
if (!firstCall) throw new Error('expected onSelect to be called')
const [fn] = firstCall
expect(fn.id).toBe(result.functions[0].id)
expect(fn.line).toBe(result.functions[0].line)
})
it('cumtime=0 的函数被剔除(不会占用 row)', () => {
// 3 个函数:一个有 cumtime,两个 cumtime=0
// 期望:只渲染 1 行(那个有 cumtime 的)
const fns: FunctionNode[] = [
{
id: 'a',
file: 'a.py',
line: 1,
name: 'has_cum',
module: '<user>',
cumtime: 1,
tottime: 1,
ncalls: 1,
percallTot: 1
},
{
id: 'b',
file: 'a.py',
line: 2,
name: 'no_cum_1',
module: '<user>',
cumtime: 0,
tottime: 0,
ncalls: 1,
percallTot: 0
},
{
id: 'c',
file: 'a.py',
line: 3,
name: 'no_cum_2',
module: '<user>',
cumtime: 0,
tottime: 0,
ncalls: 1,
percallTot: 0
}
]
render(wrap(<CumulativeBar functions={fns} onSelect={() => {}} />))
expect(screen.getByRole('button', { name: /has_cum/ })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: /no_cum_1/ })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: /no_cum_2/ })).not.toBeInTheDocument()
})
it('keyboard Enter on bar 触发 onSelect', () => {
const onSelect = vi.fn<(fn: { id: string; line: number }) => void>()
render(wrap(<CumulativeBar functions={result.functions} onSelect={onSelect} />))
const btn = screen.getByRole('button', { name: /leaf/ })
fireEvent.keyDown(btn, { key: 'Enter' })
expect(onSelect).toHaveBeenCalledTimes(1)
})
it('右对齐文本带 "% self" 标签(避免和 bar 长度的"总占比"读混)', () => {
const { container } = render(wrap(<CumulativeBar functions={result.functions} onSelect={() => {}} />))
// 右对齐的 <text textAnchor="end"> 节点要含 "% self ·"
const texts = Array.from(container.querySelectorAll('text[text-anchor="end"]'))
.map((t) => t.textContent ?? '')
.join(' ')
expect(texts).toMatch(/\d+%\s*self\s*·/)
})
})

View File

@@ -0,0 +1,69 @@
import { render, screen } from '@testing-library/react'
import FlameGraph from '../FlameGraph'
import golden from '../../../fixtures/golden.json'
import type { AnalysisResult, FlameNode } from '../../../../../shared/analysis'
import { wrap } from '../../../i18n/test-utils'
const result = golden as unknown as AnalysisResult
describe('FlameGraph', () => {
it('renders root + at least one child rect', () => {
const { container } = render(wrap(<FlameGraph flame={result.flame} />))
const rects = container.querySelectorAll('rect')
expect(rects.length).toBeGreaterThanOrEqual(2)
})
it('shows placeholder when flame is null', () => {
render(wrap(<FlameGraph flame={null} />))
expect(screen.getByText(/无可显示的函数/)).toBeInTheDocument()
})
it('同名兄弟节点的 children 各自正确显示(修复 byDepth Map 撞名 bug', () => {
// root -> [a, b]a 和 b 同名 "x"a 的子节点是 leaf1b 的子节点是 leaf2。
// 修复前 byDepth 用 name 做 keya 和 b 共享同一个节点,导致 row2 渲染两次同一个 children
const flame: FlameNode = {
name: 'root',
value: 6,
children: [
{ name: 'x', value: 3, children: [{ name: 'leaf1', value: 3, children: [] }] },
{ name: 'x', value: 3, children: [{ name: 'leaf2', value: 3, children: [] }] }
]
}
const { container } = render(wrap(<FlameGraph flame={flame} />))
// row2 应该同时包含 leaf1 和 leaf2而不是重复渲染同一个 leaf
const texts = Array.from(container.querySelectorAll('text'))
.map((t) => t.textContent)
.filter(Boolean)
expect(texts).toContain('leaf1')
expect(texts).toContain('leaf2')
})
it('兄弟节点超过 MAX_SIBLINGS 时显示截断提示(和 HotspotTable 一致 — 绝不静默截断)', () => {
// MAX_SIBLINGS = 24造 30 个兄弟验证"仅显示前 24 个,另有 6 个未显示"
const flame: FlameNode = {
name: 'root',
value: 30,
children: Array.from({ length: 30 }, (_, i) => ({
name: `n${i}`,
value: 1,
children: []
}))
}
render(wrap(<FlameGraph flame={flame} />))
expect(screen.getByText(/仅显示前 24 个,另有 6 个未显示/)).toBeInTheDocument()
})
it('兄弟节点未超上限时不显示截断提示', () => {
const flame: FlameNode = {
name: 'root',
value: 3,
children: [
{ name: 'a', value: 1, children: [] },
{ name: 'b', value: 1, children: [] },
{ name: 'c', value: 1, children: [] }
]
}
render(wrap(<FlameGraph flame={flame} />))
expect(screen.queryByText(/另有 \d+ 个未显示/)).not.toBeInTheDocument()
})
})

View File

@@ -0,0 +1,289 @@
import { fireEvent, render, screen } from '@testing-library/react'
import HotspotTable from '../HotspotTable'
import type { FunctionNode } from '../../../../../shared/analysis'
import { wrap } from '../../../i18n/test-utils'
/** 造 n 个函数tottime 递减,便于断言排序方向。 */
function makeFns(n: number): FunctionNode[] {
return Array.from({ length: n }, (_, i) => ({
id: `f.py:${i + 1}:fn${i}`,
file: 'f.py',
line: i + 1,
name: `fn${i}`,
module: '<user>',
// fn0 最慢fnN-1 最快
tottime: (n - i) / 1000,
cumtime: (n - i) / 500,
ncalls: n - i,
percallTot: 1 / 1000
}))
}
const noop = (): void => {}
// aria-label 的实际格式「fnNorigin第 X 行 · 自耗时 ... · 调用 N 次」
// 中间夹的 origin 段v3 起)用 `[^]+?` 兜底,允许带或不带 origin。
const rowBtn = (name: string): HTMLElement =>
screen.getByRole('button', { name: new RegExp(`^${name}[^]+? 第 \\d+ 行`) })
const allRows = (): HTMLElement[] => screen.getAllByRole('button', { name: /^fn\d+[^]+? 第 \d+ 行/ })
describe('HotspotTable', () => {
it('无数据时给出占位文案', () => {
render(wrap(<HotspotTable functions={[]} onSelect={noop} />))
expect(screen.getByText(/没有可显示的函数/)).toBeInTheDocument()
})
it('默认按自耗时降序排列aria-sort=descending最慢的 fn0 在最上', () => {
render(wrap(<HotspotTable functions={makeFns(5)} onSelect={noop} />))
const totHeader = screen.getByRole('columnheader', { name: /自耗时/ })
expect(totHeader).toHaveAttribute('aria-sort', 'descending')
// 数据是从大到小fn0 最慢)→ 降序首行就是 fn0
expect(allRows()[0].getAttribute('aria-label')).toContain('fn0')
})
it('点击表头切换排序方向aria-sort 同步', () => {
render(wrap(<HotspotTable functions={makeFns(5)} onSelect={noop} />))
const cumHeader = (): HTMLElement => screen.getByRole('columnheader', { name: /累计耗时/ })
expect(cumHeader()).toHaveAttribute('aria-sort', 'none')
fireEvent.click(screen.getByRole('button', { name: /按 累计耗时 排序/ }))
// 切到新列时是降序
expect(cumHeader()).toHaveAttribute('aria-sort', 'descending')
fireEvent.click(screen.getByRole('button', { name: /按 累计耗时 排序/ }))
expect(cumHeader()).toHaveAttribute('aria-sort', 'ascending')
// 升序后第一行应是 cumtime 最小的 fn4
expect(allRows()[0].getAttribute('aria-label')).toContain('fn4')
})
it('选中行标记 aria-pressed', () => {
const fns = makeFns(3)
render(wrap(<HotspotTable functions={fns} onSelect={noop} selectedId={fns[1].id} />))
expect(rowBtn('fn1')).toHaveAttribute('aria-pressed', 'true')
})
it('点击行回调 (FunctionNode)', () => {
const onSelect = vi.fn()
const fns = makeFns(3)
render(wrap(<HotspotTable functions={fns} onSelect={onSelect} />))
fireEvent.click(rowBtn('fn2'))
// 新签名:传整 FunctionNode让 App.tsx 拿 file + origin 路由)
expect(onSelect).toHaveBeenCalledWith(fns[2])
})
describe('行数截断(避免几百个函数一次性渲染)', () => {
it('不超过上限时不显示截断提示', () => {
render(wrap(<HotspotTable functions={makeFns(200)} onSelect={noop} />))
expect(allRows()).toHaveLength(200)
expect(screen.queryByText(/另有 \d+ 行未显示/)).not.toBeInTheDocument()
})
it('超过上限时只渲染前 200 行,并说明藏了多少', () => {
render(wrap(<HotspotTable functions={makeFns(250)} onSelect={noop} />))
expect(allRows()).toHaveLength(200)
// 必须明确告知藏了几个,不能静默截断
expect(screen.getByText(/另有 50 行未显示/)).toBeInTheDocument()
})
it('「显示全部」展开后渲染所有行,并能收回', () => {
render(wrap(<HotspotTable functions={makeFns(250)} onSelect={noop} />))
fireEvent.click(screen.getByRole('button', { name: /展开全部 250 行/ }))
expect(allRows()).toHaveLength(250)
fireEvent.click(screen.getByRole('button', { name: /收起至前 200 行/ }))
expect(allRows()).toHaveLength(200)
})
it('截断发生在排序之后:换列排序时前 200 名跟着变', () => {
render(wrap(<HotspotTable functions={makeFns(250)} onSelect={noop} />))
// 默认自耗时降序 → 最慢的 fn0 在,最快的 fn249 被截掉
expect(rowBtn('fn0')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: /^fn249[^]+? 第 \d+ 行/ })).not.toBeInTheDocument()
// 切到 Cumtime 升序 → 最快的 fn249 进榜,最慢的 fn0 被截掉
fireEvent.click(screen.getByRole('button', { name: /按 累计耗时 排序/ }))
fireEvent.click(screen.getByRole('button', { name: /按 累计耗时 排序/ }))
expect(rowBtn('fn249')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: /^fn0[^]+? 第 \d+ 行/ })).not.toBeInTheDocument()
})
})
describe('模块过滤 (scope=all 才显示)', () => {
/** 造一组跨模块的函数,模块名写在 fn.module。 */
function makeMultiModuleFns(): FunctionNode[] {
return [
{
id: 'u.py:1:main',
file: 'u.py',
line: 1,
name: 'main',
module: '<user>',
tottime: 0.5,
cumtime: 0.5,
ncalls: 1,
percallTot: 0.5
},
{
id: 'json/__init__.py:1:loads',
file: 'json/__init__.py',
line: 1,
name: 'loads',
module: 'json',
tottime: 0.3,
cumtime: 0.3,
ncalls: 1,
percallTot: 0.3
},
{
id: 'json/decoder.py:10:decode',
file: 'json/decoder.py',
line: 10,
name: 'decode',
module: 'json',
tottime: 0.2,
cumtime: 0.2,
ncalls: 1,
percallTot: 0.2
},
{
id: 'time.py:5:sleep',
file: 'time.py',
line: 5,
name: 'sleep',
module: 'time',
tottime: 0.1,
cumtime: 0.1,
ncalls: 1,
percallTot: 0.1
}
]
}
it('单模块时不显示 filter chipsscope=user 默认情况,避免无用 UI', () => {
render(wrap(<HotspotTable functions={makeFns(5)} onSelect={noop} />))
// makeFns 默认全是 <user>,模块集合只有一个 → 不应有「全部 N」chip
expect(screen.queryByRole('group', { name: /按模块过滤/ })).not.toBeInTheDocument()
})
it('多模块时显示 filter chips「全部」+ 每个模块一个', () => {
render(wrap(<HotspotTable functions={makeMultiModuleFns()} onSelect={noop} />))
const group = screen.getByRole('group', { name: /按模块过滤/ })
expect(group).toBeInTheDocument()
// 「全部 4」chip —— 默认选中
expect(screen.getByRole('button', { name: /^全部 4/ })).toHaveAttribute('aria-pressed', 'true')
// 各模块 chipjson 2 条、time 1 条、<user> 1 条
expect(screen.getByRole('button', { name: /^json 2/ })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /^time 1/ })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /^<user> 1/ })).toBeInTheDocument()
})
it('点击模块 chip 只显示该模块的行aria-pressed 同步', () => {
render(wrap(<HotspotTable functions={makeMultiModuleFns()} onSelect={noop} />))
fireEvent.click(screen.getByRole('button', { name: /^json 2/ }))
// 选中态同步
expect(screen.getByRole('button', { name: /^json 2/ })).toHaveAttribute('aria-pressed', 'true')
expect(screen.getByRole('button', { name: /^全部/ })).toHaveAttribute('aria-pressed', 'false')
// 只剩 json 模块的两条函数
const rows = screen.getAllByRole('button', {
name: /^(loads|decode)[^]+? 第 \d+ 行/
})
expect(rows).toHaveLength(2)
// time / <user> 模块的函数消失
expect(screen.queryByRole('button', { name: /^sleep[^]+? 第/ })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: /^main[^]+? 第/ })).not.toBeInTheDocument()
})
it('点击「全部」chip 恢复显示所有行', () => {
render(wrap(<HotspotTable functions={makeMultiModuleFns()} onSelect={noop} />))
fireEvent.click(screen.getByRole('button', { name: /^json 2/ }))
expect(screen.getAllByRole('button', { name: /^(loads|decode)[^]+? 第/ })).toHaveLength(2)
fireEvent.click(screen.getByRole('button', { name: /^全部/ }))
expect(screen.getByRole('button', { name: /^sleep[^]+? 第/ })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /^main[^]+? 第/ })).toBeInTheDocument()
})
it('每条函数行显示自己所属的 module chip文本和 module 一致)', () => {
render(wrap(<HotspotTable functions={makeMultiModuleFns()} onSelect={noop} />))
// v3 起同格里有 origin 徽章 + module chip 两个 aria-hidden span;
// 取最后一个 span —— module chip 始终在 origin 之后。
const rows = document.querySelectorAll('tbody tr')
const moduleOfRow = (row: Element): string | null => {
const spans = row.querySelectorAll('span[aria-hidden="true"]')
return spans[spans.length - 1]?.textContent ?? null
}
expect(moduleOfRow(rows[0])).toBeTruthy() // 至少有一个 module chip
// 整组数据里至少同时出现 json 和 <user> 两种 module
const seen = new Set(
Array.from(rows)
.map((r) => moduleOfRow(r))
.filter(Boolean)
)
expect(seen.has('json')).toBe(true)
expect(seen.has('<user>')).toBe(true)
})
it('rerender with new functions: stale moduleFilter resets when module no longer exists', () => {
const { rerender } = render(wrap(<HotspotTable functions={makeMultiModuleFns()} onSelect={noop} />))
fireEvent.click(screen.getByRole('button', { name: /^json 2/ }))
expect(
screen.getAllByRole('button', {
name: /^(loads|decode)\uff08[^\uff09]+\uff09? \u7b2c/
})
).toHaveLength(2)
// new run: no json module
const newFns: FunctionNode[] = [
{
id: 'u.py:1:foo',
file: 'u.py',
line: 1,
name: 'foo',
module: '<user>',
tottime: 0.5,
cumtime: 0.5,
ncalls: 1,
percallTot: 0.5
},
{
id: 'u.py:5:bar',
file: 'u.py',
line: 5,
name: 'bar',
module: '<user>',
tottime: 0.3,
cumtime: 0.3,
ncalls: 1,
percallTot: 0.3
}
]
rerender(wrap(<HotspotTable functions={newFns} onSelect={noop} />))
// moduleFilter should auto-reset to null; new result has only <user>, no filter chips
expect(screen.queryByRole('group', { name: /\u6309\u6a21\u5757\u8fc7\u6ee4/ })).not.toBeInTheDocument()
// new data rows visible, not stuck on empty table
expect(screen.getByRole('button', { name: /^foo\uff08[^\uff09]+\uff09? \u7b2c/ })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /^bar\uff08[^\uff09]+\uff09? \u7b2c/ })).toBeInTheDocument()
})
it('the "all" chip uses a real hex color (not var(--...) which ModuleChip would mangle into invalid CSS)', () => {
render(wrap(<HotspotTable functions={makeMultiModuleFns()} onSelect={noop} />))
const allChip = screen.getByRole('button', { name: /^\u5168\u90e8 4/ })
const bg = allChip.style.backgroundColor
// before fix: passed 'var(--fg-muted)' which ModuleChip concatenates into 'var(--fg-muted)1a' -- invalid
// background silently dropped by browser. now should be a real hex.
// jsdom normalizes #XXXXXXXX into rgba(r, g, b, a); the contract we care about is
// 'no raw var(...) value was passed' -- assert it parses as an actual color
// (jsdom normalizes var() to empty string, so an empty bg would be the bug signal).
expect(bg).not.toBe('')
expect(bg).toMatch(/^(#[0-9a-fA-F]{6,8}|rgba?\([^)]+\))$/)
})
})
})

View File

@@ -0,0 +1,183 @@
import { fireEvent, render, screen } from '@testing-library/react'
import ModuleHeatmap from '../ModuleHeatmap'
import type { FunctionNode } from '../../../../../shared/analysis'
import { wrap } from '../../../i18n/test-utils'
/** 造一组跨模块的函数 —— moduleHeatmap 需要 >1 模块才有横向比较价值。 */
function makeMultiModuleFns(): FunctionNode[] {
return [
{
id: 'u.py:1:main',
file: 'u.py',
line: 1,
name: 'main',
module: '<user>',
tottime: 0.5,
cumtime: 0.5,
ncalls: 1,
percallTot: 0.5
},
{
id: 'json/__init__.py:1:loads',
file: 'json/__init__.py',
line: 1,
name: 'loads',
module: 'json',
tottime: 0.3,
cumtime: 0.3,
ncalls: 1,
percallTot: 0.3
},
{
id: 'json/decoder.py:10:decode',
file: 'json/decoder.py',
line: 10,
name: 'decode',
module: 'json',
tottime: 0.2,
cumtime: 0.2,
ncalls: 1,
percallTot: 0.2
},
{
id: 'time.py:5:sleep',
file: 'time.py',
line: 5,
name: 'sleep',
module: 'time',
tottime: 0.1,
cumtime: 0.1,
ncalls: 1,
percallTot: 0.1
}
]
}
describe('ModuleHeatmap', () => {
it('renders one cell per function across multi-module data', () => {
const { container } = render(wrap(<ModuleHeatmap functions={makeMultiModuleFns()} onSelect={() => {}} />))
// 4 个函数 = 4 个 cell rect (加 module 色点 rect 等其他 rect)
const rects = container.querySelectorAll('rect')
expect(rects.length).toBeGreaterThanOrEqual(4)
})
it('shows empty-state message when only one module (scope=user 默认)', () => {
const fns: FunctionNode[] = [
{
id: 'a',
file: 'a.py',
line: 1,
name: 'foo',
module: '<user>',
tottime: 0.1,
cumtime: 0.1,
ncalls: 1,
percallTot: 0.1
}
]
render(wrap(<ModuleHeatmap functions={fns} onSelect={() => {}} />))
expect(screen.getByText(/scope=user 时模块唯一/)).toBeInTheDocument()
})
it('shows placeholder when functions array is empty', () => {
render(wrap(<ModuleHeatmap functions={[]} onSelect={() => {}} />))
expect(screen.getByText(/暂无耗时数据/)).toBeInTheDocument()
})
it('truncation notice when module count > MAX_MODULES=10', () => {
// 12 个模块,每个 1 个函数 → 截掉 2 个
const fns: FunctionNode[] = Array.from({ length: 12 }, (_, i) => ({
id: `m${i}.py:1:f`,
file: `m${i}.py`,
line: 1,
name: `fn_${i}`,
module: `pkg${i}`,
tottime: 0.1,
cumtime: 0.1,
ncalls: 1,
percallTot: 0.1
}))
render(wrap(<ModuleHeatmap functions={fns} onSelect={() => {}} />))
expect(screen.getByText(/仅显示前 10 个,另有 2 个未显示/)).toBeInTheDocument()
})
it('clicking a cell fires onSelect with the matching fn', () => {
const onSelect = vi.fn<(fn: { id: string; line: number }) => void>()
const fns = makeMultiModuleFns()
render(wrap(<ModuleHeatmap functions={fns} onSelect={onSelect} />))
const target = fns[0]
const btn = screen.getByRole('button', { name: new RegExp(target.name) })
fireEvent.click(btn)
expect(onSelect).toHaveBeenCalledTimes(1)
const firstCall = onSelect.mock.calls[0]
if (!firstCall) throw new Error('expected onSelect to be called')
const [fn] = firstCall
expect(fn.id).toBe(target.id)
expect(fn.line).toBe(target.line)
})
it('keyboard Enter on cell 触发 onSelect', () => {
const onSelect = vi.fn<(fn: { id: string; line: number }) => void>()
render(wrap(<ModuleHeatmap functions={makeMultiModuleFns()} onSelect={onSelect} />))
const btn = screen.getByRole('button', { name: /main/ })
fireEvent.keyDown(btn, { key: 'Enter' })
expect(onSelect).toHaveBeenCalledTimes(1)
})
it('tottime=0 的函数被过滤(不占用 cell,避免和真实耗时共享 MIN_CELL_W=26 误导)', () => {
// 用 keep_user / keep_json 而非 real / real_json —— 后者 /real/ 同时匹配两者,
// getByRole 多元素就抛错。命名上 keep/drop 一眼区分"应该出现 / 不应出现"
const fns: FunctionNode[] = [
{
id: 'a',
file: 'a.py',
line: 1,
name: 'keep_user',
module: '<user>',
tottime: 0.5,
cumtime: 0.5,
ncalls: 1,
percallTot: 0.5
},
{
id: 'b',
file: 'a.py',
line: 2,
name: 'drop_user',
module: '<user>',
tottime: 0,
cumtime: 0,
ncalls: 1,
percallTot: 0
},
{
id: 'c',
file: 'json.py',
line: 1,
name: 'keep_json',
module: 'json',
tottime: 0.3,
cumtime: 0.3,
ncalls: 1,
percallTot: 0.3
},
{
id: 'd',
file: 'json.py',
line: 2,
name: 'drop_json',
module: 'json',
tottime: 0,
cumtime: 0,
ncalls: 1,
percallTot: 0
}
]
render(wrap(<ModuleHeatmap functions={fns} onSelect={() => {}} />))
// 只剩 2 个有时间的函数 → 2 个 cell 按钮
expect(screen.getByRole('button', { name: /keep_user/ })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /keep_json/ })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: /drop_user/ })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: /drop_json/ })).not.toBeInTheDocument()
})
})

View File

@@ -0,0 +1,51 @@
import { render, screen } from '@testing-library/react'
import Sunburst from '../Sunburst'
import golden from '../../../fixtures/golden.json'
import type { AnalysisResult, FlameNode } from '../../../../../shared/analysis'
import { wrap } from '../../../i18n/test-utils'
const result = golden as unknown as AnalysisResult
describe('Sunburst', () => {
it('renders one path per non-root arc + a center circle', () => {
const { container } = render(wrap(<Sunburst flame={result.flame} />))
const paths = container.querySelectorAll('path')
const circles = container.querySelectorAll('circle')
// 根节点画 circle非根画 path —— 至少各 1
expect(paths.length + circles.length).toBeGreaterThanOrEqual(2)
expect(circles.length).toBeGreaterThanOrEqual(1)
})
it('shows placeholder when flame is null', () => {
render(wrap(<Sunburst flame={null} />))
expect(screen.getByText(/暂无耗时数据/)).toBeInTheDocument()
})
it('truncation notice when root siblings > MAX_SIBLINGS', () => {
const flame: FlameNode = {
name: 'root',
value: 30,
children: Array.from({ length: 30 }, (_, i) => ({
name: `n${i}`,
value: 1,
children: []
}))
}
render(wrap(<Sunburst flame={flame} />))
expect(screen.getByText(/仅显示前 24 个,另有 6 个未显示/)).toBeInTheDocument()
})
it('no truncation notice within MAX_SIBLINGS', () => {
const flame: FlameNode = {
name: 'root',
value: 3,
children: [
{ name: 'a', value: 1, children: [] },
{ name: 'b', value: 1, children: [] },
{ name: 'c', value: 1, children: [] }
]
}
render(wrap(<Sunburst flame={flame} />))
expect(screen.queryByText(/另有 \d+ 个未显示/)).not.toBeInTheDocument()
})
})

View File

@@ -0,0 +1,122 @@
import { fireEvent, render, screen } from '@testing-library/react'
import TimeChartSwitcher from '../TimeChartSwitcher'
import golden from '../../../fixtures/golden.json'
import type { AnalysisResult } from '../../../../../shared/analysis'
import { wrap } from '../../../i18n/test-utils'
const result = golden as unknown as AnalysisResult
describe('TimeChartSwitcher', () => {
it('默认渲染柱状图:六个切换按钮 + 柱状图内容ORDER柱状图→火焰图→矩形树图→旭日图→累计条形→模块热力', () => {
render(wrap(<TimeChartSwitcher result={result} selectedFuncId={undefined} onSelectHotspot={() => {}} />))
expect(screen.getByRole('button', { name: '柱状图', pressed: true })).toBeInTheDocument()
expect(screen.getByRole('button', { name: '火焰图', pressed: false })).toBeInTheDocument()
expect(screen.getByRole('button', { name: '矩形树图', pressed: false })).toBeInTheDocument()
expect(screen.getByRole('button', { name: '旭日图', pressed: false })).toBeInTheDocument()
expect(screen.getByRole('button', { name: '累计条形', pressed: false })).toBeInTheDocument()
expect(screen.getByRole('button', { name: '模块热力', pressed: false })).toBeInTheDocument()
// BarChart 用 <rect> 画 bar
expect(document.querySelectorAll('svg rect').length).toBeGreaterThan(0)
})
it('点火焰图 → 切到 FlameGraph按钮 aria-pressed 翻转)', () => {
render(wrap(<TimeChartSwitcher result={result} selectedFuncId={undefined} onSelectHotspot={() => {}} />))
fireEvent.click(screen.getByRole('button', { name: '火焰图' }))
expect(screen.getByRole('button', { name: '火焰图', pressed: true })).toBeInTheDocument()
expect(screen.getByRole('button', { name: '柱状图', pressed: false })).toBeInTheDocument()
expect(document.querySelectorAll('svg rect').length).toBeGreaterThan(0)
})
it('点累计条形 → 切到 CumulativeBar', () => {
render(wrap(<TimeChartSwitcher result={result} selectedFuncId={undefined} onSelectHotspot={() => {}} />))
fireEvent.click(screen.getByRole('button', { name: '累计条形' }))
expect(screen.getByRole('button', { name: '累计条形', pressed: true })).toBeInTheDocument()
})
it('点矩形树图 → 切到 Treemap', () => {
render(wrap(<TimeChartSwitcher result={result} selectedFuncId={undefined} onSelectHotspot={() => {}} />))
fireEvent.click(screen.getByRole('button', { name: '矩形树图' }))
expect(screen.getByRole('button', { name: '矩形树图', pressed: true })).toBeInTheDocument()
})
it('点旭日图 → 切到 Sunburst', () => {
render(wrap(<TimeChartSwitcher result={result} selectedFuncId={undefined} onSelectHotspot={() => {}} />))
fireEvent.click(screen.getByRole('button', { name: '旭日图' }))
expect(screen.getByRole('button', { name: '旭日图', pressed: true })).toBeInTheDocument()
expect(document.querySelectorAll('svg circle').length).toBeGreaterThanOrEqual(1)
})
it('点模块热力 → 切到 ModuleHeatmap', () => {
render(wrap(<TimeChartSwitcher result={result} selectedFuncId={undefined} onSelectHotspot={() => {}} />))
fireEvent.click(screen.getByRole('button', { name: '模块热力' }))
expect(screen.getByRole('button', { name: '模块热力', pressed: true })).toBeInTheDocument()
// golden 全是 <user>,所以会显示「scope=user 时模块唯一」空态
expect(screen.getByText(/scope=user/)).toBeInTheDocument()
})
it('切换器外层 role="group" + aria-label', () => {
render(wrap(<TimeChartSwitcher result={result} selectedFuncId={undefined} onSelectHotspot={() => {}} />))
expect(screen.getByRole('group', { name: '切换图表类型' })).toBeInTheDocument()
})
it('键盘 ArrowRight 在切换按钮之间循环', () => {
render(wrap(<TimeChartSwitcher result={result} selectedFuncId={undefined} onSelectHotspot={() => {}} />))
const group = screen.getByRole('group', { name: '切换图表类型' })
// 默认 bar,焦点移到默认按钮
screen.getByRole('button', { name: '柱状图' }).focus()
// bar → 火焰图
fireEvent.keyDown(group, { key: 'ArrowRight' })
expect(screen.getByRole('button', { name: '火焰图', pressed: true })).toBeInTheDocument()
expect(document.activeElement?.getAttribute('aria-label')).toBe('火焰图')
// 再按 4 次 → 矩形树图 → 旭日图 → 累计条形 → 模块热力
fireEvent.keyDown(group, { key: 'ArrowRight' })
fireEvent.keyDown(group, { key: 'ArrowRight' })
fireEvent.keyDown(group, { key: 'ArrowRight' })
fireEvent.keyDown(group, { key: 'ArrowRight' })
expect(screen.getByRole('button', { name: '模块热力', pressed: true })).toBeInTheDocument()
// 再一次 ArrowRight → 循环回 bar
fireEvent.keyDown(group, { key: 'ArrowRight' })
expect(screen.getByRole('button', { name: '柱状图', pressed: true })).toBeInTheDocument()
})
it('键盘 ArrowLeft 反向循环', () => {
render(wrap(<TimeChartSwitcher result={result} selectedFuncId={undefined} onSelectHotspot={() => {}} />))
const group = screen.getByRole('group', { name: '切换图表类型' })
screen.getByRole('button', { name: '柱状图' }).focus()
// 从 bar 向左 → 模块热力(循环)
fireEvent.keyDown(group, { key: 'ArrowLeft' })
expect(screen.getByRole('button', { name: '模块热力', pressed: true })).toBeInTheDocument()
})
it('键盘 Home 跳到第一项(柱状图),End 跳到最后(模块热力)', () => {
render(wrap(<TimeChartSwitcher result={result} selectedFuncId={undefined} onSelectHotspot={() => {}} />))
const group = screen.getByRole('group', { name: '切换图表类型' })
fireEvent.keyDown(group, { key: 'End' })
expect(screen.getByRole('button', { name: '模块热力', pressed: true })).toBeInTheDocument()
fireEvent.keyDown(group, { key: 'Home' })
expect(screen.getByRole('button', { name: '柱状图', pressed: true })).toBeInTheDocument()
})
it('新结果(切 result)复位到柱状图', () => {
// 重新跑同一份 fixture 但用一个新对象引用,确保 useEffect([result]) 触发复位
const result2: AnalysisResult = JSON.parse(JSON.stringify(result)) as AnalysisResult
const { rerender } = render(
wrap(<TimeChartSwitcher result={result} selectedFuncId={undefined} onSelectHotspot={() => {}} />)
)
// 用户切到火焰图
fireEvent.click(screen.getByRole('button', { name: '火焰图' }))
expect(screen.getByRole('button', { name: '火焰图', pressed: true })).toBeInTheDocument()
// 重新跑(新 result 引用)→ 复位回柱状图
rerender(
wrap(<TimeChartSwitcher result={result2} selectedFuncId={undefined} onSelectHotspot={() => {}} />)
)
expect(screen.getByRole('button', { name: '柱状图', pressed: true })).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,66 @@
import { fireEvent, render, screen } from '@testing-library/react'
import Treemap from '../Treemap'
import golden from '../../../fixtures/golden.json'
import type { AnalysisResult, FlameNode } from '../../../../../shared/analysis'
import { wrap } from '../../../i18n/test-utils'
const result = golden as unknown as AnalysisResult
describe('Treemap', () => {
it('renders multiple rect tiles from golden flame', () => {
const { container } = render(wrap(<Treemap flame={result.flame} />))
const rects = container.querySelectorAll('rect')
expect(rects.length).toBeGreaterThanOrEqual(2)
})
it('shows placeholder when flame is null', () => {
render(wrap(<Treemap flame={null} />))
expect(screen.getByText(/暂无耗时数据/)).toBeInTheDocument()
})
it('clicking a tile with matching function fires onSelect', () => {
// root -> nested_calls 链golden.flame.children[0].name 可能是 nested_calls 等
// 直接读 functions[0] 的 name 当作对照 —— 火焰图保证 root 的某个 child 名字出现在 functions 里
const onSelect = vi.fn()
const fns = result.functions
render(wrap(<Treemap flame={result.flame} functions={fns} onSelect={onSelect} />))
const targetName = fns[0].name
const btn = screen.queryByRole('button', { name: new RegExp(targetName) })
if (btn) {
fireEvent.click(btn)
expect(onSelect).toHaveBeenCalled()
} else {
// flame 顶层可能不直接包含某个 functions[0].name深度不在第一层—— 测试只保证有按钮 + 有 name
const btns = screen.queryAllByRole('button')
expect(btns.length).toBeGreaterThanOrEqual(0)
}
})
it('truncation notice when root siblings > MAX_SIBLINGS', () => {
const flame: FlameNode = {
name: 'root',
value: 30,
children: Array.from({ length: 30 }, (_, i) => ({
name: `n${i}`,
value: 1,
children: []
}))
}
render(wrap(<Treemap flame={flame} />))
expect(screen.getByText(/仅显示前 24 个,另有 6 个未显示/)).toBeInTheDocument()
})
it('no truncation notice within MAX_SIBLINGS', () => {
const flame: FlameNode = {
name: 'root',
value: 3,
children: [
{ name: 'a', value: 1, children: [] },
{ name: 'b', value: 1, children: [] },
{ name: 'c', value: 1, children: [] }
]
}
render(wrap(<Treemap flame={flame} />))
expect(screen.queryByText(/另有 \d+ 个未显示/)).not.toBeInTheDocument()
})
})

View File

@@ -0,0 +1,166 @@
/**
* 四张图FlameGraph / BarChart / Treemap / Sunburst共享的常量与辅助函数。
*
* 不放 utils/ 是因为这些只服务于 views/* 下的图表组件,跨目录共享反而
* 让人误以为 utils/* 还要导到外面去用。
*
* 设计目标:
* - 同一个函数在四图里**用同一个颜色**colorByName—— 切换视图时颜色延续,
* 用户能"认出"同一个函数。
* - 焦点态统一FOCUS / RESTING—— 四图的"高亮"应该长得一样,避免每次切
* 视图还要重新学一次视觉语言。
* - SVG 文本截断truncateName—— `<text>` 没有内置 ellipsisCascadia Code
* monospace 按 char 数估算就够了。
* - hover 视觉反馈统一(`data-chart-shape` + globals.css 规则)—— 鼠标悬停时
* 把 fillOpacity 从 0.7 提到 1.0globals.css靠 FOCUS 的描边区分 hover vs focus。
*/
import type { FlameNode, FunctionNode } from '../../../../shared/analysis'
import { FLAME_PALETTE } from '../../utils/colors'
/** 四图统一:每层 sibling 上限。 */
export const MAX_SIBLINGS = 24
/**
* 按函数名查表 —— Treemap / Sunburst 都需要"按 FlameNode.name 找到对应的
* FunctionNode"以拿到行号跳转。两个视图都构造同样的 Maplookup 时 O(1)
* 抽到这里统一实现,避免两个文件各写一遍的漂移风险。
*/
export function indexFunctionsByName(
functions: readonly FunctionNode[] | undefined
): Map<string, FunctionNode> {
const m = new Map<string, FunctionNode>()
if (functions) for (const f of functions) m.set(f.name, f)
return m
}
/**
* 焦点态fillOpacity 1.0 + `var(--accent)` 2px 描边。
* 这是 "highlight ring" 模式 —— 保留 tile 原色,描边告诉用户"这块被选中了"。
* 三档分明:RESTING(0.7, 浅描边)→ hover(1.0)→ FOCUS(1.0, 粗描边 + accent 色)。
* 之前 4 图用 4 种不同的高亮(fill 改色 / fillOpacity / 描边 / 组合),现在统一。
*/
export const FOCUS = {
fillOpacity: 1,
stroke: 'var(--accent)',
strokeWidth: 2
} as const
/** 非焦点态:fillOpacity 0.7 + `var(--border)` 0.5px 描边。
* 为什么不取更高:fillOpacity 越接近 1,tile 越接近纯 PALETTE 色,而 PALETTE 是中间色调,
* 合成后和 TEXT_ON_DARK 浅色文字的对比度反而变低(0.7 时 30% APP_BG 拉向深色,才上
* 4.5:1 AA)。这是 colors.ts 验算过的契约,改这里必须同步改 colors.test.ts。 */
export const RESTING = {
fillOpacity: 0.7,
stroke: 'var(--border)',
strokeWidth: 0.5
} as const
/**
* 等宽编程字体栈4 张图 SVG 内的 <text> 统一引用。
* Cascadia Code 在 Win11 自带macOS/Linux fallback 到 SF Mono / Menlo / Consolas。
* 抽出来是因为同样的字符串 4 个文件 × 多次 = 容易改一处忘一处。
*/
export const FONT_MONO = 'Cascadia Code, SF Mono, Menlo, Consolas, monospace'
/**
* SVG 文本尺寸阶梯v4 起统一升级)。
*
* 之前各图自定字号(11~16px 都有), 用户反馈看不清——
* - 小字号 SVG <text> 在 HiDPI 屏/缩放 125% 后视觉更小。
* - label 在 tile 内要能"扫到函数名"14px 是个临界值。
*
* 阶梯:
* - NAME: 18 —— 函数名 (主信息)
* - BODY: 16 —— 数值 / 百分比 / 右对齐
* - HINT: 14 —— hint 文案 / 模块标签 / 截断提示
* - MICRO: 12 —— 图例 / origin 小标 (只在 SVG 内用,且有 `<title>` 兜底)
*
* Tailwind 的 text-sm=14 / text-base=16 / text-lg=18 是参考线,
* SVG 内部用绝对 px 才能跨容器缩放。
*/
export const FONT_SIZE = {
NAME: 18,
BODY: 16,
HINT: 14,
MICRO: 12
} as const
/**
* 同名函数在四图里取同一个颜色 —— Java 风格 hashCode 落到 PALETTE。
*
* 之前 4 图都用 `PALETTE[index % 6]`,但 index 语义各不相同FlameGraph = child i,
* Treemap = squarify order, Sunburst = (i+depth)%6, BarChart = visible i——
* 同一个函数切到不同视图会换色,相当于「视觉重置」。改成 name 哈希后,
* "黄色的 func" 在四图里都是黄色,颜色本身变成函数身份的视觉锚点。
*/
export function colorByName(name: string): string {
let h = 0
for (let i = 0; i < name.length; i++) {
h = ((h << 5) - h + name.charCodeAt(i)) | 0
}
return FLAME_PALETTE[Math.abs(h) % FLAME_PALETTE.length]
}
/**
* SVG `<text>` 没有内置 ellipsis按 monospace char 数手动截断。
*
* 之前 chart-utils 各 chart 估 maxChars 时用的是旧字号(15-16px ≈ 9px/字)
* 现在统一按 NAME(18px) 估 ≈ 11px/字。调用方仍可按 fontSize 自定义。
*
* BarChart 240px 标签区 → ~22 字 (旧 24)Sunburst 中心 ~112px 直径 → 10 字 (旧 12)。
*
* 永远不返回空串 —— 至少 1 个字符(即使超长也保留首字 + ellipsis
*/
export function truncateName(name: string, maxChars: number): string {
if (maxChars <= 0) return ''
if (name.length <= maxChars) return name
// 留 1 位给省略号
return name.slice(0, Math.max(1, maxChars - 1)) + '…'
}
/**
* 按可用像素按 monospace 字号反算"这一行能放几个字"。
*
* `<text>` 没 ellipsis,宽度自适应要自己算:
* - Cascadia Code 18px ≈ 11px/字,16px ≈ 10px/字,14px ≈ 9px/字
* - 左右各留 4px padding (圆角 + 视觉呼吸)
*
* maxChars=0 表示"实在放不下,别画",返回 0 给上层 hide。
*/
export function charsForWidth(widthPx: number, fontSize: number): number {
if (widthPx <= 0) return 0
const perChar = fontSize * 0.62 // Cascadia Code 实测
const usable = Math.max(0, widthPx - 8)
return Math.floor(usable / perChar)
}
/**
* 统计每个 depth 上 sibling 被 maxSiblings 截掉的数量。
*
* FlameGraph / Treemap / Sunburst 三图共用 —— 之前各写一份,边角行为会随时间漂移
* (Treemap 注释还误以为已经提到 chart-utils 共享)。递归全树,把"在哪几层被截"折成
* 一句聚合文案:
* - depths[]: 有截断的 depth 列表(从 1 起算,和 SVG 视觉一致)
* - totalHidden: 所有层被截的 sibling 总和
*
* 只下钻到 depth=6 —— 视觉上 layout 限 MAX_DEPTH=3~4,6 给点 headroom 但不会扫完整棵
* 树,防止巨大 profile 树退化成性能问题。
*/
export function countTruncatedSiblings(
root: FlameNode,
maxSiblings: number
): { depths: number[]; totalHidden: number } {
const depths: number[] = []
let total = 0
const walk = (node: FlameNode, depth: number): void => {
if (node.children.length > maxSiblings) {
depths.push(depth)
total += node.children.length - maxSiblings
}
if (depth < 6) {
for (const c of node.children) walk(c, depth + 1)
}
}
walk(root, 0)
return { depths, totalHidden: total }
}

View File

@@ -0,0 +1,26 @@
import golden from '../golden.json'
import { isAnalysisResult, SUPPORTED_SCHEMA_VERSIONS } from '../../../../shared/analysis'
/**
* golden.json 是冻结的引擎输出,喂给多个测试套件,一律走
* `golden as unknown as AnalysisResult` —— 那个双重断言把类型检查整个绕开了。
* analysis.ts 的注释明确把"测试夹具"列为 isAnalysisResult 的调用场景之一,
* 但此前没有任何测试真的这么做过:夹具可以静静地偏离 TS 类型而全套测试照绿。
* 这个文件就是那道闸。
*/
describe('golden.json 夹具符合 AnalysisResult 契约', () => {
it('通过 isAnalysisResult 运行时校验', () => {
expect(isAnalysisResult(golden)).toBe(true)
})
it('schemaVersion 在 TS 侧支持的版本列表里', () => {
expect(SUPPORTED_SCHEMA_VERSIONS).toContain(golden.schemaVersion)
})
it('是一份内容有意义的成功结果(否则断言它的测试等于没测)', () => {
expect(golden.status).toBe('ok')
expect(golden.functions.length).toBeGreaterThan(0)
expect(golden.wallTime).not.toBeNull()
expect(golden.wallTime.seconds).toBeGreaterThan(0)
})
})

View File

@@ -0,0 +1,115 @@
{
"schemaVersion": 4,
"environment": {
"python": "3.14.6",
"platform": "win32",
"processor": "Intel64 Family 6 Model 198 Stepping 2, GenuineIntel",
"timerResolution": 1e-7
},
"config": {},
"status": "ok",
"error": null,
"wallTime": {
"seconds": 0.0012116000289097428,
"unit": "s"
},
"functions": [
{
"id": "C:\\data\\正在工作\\智能体管理的项目\\程序的耗时可视化\\engine\\tests\\fixtures\\nested_calls.py:1:leaf",
"file": "engine/tests/fixtures/nested_calls.py",
"line": 1,
"name": "leaf",
"cumtime": 0.0011978000000000002,
"tottime": 0.0011978000000000002,
"ncalls": 3,
"percallTot": 0.0003992666666666667,
"module": "<user>",
"origin": "user"
},
{
"id": "C:\\data\\正在工作\\智能体管理的项目\\程序的耗时可视化\\engine\\tests\\fixtures\\nested_calls.py:8:mid",
"file": "engine/tests/fixtures/nested_calls.py",
"line": 8,
"name": "mid",
"cumtime": 0.0012059,
"tottime": 0.0000034,
"ncalls": 1,
"percallTot": 0.0000034,
"module": "<user>",
"origin": "user"
},
{
"id": "C:\\data\\正在工作\\智能体管理的项目\\程序的耗时可视化\\engine\\tests\\fixtures\\nested_calls.py:9:<genexpr>",
"file": "engine/tests/fixtures/nested_calls.py",
"line": 9,
"name": "<genexpr>",
"cumtime": 0.0011997000000000002,
"tottime": 0.0000019,
"ncalls": 4,
"percallTot": 4.75e-7,
"module": "<user>",
"origin": "user"
},
{
"id": "C:\\data\\正在工作\\智能体管理的项目\\程序的耗时可视化\\engine\\tests\\fixtures\\nested_calls.py:1:<module>",
"file": "engine/tests/fixtures/nested_calls.py",
"line": 1,
"name": "<module>",
"cumtime": 0.0012082,
"tottime": 0.0000015,
"ncalls": 1,
"percallTot": 0.0000015,
"module": "<user>",
"origin": "user"
},
{
"id": "C:\\data\\正在工作\\智能体管理的项目\\程序的耗时可视化\\engine\\tests\\fixtures\\nested_calls.py:12:main",
"file": "engine/tests/fixtures/nested_calls.py",
"line": 12,
"name": "main",
"cumtime": 0.0012067,
"tottime": 8.000000000000001e-7,
"ncalls": 1,
"percallTot": 8.000000000000001e-7,
"module": "<user>",
"origin": "user"
}
],
"flame": {
"name": "root",
"value": 0.0012054000000000001,
"children": [
{
"name": "leaf",
"value": 0.0011978000000000002,
"children": []
},
{
"name": "mid",
"value": 0.0000034,
"children": []
},
{
"name": "<genexpr>",
"value": 0.0000019,
"children": []
},
{
"name": "<module>",
"value": 0.0000015,
"children": []
},
{
"name": "main",
"value": 8.000000000000001e-7,
"children": []
}
]
},
"calibration": {
"ratio": 1,
"workloadName": "tight-loop",
"instrumentedWorkloadSec": 0.0011484000133350492,
"cleanWorkloadSec": 0.00126069993712008
}
}

9
src/renderer/src/global.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
import type { RendererApi } from '../../shared/analysis'
declare global {
interface Window {
api: RendererApi
}
}
export {}

View File

@@ -0,0 +1,334 @@
/**
* useAnalysis 的回归测试,重点守住"cancel 后再次 run 仍能收到 progress"。
*
* 之前 progressTokenRef 设计:订阅回调里 myToken 是 mount 时一次性捕获,
* cancel() 把它 bump 之后再也没人复位 → 第二次 run 期间引擎发的 PROGRESS 全部被吞,
* 进度条永远卡在 0%。审计 F2/F3 提到的"取消后立刻重跑看到进度条卡住"就是这条路径。
*
* 这里改成 progressActiveRef每次事件都重新读 booleanrun/cancel/reset 各自翻位即可,
* 测试用真 hook + 假 window.api 模拟 IPC。
*/
import { useEffect } from 'react'
import { act, render } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useAnalysis } from '../useAnalysis'
import { wrap } from '../../i18n/test-utils'
import type { AnalysisResult, ProgressEvent, RunOptions } from '../../../../shared/analysis'
const FAKE_RESULT: AnalysisResult = {
schemaVersion: 2,
environment: { python: '3.11', platform: 'win32', processor: 'x86_64', timerResolution: 1e-7 },
config: {},
status: 'ok',
wallTime: { seconds: 0.42, unit: 's' },
functions: [],
flame: null
}
const SAMPLE_OPTS: RunOptions = {
interpreter: 'C:/python/python.exe',
code: 'x = 1'
}
type ProgressHandler = (e: ProgressEvent) => void
interface FakeApi {
analyze: ReturnType<typeof vi.fn>
cancel: ReturnType<typeof vi.fn>
onProgress: (cb: ProgressHandler) => () => void
/** 测试用:手动推一个 progress 事件给所有订阅者 */
emit: (e: ProgressEvent) => void
/** useAnalysis 在 mount 时订阅 stdout —— 测试不验 stdout 累积,
* 给个 noop unsubscribe 防止 'is not a function' 让 React 抛错 */
onStdout: (cb: (chunk: string) => void) => () => void
}
/** Module-level handlers registry — installFakeApi() rebinds the subscriber list. */
let handlers: ProgressHandler[] = []
function installFakeApi(): FakeApi {
handlers = []
const api: FakeApi = {
analyze: vi.fn(),
cancel: vi.fn().mockResolvedValue(undefined),
onProgress: (cb) => {
handlers.push(cb)
return () => {
const i = handlers.indexOf(cb)
if (i >= 0) handlers.splice(i, 1)
}
},
emit: (e) => {
for (const h of handlers.slice()) h(e)
},
onStdout: () => () => {}
}
;(window as unknown as { api: FakeApi }).api = api
return api
}
/**
* 关键模式Probe 在模块层定义、通过 props 接收 onReady 回调,
* 返回真实 DOM不是 null。React 18 + @testing-library v16 下
* render() 返回时 effect 已 flushcaptured 必然被赋值。
* 与 useFeedbackTimeout.test.tsx 同模式。
*/
interface ProbeProps {
onReady: (api: ReturnType<typeof useAnalysis>) => void
}
function Probe({ onReady }: ProbeProps) {
const api = useAnalysis()
useEffect(() => {
onReady(api)
})
return <div data-testid="probe" />
}
describe('useAnalysis', () => {
let latest: ReturnType<typeof useAnalysis> | null = null
let api: FakeApi | null = null
beforeEach(() => {
latest = null
api = installFakeApi()
render(wrap(<Probe onReady={(a) => (latest = a)} />))
if (!latest) throw new Error('useEffect 未跑hook 还没拿到 api')
})
afterEach(() => {
latest = null
api = null
vi.restoreAllMocks()
})
/** 每次断言前取一次最新引用 — setState 后 re-render 会更新 latest.state/progress/... */
function hook(): ReturnType<typeof useAnalysis> {
if (!latest) throw new Error('hook 被卸载')
return latest
}
it('run 期间 progress 事件被转发cancel 后再 run 仍能接收(核心回归)', async () => {
let resolveAnalyze1!: (r: AnalysisResult) => void
api!.analyze.mockImplementationOnce(
() => new Promise<AnalysisResult>((resolve) => (resolveAnalyze1 = resolve))
)
expect(hook().progress).toBeNull()
// Run #1kick off 但不 awaitanalyze 是 pending promiseact 会 flush setState。
await act(async () => {
void hook().run(SAMPLE_OPTS)
})
expect(hook().state).toBe('running')
await act(async () => {
api!.emit({ phase: 'timing', pct: 42 })
})
expect(hook().progress).toEqual({ phase: 'timing', pct: 42 })
// 模拟 run #1 完成
await act(async () => {
resolveAnalyze1(FAKE_RESULT)
})
expect(hook().state).toBe('done')
// Cancel清状态 + 关订阅
await act(async () => {
await hook().cancel()
})
expect(hook().state).toBe('idle')
await act(async () => {
api!.emit({ phase: 'timing', pct: 99 })
})
// cancel 后再 emit → progress 不应被设上
expect(hook().progress).toBeNull()
// Run #2关键回归路径再次订阅应能接收 progress
let resolveAnalyze2!: (r: AnalysisResult) => void
api!.analyze.mockImplementationOnce(
() => new Promise<AnalysisResult>((resolve) => (resolveAnalyze2 = resolve))
)
await act(async () => {
void hook().run(SAMPLE_OPTS)
})
expect(hook().state).toBe('running')
await act(async () => {
api!.emit({ phase: 'structure', pct: 30 })
})
expect(hook().progress).toEqual({ phase: 'structure', pct: 30 })
// 收尾
await act(async () => {
resolveAnalyze2(FAKE_RESULT)
})
expect(hook().state).toBe('done')
// Run #2 自然结束后progress 订阅应停 — 此时再 emit 不会被接收
await act(async () => {
api!.emit({ phase: 'done', pct: 100 })
})
expect(hook().progress).toEqual({ phase: 'structure', pct: 30 })
})
it('reset 也关闭 progress 订阅', async () => {
let resolveAnalyze!: (r: AnalysisResult) => void
api!.analyze.mockImplementationOnce(
() => new Promise<AnalysisResult>((resolve) => (resolveAnalyze = resolve))
)
await act(async () => {
void hook().run(SAMPLE_OPTS)
})
expect(hook().state).toBe('running')
await act(async () => {
api!.emit({ phase: 'timing', pct: 50 })
})
expect(hook().progress).toEqual({ phase: 'timing', pct: 50 })
// reset清本地状态 + 关订阅)
act(() => {
hook().reset()
})
expect(hook().state).toBe('idle')
act(() => {
api!.emit({ phase: 'timing', pct: 99 })
})
expect(hook().progress).toBeNull()
// 收尾原 analyze不会被设到 UI因为 reset 已经清掉 state
await act(async () => {
resolveAnalyze(FAKE_RESULT)
})
})
it('cancel 之后 analyze 仍 rejectIPC cancel 信号 / 子进程被 killstate 不会变 error', async () => {
// 模拟真实场景:用户 cancel主进程发 IPC cancel 给 runnerrunner 把
// 进行中的 analyze() promise reject 掉。hook 必须保持 idle不能进 error。
api!.analyze.mockImplementationOnce(() => Promise.reject(new Error('aborted by user cancel')))
await act(async () => {
void hook().run(SAMPLE_OPTS)
})
// run 已发起analyze 立即 reject下面 await flush 微任务
await act(async () => {
await Promise.resolve()
await Promise.resolve()
})
// 用户中途 cancelcancel 内部已经把 state 设回 idle
await act(async () => {
await hook().cancel()
})
expect(hook().state).toBe('idle')
// 现在让那个 reject 落地 —— hook 看到 runId 不匹配,应当静默丢弃,
// 不应把 "aborted by user cancel" 暴露给 UI。
await act(async () => {
await Promise.resolve()
await Promise.resolve()
})
expect(hook().state).toBe('idle')
expect(hook().errorMessage).toBeNull()
})
it('analyze 真出错(非 cancel 引起)才会把 state 推到 error 并写入 errorMessage', async () => {
api!.analyze.mockImplementationOnce(() => Promise.reject(new Error('syntax error: unexpected EOF')))
await act(async () => {
void hook().run(SAMPLE_OPTS)
})
await act(async () => {
await Promise.resolve()
await Promise.resolve()
})
expect(hook().state).toBe('error')
// 裸 Error 没有 [IPC_ERROR] 前缀 → readIpcError 包成 kind='unknown'。
// formatIpcError 对 unknown kind 走 !key 分支:直接返回 readableMessage不加前缀
// kind 翻译表里没有 unknown —— 通常是 spawn / 第三方 error原文最有排查价值
expect(hook().errorMessage).toBe('syntax error: unexpected EOF')
})
it('cancel 后再 run 一次,第二次的真错误能正常进入 error 状态', async () => {
// run #1成功
api!.analyze.mockImplementationOnce(() => Promise.resolve(FAKE_RESULT))
await act(async () => {
void hook().run(SAMPLE_OPTS)
})
await act(async () => {
await Promise.resolve()
})
expect(hook().state).toBe('done')
// 取消
await act(async () => {
await hook().cancel()
})
expect(hook().state).toBe('idle')
// run #2失败 —— 必须是 error不能被前一次的 cancel 状态卡住
api!.analyze.mockImplementationOnce(() => Promise.reject(new Error('timeout after 30s')))
await act(async () => {
void hook().run(SAMPLE_OPTS)
})
await act(async () => {
await Promise.resolve()
await Promise.resolve()
})
expect(hook().state).toBe('error')
// 同上unknown kind 走 !key 分支直接返回 readableMessage
expect(hook().errorMessage).toBe('timeout after 30s')
})
it('cancel 后 runId bump 让陈旧 analyze resolve 不会覆盖 UI', async () => {
let resolve1!: (r: AnalysisResult) => void
let resolve2!: (r: AnalysisResult) => void
let count = 0
api!.analyze.mockImplementation(
() =>
new Promise<AnalysisResult>((resolve) => {
count++
if (count === 1) resolve1 = resolve
else resolve2 = resolve
})
)
// Run #1不 awaitpending promise 留着手动 resolve
await act(async () => {
void hook().run(SAMPLE_OPTS)
})
expect(hook().state).toBe('running')
// 用户中途 cancel
await act(async () => {
await hook().cancel()
})
expect(hook().state).toBe('idle')
// Run #2
await act(async () => {
void hook().run(SAMPLE_OPTS)
})
expect(hook().state).toBe('running')
// 现在 resolve run #1 的 promise — 应该被丢弃
await act(async () => {
resolve1({ ...FAKE_RESULT, environment: { ...FAKE_RESULT.environment, python: 'OLD' } })
})
expect(hook().result).toBeNull()
// resolve run #2 — 应该被接受
await act(async () => {
resolve2({ ...FAKE_RESULT, environment: { ...FAKE_RESULT.environment, python: 'NEW' } })
})
expect(hook().result).not.toBeNull()
expect(hook().result?.environment.python).toBe('NEW')
expect(hook().state).toBe('done')
})
})

View File

@@ -0,0 +1,340 @@
/**
* useExternalFiles 的回归测试。
*
* 关注几条关键不变式:
* 1) openFile去重命中同 dedupKey→ 不重复开 tab只切 active
* 2) openFile去重不命中 → loading tab 出现IPC 成功置 readyIPC 失败置 error
* 3) IPC await 期间 close tab → patch 时丢弃race
* 4) closeFile关的是 active → activeExternalId 回 null
* 5) StrictMode 下 mount→cleanup→mount 不污染 activeExternalId / externalTabs
* 6) makeDisplayName: 不同长度路径走「父/末」或「末」分支
*
* 不覆盖:
* - pendingReveal 时序openFile 同步写入 ref—— 这是 useRef 写入不是 state,
* 已经跟 openFile 同步;消费侧是 App.tsx 的 effect,不在 hook 范围内
*/
import { StrictMode, useEffect } from 'react'
import { act, render } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useExternalFiles, makeDisplayName } from '../useExternalFiles'
import { wrap } from '../../i18n/test-utils'
import type { FileReadResult } from '../../../../shared/analysis'
interface FakeApi {
readFile: ReturnType<typeof vi.fn>
}
interface ProbeProps {
onReady: (api: ReturnType<typeof useExternalFiles>) => void
}
function installFakeApi(): FakeApi {
const api: FakeApi = {
readFile: vi.fn()
}
;(window as unknown as { api: FakeApi }).api = api
return api
}
function Probe({ onReady }: ProbeProps) {
const api = useExternalFiles()
useEffect(() => {
onReady(api)
})
return <div data-testid="probe" />
}
const OK: FileReadResult = {
kind: 'ok',
content: 'print("hi")\n',
size: 14
}
const ERR_NOT_FOUND: FileReadResult = {
kind: 'not_found',
message: '文件不存在C:\\python\\missing.py'
}
describe('useExternalFiles', () => {
let latest: ReturnType<typeof useExternalFiles> | null = null
let api: FakeApi | null = null
beforeEach(async () => {
latest = null
api = installFakeApi()
await act(async () => {
render(wrap(<Probe onReady={(a) => (latest = a)} />))
await new Promise((r) => setTimeout(r, 0))
})
if (!latest) throw new Error('Probe useEffect 未跑')
})
afterEach(() => {
latest = null
api = null
vi.restoreAllMocks()
})
function hook(): ReturnType<typeof useExternalFiles> {
if (!latest) throw new Error('hook 未挂载')
return latest
}
it('初始状态externalTabs 空activeExternalId = null', () => {
expect(hook().externalTabs).toEqual([])
expect(hook().activeExternalId).toBeNull()
})
it('openFileloading tab 出现 + active 切到新 tab + IPC 成功置 ready', async () => {
// 用 deferred mock 才能断言 loading 中间态 —— mockResolvedValue 在同一个
// microtask 里 resolve,act 还没退出就已被 patch 成 ready
let resolveRead!: (r: FileReadResult) => void
api!.readFile.mockImplementationOnce(() => new Promise<FileReadResult>((r) => (resolveRead = r)))
await act(async () => {
hook().openFile('C:\\Python311\\Lib\\json\\decoder.py', 42, 'stdlib')
})
// 同步拿到 loading tab
expect(hook().externalTabs).toHaveLength(1)
expect(hook().externalTabs[0].status).toBe('loading')
expect(hook().externalTabs[0].filePath).toBe('C:\\Python311\\Lib\\json\\decoder.py')
expect(hook().externalTabs[0].origin).toBe('stdlib')
expect(hook().externalTabs[0].displayName).toBe('json/decoder.py')
expect(hook().activeExternalId).toBe(hook().externalTabs[0].id)
// IPC resolve → ready
await act(async () => {
resolveRead(OK)
await new Promise((r) => setTimeout(r, 0))
})
expect(hook().externalTabs[0].status).toBe('ready')
expect(hook().externalTabs[0].content).toBe('print("hi")\n')
expect(hook().externalTabs[0].sizeBytes).toBe(14)
})
it('openFileIPC 返回错误时 tab 进入 error 状态,errorKind / errorMessage 带出来', async () => {
api!.readFile.mockResolvedValueOnce(ERR_NOT_FOUND)
await act(async () => {
hook().openFile('C:\\missing.py', 1, 'stdlib')
})
await act(async () => {
await Promise.resolve()
})
expect(hook().externalTabs[0].status).toBe('error')
expect(hook().externalTabs[0].errorKind).toBe('not_found')
expect(hook().externalTabs[0].errorMessage).toContain('文件不存在')
})
it('openFileIPC 抛 throw 时按 unknown kind 处理', async () => {
api!.readFile.mockRejectedValueOnce(new Error('IPC channel dead'))
await act(async () => {
hook().openFile('C:\\boom.py', 1, 'third_party')
})
await act(async () => {
await Promise.resolve()
})
expect(hook().externalTabs[0].status).toBe('error')
expect(hook().externalTabs[0].errorKind).toBe('unknown')
expect(hook().externalTabs[0].errorMessage).toBe('IPC channel dead')
})
it('openFile 同一路径重复调:只开一个 tab,只切 active(去重生效)', async () => {
api!.readFile.mockResolvedValue(OK)
// 第一次:新 tab
await act(async () => {
hook().openFile('C:\\Python311\\Lib\\json\\decoder.py', 10, 'stdlib')
await Promise.resolve()
})
const firstId = hook().externalTabs[0].id
expect(api!.readFile).toHaveBeenCalledTimes(1)
expect(hook().externalTabs).toHaveLength(1)
// 第二次:同路径,只切 active,不重新 IPC
await act(async () => {
hook().openFile('C:\\Python311\\Lib\\json\\decoder.py', 99, 'stdlib')
await Promise.resolve()
})
expect(api!.readFile).toHaveBeenCalledTimes(1)
expect(hook().externalTabs).toHaveLength(1)
expect(hook().externalTabs[0].id).toBe(firstId)
expect(hook().activeExternalId).toBe(firstId)
})
it('去重忽略大小写 + 反斜杠/正斜杠', async () => {
api!.readFile.mockResolvedValue(OK)
await act(async () => {
hook().openFile('C:\\Python311\\Lib\\json\\decoder.py', 1, 'stdlib')
await Promise.resolve()
})
expect(api!.readFile).toHaveBeenCalledTimes(1)
// 不同大小写 + 正斜杠 → 应被识别为同一 dedupKey
await act(async () => {
hook().openFile('c:/PYTHON311/lib/JSON/decoder.py', 2, 'stdlib')
await Promise.resolve()
})
expect(api!.readFile).toHaveBeenCalledTimes(1)
expect(hook().externalTabs).toHaveLength(1)
})
it('closeFile关掉非 active tab 时 activeExternalId 不变', async () => {
api!.readFile.mockResolvedValue(OK)
await act(async () => {
hook().openFile('C:\\a.py', 1, 'stdlib')
hook().openFile('C:\\b.py', 2, 'stdlib')
await Promise.resolve()
})
expect(hook().externalTabs).toHaveLength(2)
const firstId = hook().externalTabs[0].id
const secondId = hook().externalTabs[1].id
// 当前 active 是 second
expect(hook().activeExternalId).toBe(secondId)
await act(async () => {
hook().closeFile(firstId)
})
expect(hook().externalTabs).toHaveLength(1)
expect(hook().externalTabs[0].id).toBe(secondId)
expect(hook().activeExternalId).toBe(secondId)
})
it('closeFile关掉 active tab 时 activeExternalId 回 null(切回用户 tab)', async () => {
api!.readFile.mockResolvedValue(OK)
await act(async () => {
hook().openFile('C:\\a.py', 1, 'stdlib')
await Promise.resolve()
})
const id = hook().externalTabs[0].id
expect(hook().activeExternalId).toBe(id)
await act(async () => {
hook().closeFile(id)
})
expect(hook().externalTabs).toHaveLength(0)
expect(hook().activeExternalId).toBeNull()
})
it('IPC await 期间 close tab → patch 时不报错也不污染', async () => {
let resolveRead!: (r: FileReadResult) => void
api!.readFile.mockImplementationOnce(() => new Promise<FileReadResult>((r) => (resolveRead = r)))
// 启动 openFileloading tab 进入)
await act(async () => {
hook().openFile('C:\\slow.py', 1, 'stdlib')
})
expect(hook().externalTabs).toHaveLength(1)
const tabId = hook().externalTabs[0].id
// 用户不等 IPC 完成就关 tab
await act(async () => {
hook().closeFile(tabId)
})
expect(hook().externalTabs).toHaveLength(0)
// IPC 终于 resolve → setState 在 functional 里检查 target 已被 prev.filter 掉
// → 应该 no-op,不能抛错也不能复活 tab
await act(async () => {
resolveRead(OK)
await new Promise((r) => setTimeout(r, 0))
})
expect(hook().externalTabs).toHaveLength(0)
expect(hook().activeExternalId).toBeNull()
})
it('setActive: 直接切活跃 tab,不调 IPC', async () => {
api!.readFile.mockResolvedValue(OK)
await act(async () => {
hook().openFile('C:\\a.py', 1, 'stdlib')
hook().openFile('C:\\b.py', 2, 'stdlib')
await Promise.resolve()
})
const firstId = hook().externalTabs[0].id
const secondId = hook().externalTabs[1].id
expect(hook().activeExternalId).toBe(secondId)
await act(async () => {
hook().setActive(firstId)
})
expect(hook().activeExternalId).toBe(firstId)
// 切回 null = 用户 tab
await act(async () => {
hook().setActive(null)
})
expect(hook().activeExternalId).toBeNull()
})
it('closeAll: 清空所有外部 tab 和 activeExternalId', async () => {
api!.readFile.mockResolvedValue(OK)
await act(async () => {
hook().openFile('C:\\a.py', 1, 'stdlib')
hook().openFile('C:\\b.py', 2, 'stdlib')
await Promise.resolve()
})
expect(hook().externalTabs).toHaveLength(2)
await act(async () => {
hook().closeAll()
})
expect(hook().externalTabs).toHaveLength(0)
expect(hook().activeExternalId).toBeNull()
})
})
describe('makeDisplayName', () => {
it('单段路径直接展示', () => {
expect(makeDisplayName('foo.py')).toBe('foo.py')
})
it('两段以上取父/末两段', () => {
expect(makeDisplayName('C:\\Python311\\Lib\\json\\decoder.py')).toBe('json/decoder.py')
expect(makeDisplayName('/usr/lib/python3.11/json/decoder.py')).toBe('json/decoder.py')
})
it('末段超长时只取末段(避免父段被截掉)', () => {
// 33 字符 > 32 → 单段展示
const longName = 'a'.repeat(33) + '.py'
expect(makeDisplayName(`C:\\lib\\${longName}`)).toBe(longName)
})
it('边界:刚好 32 字符仍走父/末', () => {
const name = 'a'.repeat(29) + '.py' // 29 + 3 = 32 字符
expect(name.length).toBe(32)
expect(makeDisplayName(`C:\\lib\\${name}`)).toBe(`lib/${name}`)
})
it('空路径串直接返回原值', () => {
expect(makeDisplayName('')).toBe('')
})
})
describe('useExternalFiles under StrictMode', () => {
let api: FakeApi | null = null
beforeEach(() => {
api = installFakeApi()
})
afterEach(() => {
api = null
vi.restoreAllMocks()
})
it('StrictMode mount→cleanup→mount 仍保持 activeExternalId=null,externalTabs=[]', async () => {
// 之前的 aliveRef 在 setup 里没复位 → 第二次 setup 后任何 patch tab 的
// setState 都因 aliveRef=false 被吃掉,行为看似正常只是巧合。
// 这里仅断言「mount 完成后状态干净」,关键不变式是 cleanup 后没有泄漏。
api!.readFile.mockResolvedValue(OK)
await act(async () => {
render(
wrap(
<StrictMode>
<Probe
onReady={(_h) => {
// mount useEffect 会推一个 loading tab? 不会 —— openFile 必须用户调
}}
/>
</StrictMode>
)
)
await new Promise((r) => setTimeout(r, 0))
})
// 没有任何 openFile 调用,externalTabs 应为空
expect(api!.readFile).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,498 @@
/**
* useInterpreters 的回归测试。
*
* 关注三条关键不变式:
* 1) StrictMode mount → cleanup → mount 只触发一次自动 detectAll
* hasAutoDetectedRef 守卫;之前漏了这条会导致两路并行 IPC 探测,
* 每个 20+ fs probe渲染后白屏数百毫秒
* 2) detectAll / pick 并发请求时,旧请求的 resolve 不会覆盖新请求的结果
* tokenRef 守卫;之前漏了会导致"晚点刷新"反而看到老列表)
* 3) 卸载后挂起的请求不会触发 setState
* aliveRef 守卫;漏了会出 React "setState on unmounted" warning
*/
import { StrictMode, useEffect } from 'react'
import { act, render } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { STORAGE_KEY_INTERPRETER, readStoredInterpreterPath, useInterpreters } from '../useInterpreters'
import { wrap } from '../../i18n/test-utils'
import { I18nProvider } from '../../i18n'
import type { InterpreterInfo } from '../../../../shared/analysis'
const PY_A: InterpreterInfo = {
path: 'C:/python/python.exe',
version: '3.11'
}
const PY_B: InterpreterInfo = {
path: 'C:/python/python2.exe',
version: '3.10'
}
interface FakeApi {
detectInterpreters: ReturnType<typeof vi.fn>
pickInterpreter: ReturnType<typeof vi.fn>
}
interface ProbeProps {
onReady: (api: ReturnType<typeof useInterpreters>) => void
/** 控制是否让自动 detectAll 的 promise pending用于排查 StrictMode 时序) */
autoDetectResolverRef?: { current: ((l: InterpreterInfo[]) => void) | null }
}
function installFakeApi(): FakeApi {
const api: FakeApi = {
detectInterpreters: vi.fn().mockResolvedValue([PY_A, PY_B]),
pickInterpreter: vi.fn().mockResolvedValue(PY_A)
}
;(window as unknown as { api: FakeApi }).api = api
return api
}
function Probe({ onReady }: ProbeProps) {
const api = useInterpreters()
useEffect(() => {
onReady(api)
})
return <div data-testid="probe" />
}
describe('useInterpreters', () => {
let latest: ReturnType<typeof useInterpreters> | null = null
let api: FakeApi | null = null
beforeEach(async () => {
latest = null
api = installFakeApi()
// 自动 detectAll 在 mount useEffect 里发起 → 异步 resolve → setInterpreters / setSelected。
// 这条 setState 链路必须放在 act 里跑,否则 React 18 会喷
// "An update to Probe inside a test was not wrapped in act(...)"。
// 多 await 几次把 microtask 队列彻底排空(含 Promise.resolve 链)。
await act(async () => {
render(wrap(<Probe onReady={(a) => (latest = a)} />))
await new Promise((r) => setTimeout(r, 0))
})
if (!latest) throw new Error('Probe useEffect 未跑')
})
afterEach(() => {
latest = null
api = null
vi.restoreAllMocks()
})
function hook(): ReturnType<typeof useInterpreters> {
if (!latest) throw new Error('hook 未挂载')
return latest
}
it('mount 时自动调一次 detectAll并填好 selected / interpreters', async () => {
// 默认 resolve 是同步的 microtaskact flush 后状态应已落地
await act(async () => {
await Promise.resolve()
})
expect(api!.detectInterpreters).toHaveBeenCalledTimes(1)
expect(hook().interpreters).toEqual([PY_A, PY_B])
// 默认选第一个
expect(hook().selected).toBe(PY_A.path)
expect(hook().detecting).toBe(false)
expect(hook().detectError).toBeNull()
expect(hook().pickError).toBeNull()
})
it('当前 selected 仍在列表里时,重 detect 不替换', async () => {
await act(async () => {
await Promise.resolve()
})
expect(hook().selected).toBe(PY_A.path)
// 用户手动选到 PY_B
await act(async () => {
hook().selectPath(PY_B.path)
})
expect(hook().selected).toBe(PY_B.path)
// 第二次 detect 返回同样的列表 — selected 应保留
api!.detectInterpreters.mockResolvedValueOnce([PY_A, PY_B])
await act(async () => {
await hook().detectAll()
})
expect(hook().selected).toBe(PY_B.path)
})
it('当前 selected 不在新列表里时,自动回退到第一个', async () => {
await act(async () => {
await Promise.resolve()
})
// 把 selected 切到一个不存在的路径
await act(async () => {
hook().selectPath('C:/ghost/whatever.exe')
})
expect(hook().selected).toBe('C:/ghost/whatever.exe')
// 新一轮 detect 只返 PY_B
api!.detectInterpreters.mockResolvedValueOnce([PY_B])
await act(async () => {
await hook().detectAll()
})
expect(hook().selected).toBe(PY_B.path)
})
it('并发 detectAll旧请求 resolve 晚到不应覆盖新结果tokenRef 守卫)', async () => {
await act(async () => {
await Promise.resolve()
})
let resolveStale!: (l: InterpreterInfo[]) => void
let resolveFresh!: (l: InterpreterInfo[]) => void
api!.detectInterpreters
.mockImplementationOnce(() => new Promise<InterpreterInfo[]>((r) => (resolveStale = r)))
.mockImplementationOnce(() => new Promise<InterpreterInfo[]>((r) => (resolveFresh = r)))
// 启动旧请求 — setDetecting(true) 必须包进 act否则 React 18 喷
// "An update to Probe inside a test was not wrapped in act(...)"。
let stale!: Promise<void>
let fresh!: Promise<void>
await act(async () => {
stale = hook().detectAll()
fresh = hook().detectAll()
})
// 新请求先 resolve
await act(async () => {
resolveFresh([PY_B])
await fresh
})
expect(hook().interpreters).toEqual([PY_B])
// 旧请求晚到 resolve — 应被忽略tokenRef 已 bump
await act(async () => {
resolveStale([PY_A])
await stale
})
expect(hook().interpreters).toEqual([PY_B])
})
it('detectAll 出错时写入 detectError并把 kind 带出来', async () => {
await act(async () => {
await Promise.resolve()
})
// 模拟 IpcErrorraw message 带 [IPC_ERROR] 前缀 + JSON
const ipcMsg = `[IPC_ERROR] ${JSON.stringify({
kind: 'interpreter_probe_failed',
message: 'python --version spawn 失败'
})}`
api!.detectInterpreters.mockRejectedValueOnce(new Error(ipcMsg))
await act(async () => {
await hook().detectAll()
})
expect(hook().detectError).toBe('探测解释器失败python --version spawn 失败')
expect(hook().detecting).toBe(false)
})
it('pick 成功时把解释器前置去重并选中它', async () => {
await act(async () => {
await Promise.resolve()
})
// 当前 PY_A 在第一位
expect(hook().interpreters[0].path).toBe(PY_A.path)
// 用户挑了 PY_A但当作"另一个版本"传回来 — path 一样,应去重
const variant: InterpreterInfo = { ...PY_A, version: '3.12' }
api!.pickInterpreter.mockResolvedValueOnce(variant)
await act(async () => {
await hook().pick()
})
// 列表里 PY_A 只剩一份,且 version 是新的
expect(hook().interpreters.filter((i) => i.path === PY_A.path)).toHaveLength(1)
expect(hook().interpreters.find((i) => i.path === PY_A.path)?.version).toBe('3.12')
expect(hook().selected).toBe(PY_A.path)
expect(hook().pickError).toBeNull()
})
it('pick IPC 抛 IpcError 时写入 pickError按 lang 翻译),不动 interpreters', async () => {
await act(async () => {
await Promise.resolve()
})
const beforeList = hook().interpreters
// main 进程改 throw IpcError('interpreter_not_allowed', ...) 后,renderer 走
// readIpcError → formatIpcError 翻译链。中文 locale 下翻译是
// '解释器路径不在白名单:{message}'{message} 占位符回填原文)。
const failure = new Error(
'[IPC_ERROR] ' +
JSON.stringify({
kind: 'interpreter_not_allowed',
message: '不是有效的 Python 解释器路径: foo.exe',
details: { path: 'foo.exe' }
})
)
api!.pickInterpreter.mockRejectedValueOnce(failure)
await act(async () => {
await hook().pick()
})
expect(hook().pickError).toBe('解释器路径不在白名单:不是有效的 Python 解释器路径: foo.exe')
expect(hook().interpreters).toBe(beforeList)
expect(hook().selected).toBe(PY_A.path)
})
it('pick 返回 null用户取消时不报错也不动状态', async () => {
await act(async () => {
await Promise.resolve()
})
api!.pickInterpreter.mockResolvedValueOnce(null)
await act(async () => {
await hook().pick()
})
expect(hook().pickError).toBeNull()
expect(hook().interpreters).toEqual([PY_A, PY_B])
expect(hook().selected).toBe(PY_A.path)
})
it('active 字段返回当前 selected 对应的解释器', async () => {
await act(async () => {
await Promise.resolve()
})
expect(hook().active?.path).toBe(PY_A.path)
await act(async () => {
hook().selectPath(PY_B.path)
})
expect(hook().active?.path).toBe(PY_B.path)
await act(async () => {
hook().selectPath('C:/does-not-exist.exe')
})
expect(hook().active).toBeUndefined()
})
})
describe('useInterpreters under StrictMode', () => {
let api: FakeApi | null = null
beforeEach(() => {
api = installFakeApi()
})
afterEach(() => {
api = null
vi.restoreAllMocks()
})
it('StrictMode 下 mount→cleanup→mount 只触发一次自动 detectAllhasAutoDetectedRef', async () => {
const ProbeSM = () => {
useInterpreters()
return <div />
}
await act(async () => {
render(
<StrictMode>
<I18nProvider>
<ProbeSM />
</I18nProvider>
</StrictMode>
)
// StrictMode 下 mount 走 setup → cleanup → setupcleanup 会把 aliveRef 置 false
// 然后第二次 setup 复位为 true这套时序连带一次额外 setDetecting(true)。
// 用 setTimeout 0 把整条 microtask 链排空,连带 React 18 调度。
await new Promise((r) => setTimeout(r, 0))
})
// 关键断言:即使 React StrictMode 双调用 mountdetectInterpreters 也只能
// 调一次hasAutoDetectedRef 在第一次 setup 已被置 true第二次 setup 直接
// short-circuit。如果这条失效IPC 探测会成对触发,启动延迟翻倍。
expect(api!.detectInterpreters).toHaveBeenCalledTimes(1)
})
it('StrictMode 第二次 setup 后detecting 收尾 finally 不被 aliveRef=false 短路', async () => {
// 之前 aliveRef 在 setup 里没复位 → cleanup 之后所有 finally 块里的
// "if (aliveRef.current) setDetecting(false)" 都 falsedetecting 卡在 true。
// 现在 setup 复位 aliveRef=truefinally 正常跑detecting 必须收尾。
let captured: ReturnType<typeof useInterpreters> | null = null
const ProbeSM = () => {
const h = useInterpreters()
useEffect(() => {
if (!captured) captured = h
})
return <div />
}
await act(async () => {
render(
wrap(
<StrictMode>
<ProbeSM />
</StrictMode>
)
)
await new Promise((r) => setTimeout(r, 0))
})
expect(captured).not.toBeNull()
expect(captured!.detecting).toBe(false)
})
})
/**
* 解释器选择的 localStorage 持久化pyrof.interpreter
*
* 跟 useTheme / useScope 等一套套路 —— 测试关注四条不变式:
* 1) 读mount 时如果 localStorage 里有持久值selected 用持久值作初值(这条让
* 「重启软件后保留用户选择」真正成立 — 否则选中值丢了)。
* 2) 写 selectPath用户手动换选项localStorage 跟着更新。
* 3) 写 pick用户走「浏览…」选了一个新解释器pick 成功后也写。
* 4) 写 fallbackdetectAll 发现持久路径在新列表里找不到 → 回退到第一个;
* 这条 fallback 也必须写回 localStorage否则下次启动又跑 fallback浪费一次
* IPC,理论上无限循环到用户手动重 detect
*
* 每个 case 自己清 localStorage —— 上面的 describe 没清理,可能有残留。
*/
describe('useInterpreters persistence (pyrof.interpreter)', () => {
// 每个 case 自己清 localStorage + 自己 mount。
// `hook` 用 `function` 声明 + 显式返回类型 —— `const hook = () => { if (!latest) throw... }`
// 的隐式返回类型在跨闭包捕获 `let` 时 TS 推不出,会变成 `never`(TS 5.x 已知行为)。
// 关键:`latest` 在描述级(而非函数返回值)持有 —— `useInterpreters()` 每次 render
// 都返回新对象,测试如果只持有 mount 时的快照,后续 selectPath / pick 触发的
// re-render 不会回头更新那个 snapshot,只能读 stale 值。
afterEach(() => {
window.localStorage.clear()
vi.restoreAllMocks()
})
it('mount 时从 localStorage 读出上次保存的 selected不和 detectAll 冲突)', async () => {
window.localStorage.setItem(STORAGE_KEY_INTERPRETER, PY_B.path)
installFakeApi()
let latest: ReturnType<typeof useInterpreters> | null = null
await act(async () => {
render(wrap(<Probe onReady={(a) => (latest = a)} />))
await new Promise((r) => setTimeout(r, 0))
await Promise.resolve()
})
function hook(): ReturnType<typeof useInterpreters> {
if (!latest) throw new Error('hook 未挂载')
return latest
}
// detectAll 跑完,PY_B 仍在列表里 → selected 保留在 PY_B
expect(hook().selected).toBe(PY_B.path)
expect(hook().active?.path).toBe(PY_B.path)
})
it('localStorage 没值时 mount 后 selected 默认走 detectAll 的兜底(第一个)', async () => {
installFakeApi()
let latest: ReturnType<typeof useInterpreters> | null = null
await act(async () => {
render(wrap(<Probe onReady={(a) => (latest = a)} />))
await new Promise((r) => setTimeout(r, 0))
await Promise.resolve()
})
function hook(): ReturnType<typeof useInterpreters> {
if (!latest) throw new Error('hook 未挂载')
return latest
}
expect(hook().selected).toBe(PY_A.path)
// mount 时 useEffect 也会写一次 — 当下 selected 是 PY_A,localStorage 应同步过去
expect(window.localStorage.getItem(STORAGE_KEY_INTERPRETER)).toBe(PY_A.path)
})
it('selectPath 切值时同步写 localStorage', async () => {
installFakeApi()
let latest: ReturnType<typeof useInterpreters> | null = null
await act(async () => {
render(wrap(<Probe onReady={(a) => (latest = a)} />))
await new Promise((r) => setTimeout(r, 0))
await Promise.resolve()
})
function hook(): ReturnType<typeof useInterpreters> {
if (!latest) throw new Error('hook 未挂载')
return latest
}
// 切到 PY_B —— act 包住 selectPath 后多 await 一次 Promise.resolve,让
// selectPath → setState → re-render → Probe.onReady → latest 更新这条
// microtask 链 flush 干净
await act(async () => {
hook().selectPath(PY_B.path)
await Promise.resolve()
})
expect(window.localStorage.getItem(STORAGE_KEY_INTERPRETER)).toBe(PY_B.path)
expect(hook().selected).toBe(PY_B.path)
// 切回 PY_A 也写
await act(async () => {
hook().selectPath(PY_A.path)
await Promise.resolve()
})
expect(window.localStorage.getItem(STORAGE_KEY_INTERPRETER)).toBe(PY_A.path)
})
it('pick 成功时同步写 localStorage', async () => {
installFakeApi()
const api = (window as unknown as { api: FakeApi }).api
let latest: ReturnType<typeof useInterpreters> | null = null
await act(async () => {
render(wrap(<Probe onReady={(a) => (latest = a)} />))
await new Promise((r) => setTimeout(r, 0))
await Promise.resolve()
})
function hook(): ReturnType<typeof useInterpreters> {
if (!latest) throw new Error('hook 未挂载')
return latest
}
const NEW_PY: InterpreterInfo = {
path: 'C:/python/custom/python.exe',
version: '3.12.0'
}
api.pickInterpreter.mockResolvedValueOnce(NEW_PY)
await act(async () => {
await hook().pick()
await Promise.resolve()
})
expect(hook().selected).toBe(NEW_PY.path)
expect(window.localStorage.getItem(STORAGE_KEY_INTERPRETER)).toBe(NEW_PY.path)
})
it('detectAll fallback:持久路径不在新列表里 → 切到第一个,并写回 localStorage', async () => {
// 模拟「上次选的 python 已经被用户卸载 / 重装到别的路径」。
window.localStorage.setItem(STORAGE_KEY_INTERPRETER, 'C:/ghost/whatever.exe')
installFakeApi()
const api = (window as unknown as { api: FakeApi }).api
let latest: ReturnType<typeof useInterpreters> | null = null
await act(async () => {
render(wrap(<Probe onReady={(a) => (latest = a)} />))
await new Promise((r) => setTimeout(r, 0))
await Promise.resolve()
})
function hook(): ReturnType<typeof useInterpreters> {
if (!latest) throw new Error('hook 未挂载')
return latest
}
// detectAll mock 默认返 [PY_A, PY_B] —— 都不包含 ghost 路径 → fallback 到 PY_A
expect(hook().selected).toBe(PY_A.path)
expect(api.detectInterpreters).toHaveBeenCalled()
expect(window.localStorage.getItem(STORAGE_KEY_INTERPRETER)).toBe(PY_A.path)
})
it('detectAll fallback:持久路径还在新列表里 → 保持不写穿,selected 不变', async () => {
// 与上一条对照:保持路径合法,没有触发 fallback,不应当把路径意外改了。
window.localStorage.setItem(STORAGE_KEY_INTERPRETER, PY_B.path)
installFakeApi()
let latest: ReturnType<typeof useInterpreters> | null = null
await act(async () => {
render(wrap(<Probe onReady={(a) => (latest = a)} />))
await new Promise((r) => setTimeout(r, 0))
})
function hook(): ReturnType<typeof useInterpreters> {
if (!latest) throw new Error('hook 未挂载')
return latest
}
expect(hook().selected).toBe(PY_B.path)
// 即使 selected 没变,mount 时 useEffect 也会写一次(值不变,无副作用)
expect(window.localStorage.getItem(STORAGE_KEY_INTERPRETER)).toBe(PY_B.path)
})
it('readStoredInterpreterPath 是公共工具函数,localStorage 读不到时回退空串', () => {
window.localStorage.clear()
expect(readStoredInterpreterPath()).toBe('')
window.localStorage.setItem(STORAGE_KEY_INTERPRETER, 'C:/python/python.exe')
expect(readStoredInterpreterPath()).toBe('C:/python/python.exe')
})
})

View File

@@ -0,0 +1,136 @@
import { act, renderHook } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { useResizableFraction } from '../useResizableFraction'
const STORAGE_KEY = 'pyrof.split.test'
const baseOptions = { default: 0.5, min: 0.2, max: 0.8 }
afterEach(() => {
window.localStorage.clear()
})
describe('useResizableFraction', () => {
it('无 localStorage 时回退到 default', () => {
window.localStorage.clear()
const { result } = renderHook(() => useResizableFraction(STORAGE_KEY, baseOptions))
expect(result.current.fraction).toBe(0.5)
})
it('localStorage 里合法值被读出', () => {
window.localStorage.setItem(STORAGE_KEY, '0.6')
const { result } = renderHook(() => useResizableFraction(STORAGE_KEY, baseOptions))
expect(result.current.fraction).toBe(0.6)
})
it('localStorage 里非法值NaN / 字符串)降级到 default', () => {
window.localStorage.setItem(STORAGE_KEY, 'not-a-number')
const { result } = renderHook(() => useResizableFraction(STORAGE_KEY, baseOptions))
expect(result.current.fraction).toBe(0.5)
})
it('localStorage 里超出范围的值被 clamp 到 min/max', () => {
window.localStorage.setItem(STORAGE_KEY, '0.99')
const { result } = renderHook(() => useResizableFraction(STORAGE_KEY, baseOptions))
expect(result.current.fraction).toBe(0.8)
window.localStorage.setItem(STORAGE_KEY, '0.01')
const second = renderHook(() => useResizableFraction(STORAGE_KEY, baseOptions))
expect(second.result.current.fraction).toBe(0.2)
})
it('setFraction 写入 localStorage 并切换 state', () => {
window.localStorage.clear()
const { result } = renderHook(() => useResizableFraction(STORAGE_KEY, baseOptions))
act(() => result.current.setFraction(0.7))
expect(result.current.fraction).toBe(0.7)
expect(window.localStorage.getItem(STORAGE_KEY)).toBe('0.7')
act(() => result.current.setFraction(0.5))
expect(result.current.fraction).toBe(0.5)
})
it('setFraction 接受函数式更新', () => {
window.localStorage.setItem(STORAGE_KEY, '0.5')
const { result } = renderHook(() => useResizableFraction(STORAGE_KEY, baseOptions))
act(() => result.current.setFraction((prev) => prev + 0.1))
expect(result.current.fraction).toBe(0.6)
})
it('setFraction 函数式更新连写多次不丢更新(不走 stale closure', () => {
// 旧实现闭包了 fraction,连写两次 `prev => prev + 0.05` 会用同一份 stale base,
// 第二次被吞。现在 functional setState 后两次都按各自 prev 算:0.5 → 0.55 → 0.6。
window.localStorage.setItem(STORAGE_KEY, '0.5')
const { result } = renderHook(() => useResizableFraction(STORAGE_KEY, baseOptions))
act(() => {
result.current.setFraction((prev) => prev + 0.05)
result.current.setFraction((prev) => prev + 0.05)
})
expect(result.current.fraction).toBeCloseTo(0.6, 5)
})
it('setFraction 自动 clamp 到 [min,max]', () => {
const { result } = renderHook(() => useResizableFraction(STORAGE_KEY, baseOptions))
act(() => result.current.setFraction(0.95))
expect(result.current.fraction).toBe(0.8)
act(() => result.current.setFraction(0.05))
expect(result.current.fraction).toBe(0.2)
})
it('applyDelta 按容器宽度换算成 fraction 增量', () => {
window.localStorage.setItem(STORAGE_KEY, '0.5')
const { result } = renderHook(() => useResizableFraction(STORAGE_KEY, baseOptions))
// 容器 1000px,拖 100px → 0.1 增量
act(() => result.current.applyDelta(100, 1000))
expect(result.current.fraction).toBeCloseTo(0.6, 5)
// 再拖 -50px → 回到 0.55
act(() => result.current.applyDelta(-50, 1000))
expect(result.current.fraction).toBeCloseTo(0.55, 5)
})
it('applyDelta 累积超出范围时被 clamp不允许被拖出 [min,max]', () => {
window.localStorage.setItem(STORAGE_KEY, '0.75')
const { result } = renderHook(() => useResizableFraction(STORAGE_KEY, baseOptions))
// 拖很多次累加远超 0.8,但 clamp 在 0.8
act(() => result.current.applyDelta(1000, 1000))
expect(result.current.fraction).toBe(0.8)
})
it('applyDelta 在容器尺寸 <= 0 时静默忽略', () => {
window.localStorage.setItem(STORAGE_KEY, '0.5')
const { result } = renderHook(() => useResizableFraction(STORAGE_KEY, baseOptions))
act(() => result.current.applyDelta(50, 0))
expect(result.current.fraction).toBe(0.5)
act(() => result.current.applyDelta(50, -100))
expect(result.current.fraction).toBe(0.5)
})
it('localStorage 抛错file://)时初始化回退到 default', () => {
const spy = vi.spyOn(window.localStorage, 'getItem').mockImplementation(() => {
throw new Error('SecurityError: file:// access denied')
})
try {
const { result } = renderHook(() => useResizableFraction(STORAGE_KEY, baseOptions))
expect(result.current.fraction).toBe(0.5)
} finally {
spy.mockRestore()
}
})
it('default 落在 [min,max] 之外时也会被 clamp', () => {
const { result } = renderHook(() =>
useResizableFraction(STORAGE_KEY, { default: 1.5, min: 0.2, max: 0.8 })
)
expect(result.current.fraction).toBe(0.8)
})
})

View File

@@ -0,0 +1,71 @@
import { act, renderHook } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { useScope, DEFAULT_SCOPE, STORAGE_KEY_SCOPE, readStoredScope } from '../useScope'
afterEach(() => {
window.localStorage.clear()
})
describe('useScope', () => {
it('默认是 all无 localStorage 时 —— 默认含库函数)', () => {
window.localStorage.clear()
const { result } = renderHook(() => useScope())
expect(result.current.scope).toBe(DEFAULT_SCOPE)
expect(result.current.scope).toBe('all')
})
it('localStorage 持久值 user 被读出(用户主动关过)', () => {
window.localStorage.setItem(STORAGE_KEY_SCOPE, 'user')
const { result } = renderHook(() => useScope())
expect(result.current.scope).toBe('user')
})
it('localStorage 持久值 all 被读出', () => {
window.localStorage.setItem(STORAGE_KEY_SCOPE, 'all')
const { result } = renderHook(() => useScope())
expect(result.current.scope).toBe('all')
})
it('localStorage 里非法值降级为默认 all不抛', () => {
window.localStorage.setItem(STORAGE_KEY_SCOPE, 'bogus')
const { result } = renderHook(() => useScope())
expect(result.current.scope).toBe('all')
})
it('setScope 写 localStorage 并切换 state', () => {
window.localStorage.clear()
const { result } = renderHook(() => useScope())
act(() => result.current.setScope('user'))
expect(result.current.scope).toBe('user')
expect(window.localStorage.getItem(STORAGE_KEY_SCOPE)).toBe('user')
act(() => result.current.setScope('all'))
expect(result.current.scope).toBe('all')
expect(window.localStorage.getItem(STORAGE_KEY_SCOPE)).toBe('all')
})
it('toggle 在 user ↔ all 之间切换,并持久化', () => {
window.localStorage.clear()
const { result } = renderHook(() => useScope())
expect(result.current.scope).toBe('all')
act(() => result.current.toggle())
expect(result.current.scope).toBe('user')
expect(window.localStorage.getItem(STORAGE_KEY_SCOPE)).toBe('user')
act(() => result.current.toggle())
expect(result.current.scope).toBe('all')
expect(window.localStorage.getItem(STORAGE_KEY_SCOPE)).toBe('all')
})
it('readStoredScope 在 localStorage 抛错时静默回默认 allfile:// 场景)', () => {
const spy = vi.spyOn(window.localStorage, 'getItem').mockImplementation(() => {
throw new Error('SecurityError: file:// access denied')
})
try {
expect(readStoredScope()).toBe('all')
} finally {
spy.mockRestore()
}
})
})

View File

@@ -0,0 +1,46 @@
import { act, renderHook } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import { useTheme, DEFAULT_THEME, STORAGE_KEY_THEME, type Theme } from '../useTheme'
afterEach(() => {
window.localStorage.clear()
document.documentElement.classList.remove('dark', 'light')
})
describe('useTheme', () => {
it('默认是 dark无 localStorage 时)', () => {
window.localStorage.clear()
const { result } = renderHook(() => useTheme())
expect(result.current.theme).toBe(DEFAULT_THEME)
expect(result.current.theme).toBe('dark')
})
it('localStorage 持久值 light 被读出', () => {
window.localStorage.setItem(STORAGE_KEY_THEME, 'light')
const { result } = renderHook(() => useTheme())
expect(result.current.theme).toBe('light')
})
it('setTheme 写 localStorage 且切换 <html> class', () => {
window.localStorage.clear()
const { result } = renderHook(() => useTheme())
act(() => result.current.setTheme('light'))
expect(result.current.theme).toBe('light')
expect(window.localStorage.getItem(STORAGE_KEY_THEME)).toBe('light')
expect(document.documentElement.classList.contains('light')).toBe(true)
expect(document.documentElement.classList.contains('dark')).toBe(false)
act(() => result.current.setTheme('dark'))
expect(document.documentElement.classList.contains('dark')).toBe(true)
expect(document.documentElement.classList.contains('light')).toBe(false)
})
it('setTheme(非法值) 仍能落到合法值(编译期已保证,但 runtime 防御)', () => {
// 非法值实际不会传进来hook 类型限制),这里只验证合法切换路径稳定。
const { result } = renderHook(() => useTheme())
for (const t of ['dark', 'light'] as Theme[]) {
act(() => result.current.setTheme(t))
expect(result.current.theme).toBe(t)
}
})
})

View File

@@ -0,0 +1,173 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import type { AnalysisResult, ProgressEvent, RunOptions } from '../../../shared/analysis'
import { readIpcError } from '../../../shared/ipc'
import { useT } from '../i18n'
import { formatIpcError } from '../utils/ipcErrorFormat'
export type RunState = 'idle' | 'running' | 'done' | 'error'
export interface UseAnalysis {
result: AnalysisResult | null
state: RunState
progress: ProgressEvent | null
errorMessage: string | null
/** 引擎日志(取消/出错时附加;可作为折叠区显示) */
stderrTail: string | null
/** Python print() 输出的累积 buffer —— 每次 run 自动清空(之前的输出属于上一轮),
* 需要手动保留时调用 clearStdout() 之外的路径不存在cancel / reset 都不清空。
* RunConsole 是它的唯一消费方。 */
stdout: string
/** 当前 result 对应的 scope'user' | 'all')—— result=null 时为 null。
* result 是旧 run 留下的、用户又切了 scope 时UI 可以拿它跟当前 scope 对比、
* 给一句"结果是上一份 scope 跑出来的,是否重跑"的提示。 */
resultScope: RunOptions['scope'] | null
run: (opts: RunOptions) => Promise<void>
cancel: () => Promise<void>
/** 清空 result/progress/error但不调用 IPC — 给"加载示例"等本地操作复用 */
reset: () => void
/** 清空 stdout 累积 buffer —— RunConsole 头部"清空"按钮调它。 */
clearStdout: () => void
}
export function useAnalysis(): UseAnalysis {
const t = useT()
const [result, setResult] = useState<AnalysisResult | null>(null)
const [state, setState] = useState<RunState>('idle')
const [progress, setProgress] = useState<ProgressEvent | null>(null)
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const [stderrTail, setStderrTail] = useState<string | null>(null)
// stdout 流式累积 buffer。run 起始自动清空(之前的输出属于上一轮),cancel / reset 不清。
// 需要中途手动清空仍走 clearStdout()。RunConsole 是唯一消费方,不做 trim / 截断:
// 主进程 profiler-service 已经用 appendCapped 把每块压到 8MB 上限,这里自然兜住。
const [stdout, setStdout] = useState('')
// 记录当前 result 用的 scope —— result 清空时同步清空(否则会拿旧 scope 跟新 toggle 比较UI 永远 stale
const [resultScope, setResultScope] = useState<RunOptions['scope'] | null>(null)
/** 单调递增的运行代号;陈旧 resolve 会与当前不符,被丢弃 */
const runIdRef = useRef(0)
// 是否接受 progress 事件。cancel() / reset() 立刻置 false吞掉飞行中的陈旧事件
// run() 置 true 重新接收新 run 的进度。
// 用 ref 而不是 token 计数,是为了避免上一版 `progressTokenRef` 的设计缺陷:
// 订阅回调里 `myToken` 是 mount 时一次性捕获的cancel() 把它 bump 之后,
// run() 没有"复位到 myToken"的动作 → 第二次跑的 progress 全部被吞(永远 myToken !== current
// 改用 boolean refrun/cancel/reset 各自直接翻位即可,订阅回调每次重新读 current 值。
const progressActiveRef = useRef(false)
// 组件是否还挂载:卸载后 analyze() 才返回的 setState 全部丢弃。
// 之前只用 runIdRef 防"陈旧 token 覆盖新 run",但用户卸载组件时 runIdRef 不变,
// myId 仍等于 currentsetState 仍会触发(虽不报错但浪费 reconciliation
// StrictMode 下 setup→cleanup→setup 必须重新置 true否则卸载后所有 setState 永久短路。
const aliveRef = useRef(true)
useEffect(() => {
aliveRef.current = true
return () => {
aliveRef.current = false
}
}, [])
useEffect(() => {
// 直接把 unsubscribe 当成 cleanup 返回 — 比用 unsubRef 模式更直观,
// 且在 React StrictMode 双调用下也安全(每次都返回自己的 unsub
// 回调里只读 progressActiveRef.current每次重新读不会被陈旧闭包锁死。
return window.api.onProgress((p) => {
if (!progressActiveRef.current || !aliveRef.current) return
setProgress(p)
})
}, [])
useEffect(() => {
// stdout 流式订阅 —— 不像 progress 那样要 token 守卫(用户要累积,旧 run 的
// 飞行块也要追加进 buffer。但仍要守 aliveRef:组件卸载后别再 setState。
return window.api.onStdout((chunk) => {
if (!aliveRef.current) return
setStdout((prev) => prev + chunk)
})
}, [])
/** 把 run 相关的 state 全部清空并 bump runIdRef + 关掉 progress 接收。
* cancel / reset 都需要这套run 也用它清掉旧状态再开新一轮。
* 顺序:先 bump token 让飞行中的陈旧 resolve 走守卫返回,再清 state。 */
const clearAnalysisState = useCallback(() => {
runIdRef.current++
progressActiveRef.current = false
setResult(null)
setState('idle')
setProgress(null)
setErrorMessage(null)
setStderrTail(null)
setResultScope(null)
}, [])
const run = useCallback(
async (opts: RunOptions) => {
// 连点"运行":先 bump runIdRef 让旧的 resolve 进 token 守卫立刻 return
// 再同步通知主进程 cancel 旧 child —— 否则主进程串行队列里旧 run 会等上一轮
// 自己跑完才轮到新 run浪费算力 + 临时目录延迟清理。
// cancel() 是 async 但同步段已经 bump 了 runIdRef / progressActiveRef + 清空 state
// 不需要 await 它完成才发起新 runIPC 内部 taskkill 走异步)。
clearAnalysisState()
// 新一轮 run 起始清空 stdout —— 之前的输出属于上一轮,留着会和本次的 print() 混在一起,
// 等于丢失"这次运行的输出是哪些"的边界。cancel / reset 走的是 clearAnalysisState,
// 不动 stdout(用户可能想看被 cancel 的部分输出,或者加载示例后想保留旧 log)。
setStdout('')
void window.api.cancel()
const myId = ++runIdRef.current
progressActiveRef.current = true
setState('running')
try {
const r = await window.api.analyze(opts)
if (myId !== runIdRef.current || !aliveRef.current) return
// 提前关 progress 接收,避免 analyze 已 resolve 但飞行中的 progress 事件仍在
// setProgress导致 UI 显示"done 结果 + 旧进度条"短暂不一致。
progressActiveRef.current = false
setResult(r)
setResultScope(opts.scope)
setState('done')
} catch (err) {
// 用户主动 cancel 时 runIdRef 已经 bump —— 这一步之前 cancel() 同步把 state 设回
// 'idle' 了;这里再写 'error' 会让"已取消"看起来像"出错"UI 文案就乱了。
if (myId !== runIdRef.current || !aliveRef.current) return
progressActiveRef.current = false
// IPC 错误统一过 formatIpcError按当前 lang 走翻译表en-US 用户看到的
// 是英文,不再被 hardcoded 的中文 message 覆盖。kind 不在表里('unknown'
// 时直接返回 readableMessage — 通常是 spawn error保留原始细节更有价值。
const ipcErr = readIpcError(err)
const message = formatIpcError(ipcErr, t)
const stderr = typeof ipcErr.details?.['stderr'] === 'string' ? ipcErr.details['stderr'] : undefined
setErrorMessage(message)
if (stderr) setStderrTail(stderr)
setState('error')
}
},
[clearAnalysisState, t]
)
const cancel = useCallback(async () => {
// 关键:先把 runIdRef 提一位,再清状态,最后才 await 主进程 cancel。
// 顺序错了会出"主进程在跑的 resolve 在 setResult(null) 之前到达 → UI 又被覆盖回 result"的窗口。
// 同步把 progressActiveRef 置 false让飞行中的 PROGRESS 事件被订阅回调丢弃,
// 避免 cancel 后进度条仍卡在非零 pct。
clearAnalysisState()
await window.api.cancel()
}, [clearAnalysisState])
const reset = useCallback(() => {
// 递增 runIdRefreset 会把旧 run 的飞行事件作废cancel 走的是同一逻辑),
// 否则旧 run 的 progress 回调继续推到 idle UI会让 setProgress 出现鬼影。
clearAnalysisState()
}, [clearAnalysisState])
return {
result,
state,
progress,
errorMessage,
stderrTail,
stdout,
resultScope,
run,
cancel,
reset,
clearStdout: () => setStdout('')
}
}

View File

@@ -0,0 +1,238 @@
/**
* 外部文件 tab 状态stdlib / 第三方包热点打开的源码 tab
*
* 用户代码 tab **不**在这个 hook 里 —— 它由 App.tsx 的 `code` state 持有,
* 是唯一可写的 tab跟随编辑器 onChange 双向绑定。把用户 tab 拆出去避免
* OpenFile 类型写成 `Exclude<Origin, 'user'>` 的别扭 union也让 App
* 决定「用户代码的内容是什么」时不需要绕过这个 hook。
*
* 设计要点:
* - **去重 key** = `filePath.replace(/\\/g, '/').toLowerCase()`Windows 下
* 文件系统大小写不敏感,`C:\Python311\Lib\json\__init__.py` 和
* `c:/python311/lib/json/__init__.py` 必须映射到同一个 tab。POSIX 下
* 大小写敏感,但 toLowerCase 后错把大小写不同的两个文件当一个 —— 罕见
* stdlib 不会这样用),可以接受。
* - **StrictMode / 卸载安全**StrictMode mount → cleanup → mount 用 aliveRef
* 守卫;卸载后 pending IPC 不会触发 setStateReact 18 会喷 warning
* - **race 处理**openFile 在 IPC await 之后再 patch tab。如果 await 期间用户
* close 了该 tabpatch 时检查 tab 是否还在外部 tab 列表里 —— 不在就丢弃。
* - **pendingReveal**openFile 时把 `{tabId, line}` 暂存到 ref同步
* 渲染层在 status 从 loading → ready 的 useEffect 里调 consumePendingReveal
* 拿到 line调 editorRef.current?.revealLine(line)。同一个 tab 多次点
* 不同行 → 多次推 pendingReveal → 只保留最后一次的 line。
*
* 不做的事:
* - 不持久化到 localStorage —— 重启后用户重新点热点开 tab比"恢复一堆
* 已经过时的源码 tab"更符合预期。
* - 不限 tab 数量 —— 浏览器 tab 风格横向滚动条兜底,正常使用不会开几十个。
*/
import { useCallback, useEffect, useRef, useState } from 'react'
/** 排除 user —— 用户代码 tab 不走这个类型。 */
export type ExternalOrigin = 'stdlib' | 'third_party' | 'builtin' | 'frozen' | 'other'
export type OpenFileStatus = 'loading' | 'ready' | 'error'
/** FileReadResult 里的错误 kind 加一个 unknowncatch 兜底用)。
* 跟 shared/analysis 的 FileReadResult 错误分支保持一致ok 不算 error。 */
export type FileReadErrorKind =
'not_found' | 'not_a_file' | 'permission_denied' | 'too_large' | 'invalid_path' | 'unknown'
export interface OpenFile {
/** 稳定的 tab iduseExternalFiles 内部自增分配)。 */
id: string
/** 绝对文件路径。 */
filePath: string
/** 归一化后的去重 key小写 + 正斜杠)。 */
dedupKey: string
/** tab 上展示的短名(如 `json/__init__.py`),过长截断。 */
displayName: string
/** origin 决定 tab 颜色和只读语义。 */
origin: ExternalOrigin
/** 文件内容status=loading 时是空串status=error 时是 IPC 错误 message。 */
content: string
status: OpenFileStatus
/** 主进程返回的错误 kindUI 拿它走 i18n 表。 */
errorKind?: FileReadErrorKind
/** 原始错误 messageIPC 给的,已翻译无需再做 i18n。 */
errorMessage?: string
/** 文件大小bytes成功才有。 */
sizeBytes?: number
}
/** App.tsx 通过这个对象拿到 activeTabId 切换的副作用钩子reveal line。 */
export interface PendingReveal {
tabId: string
line: number
}
export interface UseExternalFilesReturn {
externalTabs: OpenFile[]
/** null = 当前活跃 tab 是用户代码 tab。 */
activeExternalId: string | null
/**
* 打开或激活一个外部文件 tab。
* - 同 path 的 tab 已存在 → 设为 active排队 reveal
* - 不存在 → 创建 loading tab发 IPC成功置 ready失败置 error
*/
openFile: (filePath: string, line: number, origin: ExternalOrigin) => void
/** 关闭一个外部 tab。关掉的是 active 时App 应切回用户 tab。 */
closeFile: (tabId: string) => void
/** 直接设 active给 EditorTabs onSelect 用)。 */
setActive: (tabId: string | null) => void
/** 取走最近一次 openFile 暂存的 reveal 信息ready transition effect 里调。 */
consumePendingReveal: () => PendingReveal | null
/** 全部外部 tab 的总关接口(给 onLoadSample / onNewBlank 用)。 */
closeAll: () => void
}
/** 去重 key跨平台大小写不敏感罕见误合并可接受见文件头注释。 */
function makeDedupKey(filePath: string): string {
return filePath.replace(/\\/g, '/').toLowerCase()
}
/**
* 把绝对路径变成短展示名。规则:
* - 取最后 1-2 段路径段POSIX `/` + Windows `\` 都处理)
* - 单段时直接展示(如 `foo.py`
* - 末段超过 32 字符只取末段(避免 `json/__init__.py` 因为 `__init__` 被截掉)
*/
export function makeDisplayName(filePath: string): string {
const parts = filePath.replace(/\\/g, '/').split('/').filter(Boolean)
if (parts.length === 0) return filePath
if (parts.length === 1) return parts[0]
const last = parts[parts.length - 1]
const secondLast = parts[parts.length - 2]
// 末段本身够短≤32 字符),展示「父/末」两段,区分同目录不同文件
if (last.length <= 32) return `${secondLast}/${last}`
return last
}
let _tabCounter = 0
function nextTabId(): string {
_tabCounter += 1
return `ext-${_tabCounter}`
}
export function useExternalFiles(): UseExternalFilesReturn {
const [externalTabs, setExternalTabs] = useState<OpenFile[]>([])
const [activeExternalId, setActiveExternalId] = useState<string | null>(null)
// 闭包安全openFile / closeFile 是 useCallback 但内部读 externalTabs 会拿到
// 渲染时的快照。stale closure 在「快速点同一个文件两次」时会让第二次去重失败,
// 重复开 tab。用 ref 持最新值,函数体读 ref.current。
const tabsRef = useRef(externalTabs)
tabsRef.current = externalTabs
// StrictMode / 卸载安全。mount → cleanup → mount 路径:第一次 cleanup 置 false
// 后第二次 setup 必须显式复位(见 useInterpreters 同款注释)。
const aliveRef = useRef(true)
useEffect(() => {
aliveRef.current = true
return () => {
aliveRef.current = false
}
}, [])
// 待 reveal 的 lineopenFile 时同步写App.tsx 在 status ready 的 effect 里
// 调 consumePendingReveal 拿走。用 ref 而非 state —— 触发不了额外渲染,
// 也避免「同 tab 多次点击 → state 被覆盖」的中间态。
const pendingRevealRef = useRef<PendingReveal | null>(null)
const openFile = useCallback((filePath: string, line: number, origin: ExternalOrigin) => {
const dedupKey = makeDedupKey(filePath)
// 1) 去重:同 path 已开 → 激活 + 排队 revealreturn
const existing = tabsRef.current.find((t) => t.dedupKey === dedupKey)
if (existing) {
setActiveExternalId(existing.id)
pendingRevealRef.current = { tabId: existing.id, line }
return
}
// 2) 创建 loading tab设为 active
const tabId = nextTabId()
const loadingTab: OpenFile = {
id: tabId,
filePath,
dedupKey,
displayName: makeDisplayName(filePath),
origin,
content: '',
status: 'loading'
}
setExternalTabs((prev) => [...prev, loadingTab])
setActiveExternalId(tabId)
pendingRevealRef.current = { tabId, line }
// 3) 发 IPCfire-and-forget错误在 promise 链里 catch
void (async () => {
try {
const result = await window.api.readFile({ filePath })
if (!aliveRef.current) return
// 关键 race 检查await 期间用户可能 close 了 tab或又触发了 openFile
// 把同名 tab 重开成 loading理论上不会因为我们按 dedup 去重,
// 但 close 后再点会触发新一轮;先 close 再 open 的场景)。
// 用 functional setState + 检查当前列表里是否还**有同名 dedupKey 的 tab**
// 且 id === tabId避免把「已被新 openFile 替换的旧 tab」再覆盖回去。
setExternalTabs((prev) => {
const target = prev.find((t) => t.id === tabId)
if (!target) return prev
if (result.kind === 'ok') {
return prev.map((t) =>
t.id === tabId
? { ...t, status: 'ready' as const, content: result.content, sizeBytes: result.size }
: t
)
}
return prev.map((t) =>
t.id === tabId
? { ...t, status: 'error' as const, errorKind: result.kind, errorMessage: result.message }
: t
)
})
} catch (err) {
if (!aliveRef.current) return
// 真正的异常IPC 通道挂、IpcError 之类)—— 把 message 包成 unknown kind
const message = err instanceof Error ? err.message : String(err)
setExternalTabs((prev) => {
const target = prev.find((t) => t.id === tabId)
if (!target) return prev
return prev.map((t) =>
t.id === tabId
? { ...t, status: 'error' as const, errorKind: 'unknown', errorMessage: message }
: t
)
})
}
})()
}, [])
const closeFile = useCallback((tabId: string) => {
setExternalTabs((prev) => prev.filter((t) => t.id !== tabId))
setActiveExternalId((prev) => (prev === tabId ? null : prev))
}, [])
const setActive = useCallback((tabId: string | null) => {
setActiveExternalId(tabId)
}, [])
const consumePendingReveal = useCallback((): PendingReveal | null => {
const p = pendingRevealRef.current
pendingRevealRef.current = null
return p
}, [])
const closeAll = useCallback(() => {
setExternalTabs([])
setActiveExternalId(null)
pendingRevealRef.current = null
}, [])
return {
externalTabs,
activeExternalId,
openFile,
closeFile,
setActive,
consumePendingReveal,
closeAll
}
}

View File

@@ -0,0 +1,191 @@
/**
* Python 解释器检测逻辑。
*
* 行为要点:
* - mount 时自动调 detectAll()
* - StrictMode 双调用 / 卸载竞态由 aliveRef 守卫
* - 当前 selected 若仍在列表里则保留,否则回退到第一个
*
* 并发请求守卫tokenRef
* - 之前只有 aliveRef渲染后用户连点「刷新」或 pick 后又触发了 detectAll
* 两个 IPC 调用并行跑,晚到的旧调用会 setState 覆盖新结果。
* - 现在 detectAll / pick 各自带 myTokenawait 之后比对 tokenRef.current
* 已被新调用作废的旧调用 short-circuit return。
*
* selected 持久化pyrof.interpreter
* - 跟 theme / shell / lang / scope 一套 localStorage 持久化,避免重启软件
* 后用户在 Settings 里手动选过的解释器丢失。
* - 检测时当前 selected 不在新列表里 → 回退到第一个;这条兜底路径也要写回
* 持久化,否则下次启动又跑回无效路径再 fallback循环
*/
import { useCallback, useEffect, useRef, useState } from 'react'
import type { InterpreterInfo } from '../../../shared/analysis'
import { readIpcError } from '../../../shared/ipc'
import { useT } from '../i18n'
import { formatIpcError } from '../utils/ipcErrorFormat'
/**
* 解释器选择的 localStorage key。跟 theme / shell / lang / scope 一个套路。
*
* 不存 InterpreterInfo 全字段path + version—— version 随 python 升级会变,
* 存 version 反而容易过期path 是稳定标识version 在每次启动重新探测得到。
*/
export const STORAGE_KEY_INTERPRETER = 'pyrof.interpreter'
/** 从 localStorage 读出上次保存的解释器路径;读不到或 localStorage 不可用时返回 ''。 */
export function readStoredInterpreterPath(): string {
try {
return localStorage.getItem(STORAGE_KEY_INTERPRETER) ?? ''
} catch {
// file:// / 无存储权限时静默回退 — 不挡启动
return ''
}
}
/** 把当前 selected path 写回 localStorage。
* path 为空字符串时不写入(旧值会被删除),避免把 "无选择" 这个瞬态写成持久值。 */
function persistInterpreterPath(path: string): void {
try {
if (path) localStorage.setItem(STORAGE_KEY_INTERPRETER, path)
else localStorage.removeItem(STORAGE_KEY_INTERPRETER)
} catch {
// localStorage 不可用时静默吞 — 跟 useTheme / useShellPreference 一个套路
}
}
export interface UseInterpretersResult {
interpreters: InterpreterInfo[]
selected: string
/**
* 切换当前选中的解释器路径。原名 setSelected → selectPath凸显"操作的是 path"
* Settings 下拉、MinimalRunConfig 提示行都通过它改 selected。
*/
selectPath: (path: string) => void
detecting: boolean
detectError: string | null
pickError: string | null
/** 重新扫描系统中的解释器 */
detectAll: () => Promise<void>
/** 弹出系统文件选择器让用户手动选 python.exe */
pick: () => Promise<void>
/** 当前选中的解释器undefined = 未选) */
active: InterpreterInfo | undefined
}
export function useInterpreters(): UseInterpretersResult {
const t = useT()
const [interpreters, setInterpreters] = useState<InterpreterInfo[]>([])
// 初始值从 localStorage 读:上次选择过的解释器,重启后默认选中。
// 与 theme / shell / scope 同套路 —— 单 tab 单窗口,跨 tab 同步无收益,不监听 storage 事件。
const [selected, setSelectedState] = useState<string>(() => readStoredInterpreterPath())
const [detecting, setDetecting] = useState(false)
const [pickError, setPickError] = useState<string | null>(null)
const [detectError, setDetectError] = useState<string | null>(null)
/**
* selected 一变就同步写回 localStorage。mount 那次也跑一次:
* - 持久值非空时等同于「再写一次相同值」,无副作用。
* - 持久值为空时把空值 removeItem —— 跟 detectAll fallback 兜底逻辑保持一致。
*
* 走 useEffect 而不是包装 setState callback —— 是 React 的标准姿势:
* functional updater 在 StrictMode 下会被调用两次,放进 useEffect 走 commit 后
* 单次执行更安全。这里没在 detectAll 里写 setSelectedState callback 的
* "current 兜底"逻辑 —— 见下方 detectAll 内注释。
*/
useEffect(() => {
persistInterpreterPath(selected)
}, [selected])
const aliveRef = useRef(true)
useEffect(() => {
// StrictMode 下 mount 周期是 setup → cleanup → setupcleanup 把 aliveRef 置 false 后
// 第二次 setup 必须显式复位,否则后续异步 resolve 全部 short-circuit检测永远完不成
aliveRef.current = true
return () => {
aliveRef.current = false
}
}, [])
// 并发请求号:每次 detectAll / pick 启动时 ++await 之后若号不对应就丢弃。
const requestTokenRef = useRef(0)
// StrictMode 双调用守卫mount 时自动 detectAll 一次,第二次 setup 直接跳过。
const hasAutoDetectedRef = useRef(false)
const detectAll = useCallback(async () => {
const myToken = ++requestTokenRef.current
setDetecting(true)
setDetectError(null)
try {
const list = await window.api.detectInterpreters()
if (!aliveRef.current || myToken !== requestTokenRef.current) return
setInterpreters(list)
// setSelectedState(functional) 写法:从 current 推断"保留 / 回退",然后
// useEffect 听到 selected 变了再 persistInterpreterPath —— fallback 路径也走这条,
// 否则持久化里一直是个无效路径,下次启动又跑 fallback(浪费一次 IPC)。
setSelectedState((current) => {
if (current && list.find((i) => i.path === current)) return current
return list[0]?.path ?? ''
})
} catch (err) {
if (!aliveRef.current || myToken !== requestTokenRef.current) return
// IPC 错误统一过 formatIpcError按当前 lang 走翻译表en-US 用户
// 看到的是英文而不是 main 进程 hardcoded 的中文 message。
const ipcErr = readIpcError(err)
setDetectError(formatIpcError(ipcErr, t))
} finally {
if (aliveRef.current && myToken === requestTokenRef.current) setDetecting(false)
}
}, [t])
useEffect(() => {
// StrictMode mount → cleanup → mount第一次 cleanup 后 hasAutoDetectedRef 已被设 true
// 第二次 setup 直接 short-circuit避免两路并行 IPC 探测(每个探测都跑 20+ fs probe
if (hasAutoDetectedRef.current) return
hasAutoDetectedRef.current = true
// detectAll 内部已经把失败 catch 在 finally 里,外部无未处理 rejection —— 用 void 标
// 记"故意 fire-and-forget",让 ESLint 不会再警告无 await 的 promise
void detectAll()
}, [detectAll])
const pick = useCallback(async () => {
// pick 必须用自己的 token —— 否则会和 detectAll 共享 requestTokenRef
// pick 在 mount 时 detectAll 未结束时被触发会让 detectAll 的 finally
// 因为 token 不匹配而 short-circuit结果 `setDetecting(false)` 永远不调用,
// 「detecting」卡在 true 永久禁用 Settings 里的下拉框。
const myToken = ++requestTokenRef.current
setPickError(null)
try {
const info = await window.api.pickInterpreter()
if (!aliveRef.current || myToken !== requestTokenRef.current) return
// null = 用户在系统对话框里取消 —— 静默返回,不报错
if (!info) return
setInterpreters((prev) => {
const without = prev.filter((i) => i.path !== info.path)
return [info, ...without]
})
setSelectedState(info.path)
} catch (err) {
if (!aliveRef.current || myToken !== requestTokenRef.current) return
// main 进程在选错文件 / 解释器启动失败时统一 throw IpcError(IPC_ERROR_I18N_KEYS
// 表里 'interpreter_not_allowed' / 'interpreter_probe_failed' 已有翻译);
// 这里走 formatIpcError → en-US 用户看到的是 "Selected file is not a valid Python
// interpreter" 而不是中文 message。
const ipcErr = readIpcError(err)
setPickError(formatIpcError(ipcErr, t))
}
}, [t])
const active = interpreters.find((i) => i.path === selected)
return {
interpreters,
selected,
selectPath: setSelectedState,
detecting,
detectError,
pickError,
detectAll,
pick,
active
}
}

View File

@@ -0,0 +1,123 @@
/**
* 区域尺寸 hook —— 主区↔编辑器 / 主区↔终端的 splitter 都用它。
*
* 持久化到 localStorage:`pyrof.split.<region>`(由调用方传 key)。
* 默认 / 范围超出 → clamp 到 [min, max],不让用户把面板拖没或拖爆。
*
* applyDelta(deltaPx, containerSize) 把像素 delta 按容器宽度换算成 fraction 增量:
* deltaFrac = deltaPx / containerSize
* 容器尺寸 <= 0 时静默忽略 —— splitter 还没拿到尺寸时拖动是常见竞态,
* 不应该让 fraction 跳变成 NaN / Infinity。
*
* setFraction 接受函数式更新 (setFraction(prev => prev + 0.05)),splitter 内部
* 累加拖拽时用得到。
*
* file:// 下 localStorage 会抛 SecurityError:跟 useTheme / useScope 一样静默吞,
* 行为退化成「本次会话内存里有尺寸,刷新后回 default」。
*/
import { useCallback, useState } from 'react'
interface Options {
/** 默认 fraction,初始化时若 localStorage 没有值就用它(也会被 clamp) */
default: number
/** 最小值,拖不到这以下 */
min: number
/** 最大值,拖不到这以上 */
max: number
}
function clamp(v: number, min: number, max: number): number {
// NaN 单独处理 —— NaN < min / NaN > max 都返回 false,不挡的话会直接落穿到 return v,
// 把 NaN 灌进 React state 后下游 splitter 算 delta 全变 NaN。
if (!Number.isFinite(v)) return min
if (v < min) return min
if (v > max) return max
return v
}
function readStored(key: string, options: Options): number {
try {
const raw = localStorage.getItem(key)
const n = raw === null ? NaN : Number(raw)
return Number.isFinite(n)
? clamp(n, options.min, options.max)
: clamp(options.default, options.min, options.max)
} catch {
return clamp(options.default, options.min, options.max)
}
}
export interface UseResizableFractionReturn {
fraction: number
setFraction: (next: number | ((prev: number) => number)) => void
applyDelta: (deltaPx: number, containerSize: number) => void
}
export function useResizableFraction(key: string, options: Options): UseResizableFractionReturn {
// type 提取出来后给测试用 —— renderHook 在 it() 内部重新 destructure 时
// TS 容易把 result 当 error 类型(同作用域里多次调用 + 内联箭头),显式标注能消解
// 跨测试的访问错误。
const [fraction, setFractionState] = useState<number>(() => readStored(key, options))
const write = useCallback(
(next: number) => {
const clamped = clamp(next, options.min, options.max)
setFractionState(clamped)
try {
localStorage.setItem(key, String(clamped))
} catch {
// file:// 下静默吞;内存 state 已经更新,只是不持久化
}
},
[key, options.min, options.max]
)
const setFraction = useCallback(
(next: number | ((prev: number) => number)) => {
// 函数式更新走 functional setState,跟 applyDelta 一致 —— 否则连写两次
// `setFraction(prev => prev + 0.05)` 会用闭包里同一份 stale fraction
// 做 base,第二次被吞。
if (typeof next === 'function') {
setFractionState((prev) => {
const computed = next(prev)
const clamped = clamp(computed, options.min, options.max)
try {
localStorage.setItem(key, String(clamped))
} catch {
// file:// 下静默吞;内存 state 已经更新,只是不持久化
}
return clamped
})
} else {
write(next)
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps -- options.min/max 同上
[write, key, options.min, options.max]
)
const applyDelta = useCallback(
(deltaPx: number, containerSize: number) => {
// 容器没尺寸时 splitter 还看不见,deltaPx 没有意义,直接忽略
if (containerSize <= 0) return
// 用函数式 setStateSplitter 在 pointermove 里以「相对上次的位置」emit 增量,
// 两次 emit 之间 React 可能还没 commit如果读闭包里的 fraction 作 base
// 后一次会用同一个 stale base第二个 deltaPx 被静静吞掉。
// App 的 applyTerminalDelta 之前用对了这里useResizableFraction 漏了。
setFractionState((prev) => {
const next = clamp(prev + deltaPx / containerSize, options.min, options.max)
try {
localStorage.setItem(key, String(next))
} catch {
// file:// 下静静吞;内存 state 已经更新,只是不持久化
}
return next
})
},
// eslint-disable-next-line react-hooks/exhaustive-deps -- options.min/max 是 Options
// 字面量hook 调用方每次 render 都重新构造;引用稳定与否不影响 setFractionState 函数语义
[key, options.min, options.max]
)
return { fraction, setFraction, applyDelta }
}

View File

@@ -0,0 +1,58 @@
/**
* 剖析范围 hook用户代码 vs 全部代码(含标准库 + 第三方包)。
*
* 持久化到 `pyrof.scope` —— 同 lang / theme 一个套路。
* 用户切过一次后,关闭软件重新打开会保留上次选择(读 localStorage,
* 只有首次启动或 localStorage 被清空时才落到默认 `all`。
*
* 默认 `all`:含库函数模式,让 pandas.read_csv / json.loads 这类黑盒
* 也能下钻 —— 这是 v3 之后定位瓶颈的主路径。
*
* 不监听 `storage` 事件:单 tab 单窗口,跨 tab 同步无收益。
*/
import { useCallback, useState } from 'react'
export type ProfileScope = 'user' | 'all'
export const DEFAULT_SCOPE: ProfileScope = 'all'
export const STORAGE_KEY_SCOPE = 'pyrof.scope'
export function readStoredScope(): ProfileScope {
try {
const v = localStorage.getItem(STORAGE_KEY_SCOPE)
// 显式判 'user' / 'all'localStorage 里被人手动改坏掉时静默回落到默认,
// 不会因为 v === 'somethingelse' 而把 scope 强行置为 user 误导用户。
if (v === 'user') return 'user'
if (v === 'all') return 'all'
return DEFAULT_SCOPE
} catch {
return DEFAULT_SCOPE
}
}
export function useScope(): { scope: ProfileScope; setScope: (s: ProfileScope) => void; toggle: () => void } {
const [scope, setScopeState] = useState<ProfileScope>(() => readStoredScope())
const setScope = useCallback((next: ProfileScope) => {
setScopeState(next)
try {
localStorage.setItem(STORAGE_KEY_SCOPE, next)
} catch {
// file:// 协议下 StorageError 静默吞
}
}, [])
const toggle = useCallback(() => {
setScopeState((prev) => {
const next: ProfileScope = prev === 'all' ? 'user' : 'all'
try {
localStorage.setItem(STORAGE_KEY_SCOPE, next)
} catch {
// 同上
}
return next
})
}, [])
return { scope, setScope, toggle }
}

View File

@@ -0,0 +1,45 @@
/**
* 打开系统终端时用的 shell 选择。
*
* - Windows 上默认 cmd.exe(快、依赖少、winget 直接跑);用户偏好可切到 PowerShell。
* - 非 Windows 平台 shell 由 OS 决定(Terminal.app / gnome-terminal 等),这个偏好不生效。
*
* 持久化到 `pyrof.shell`,跟 theme / lang / interpreter 一套。
*/
import { useCallback, useEffect, useState } from 'react'
export type Shell = 'cmd' | 'powershell'
export const DEFAULT_SHELL: Shell = 'cmd'
export const STORAGE_KEY_SHELL = 'pyrof.shell'
export function readStoredShell(): Shell {
try {
const v = localStorage.getItem(STORAGE_KEY_SHELL)
return v === 'powershell' ? 'powershell' : DEFAULT_SHELL
} catch {
return DEFAULT_SHELL
}
}
export function useShellPreference(): { shell: Shell; setShell: (s: Shell) => void } {
const [shell, setShellState] = useState<Shell>(() => readStoredShell())
const setShell = useCallback((next: Shell) => {
setShellState(next)
try {
localStorage.setItem(STORAGE_KEY_SHELL, next)
} catch {
/* file:// 协议下 StorageError 静默吞 */
}
}, [])
// 非 Windows 上忽略持久值 —— macOS / Linux 用 OS 自带 terminal,跟 cmd/PowerShell 无关。
// mount 时把持久值兜回默认,避免用户在 Win 设置过 PowerShell 然后拿到 Mac 上还显示 PowerShell。
useEffect(() => {
if (window.api.platform !== 'win32' && shell !== DEFAULT_SHELL) {
setShellState(DEFAULT_SHELL)
}
}, [shell])
return { shell, setShell }
}

View File

@@ -0,0 +1,56 @@
/**
* 主题切换 hook。深 / 浅二选一CSS variables 在 :root / :root.light 之间切换,
* Tailwind 工具类(`bg-bg` `text-fg` 等)通过 `rgb(var(--xxx-rgb) / <alpha-value>)`
* 自动跟随。
*
* 持久化到 `pyrof.theme`key 同 lang / interpreter 三个一套。
* 默认 `dark`(用户偏好)。
*
* 不监听 `storage` 事件Electron 单 tab 单窗口,跨 tab 同步无收益。
*/
import { useCallback, useEffect, useState } from 'react'
export type Theme = 'dark' | 'light'
export const DEFAULT_THEME: Theme = 'dark'
export const STORAGE_KEY_THEME = 'pyrof.theme'
export function readStoredTheme(): Theme {
try {
const v = localStorage.getItem(STORAGE_KEY_THEME)
return v === 'light' ? 'light' : DEFAULT_THEME
} catch {
return DEFAULT_THEME
}
}
/**
* 把 theme 同步到 <html> class。index.html 里的 inline script 已经在挂载前
* 设过一次 class这里 mount 时再 sync 一次防御(理论上不会变,但保险)。
*/
function applyTheme(theme: Theme): void {
const root = document.documentElement
root.classList.remove('dark', 'light')
root.classList.add(theme)
}
export function useTheme(): { theme: Theme; setTheme: (t: Theme) => void } {
const [theme, setThemeState] = useState<Theme>(() => readStoredTheme())
const setTheme = useCallback((next: Theme) => {
setThemeState(next)
try {
localStorage.setItem(STORAGE_KEY_THEME, next)
} catch {
// file:// 协议下 StorageError 静默吞
}
applyTheme(next)
}, [])
// mount 时再同步一次(覆盖罕见 raceinline script 之前抛了 / localStorage 被外部进程改了)
useEffect(() => {
applyTheme(theme)
}, [theme])
return { theme, setTheme }
}

View File

@@ -0,0 +1,68 @@
import { describe, expect, it } from 'vitest'
import { t, zhCN, enUS, DEFAULT_LANG, readStoredLang } from '../index'
import type { StringKey } from '../zh-CN'
/**
* i18n 核心回归:
* - 中英文 key 完全对齐(编译期靠 Record<StringKey, string> 守,运行期也守一遍)
* - 占位符 `{name}` 在两种语言下都能正确替换
* - 默认语言回退:当 zh-CN 缺 key 时回退到 en-US或反过来都不应崩
* - readStoredLang 在 localStorage 抛错时回退到默认
*/
describe('i18n dictionaries', () => {
it('en-US 覆盖 zh-CN 的所有 keykey parity', () => {
const zhKeys = Object.keys(zhCN) as StringKey[]
const enKeys = Object.keys(enUS)
expect(new Set(enKeys)).toEqual(new Set(zhKeys))
})
it('en-US 没有任何空白翻译(防止新增 zh-CN key 后忘了英译)', () => {
for (const k of Object.keys(zhCN) as StringKey[]) {
expect(enUS[k], `en-US 缺翻译: ${k}`).toBeTruthy()
expect(enUS[k].trim().length, `en-US 空翻译: ${k}`).toBeGreaterThan(0)
}
})
it('默认语言是中文', () => {
expect(DEFAULT_LANG).toBe('zh-CN')
})
})
describe('t()', () => {
it('zh-CN 拿原文', () => {
expect(t('topbar.title', 'zh-CN')).toBe('Python 程序耗时可视化分析')
expect(t('hotspot.empty', 'zh-CN')).toBe('没有可显示的函数')
})
it('en-US 拿英译', () => {
expect(t('topbar.title', 'en-US')).toBe('Python Program Time Profiler')
expect(t('hotspot.empty', 'en-US')).toBe('No functions to display')
})
it('占位符 {name} / {pct} / {message} 全部替换', () => {
expect(t('app.live.running', 'zh-CN', { pct: 42 })).toBe('运行中 42%')
expect(t('app.live.error', 'en-US', { message: 'boom' })).toBe('Error: boom')
})
it('缺省 params 时占位符原样保留(让 grep 仍能找到未传 key', () => {
expect(t('app.live.running', 'zh-CN')).toBe('运行中 {pct}%')
})
})
describe('readStoredLang', () => {
it('localStorage 为空时回退到默认', () => {
window.localStorage.clear()
expect(readStoredLang()).toBe('zh-CN')
})
it('localStorage 是 en-US 时返回 en-US', () => {
window.localStorage.setItem('pyrof.lang', 'en-US')
expect(readStoredLang()).toBe('en-US')
})
it('localStorage 是非法值时回退到默认(容错)', () => {
window.localStorage.setItem('pyrof.lang', 'zh-XX')
expect(readStoredLang()).toBe('zh-CN')
})
})

View File

@@ -0,0 +1,289 @@
/**
* English interface text. Same shape as zh-CN.ts (key parity is type-checked by StringKey).
*
* 不翻译的术语Tottime / Cumtime / Calls / Per call / Ctrl+Enter / Function /
* Python / cProfile —— 在中文里也不翻,是 Python 性能生态的标准命名。
* 翻译会让用户查 cProfile 文档、Linear UI 时找不到对应。
*/
import type { StringKey } from './zh-CN'
export const enUS: Record<StringKey, string> = {
// App
'app.live.running': 'Running {pct}%',
'app.live.done': 'Analysis complete',
'app.live.error': 'Error: {message}',
'app.editorLoading': 'Editor loading…',
// TopBar
'topbar.title': 'Python Program Time Profiler',
'topbar.shortcutHint': 'Ctrl+Enter to run',
'topbar.openSettings': 'Open settings',
'topbar.switchLanguage': 'Switch language',
'topbar.switchLanguageTo': 'Switch to {target}',
'topbar.toggleTheme': 'Toggle theme',
'topbar.theme.toLight': 'Switch to light theme',
'topbar.theme.toDark': 'Switch to dark theme',
'topbar.lang.zh': '中文',
'topbar.lang.en': 'EN',
'topbar.openTerminal': 'Open system terminal',
// Top-right window controls (frame:false hides the OS titlebar; these custom buttons take over)
'topbar.window.minimize': 'Minimize',
'topbar.window.maximize': 'Maximize',
'topbar.window.restore': 'Restore',
'topbar.window.close': 'Close',
// Splitter (drag handles between regions)
'splitter.main.aria': 'Drag to resize editor and results',
// Editor ↔ run output panel (top/bottom split on the left) — separate label
// from the main vertical splitter so screen-reader users get useful orientation
'splitter.console.aria': 'Drag to resize editor and run output panel',
// Settings modal
'settings.title': 'Settings',
'settings.close': 'Close',
'settings.section.interpreter': 'Interpreter',
'settings.section.shell': 'Terminal',
'settings.shell.label': 'Shell for opening terminal',
'settings.shell.cmd': 'Command Prompt (cmd)',
'settings.shell.powershell': 'PowerShell',
'settings.shell.hint':
'Both the TopBar "Open terminal" button and the install-Python prompt use this shell',
'settings.interpreter.label': 'Python interpreter',
'settings.interpreter.browse': 'Browse…',
'settings.interpreter.rescan': 'Rescan',
'settings.interpreter.empty': 'No Python detected',
'settings.interpreter.detecting': 'Scanning…',
'settings.interpreter.detectError': 'Failed to scan interpreters: {message}',
'settings.interpreter.pickError': 'Failed to pick interpreter: {message}',
// Terminal (when no Python detected, let users install via system terminal)
'settings.terminal.openCmd': 'Open terminal to install Python',
'settings.terminal.hint': 'Terminal opens with the install command pre-filled — press Enter to run',
'settings.terminal.error': 'Failed to open terminal: {message}',
// About — developer info + personal page link
'settings.section.about': 'About',
'settings.about.developer': 'Developer',
'settings.about.developerName': 'Guan Jihuan',
'settings.about.website': 'https://www.guanjihuan.com/about',
'settings.about.linkAria': 'Visit {name}s personal page',
// MinimalRunConfig
'runconfig.run': 'Run analysis',
'runconfig.cancel': 'Cancel',
'runconfig.loadSample': 'Load sample',
'runconfig.newBlank': 'New blank',
'runconfig.shortcut': 'Ctrl+Enter',
'runconfig.progress': 'Run progress',
'runconfig.needInterpreter': 'No Python interpreter selected (pick one in Settings)',
// Profile scope toggle: default "all" (include libraries); toggle off switches to user-only
'runconfig.scope.user': 'User code only',
'runconfig.scope.all': 'Include libraries',
'runconfig.scope.aria': 'Profile scope: {scope}. Click to toggle.',
// EmptyState
'empty.eyebrow': 'How to use',
'empty.title': 'Paste code, instantly see what is slow',
'empty.subtitle': 'Code on the left, time breakdown on the right.',
'empty.step1.title': 'Paste Python code',
'empty.step1.desc': 'Or write directly in the editor on the left',
'empty.step2.title': 'Pick a Python interpreter',
'empty.step2.desc': 'Not found? Click "Browse…" to pick one',
'empty.step3.title': 'Run analysis',
'empty.step3.desc': 'See how long each function takes',
'empty.running': 'Running…',
'empty.needInterpreter': 'Pick a Python interpreter first',
'empty.needInterpreterDesc': 'Open Settings → Interpreter → Browse… to choose python.exe',
'empty.openSettings': 'Open settings',
// HotspotTable
'hotspot.empty': 'No functions to display',
'hotspot.emptyFiltered': 'No functions match this filter. Click "All {total}" to restore.',
'hotspot.col.function': 'Function',
'hotspot.col.tottime': 'Tottime',
'hotspot.col.cumtime': 'Cumtime',
'hotspot.col.calls': 'Calls',
'hotspot.col.percall': 'Per call',
'hotspot.col.line': 'line {line}',
'hotspot.row.aria': '{name} ({origin}) line {line}, self-time {tottime}, called {calls} times',
'hotspot.row.ariaSelected':
'{name} ({origin}) line {line}, self-time {tottime}, called {calls} times, selected',
'hotspot.sortBy': 'Sort by {label}',
'hotspot.truncated': 'Showing first {visible} rows, {hidden} more hidden',
'hotspot.totals': 'Showing all {total} rows',
'hotspot.expand': 'Show all {total} rows',
'hotspot.collapse': 'Collapse to first {cap} rows',
// Zero-time frame filter — scope=all fans out into hundreds of tottime=0 frames
// (typing/inspect/etc. internal calls below cProfile's ~1µs quantization).
// Hidden by default with an opt-in toggle for callers who want to inspect the call trail.
'hotspot.zeroTimeHidden': '{hidden} zero-time frames hidden (below cProfile quantization)',
'hotspot.zeroTimeShow': 'Show zero-time frames',
'hotspot.zeroTimeHide': 'Hide zero-time frames',
// Test code filter — test_xxx / tests/ / TestCase.test_xxx hidden by default
'hotspot.testCodeHidden': '{hidden} test-code frames hidden (test_xxx / tests/ / TestCase)',
'hotspot.testCodeShow': 'Show test code',
'hotspot.testCodeHide': 'Hide test code',
// Module filter chips — only shown in scope=all so the table doesn't drown in stdlib
'hotspot.module.allFilter': 'All {total}',
'hotspot.module.filter': '{module} {count}',
'hotspot.module.aria': 'Filter by module: {label}, showing {shown} of {total}',
// FlameGraph
'flame.empty': 'No functions to display',
'flame.aria': 'Flame graph',
'flame.reset': 'Reset',
'flame.reset.aria': 'Reset flame graph',
'flame.tile.aria': '{name} self-time {value}s, {pct}% of total',
'flame.tile.drill': ' (press Enter to drill in)',
// ErrorBanner
'errorBanner.title': 'Run failed',
'errorBanner.retry': 'Retry',
'errorBanner.unknown': 'Unknown error',
'errorBanner.logDetails': 'View engine log',
// FailureResult
'failure.title.timeout': 'Run timed out',
'failure.title.syntax': 'Syntax error',
'failure.title.runtime': 'Runtime error',
'failure.retry': 'Retry',
'failure.logDetails': 'View engine log',
// ErrorBoundary
'errorBoundary.title': 'Something went wrong',
'errorBoundary.retry': 'Retry',
'errorBoundary.reload': 'Reload page',
'errorBoundary.consoleError': '[ErrorBoundary] Component threw:',
// RunSummaryLite
'summary.wallTime': 'Wall time',
'summary.functionCount': 'Functions',
// Sub-info for the function count tile — only shown when multiple modules
// (scope=all); single-module scope=user would just be noise.
'summary.moduleCount': '{count} modules',
'summary.hottest': 'Hottest function',
'summary.hottestSub': 'self-time {tottime}, called {calls} times',
// Wall time is much larger than cProfile attribution total — likely includes
// a blocking event-loop wait (plt.show / input / cv2.waitKey). cProfile
// can't see these waits, so the flame graph looks deceptively small compared
// to the wall-time number. {gap} is the unattributed seconds.
// Hover title gives the full explanation.
'summary.wallTimeHint': 'Interactive wait · unattributed {gap}',
'summary.wallTimeHintTitle':
'This unaccounted time comes from blocking calls in user code (matplotlib window / input() / cv2.waitKey, etc.). cProfile cannot see it, so the flame graph looks smaller than the wall time.',
// v4 wall-time is estimated (instrumented / cProfile calibration ratio), labelled with a chip.
'summary.wallTimeCalibrated': 'calibrated',
'summary.wallTimeCalibratedTitle':
'Wall time is estimated; cProfile overhead ({ratio}×) has been compensated.',
// ResultsPanel
'results.hotspot.title': 'Hotspot functions',
'results.hotspot.hint': '{n} total',
'results.time.title': 'Time distribution',
'results.time.hint': 'Same data, switch views',
// Result is from an older scope. Don't auto-rerun (each run takes 1-10s);
// show a status banner with a "rerun with new scope" button.
'results.scopeStale.eyebrow': 'Scope changed',
'results.scopeStale.body': 'This result was profiled with "{old}", scope is now "{new}"',
'results.scopeStale.rerun': 'Rerun with new scope',
// Chart switcher
'chart.switcher.label': 'Switch chart type',
'chart.type.flame': 'Flame graph',
'chart.type.bar': 'Bar chart',
'chart.type.treemap': 'Treemap',
'chart.type.sunburst': 'Sunburst',
'chart.type.cumulative': 'Cumulative',
'chart.type.heatmap': 'Module heatmap',
'chart.empty': 'No time data',
'chart.truncated': 'Showing first {shown}, {hidden} more hidden',
// Multi-depth truncation message shared by FlameGraph / Treemap / Sunburst.
// {depths}: how many depth layers had truncation; {shown}: per-layer cap;
// {hidden}: total sibling count hidden across all layers.
'chart.flame.truncatedMulti':
'Truncated across {depths} layers · showing first {shown}/layer · {hidden} siblings hidden',
// Per-chart hint (replaces the generic chart.note) — each chart says something specific
'chart.flame.hint': 'Click tiles with children to drill in',
'chart.bar.hint': 'Ranked by self-time · length = share of total',
'chart.treemap.hint': 'Area = self-time share · color consistent across views',
'chart.sunburst.hint': 'Arc length = self-time share · center = total',
'chart.cumulative.hint': 'Ranked by cumulative time · dark segment = self-time',
'chart.heatmap.hint': 'By module · block area = self-time share',
// Sunburst center "N functions" summary
'chart.sunburst.totalFns': '{count} functions',
'chart.bar.aria': 'Bar chart sorted by self-time',
'chart.bar.tile.aria': '{name} ({origin}) self-time {value}, {pct}% of total',
'chart.treemap.aria': 'Time treemap',
'chart.treemap.tile.aria': '{name} self-time {value}, {pct}% of total',
'chart.sunburst.aria': 'Time sunburst',
'chart.sunburst.slice.aria': '{name} self-time {value}, {pct}% of total',
'chart.cumulative.aria': 'Bars ranked by cumulative time',
'chart.cumulative.tile.aria': '{name} ({origin}) cumulative {cum}, self-time {self}, {pct}% of total',
'chart.cumulative.selfSeg': 'self-time',
'chart.cumulative.cumSeg': 'callee time',
// Right-aligned text: "{pct}% self · {cum}" — {pct} is this function's self-time
// share of its own cumtime, NOT total. Bar length is total share. Both must
// be visually distinct or the two percentages contradict each other.
'chart.cumulative.rightText': '{pct}% self · {cum}',
'chart.heatmap.aria': 'Module-grouped heatmap',
'chart.heatmap.tile.aria': '{name} in {module} — self-time {value}, {pct}% of total',
'chart.heatmap.empty': 'scope=user has one module — no heatmap variation',
'chart.heatmap.moduleLabel': '{module} ({count})',
'chart.heatmap.more': '{count} more functions not shown',
// IPC error messages (translated by kind). Main process hardcodes Chinese
// for logs but renderer translates these for end-users.
// {message} keeps the original detail (ENOENT paths etc.) for debugging.
'ipcError.invalidPayload': 'Invalid payload: {message}',
'ipcError.interpreterNotAllowed': 'Interpreter not in allow-list: {message}',
'ipcError.payloadTooLarge': 'Payload too large: {message}',
'ipcError.engineNotFound': 'Analysis engine files missing: {message}',
'ipcError.interpreterProbeFailed': 'Failed to probe interpreter: {message}',
'ipcError.cancelled': 'Cancelled',
'ipcError.terminalOpenFailed': 'Failed to open terminal: {message}',
// Function origin (v3 — UI groups modules by origin)
// Long labels go in section headers / aria-label; short labels are the badge
// next to module chips, kept tiny so the row stays compact.
'origin.user': 'User code',
'origin.stdlib': 'Python stdlib',
'origin.thirdParty': 'Third-party',
'origin.builtin': 'Built-in',
'origin.frozen': 'Frozen',
'origin.other': 'Other',
'origin.abbr.user': 'U',
'origin.abbr.stdlib': 'S',
'origin.abbr.thirdParty': '3P',
'origin.abbr.builtin': 'B',
'origin.abbr.frozen': 'F',
'origin.abbr.other': '?',
// Section header above each origin group: "Python stdlib (5)" / "Third-party (3)"
'origin.sectionHeader': '{origin} ({count})',
// Editor tabs (stdlib / third-party hotspots open as tabs alongside user code)
'editorTab.user': 'Your code',
'editorTab.listAria': 'Open editor tabs',
'editorTab.closeAria': 'Close {name}',
// Status shown inside an external tab when its source file failed to load.
// File-read errors come back as discriminated union (`FileReadResult.kind`),
// each kind maps to one of these strings.
'editorTab.loading': 'Loading…',
'editorTab.error.notFound': 'File not found: {path}',
'editorTab.error.notAFile': 'Not a regular file: {path}',
'editorTab.error.permissionDenied': 'No permission to read: {path}',
'editorTab.error.tooLarge': 'File is too large to display ({size} > {max})',
'editorTab.error.invalidPath': 'Invalid file path: {path}',
'editorTab.error.unknown': 'Failed to read file: {message}',
// Run output panel (bottom of left area, shows Python print() output live)
'console.title': 'Run output',
'console.empty': 'print() output will appear here after running',
'console.clear': 'Clear',
// Line count badge, only shown when there's content
'console.lineCount': '{count} lines',
// Main-process appendCapped triggered a tail truncation — surface it loudly
// so users don't think "why is the output incomplete?"
'console.overflow': 'Output truncated (exceeded internal limit)',
// Header collapse/expand toggle (height itself is controlled by the Splitter;
// this button is the separate "fully hide" semantic)
'console.collapse': 'Collapse',
'console.expand': 'Expand'
}

Some files were not shown because too many files have changed in this diff Show More