update
This commit is contained in:
140
.eslintrc.cjs
Normal file
140
.eslintrc.cjs
Normal 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 import(renderer 永远不该 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 测试禁止固定 sleep:waitForTimeout(>=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
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
out/
|
||||
release/
|
||||
10
.prettierignore
Normal file
10
.prettierignore
Normal 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
10
.prettierrc.json
Normal 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
67
README.md
Normal 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 5,Python 引擎用 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
|
||||
57
e2e/perf-optimizations.spec.ts
Normal file
57
e2e/perf-optimizations.spec.ts
Normal 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_NODE:Playwright 自己是 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
56
e2e/smoke.spec.ts
Normal 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 })
|
||||
|
||||
// 热点表 section(v2 标题 = results.hotspot.title)
|
||||
await expect(page.getByText('热点函数')).toBeVisible()
|
||||
|
||||
// DEMO_CODE 里的对比函数:纯 Python 高斯消元 vs numpy —— 最耗时的 `matinv` 一定出现在表里
|
||||
await expect(page.getByText('matinv').first()).toBeVisible()
|
||||
|
||||
// 火焰图 section(v2 标题 = 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
35
electron.vite.config.ts
Normal 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
0
engine/__init__.py
Normal file
186
engine/harness.py
Normal file
186
engine/harness.py
Normal 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 自动剥离 BOM(Windows 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
257
engine/runner.py
Normal 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):
|
||||
# 前导 \n:tqdm / 进度条类库会写"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() 抛 SystemExit(BaseException 的子类)—— 不被下面的 except BaseException 捕获,
|
||||
# 子进程直接退出无 JSON,UI 就会显示 "引擎无输出 (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
87
engine/schema.py
Normal 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-8,UI 解析会挂。转义成纯 ASCII 后逐字节一致,
|
||||
# JSON.parse 原生还原。主进程仍然会设那两个环境变量,这里是纵深防御。
|
||||
return json.dumps(asdict(self), ensure_ascii=True)
|
||||
576
engine/structure.py
Normal file
576
engine/structure.py
Normal 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 shim:v3 时期暴露的「只拿归因、不读 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
0
engine/tests/__init__.py
Normal file
27
engine/tests/fixtures/demo_sort.py
vendored
Normal file
27
engine/tests/fixtures/demo_sort.py
vendored
Normal 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
16
engine/tests/fixtures/nested_calls.py
vendored
Normal 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
19
engine/tests/fixtures/with_imports.py
vendored
Normal 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()
|
||||
125
engine/tests/test_contract.py
Normal file
125
engine/tests/test_contract.py
Normal 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
|
||||
77
engine/tests/test_harness.py
Normal file
77
engine/tests/test_harness.py
Normal 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
158
engine/tests/test_runner.py
Normal 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() 抛 SystemExit(BaseException 子类),之前 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
|
||||
21
engine/tests/test_schema.py
Normal file
21
engine/tests/test_schema.py
Normal 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"
|
||||
761
engine/tests/test_structure.py
Normal file
761
engine/tests/test_structure.py
Normal 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=all:C 扩展函数帧(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=user:C 帧不会进入用户脚本 —— 也过滤掉(与历史行为一致)
|
||||
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-packages(Debian 系)
|
||||
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 算 module:fixture 是用户脚本,全部 <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=all:fn.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-packages(Debian 系)
|
||||
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 —— 即使模块名未知。
|
||||
|
||||
之前 bug(M5):只看路径里含 "/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 算 origin:fixture 是用户脚本,全部 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:用户脚本帧归 user,import 的 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]} 出现了"
|
||||
)
|
||||
10589
package-lock.json
generated
Normal file
10589
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
83
package.json
Normal file
83
package.json
Normal 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
34
playwright.config.ts
Normal 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
6
postcss.config.cjs
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {}
|
||||
}
|
||||
}
|
||||
2
pytest.ini
Normal file
2
pytest.ini
Normal file
@@ -0,0 +1,2 @@
|
||||
[pytest]
|
||||
testpaths = engine/tests
|
||||
76
scripts/regen-golden.mjs
Normal file
76
scripts/regen-golden.mjs
Normal 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 指定解释器
|
||||
*
|
||||
* 注意:environment(python 版本 / 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
408
src/main/index.ts
Normal 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() 在 dev(electron-vite dev)和 prod(electron . 或打包后)都会指向
|
||||
// package.json 所在目录,也就是项目根,所以 'icon.ico' 直接拼上即可。
|
||||
// nativeImage 原生支持 .ico;createFromPath 返回 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
|
||||
}
|
||||
})
|
||||
|
||||
// macOS:BrowserWindow.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。
|
||||
// 顶栏实际显示 ~18px;ICO 默认挑最大帧(一般 256×256)直传浪费 IPC payload,
|
||||
// 还会被 Chromium 双线性缩到 18px 后发糊。先 resize 到 32×32 再 toDataURL,
|
||||
// 既省字节又是更高质量的重采样。文件不存在 / 解不开时返回 null,renderer 退 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-forget;createWindow 内的加载是同步的(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- 前缀的目录尽力删一次。
|
||||
// 同时要先杀 child:Cmd+Q / Alt+F4 中途退出时,留着 Python 子进程会成孤儿
|
||||
// (macOS / detached POSIX 尤甚)。用 cancelAndWait 而不是 cancel:Windows 上
|
||||
// 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()
|
||||
})()
|
||||
})
|
||||
112
src/main/interpreter.test.ts
Normal file
112
src/main/interpreter.test.ts
Normal 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
209
src/main/interpreter.ts
Normal 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.ts:index.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 段,每段里再串行 tryValidate,20+ 候选在慢盘上能到 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
|
||||
}
|
||||
156
src/main/ipc-validation.test.ts
Normal file
156
src/main/ipc-validation.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
94
src/main/ipc-validation.ts
Normal file
94
src/main/ipc-validation.ts
Normal 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 的依据:标准库最大的 .py(pydoc / tarfile)也就 100~200KB,site-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 / .pyi:cProfile 的 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)
|
||||
}
|
||||
201
src/main/profiler-service.test.ts
Normal file
201
src/main/profiler-service.test.ts
Normal 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('兼容 CRLF(Windows 上引擎输出是 \\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() 是 void,await 它等于 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()
|
||||
// 不关心它是 reject(AnalysisCancelledError)还是 resolve 成错误结果,
|
||||
// 只要求它不再挂着
|
||||
await Promise.allSettled([running])
|
||||
},
|
||||
20000
|
||||
)
|
||||
})
|
||||
})
|
||||
499
src/main/profiler-service.ts
Normal file
499
src/main/profiler-service.ts
Normal 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 之后下一次才开始 spawn;cancel() 通过
|
||||
// 让"等待中的调用"直接 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 时 AnalysisResult,cancel 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
|
||||
// 即使总体已 overflow,progress 行的解析仍然继续 — 用户能看到引擎在跑
|
||||
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 强转 AnalysisResult,renderer 端会撞 "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
252
src/main/terminal.test.ts
Normal 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
172
src/main/terminal.ts
Normal 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
55
src/preload/index.ts
Normal 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 —— 失败不 throw,kind 字段告诉 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 push(OS 触发的最大化状态变更也同步给 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
43
src/renderer/index.html
Normal 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 还会为此在控制台报一条 error(e2e 断言"零 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/catch:file:// 协议下某些 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
521
src/renderer/src/App.tsx
Normal 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()
|
||||
// 打开系统终端时用的 shell(Windows: 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+Enter:Monaco 会拦截这个键(默认行为是「下方插入新行」),必须通过 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
|
||||
// 置 null(App 这边等价于切回 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>
|
||||
)
|
||||
}
|
||||
549
src/renderer/src/__tests__/App.test.tsx
Normal file
549
src/renderer/src/__tests__/App.test.tsx
Normal 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 会拖垮 jsdom(10+ 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+Enter:scope=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,不调 readFile(revealLine 用户代码即可)', 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)
|
||||
})
|
||||
})
|
||||
326
src/renderer/src/components/CodePanel.tsx
Normal file
326
src/renderer/src/components/CodePanel.tsx
Normal 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 的 id(useExternalFiles 分配)。
|
||||
* CodePanel 用它决定切到哪个 model。
|
||||
*/
|
||||
activeTabId: string
|
||||
/**
|
||||
* 当前活跃 tab 应显示的内容。
|
||||
* - 用户 tab:等于 App.tsx 的 code state(外部触发 setValue 用)
|
||||
* - 外部 tab:等于该 tab 的 content(loading 时是 '')
|
||||
*
|
||||
* 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-only,Monaco 不会触发 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 })
|
||||
// 只在编辑器可写时才抢焦点:运行中 disabled(pointer-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 用 activeContent(App 的 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 之间存在一帧 race(Monaco 内部)。
|
||||
// 显式缓存更稳。
|
||||
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
|
||||
20
src/renderer/src/components/CodePanelLazy.tsx
Normal file
20
src/renderer/src/components/CodePanelLazy.tsx
Normal 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'
|
||||
262
src/renderer/src/components/EditorTabs.tsx
Normal file
262
src/renderer/src/components/EditorTabs.tsx
Normal file
@@ -0,0 +1,262 @@
|
||||
/**
|
||||
* 编辑器区域的 tab 条 —— 用户代码 tab + 0..N 个外部文件 tab(stdlib / 第三方包热点打开)。
|
||||
*
|
||||
* 设计要点:
|
||||
* - **用户 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 tab(App 的 onClose 会自动切 active),这里也同步把 focus 移过去
|
||||
// —— 不强制移,因为如果 active 不是 user,App 会自己切
|
||||
}
|
||||
}
|
||||
},
|
||||
[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 嵌套 button(HTML 不允许)。
|
||||
// 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)
|
||||
115
src/renderer/src/components/EmptyState.tsx
Normal file
115
src/renderer/src/components/EmptyState.tsx
Normal 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-live:App 顶层唯一的 polite region 在播报进度,避免重复
|
||||
<span className="text-xs text-fg-muted">{t('empty.running')}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(EmptyState)
|
||||
59
src/renderer/src/components/ErrorBanner.tsx
Normal file
59
src/renderer/src/components/ErrorBanner.tsx
Normal 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)
|
||||
99
src/renderer/src/components/ErrorBoundary.tsx
Normal file
99
src/renderer/src/components/ErrorBoundary.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
73
src/renderer/src/components/FailureResult.tsx
Normal file
73
src/renderer/src/components/FailureResult.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* 失败页(result.status !== 'ok'):单一版本。
|
||||
*
|
||||
* 之前有 FailureResult(Pro 模式专业报错)和 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)
|
||||
48
src/renderer/src/components/Logo.tsx
Normal file
48
src/renderer/src/components/Logo.tsx
Normal 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" />
|
||||
}
|
||||
172
src/renderer/src/components/MinimalRunConfig.tsx
Normal file
172
src/renderer/src/components/MinimalRunConfig.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* 极简运行配置:运行 / 取消 + 加载示例 + 进度条。
|
||||
*
|
||||
* 解释器选择已在 Settings 面板(顶栏齿轮图标入口)—— 这里不再展示解释器信息,
|
||||
* 一切交给 Settings。运行按钮在未选解释器时禁用,悬浮提示用户去设置里选。
|
||||
*
|
||||
* 剖析范围 toggle:「仅用户代码」/「含库函数」二选一,默认 all(含库函数)。
|
||||
* user:和 v2 之前一致,只看用户脚本里的函数;
|
||||
* all:耗时可以下钻到 import 的包(标准库 + 第三方)—— 这是默认,
|
||||
* pandas.read_csv() 这种黑盒直接能展开到 stdlib / numpy 内部。
|
||||
*
|
||||
* 选择持久化到 localStorage.pyrof.scope(useScope 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
|
||||
175
src/renderer/src/components/ResultsPanel.tsx
Normal file
175
src/renderer/src/components/ResultsPanel.tsx
Normal 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 用的是 user,toggle 已切到 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>
|
||||
)
|
||||
}
|
||||
125
src/renderer/src/components/RunConsole.tsx
Normal file
125
src/renderer/src/components/RunConsole.tsx
Normal 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
|
||||
185
src/renderer/src/components/RunSummaryLite.tsx
Normal file
185
src/renderer/src/components/RunSummaryLite.tsx
Normal 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 time(accent 紫)= 主指标;function count(sev-low 绿)= 信息;
|
||||
* hottest(sev-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>
|
||||
}
|
||||
372
src/renderer/src/components/SettingsModal.tsx
Normal file
372
src/renderer/src/components/SettingsModal.tsx
Normal 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}
|
||||
>
|
||||
{/*
|
||||
内嵌 dialog:role="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>
|
||||
)
|
||||
}
|
||||
236
src/renderer/src/components/Splitter.tsx
Normal file
236
src/renderer/src/components/Splitter.tsx
Normal 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)
|
||||
110
src/renderer/src/components/TopBar.tsx
Normal file
110
src/renderer/src/components/TopBar.tsx
Normal 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>
|
||||
)
|
||||
})
|
||||
74
src/renderer/src/components/WindowControls.tsx
Normal file
74
src/renderer/src/components/WindowControls.tsx
Normal 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)
|
||||
21
src/renderer/src/components/__tests__/CodePanel.test.ts
Normal file
21
src/renderer/src/components/__tests__/CodePanel.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
182
src/renderer/src/components/__tests__/EditorTabs.test.tsx
Normal file
182
src/renderer/src/components/__tests__/EditorTabs.test.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* EditorTabs 的回归测试。
|
||||
*
|
||||
* 关注几条关键不变式:
|
||||
* 1) user tab 永远是第一个、不可关(无 X)
|
||||
* 2) 外部 tab 有 X 关闭按钮,点击调 onClose,stopPropagation 不触发 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('渲染所有 tab,role=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)
|
||||
})
|
||||
})
|
||||
106
src/renderer/src/components/__tests__/EmptyState.test.tsx
Normal file
106
src/renderer/src/components/__tests__/EmptyState.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
162
src/renderer/src/components/__tests__/RunConsole.test.tsx
Normal file
162
src/renderer/src/components/__tests__/RunConsole.test.tsx
Normal 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', '运行输出')
|
||||
})
|
||||
})
|
||||
241
src/renderer/src/components/__tests__/SettingsModal.test.tsx
Normal file
241
src/renderer/src/components/__tests__/SettingsModal.test.tsx
Normal 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('点内容区不触发 onClose(stopPropagation)', () => {
|
||||
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()
|
||||
})
|
||||
})
|
||||
443
src/renderer/src/components/__tests__/Splitter.test.tsx
Normal file
443
src/renderer/src/components/__tests__/Splitter.test.tsx
Normal 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 触发 onDelta(vertical 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 触发 onDelta(horizontal 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')
|
||||
})
|
||||
})
|
||||
307
src/renderer/src/components/icons.tsx
Normal file
307
src/renderer/src/components/icons.tsx
Normal file
@@ -0,0 +1,307 @@
|
||||
/**
|
||||
* 集中图标库:内联 SVG,与设计 token(currentColor / 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 双色 logo(RunConfig 用)。 */
|
||||
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>
|
||||
)
|
||||
}
|
||||
217
src/renderer/src/components/views/BarChart.tsx
Normal file
217
src/renderer/src/components/views/BarChart.tsx
Normal 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-label(SR 友好),同 HotspotTable 行的策略。
|
||||
// 短码放 title tooltip,hover 看全名(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)
|
||||
280
src/renderer/src/components/views/CumulativeBar.tsx
Normal file
280
src/renderer/src/components/views/CumulativeBar.tsx
Normal 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)
|
||||
236
src/renderer/src/components/views/FlameGraph.tsx
Normal file
236
src/renderer/src/components/views/FlameGraph.tsx
Normal 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.46–3.62,6 个色值里 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)
|
||||
549
src/renderer/src/components/views/HotspotTable.tsx
Normal file
549
src/renderer/src/components/views/HotspotTable.tsx
Normal 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 chip:scope=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>
|
||||
)
|
||||
}
|
||||
371
src/renderer/src/components/views/ModuleHeatmap.tsx
Normal file
371
src/renderer/src/components/views/ModuleHeatmap.tsx
Normal 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)
|
||||
304
src/renderer/src/components/views/Sunburst.tsx
Normal file
304
src/renderer/src/components/views/Sunburst.tsx
Normal 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) => {
|
||||
// 根节点的 arc(depth=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 > π 取 1;sweep 始终 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)
|
||||
230
src/renderer/src/components/views/TimeChartSwitcher.tsx
Normal file
230
src/renderer/src/components/views/TimeChartSwitcher.tsx
Normal 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)
|
||||
329
src/renderer/src/components/views/Treemap.tsx
Normal file
329
src/renderer/src/components/views/Treemap.tsx
Normal 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)
|
||||
@@ -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} />))
|
||||
// 第一行是 leaf,role=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)
|
||||
})
|
||||
})
|
||||
@@ -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*·/)
|
||||
})
|
||||
})
|
||||
@@ -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 的子节点是 leaf1,b 的子节点是 leaf2。
|
||||
// 修复前 byDepth 用 name 做 key,a 和 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()
|
||||
})
|
||||
})
|
||||
@@ -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 的实际格式:「fnN(origin)第 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 chips(scope=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')
|
||||
// 各模块 chip:json 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?\([^)]+\))$/)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
66
src/renderer/src/components/views/__tests__/Treemap.test.tsx
Normal file
66
src/renderer/src/components/views/__tests__/Treemap.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
166
src/renderer/src/components/views/chart-utils.ts
Normal file
166
src/renderer/src/components/views/chart-utils.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* 四张图(FlameGraph / BarChart / Treemap / Sunburst)共享的常量与辅助函数。
|
||||
*
|
||||
* 不放 utils/ 是因为这些只服务于 views/* 下的图表组件,跨目录共享反而
|
||||
* 让人误以为 utils/* 还要导到外面去用。
|
||||
*
|
||||
* 设计目标:
|
||||
* - 同一个函数在四图里**用同一个颜色**(colorByName)—— 切换视图时颜色延续,
|
||||
* 用户能"认出"同一个函数。
|
||||
* - 焦点态统一(FOCUS / RESTING)—— 四图的"高亮"应该长得一样,避免每次切
|
||||
* 视图还要重新学一次视觉语言。
|
||||
* - SVG 文本截断(truncateName)—— `<text>` 没有内置 ellipsis,Cascadia Code
|
||||
* monospace 按 char 数估算就够了。
|
||||
* - hover 视觉反馈统一(`data-chart-shape` + globals.css 规则)—— 鼠标悬停时
|
||||
* 把 fillOpacity 从 0.7 提到 1.0(globals.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"以拿到行号跳转。两个视图都构造同样的 Map(lookup 时 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 }
|
||||
}
|
||||
26
src/renderer/src/fixtures/__tests__/golden.test.ts
Normal file
26
src/renderer/src/fixtures/__tests__/golden.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
115
src/renderer/src/fixtures/golden.json
Normal file
115
src/renderer/src/fixtures/golden.json
Normal 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
9
src/renderer/src/global.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
import type { RendererApi } from '../../shared/analysis'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
api: RendererApi
|
||||
}
|
||||
}
|
||||
|
||||
export {}
|
||||
334
src/renderer/src/hooks/__tests__/useAnalysis.test.tsx
Normal file
334
src/renderer/src/hooks/__tests__/useAnalysis.test.tsx
Normal file
@@ -0,0 +1,334 @@
|
||||
/**
|
||||
* useAnalysis 的回归测试,重点守住"cancel 后再次 run 仍能收到 progress"。
|
||||
*
|
||||
* 之前 progressTokenRef 设计:订阅回调里 myToken 是 mount 时一次性捕获,
|
||||
* cancel() 把它 bump 之后再也没人复位 → 第二次 run 期间引擎发的 PROGRESS 全部被吞,
|
||||
* 进度条永远卡在 0%。审计 F2/F3 提到的"取消后立刻重跑看到进度条卡住"就是这条路径。
|
||||
*
|
||||
* 这里改成 progressActiveRef:每次事件都重新读 boolean,run/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 已 flush,captured 必然被赋值。
|
||||
* 与 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 #1:kick off 但不 await(analyze 是 pending promise),act 会 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 仍 reject(IPC cancel 信号 / 子进程被 kill),state 不会变 error', async () => {
|
||||
// 模拟真实场景:用户 cancel,主进程发 IPC cancel 给 runner,runner 把
|
||||
// 进行中的 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()
|
||||
})
|
||||
|
||||
// 用户中途 cancel(cancel 内部已经把 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(不 await,pending 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')
|
||||
})
|
||||
})
|
||||
340
src/renderer/src/hooks/__tests__/useExternalFiles.test.tsx
Normal file
340
src/renderer/src/hooks/__tests__/useExternalFiles.test.tsx
Normal file
@@ -0,0 +1,340 @@
|
||||
/**
|
||||
* useExternalFiles 的回归测试。
|
||||
*
|
||||
* 关注几条关键不变式:
|
||||
* 1) openFile:去重命中(同 dedupKey)→ 不重复开 tab,只切 active
|
||||
* 2) openFile:去重不命中 → loading tab 出现,IPC 成功置 ready,IPC 失败置 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('openFile:loading 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('openFile:IPC 返回错误时 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('openFile:IPC 抛 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)))
|
||||
|
||||
// 启动 openFile(loading 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()
|
||||
})
|
||||
})
|
||||
498
src/renderer/src/hooks/__tests__/useInterpreters.test.tsx
Normal file
498
src/renderer/src/hooks/__tests__/useInterpreters.test.tsx
Normal 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 是同步的 microtask,act 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()
|
||||
})
|
||||
|
||||
// 模拟 IpcError:raw 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 只触发一次自动 detectAll(hasAutoDetectedRef)', async () => {
|
||||
const ProbeSM = () => {
|
||||
useInterpreters()
|
||||
return <div />
|
||||
}
|
||||
await act(async () => {
|
||||
render(
|
||||
<StrictMode>
|
||||
<I18nProvider>
|
||||
<ProbeSM />
|
||||
</I18nProvider>
|
||||
</StrictMode>
|
||||
)
|
||||
// StrictMode 下 mount 走 setup → cleanup → setup,cleanup 会把 aliveRef 置 false
|
||||
// 然后第二次 setup 复位为 true;这套时序连带一次额外 setDetecting(true)。
|
||||
// 用 setTimeout 0 把整条 microtask 链排空,连带 React 18 调度。
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
})
|
||||
// 关键断言:即使 React StrictMode 双调用 mount,detectInterpreters 也只能
|
||||
// 调一次(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)" 都 false,detecting 卡在 true。
|
||||
// 现在 setup 复位 aliveRef=true,finally 正常跑,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) 写 fallback:detectAll 发现持久路径在新列表里找不到 → 回退到第一个;
|
||||
* 这条 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')
|
||||
})
|
||||
})
|
||||
136
src/renderer/src/hooks/__tests__/useResizableFraction.test.ts
Normal file
136
src/renderer/src/hooks/__tests__/useResizableFraction.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
71
src/renderer/src/hooks/__tests__/useScope.test.ts
Normal file
71
src/renderer/src/hooks/__tests__/useScope.test.ts
Normal 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 抛错时静默回默认 all(file:// 场景)', () => {
|
||||
const spy = vi.spyOn(window.localStorage, 'getItem').mockImplementation(() => {
|
||||
throw new Error('SecurityError: file:// access denied')
|
||||
})
|
||||
try {
|
||||
expect(readStoredScope()).toBe('all')
|
||||
} finally {
|
||||
spy.mockRestore()
|
||||
}
|
||||
})
|
||||
})
|
||||
46
src/renderer/src/hooks/__tests__/useTheme.test.ts
Normal file
46
src/renderer/src/hooks/__tests__/useTheme.test.ts
Normal 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)
|
||||
}
|
||||
})
|
||||
})
|
||||
173
src/renderer/src/hooks/useAnalysis.ts
Normal file
173
src/renderer/src/hooks/useAnalysis.ts
Normal 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 ref:run/cancel/reset 各自直接翻位即可,订阅回调每次重新读 current 值。
|
||||
const progressActiveRef = useRef(false)
|
||||
// 组件是否还挂载:卸载后 analyze() 才返回的 setState 全部丢弃。
|
||||
// 之前只用 runIdRef 防"陈旧 token 覆盖新 run",但用户卸载组件时 runIdRef 不变,
|
||||
// myId 仍等于 current,setState 仍会触发(虽不报错但浪费 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 它完成才发起新 run(IPC 内部 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(() => {
|
||||
// 递增 runIdRef:reset 会把旧 run 的飞行事件作废(cancel 走的是同一逻辑),
|
||||
// 否则旧 run 的 progress 回调继续推到 idle UI,会让 setProgress 出现鬼影。
|
||||
clearAnalysisState()
|
||||
}, [clearAnalysisState])
|
||||
|
||||
return {
|
||||
result,
|
||||
state,
|
||||
progress,
|
||||
errorMessage,
|
||||
stderrTail,
|
||||
stdout,
|
||||
resultScope,
|
||||
run,
|
||||
cancel,
|
||||
reset,
|
||||
clearStdout: () => setStdout('')
|
||||
}
|
||||
}
|
||||
238
src/renderer/src/hooks/useExternalFiles.ts
Normal file
238
src/renderer/src/hooks/useExternalFiles.ts
Normal 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 不会触发 setState(React 18 会喷 warning)。
|
||||
* - **race 处理**:openFile 在 IPC await 之后再 patch tab。如果 await 期间用户
|
||||
* close 了该 tab,patch 时检查 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 加一个 unknown(catch 兜底用)。
|
||||
* 跟 shared/analysis 的 FileReadResult 错误分支保持一致;ok 不算 error。 */
|
||||
export type FileReadErrorKind =
|
||||
'not_found' | 'not_a_file' | 'permission_denied' | 'too_large' | 'invalid_path' | 'unknown'
|
||||
|
||||
export interface OpenFile {
|
||||
/** 稳定的 tab id(useExternalFiles 内部自增分配)。 */
|
||||
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
|
||||
/** 主进程返回的错误 kind;UI 拿它走 i18n 表。 */
|
||||
errorKind?: FileReadErrorKind
|
||||
/** 原始错误 message(IPC 给的,已翻译无需再做 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 的 line:openFile 时同步写,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 已开 → 激活 + 排队 reveal,return
|
||||
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) 发 IPC(fire-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
|
||||
}
|
||||
}
|
||||
191
src/renderer/src/hooks/useInterpreters.ts
Normal file
191
src/renderer/src/hooks/useInterpreters.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* Python 解释器检测逻辑。
|
||||
*
|
||||
* 行为要点:
|
||||
* - mount 时自动调 detectAll()
|
||||
* - StrictMode 双调用 / 卸载竞态由 aliveRef 守卫
|
||||
* - 当前 selected 若仍在列表里则保留,否则回退到第一个
|
||||
*
|
||||
* 并发请求守卫(tokenRef):
|
||||
* - 之前只有 aliveRef,渲染后用户连点「刷新」或 pick 后又触发了 detectAll,
|
||||
* 两个 IPC 调用并行跑,晚到的旧调用会 setState 覆盖新结果。
|
||||
* - 现在 detectAll / pick 各自带 myToken,await 之后比对 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 → setup:cleanup 把 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
|
||||
}
|
||||
}
|
||||
123
src/renderer/src/hooks/useResizableFraction.ts
Normal file
123
src/renderer/src/hooks/useResizableFraction.ts
Normal 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
|
||||
// 用函数式 setState:Splitter 在 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 }
|
||||
}
|
||||
58
src/renderer/src/hooks/useScope.ts
Normal file
58
src/renderer/src/hooks/useScope.ts
Normal 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 }
|
||||
}
|
||||
45
src/renderer/src/hooks/useShellPreference.ts
Normal file
45
src/renderer/src/hooks/useShellPreference.ts
Normal 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 }
|
||||
}
|
||||
56
src/renderer/src/hooks/useTheme.ts
Normal file
56
src/renderer/src/hooks/useTheme.ts
Normal 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 时再同步一次(覆盖罕见 race:inline script 之前抛了 / localStorage 被外部进程改了)
|
||||
useEffect(() => {
|
||||
applyTheme(theme)
|
||||
}, [theme])
|
||||
|
||||
return { theme, setTheme }
|
||||
}
|
||||
68
src/renderer/src/i18n/__tests__/i18n.test.ts
Normal file
68
src/renderer/src/i18n/__tests__/i18n.test.ts
Normal 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 的所有 key(key 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')
|
||||
})
|
||||
})
|
||||
289
src/renderer/src/i18n/en-US.ts
Normal file
289
src/renderer/src/i18n/en-US.ts
Normal 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
Reference in New Issue
Block a user