Compare commits
4
Commits
v1.0.31
...
Dev_20261020
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f462a5421b | ||
|
|
c0f1cdcecb | ||
|
|
69d24fbed6 | ||
|
|
9036bea6f3 |
No files matched your search
@@ -1,182 +1,143 @@
|
||||
# OpenTerminal 开发备忘
|
||||
|
||||
Electron + electron-vite + React 终端工具(本地终端 / SSH / SFTP)。
|
||||
Electron + electron-vite + React 终端工具(本地终端 / SSH / SFTP)。本文只留硬规则与踩坑结论;完整背景见 `docs/`(ROADMAP / STATE / PLAN-*),历史细节看 git log。
|
||||
|
||||
## 常用命令
|
||||
|
||||
- 开发:`npm run dev`(主进程改动不热重建,需重启)
|
||||
- dev 实例使用独立用户数据目录 `%APPDATA%\OpenTerminal-dev` 与独立单实例锁(`src/main/index.ts` 顶部 `!app.isPackaged` 分支),窗口标题带 `(dev)`:**可与已安装的正式版同时运行,互不干扰**,也不会把测试设置/会话写进真实配置
|
||||
- 类型检查:`npm run typecheck`(tsconfig.node.json + tsconfig.web.json;只看渲染层可单跑 `npx tsc --noEmit -p tsconfig.web.json`)
|
||||
- 测试:`npm test`(**npm 生命周期先自动跑 `pretest` 做类型检查**,再 `node tests/build-bundles.cjs` 重建 esbuild bundle,然后依次跑可离线运行的 36 个测试:ssh-loopback、commands-store、connections-store、settings-store、local-path-grants、lock-store、lock-controller、lock-shortcuts、reserved-accelerators、terminal-title、ipc-guard、updater-fallback、log-sanitizer、app-log、sftp-timeout、hl-split-smoke、hl-rules、redos-probes、rule-validation、redos-validator、perf-diagnostics、zmodem-e2e、ssh-session-e2e、sysinfo-e2e、terminal-cwd、terminal-links、broadcast-store、write-batcher、refit-throttle、semantic-theme、theme-contrast、css-theme-vars、ot-var-names、font-stack、link-rules、mode-rail-gesture;真实服务器测试需 JD_* 凭据,不在此列)。其中渲染层纯函数测试(semantic-theme、theme-contrast、ot-var-names、font-stack、write-batcher、refit-throttle、perf-diagnostics、redos-probes、rule-validation、broadcast-store、terminal-cwd、terminal-links、terminal-title、hl-split-smoke、hl-rules、link-rules、mode-rail-gesture 等)用 Node 22.18+ 的原生 TS 类型剥离**直接 import `.ts` 源文件**(不经 esbuild bundle),Node 20/22.17 以下会 `ERR_UNKNOWN_FILE_EXTENSION`
|
||||
- 依赖分类规则:**只有 `src/main/`/`src/preload/` 实际 import 的包才能进 `dependencies`**(node-pty/ssh2/zmodem.js/font-list/electron-updater);纯渲染层依赖一律 devDependencies(Vite 全量打包进 out/renderer,`externalizeDepsPlugin` 不作用渲染层)——这条让 asar 从 98MB 瘦到 8.1MB,加新依赖时别放错边
|
||||
- 下载量统计:`node scripts/download-stats.cjs`(Gitea + GitHub release 资产的 download_count 汇总;GitHub 优先直连、失败自动回退 `HTTPS_PROXY`/本地 7897;更新通道无计数接口不计入)
|
||||
- 打包:`npm run dist`(**生命周期先自动跑 `predist` → `npm test`,即类型检查 + 36 个离线测试全部通过后才 build/package**,typecheck 全程只跑一次;`electron-vite build` 之后、electron-builder 之前会跑 `node tests/worker-artifact.mjs`——真实加载 `out/main/redosProbeWorker-*.js` 并回包,防止 `?nodeWorker` 产物静默丢失;predist 末尾的 `npm install --package-lock-only` 会把 `package-lock.json` 根版本号对齐 `package.json`,**发布提交必须带上 package-lock.json**),产物在 `release/`(exe + latest.yml + blockmap)
|
||||
- GitHub Actions:`.github/workflows/ci.yml` 在 windows-latest + Node 22 上跑 `npm ci` / `npm test`(含 pretest typecheck)/ `npm run build`,只做验证,不打包安装器、不发布
|
||||
- 国内网络需镜像:`ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-binaries/ npm run dist`
|
||||
- 依赖一致性闸门:`predist` 开头跑 `node scripts/verify-deps.cjs`(逐条比对 `node_modules` 与 `package-lock.json` 的版本,跨平台 optional 依赖缺失跳过;不一致就 exit 1)——`npm install --package-lock-only` **只重算 lockfile、不碰 node_modules**(本版 npm 还会把 `node_modules/.package-lock.json` 一并重写成理想树,制造「已经装好了」的假象),所以**改动依赖版本后必须真实 `npm install` 或 `npm ci` 再构建**,别指望 lockfile 对齐就等于树里换了包;v1.0.21 的中文输入法回归正是这个坑(`@xterm/xterm` 锁 6.1.0-beta.304,树里还是 6.0.0,打进去的是旧代码)
|
||||
- **换机/重装环境后第一次打包前必须先跑 `npm ci`**(verify-deps 会兜底拦截,但别浪费一次构建);在任何机器上都先 `node scripts/verify-deps.cjs` 确认再 `npm run dist`
|
||||
- electron-builder `rename win-unpacked.tmp` 撞 EPERM = 安全软件实时扫描新解压文件短暂持锁(实测约 4s,进程退出后数秒即可手动重命名)。应对:`NODE_OPTIONS="--require $(pwd -W)/scripts/rename-retry-shim.cjs" npm run dist`(注入重试垫片,stderr 会打印每次重试);根治是把仓库目录加进安全软件信任区
|
||||
- 开发:`npm run dev`(主进程改动不热重建,需重启)。dev 实例用独立 `%APPDATA%\OpenTerminal-dev` + 独立单实例锁,可与正式版并存,测试数据不进真实配置
|
||||
- 类型检查:`npm run typecheck`;渲染层单独:`npx tsc --noEmit -p tsconfig.web.json`
|
||||
- 测试:`npm test`(pretest 先 typecheck → `tests/build-bundles.cjs` 重建 → 36 个离线测试,清单见 `package.json` 的 `test` 脚本)。渲染层纯函数测试用 Node 22.18+ 原生类型剥离直连 `.ts` 源文件,Node 20/22.17 以下报 `ERR_UNKNOWN_FILE_EXTENSION`;真实服务器测试需 `JD_*` 凭据,不在列
|
||||
- 依赖分类:**只有 `src/main/`/`src/preload/` 实际 import 的包才能进 `dependencies`**;纯渲染层依赖一律 devDependencies(Vite 全量打包进 out/renderer,`externalizeDepsPlugin` 不作用渲染层)
|
||||
- 打包:`npm run dist`(predist = verify-deps → npm test → build;build 后跑 `tests/worker-artifact.mjs` 防 `?nodeWorker` 产物静默丢失;末尾 `npm install --package-lock-only` 对齐 lockfile 根版本,**发布提交必须带 package-lock.json**)。CI(`.github/workflows/ci.yml`,windows-latest + Node 22)只验证不打包不发布
|
||||
- 国内镜像:`ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-binaries/ npm run dist`
|
||||
- **改依赖版本后必须真实 `npm install`/`npm ci`**:lockfile 对齐 ≠ 树里换了包(`scripts/verify-deps.cjs` 会拦;v1.0.21 的 @xterm 回归正是此坑)。换机/重装后第一次打包前先 `npm ci`
|
||||
- EPERM rename `win-unpacked.tmp` = 安全软件扫描持锁:`NODE_OPTIONS="--require $(pwd -W)/scripts/rename-retry-shim.cjs" npm run dist`;根治是把仓库目录加进安全软件信任区
|
||||
- 下载量统计:`node scripts/download-stats.cjs`
|
||||
|
||||
## 仓库与远程
|
||||
|
||||
- `origin` = Gitea `https://git.codingplan.site/admin/OpenTerminal.git`(主仓库 + 更新通道)
|
||||
- `github` = `https://github.com/billowliu2/OpenTerminal.git`(镜像 + 备用更新源)
|
||||
- ⚠️ Gitea 的 upload-pack 有故障,`git fetch origin` 会报 `bad pack header`;查询远端状态用 API 或 `git ls-remote`。推送正常。
|
||||
- 2026-09:远端 main 旧历史(M3–M8,至 v0.8.0 d3a628f)被 force-push 覆盖为当前历史,旧提交仍由 tag v0.6.0 / v0.7.0 / v0.8.0 保留。
|
||||
- `origin` = Gitea(主仓库 + 更新通道);`github` = GitHub 镜像 + 备用更新源(具体 URL 看 `git remote -v`)
|
||||
- ⚠️ Gitea upload-pack 故障,`git fetch origin` 报 `bad pack header`;查远端用 API 或 `git ls-remote`,推送正常
|
||||
- 2026-09 远端旧历史(M3–M8,至 v0.8.0)被 force-push 覆盖,旧提交由 tag v0.6.0/v0.7.0/v0.8.0 保留
|
||||
|
||||
## 凭据
|
||||
|
||||
- `.env`(已 gitignore):`GIT_TOKEN`(Gitea)、`GH_TOKEN`(GitHub)等。git 推送通过 credential helper 使用该令牌。
|
||||
`.env`(已 gitignore):`GIT_TOKEN`(Gitea)、`GH_TOKEN`(GitHub);git 推送经 credential helper 用该令牌。
|
||||
|
||||
## 发布新版本
|
||||
|
||||
1. `package.json` 版本号 +1,写 `RELEASE_NOTES.md`(仓库根,已 gitignore;可选 `.zh-TW/.en/.ja` 译文,缺译文回退简体)
|
||||
2. `node scripts/sync-changelog.cjs` 把发布说明并入 `CHANGELOG*.md` —— **必须在 `npm run dist` 之前**:更新日志以 `?raw` 打进安装包(「关于」页离线读),`release.cjs` 也校验 `CHANGELOG.md` 已含该版本号
|
||||
1. `package.json` 版本 +1,写 `RELEASE_NOTES.md`(可选 .zh-TW/.en/.ja 译文,缺译回退简体;`RELEASE_NOTES*.md` 已 gitignore,不随仓库提交)
|
||||
2. `node scripts/sync-changelog.cjs` 并入 `CHANGELOG*.md` —— **必须在 dist 之前**(更新日志以 `?raw` 打进安装包,release.cjs 也校验)
|
||||
3. `npm run dist` 构建
|
||||
4. `node scripts/release.cjs <版本号>`,例如 `node scripts/release.cjs 1.0.14`(**不带 skip 参数**,Gitea 与 GitHub 一起发)
|
||||
- Gitea:release(exe 资产)→ Gitea 更新通道(`api/packages/admin/generic/openterminal-update/stable`:exe.blockmap → exe → release-notes.md → **latest.yml 最后**)
|
||||
- GitHub:release 资产再次随版本同步发布(exe + exe.blockmap + latest.yml),electron-updater 标准 GitHub provider 直接吃 release 资产
|
||||
- 通道不再先删旧版:新版本文件全部传完、latest.yml 生效后才清掉上一版 exe/blockmap,中途失败不会把通道打空;同一版本可重复运行(release 复用、已传资产跳过)
|
||||
- 上传前先比通道版本:`assertNoDowngrade()` 读通道 latest.yml,若线上版本**高于**待发布版本就直接拒绝——三条本地护栏只比本地产物,旧分支发旧版本号会一路通过,覆盖 latest.yml 之后 `pruneChannel` 会把线上新版本的 exe/blockmap 删掉。该探测**fail-closed**:网络错误 / 非 404 失败 / 解析不出 version 一律抛错中止发布,只有通道真空(404)才放行;`pruneChannel` 前那次探测是 best-effort(失败只跳过清理并打日志)
|
||||
- 只补通道:`node scripts/release.cjs <版本号> --channel-only`(不建 release、不发 GitHub)
|
||||
- 跳过部分目标:`--skip-github` 只跳 GitHub release;`--skip-gitea-release` **只跳 Gitea release、更新通道照常上传**;`--skip-gitea` 两者都跳(会打印醒目警告——国内用户将收不到该版本,只想跳 release 请用 `--skip-gitea-release`)
|
||||
- 国内通道全程直连,**不需要设代理**;GitHub 请求走 `HTTPS_PROXY=http://127.0.0.1:7897`(脚本只把它用于 GitHub 请求)
|
||||
5. 验证更新通道:`curl https://git.codingplan.site/api/packages/admin/generic/openterminal-update/stable/latest.yml` 应返回新版本号
|
||||
6. `git tag v<版本号>` 并推送两个远程(代码/tag 与 release 资产的镜像保持同步)。注意顺序坑:release.cjs 创建 release 时若远端尚无该 tag,Gitea/GitHub 会在**默认分支 HEAD** 自动建一个指向错误 commit 的 tag,第 6 步推送会被拒——要么先建 tag 推上去再跑 release.cjs,要么事后 `git push -f <远端> v<版本号>` 强制修正到 release commit(v1.0.22 即踩过)
|
||||
4. `node scripts/release.cjs <版本号>`(默认 Gitea + GitHub 一起发;同版本可重复跑,已传资产跳过)
|
||||
- 更新通道上传顺序:exe.blockmap → exe → release-notes.md → **latest.yml 最后**;新版全部传完才清上一版,中途失败不会打空通道
|
||||
- `--channel-only` 只补通道;`--skip-github` 跳 GitHub;`--skip-gitea-release` 只跳 release、通道照常;`--skip-gitea` 两者都跳(国内用户收不到,慎用)。**`--channel-only --skip-gitea` 组合直接 exit 1**
|
||||
- 防降级 `assertNoDowngrade()` **fail-closed**:通道版本更高 / 网络错误 / 解析失败一律中止发布,只有通道真空(404)才放行;pruneChannel 前那次探测是 best-effort(失败只跳过清理并打日志)
|
||||
- GitHub 走 `HTTPS_PROXY=http://127.0.0.1:7897`(脚本只对 GitHub 请求用它);Gitea 通道直连不需要代理
|
||||
5. 验证:`curl https://git.codingplan.site/api/packages/admin/generic/openterminal-update/stable/latest.yml` 应返回新版本号
|
||||
6. `git tag v<版本号>` 并推两个远程。**tag 必须先推再跑 release.cjs**:否则 Gitea/GitHub 会在默认分支 HEAD 自动建错位 tag,事后要 `git push -f` 修正(v1.0.22 踩过)
|
||||
|
||||
## 诊断日志
|
||||
|
||||
- 正式版落盘的诊断日志在 `<userData>/logs/app-YYYY-MM-DD.log`(**本地日期**命名,`src/main/appLog.ts`):**保留 7 天**(今天 + 前 7 天,更早即删),启动时清扫一次 + 每 24h unref 定时器再扫(长开机的进程跨午夜也会清);只动 `app-*.log` 命名匹配的文件,目录里的外来文件不碰。会话录制日志(LOG_START)是用户文件,永远不走这里
|
||||
- **开关**:`settings.system.diagnosticLog`(默认开,设置页「系统」区)。闸门在 `appLog()` 里(`setAppLogEnabled` 由 `applySystemSettings` 每次设置变更时跟随,index.ts 启动时先按 settings 设好再写启动行);渲染层看门狗发送前也自查一遍省 IPC。关闭 = 什么都不记,但保留期清扫照常(它只删不写)
|
||||
- 写入方:主进程启动行 / `unhandledRejection` / `uncaughtException` / 事件循环 stall 看门狗(`index.ts`);渲染层 stall 看门狗与 PERF 观测经 `APP_LOG` IPC 中继(`main.tsx` → preload → `ipc.ts`)。`appLog()` 走异步串行队列、失败静默——**日志永远不能搞挂应用本身**;单行上限 2000 字符、内嵌换行压平、source 列定宽对齐
|
||||
- **渲染层 PERF 时间线(只观测,不改变执行行为)**:`src/renderer/src/terminal/perfDiagnostics.ts` + `main.tsx` 的 longtask observer 把迟到的 2s tick 分成 `renderer long task`(窗口内有真实 PerformanceObserver longtask 重叠)与 `scheduler delay/unclassified`(计时器节流/休眠/OS 调度等,不能当渲染层卡死证据);同一条报告带 pane/renderer、最近 fit、xterm write 队列与 batcher 积压。**日志落盘时间是恢复时刻,行内 `window`/`est` 才是估算故障区间**。`PERF fit`(trigger/耗时/cols×rows/bufferLines,每 pane 30s 限频)、`PERF write xterm ack`(xterm 消费完成延迟)与 `PERF renderer webgl-*` 共享每分钟 5 行预算;严重看门狗报告 urgent,不受预算吞掉。所有字段都是元数据,绝不含终端内容;`term.write` callback 只计数,不改变写入顺序,也**不是** PTY 背压
|
||||
- 排障口径:卡死报告先让用户发当天的 app log——`[main] event loop stalled` = 主进程阻塞;`main thread lag … renderer long task` = 渲染层真实长任务;`scheduler delay/unclassified` = 看门狗只能证明计时器迟到,不能归因卡死;再用同时间线的 `PERF fit` / `PERF write` / `PERF renderer` 判断是 reflow、xterm 消费积压还是 WebGL 生命周期
|
||||
- 测试:`node tests/app-log.mjs`(经 `tests/.app-log.cjs` bundle;命名/保留窗口表驱动 + 真实临时目录的清扫、追加与开关门控)、`node tests/perf-diagnostics.mjs`(longtask 分类、pane 指标聚合、fit/write 限频与 trigger 分类表驱动)
|
||||
- 落盘 `<userData>/logs/app-YYYY-MM-DD.log`(本地日期),保留 7 天(今天 + 前 7 天;启动清扫一次 + 每 24h unref 定时器再扫,长开机跨午夜也会清),只动 `app-*.log` 命名匹配的文件;会话录制日志是用户文件,永远不走这里
|
||||
- 开关 `settings.system.diagnosticLog`(默认开);关闭 = 什么都不记,但保留期清扫照常(只删不写)
|
||||
- `appLog()` 走异步串行队列、失败静默、单行 2000 字符 —— **日志永远不能搞挂应用本身**
|
||||
- 排障口径:`[main] event loop stalled` = 主进程阻塞;`renderer long task` = 渲染层真实长任务;`scheduler delay/unclassified` 只证明计时器迟到,**不是**卡死证据;再对照 `PERF fit` / `PERF write` / `PERF renderer` 判断 reflow、xterm 积压还是 WebGL 生命周期。所有字段是元数据,绝不含终端内容
|
||||
- PERF 时间线只观测不改变执行行为;日志落盘时间是恢复时刻,行内 `window`/`est` 才是故障区间;`PERF fit` 超 `FIT_SLOW_LOG_MS=200` 才记且每 pane 30s 限频(带 suppressed=N),fit/write/webgl 共享每分钟 5 行预算,严重看门狗报告 urgent 不受预算吞掉
|
||||
|
||||
## 更新机制
|
||||
|
||||
- 检查更新:**GitHub 优先**(走系统代理;前置 20 秒连通性探测 `probeGithub`——探测失败/超时直接兜底 Gitea,不给 electron-updater 挂起的机会),失败回退国内 Gitea 通用包通道(强制直连,不走系统代理)。 electron-updater 用独立 session(partition `electron-updater`),代理模式在 `useFeed` 里按源切换
|
||||
- **三条检查来源**(`runCheck(source)`,共用一条路径,`UpdateState.scheduled` 标记来源):启动后 5s 一次(`autoCheckUpdate`)、周期定时(`settings.system.updateCheckIntervalHours`,白名单 `{0,4,12,24}` 默认 4,`0`=从不;4/12/24 建 `setInterval(runCheck('scheduled'))`)、用户手动。**所有 timer 必须 `.unref()`**,否则会阻止进程退出。已有在途检查时返回同一 promise(`checkInFlight`),避免 electron-updater 的去重把来源标记记到错的调用方头上
|
||||
- **调度器是注入的**:`applyUpdateSchedule(system)` 由 `index.ts` 经 `settingsStore.setUpdateScheduleApplier` 注入——`updater.ts` 已 import `settingsStore`,反向 import 成环。applier 幂等(先清旧 interval 再建)且每次 `mutateSettings` 都会重跑;启动检查用模块级 `startupArmed` 保证**每进程只武装一次**,否则改一次设置就多排一次启动检查
|
||||
- **自动下载**(`settings.system.autoDownloadUpdate`,默认关):`update-available` 时若开关开且无在途下载 → `handleDownload('auto')`(`UpdateState.auto` 标记)。`autoUpdater.autoDownload` **保持 false**——单一事实源在 settings,不交给 electron-updater。失败落 `status:'error'` 且**不重试**,等下一个周期
|
||||
- **安装永远交给用户**:绝不因下载完成自动退出;`autoInstallOnAppQuit = true` 只覆盖「用户自己退出时装上」
|
||||
- **差量下载已禁用**(`autoUpdater.disableDifferentialDownload = true`,configureAutoUpdater 里):electron-updater 每次 `downloadUpdate()` 先试差量(拉新旧 blockmap + 多段 Range)失败再回退全量——用户看到进度条跑两遍、最坏近乎双倍流量(v1.0.x 用户报的「下载两次」)。而本项目三个前提让它注定失败:GitHub release 资产落 Azure Blob,多段 Range 返回 `501 Unsupported client range`(已实测);国内 Gitea 通道被 `pruneChannel` 删得只剩最新版 exe/blockmap,老客户端取老 blockmap 得 404;NSIS 是 LZMA 固实压缩,版本间小改动让后续压缩数据整体漂移,差量区间≈整个文件。所以直接全量是唯一正确行为
|
||||
- **同版本抑制**(`actedRelease` 单槽记录,`actedFor(version)` 版本不同即整体替换):`announced`(发现气泡)、`fetchHandled`(已发起下载)、`downloaded`(包已落盘)、`downloadedAnnounced`(已下载气泡)——否则长开机的应用每 4 小时重复弹气泡/重复走一遍 `downloadUpdate`。`runCheck('manual')` **无条件清空**记录(手动是明确意图,必须如实反映);`update-not-available`/`error` **不清空**;下载失败回滚该次 `fetchHandled`
|
||||
- `downloaded` 与 `fetchHandled` **必须分开**:前者只在 `update-downloaded` 置位,用它判断「已完成」;拿 `fetchHandled` 判断会把下载中的版本误跳过。`update-available` 里若该版本 `downloaded` 为真,要 `setState` 一个等价的 `downloaded` 状态(**不能**直接 return 不写——`performCheck` 已把状态置成 `checking`,早退会让「关于」页永远转圈),这样按钮不会从「重启安装」退回「下载」
|
||||
- **通知**:`tray.ts` 的 `notifyUpdate(title, content)` 弹 Windows 托盘气泡;非 win32 / tray 为空 / **有可见窗口**(用户在看「关于」页)都不弹。自动下载开启时不弹「发现新版本」(有意义的是「已下载」那条),手动下载完成也不弹
|
||||
- 更新日志:Gitea 仓库是私有的(匿名 API 404),改为从更新通道的 `release-notes.md` 读取(直连 session `openterminal-update-direct`),再回退 Gitea/GitHub releases API
|
||||
- 自动更新只走 NSIS exe;自 v1.0.22 起不再构建 MSI 安装包(electron-updater 本就不支持 MSI 自动更新)
|
||||
- 每次 checkForUpdates 都被 **30s 整体超时**包住(`withTimeout`):electron-updater 自带的 60s 只是 socket 空闲超时,慢滴流响应能一直占住它。超时按普通失败走 GitHub→Gitea 回退,但**被放弃的检查取消不掉**,而 electron-updater 对并发检查去重(返回同一个 in-flight promise),所以兜底那次共用被放弃的 promise——它同样被超时兜住,最终落到错误态,不会永远停在「检查中」
|
||||
- 更新日志 / releases API 的 fetch 都带 `AbortSignal.timeout(15s)`(`directFetch` 与 `fetchChangelog` 里的 `net.fetch`):卡住的通道必须让位给下一个来源,不能把「关于」页吊住
|
||||
- 检查顺序:**GitHub 优先**(前置 20s 连通探测 `probeGithub`,失败直接兜底不给挂起机会)→ Gitea 通道(强制直连)。electron-updater 用独立 session(partition `electron-updater`),代理模式在 `useFeed` 按源切换
|
||||
- 每次 checkForUpdates 包 **30s 整体超时**;changelog / releases API fetch 带 `AbortSignal.timeout(15s)`
|
||||
- 三种来源(启动 5s / 周期定时 / 手动)共用一条路径;周期 `settings.system.updateCheckIntervalHours` 白名单 `{0,4,12,24}` 默认 4,0 = 从不;**所有 timer 必须 `.unref()`**;已有在途检查返回同一 promise(`checkInFlight`)
|
||||
- 调度器由 `index.ts` 注入(`settingsStore.setUpdateScheduleApplier`)避免 import 环;applier 幂等,**interval 未变不重建定时器**;启动检查 `startupArmed` 每进程只武装一次
|
||||
- `autoUpdater.autoDownload` 恒 false,单一事实源在 settings(`autoDownloadUpdate` 默认关);下载失败落 error 不重试;**绝不自动退出安装**,`autoInstallOnAppQuit` 只覆盖用户自己退出
|
||||
- 差量下载已禁用(`disableDifferentialDownload`):GitHub 资产多段 Range 返回 501、通道被 prune 只剩最新 blockmap、NSIS LZMA 固相漂移 —— 注定失败且流量近乎翻倍
|
||||
- `actedRelease` 单槽去重(announced/fetchHandled/downloaded/downloadedAnnounced);`downloaded` 与 `fetchHandled` 必须分开判「已完成」;手动检查无条件清空记录;`update-not-available`/`error` **不清空**;下载失败回滚该次 `fetchHandled`;`update-available` 时该版本已 `downloaded` 必须先 `setState` 等价 downloaded 状态再早退(裸 return 会让「关于」页永远转圈)
|
||||
- 通知走托盘气泡:非 win32 / 无 tray / **有可见窗口**都不弹;自动下载时不弹「发现新版本」,手动下载完成也不弹
|
||||
- 只走 NSIS exe(1.0.22 起无 MSI);更新日志从通道 `release-notes.md` 读(直连 session),回退 releases API
|
||||
|
||||
## 主题机制
|
||||
|
||||
- 终端主题由 xterm 主题派生 UI 配色:`src/renderer/src/theme/chrome.ts` 的 `applyChromeTheme` 写入 `--chrome-bg/-bg-deep/-border/-hover` CSS 变量,antd token 在 `main.tsx` ThemedConfigProvider 派生
|
||||
- **语义变量层(2026-10 主题改造)**:`src/renderer/src/theme/semantic.ts`(**零 import 纯模块**——`tests/semantic-theme.mjs` 经 Node 原生类型剥离直连它,加 import 前先想清楚)导出 `isLightTheme`/`mix`/`luminance`/`contrast`/`darkenToRatio`/`SEMANTIC` 锚点 + `OtVarName`/`SemanticVars` 类型;`chrome.ts` 的 `applyChromeTheme` 随主题派生 `--ot-*` 变量并写 `color-scheme: dark|light`(原生滚动条/表单跟随)。**深色分支锁 SEMANTIC 锚点原值(零回归),浅色分支 `darkenToRatio` 按 WCAG 派生**(正文/-fg ≥4.5:1,语义基色 ≥3:1)。CSS 一律 `var(--ot-*, 锚点兜底)`,新代码禁止裸颜色字面量——`node tests/css-theme-vars.mjs` fail-closed 扫描(剥注释/color-mix/var 兜底走受控清单 FALLBACK_OK,跳过含 `${` 的模板串行,白名单按「文件+精确值」登记在测试内);**canvas 绘制吃不到 CSS 变量**(`ctx.strokeStyle` 不解析 var()),图表类组件用 `useResolvedTheme()` + `semanticVars()` 取实色(MonitorPanel 的做法)。**单一命名体系(v1.0.29 修复)**:`semanticVars()` 的键**就是最终 CSS 自定义属性名**(`'--ot-text-muted'`),返回类型 `SemanticVars = Record<OtVarName | 'color-scheme', string>` 是字面量键联合——**曾经是 camelCase 键 + `cssVarName()`/`semanticCssVars()` 转换桥,自定义属性名大小写敏感,转换一被绕过就静默回落到 `:root` 深色兜底值(浅色主题下 `--ot-text-muted` 等 9 个多词变量全部没生效过一轮)**;现在中间层已删除,键名拼错在读取处直接是 TS 类型错误,`node tests/ot-var-names.mjs` 另外断言每个键匹配 `/^--ot-[a-z0-9-]+$/`(camelCase 混回来即 FAIL)并把「CSS 引用名 = JS 写入名 = global.css `:root` 声明名」三个集合锁在一起——**值测试(semantic-theme/theme-contrast)看不出这类问题**,改语义层时别只跑它们。加变量时 `OtVarName` 联合与 `semanticVars()` 两个分支三处一起改
|
||||
- **浅色主题可读性**:内置浅色主题 ANSI 调色板已重做,16 色+fg 全部 ≥4.5:1(`node tests/theme-contrast.mjs` 守门,改浅色主题必须过它;改色用 `darkenToRatio` 统一加深算法,不许手挑色);`minimumContrastRatio` 按「背景图 4.5|浅色无图 4.5|深色无图 1」矩阵(构造+热更两处都要改),`drawBoldTextInBrightColors` 浅色关闭;WebGL 渲染器同样执行 MCR(addon-webgl 自带 `_getMinimumContrastColor` 对比度路径,已核实);antd 算法随 `isLightTheme` 切 `defaultAlgorithm`/`darkAlgorithm`(**antd 没有 lightAlgorithm,浅色就是默认算法**);github-light 底色已柔化 `#f6f8fa`
|
||||
- 标签强调色:`settings.tabAccentColor`(默认 `#3fb950`),经 `--tab-accent` CSS 变量生效;`--tab-accent-fg` 是强调底上的文字色(明暗自适应,选中态按钮文字也用它)
|
||||
- 背景图(`settings.terminal.backgroundImage`):图片层 `.term-bg-image` 在 xterm 画布之下(`terminal.css`),xterm 背景设 `#00000000` 透明。**图片模式下的三条铁律**:① `.terminal-view.has-bg-image .terminal-view-dock` 强制深色底衬 `#0d1117`,不能用 `--chrome-bg`——浅色主题下它是白色,调低透明度会把图洗白而不是压暗;② `minimumContrastRatio` 从 1 提到 4.5(WCAG AA,与 VS Code 终端默认值一致;TerminalView 构造与设置热更两处都要改;注意正确拼写是 `minimumContrastRatio`,`minContrastRatio` 会被静默忽略),xterm 的对比度计算把透明背景当黑色亮度,会把浅色主题的深色前景自动提亮到可读。无图时保持 1 不动主题配色。③ 压暗层(`settings.terminal.backgroundImageDim`,0–90,**默认 0**):`.term-bg-dim` 纯黑 scrim 在 DOM 序上位于 `.term-bg-image` 之后、xterm(z-index:1)之下(同在 z-index:0 层,靠 DOM 序压图),与透明度的区别是透明度把图与深色底衬混合、压暗在图之上叠黑(保饱和但整体变暗);**dim=0 时 TerminalView 不渲染该层,零成本**
|
||||
- 终端主题派生 UI 配色:`theme/chrome.ts` 的 `applyChromeTheme` 写 `--chrome-*` 并派生 `--ot-*` 语义变量 + `color-scheme`
|
||||
- `theme/semantic.ts` 是**零 import 纯模块**(测试经 Node 原生类型剥离直连,加 import 前先想清楚);深色分支锁 SEMANTIC 锚点原值,浅色分支 `darkenToRatio` 按 WCAG 派生(正文 ≥4.5:1,语义基色 ≥3:1)
|
||||
- **单一命名体系**:`semanticVars()` 的键**就是**最终 CSS 自定义属性名(如 `'--ot-text-muted'`);加变量时 `OtVarName` 联合与 `semanticVars()` 两个分支三处一起改。键名拼错在读取处是 TS 编译错;`tests/ot-var-names.mjs` 锁「CSS 引用名 = JS 写入名 = global.css 声明名」
|
||||
- CSS 一律 `var(--ot-*, 锚点兜底)`,**禁止裸颜色字面量**(`tests/css-theme-vars.mjs` fail-closed 扫描);**canvas 绘制吃不到 var()**,图表类用 `useResolvedTheme()` + `semanticVars()` 取实色
|
||||
- 浅色主题:内置 ANSI 调色板 16 色 + fg 全部 ≥4.5:1(`tests/theme-contrast.mjs` 守门;改色用 `darkenToRatio` 统一算法,不许手挑);`minimumContrastRatio` 矩阵 = 背景图 4.5|浅色无图 4.5|深色无图 1(构造 + 热更两处都改;正确拼写是 `minimumContrastRatio`);浅色关 `drawBoldTextInBrightColors`;antd 浅色用 `defaultAlgorithm`(没有 lightAlgorithm)
|
||||
- 背景图三铁律:① `.terminal-view.has-bg-image .terminal-view-dock` 底衬强制 `#0d1117`,不能用 `--chrome-bg`(浅色下会把图洗白);② MCR 提到 4.5(xterm 把透明背景当黑色亮度,会自动提亮深色前景);③ 压暗层 `settings.terminal.backgroundImageDim`(0–90,默认 0)纯黑 scrim 靠 DOM 序压图(z-index 同层、位于图后 xterm 前),dim=0 不渲染零成本
|
||||
- 标签强调色:`settings.tabAccentColor`(默认 `#3fb950`)经 `--tab-accent` 生效;`--tab-accent-fg` 明暗自适应
|
||||
|
||||
## 终端字体
|
||||
|
||||
- **引号包裹在消费点做,settings 存原样**:`src/renderer/src/terminal/fontStack.ts` 的 `quoteFontStack`(零 import 纯模块,`tests/font-stack.mjs` 直连)把字体栈逐段判定——裸标识符合法且非 CSS 关键字则裸写,其余(含空格/括号/`+`/非 ASCII/数字开头)双引号包裹,幂等。**只在传给 xterm(构造+热更)与预览 inline style 时调用**,settings.json 里保持用户原文。枚举端 `font-list` 的 `disableQuoting: true` 不动
|
||||
- **CJK 回退槽位**:`settings.terminal.cjkFontFamily`(默认 `''`=不追加)。TerminalView 的 `appendCjk` 先拼栈再 `quoteFontStack`(追加在引号包裹**之前**,一次切分)。fontOpts 的 useShallow 选择器含该字段,改中文字体即时热更
|
||||
- **`@xterm/addon-unicode11@0.10.0-beta.301` 精确锁定**(devDependencies):peer 恰好接受 `6.1.0-beta.304`,**升 xterm 必须同步重配 addon 版本**(beta.302+ 的 peer 已不接受 304)。`term.loadAddon` 后 `unicode.activeVersion = '11'`;addon 随 `term.dispose()` 连带清理(AddonManager 已注册),不用单独存 ref
|
||||
- `rescaleOverlappingGlyphs: true`(**仅 WebGL 渲染器有效**,DOM 渲染器按浏览器布局不受影响)——把超出 cell 步进的字形压回格内,box-drawing/CJK 宽字符不再压到邻格
|
||||
- 字体下拉等宽徽标:SettingsTabs 用离屏 canvas `measureText` 逐字符采样(`i1l.,:;|Wm0@`,容差 0.01,测失败判非等宽)——误判只会漏徽标不会错标;等宽排前面、「默认」永远第一;`filterOption` 只匹配 `searchText ?? value`,徽标文本不进搜索
|
||||
- 引号包裹在消费点做:`fontStack.ts` 的 `quoteFontStack`(幂等)只在传给 xterm(构造 + 热更)与预览 inline style 时调用,settings 存用户原文;CJK 回退槽位 `settings.terminal.cjkFontFamily` 在 `appendCjk` 里**先拼栈再 quote**(一次切分);枚举端 `font-list` 的 `disableQuoting: true` 不动
|
||||
- `@xterm/addon-unicode11@0.10.0-beta.301` **精确锁定**(peer 恰好接受 6.1.0-beta.304);**升 xterm 必须同步重配 addon 版本**。加载后 `unicode.activeVersion = '11'`,addon 随 term.dispose 连带清理
|
||||
- `rescaleOverlappingGlyphs: true` 仅 WebGL 渲染器有效(把超宽字形压回格内)
|
||||
- 字体下拉等宽徽标:离屏 canvas `measureText` 逐字符采样,误判只会漏徽标不会错标
|
||||
|
||||
## 终端尺寸同步
|
||||
|
||||
- `TerminalView.scheduleFit`:fit 后**去抖 100ms** 再把 cols/rows 发给 PTY,并跳过与上次相同的尺寸。每次 ResizeObserver 都戳 PTY 会让全屏 TUI(Claude Code 等)在最大化/还原的中间尺寸上反复重绘,留下重复帧
|
||||
- **fit 本身有 150ms 节流**(`src/renderer/src/terminal/fitThrottle.ts` 的 `refitDelay`,纯函数,`tests/refit-throttle.mjs` 表驱动):一次 fit 会全量 reflow 整个 scrollback(默认 5000 行,CJK 密集时几十 ms),拖动窗口每帧一次会占满主线程、饿死 xterm 写入队列,UI 在拖动结束后冻结数秒排积压。拖动期间 fit 最多每 150ms 一次,trailing 定时器在事件流停止后补一次终态 fit;`lastFitAtRef = 0` 表示「还没 fit 过」,**首次 fit 绝不等待**(PTY 以占位尺寸启动、shell 可能已在画),重绑时会重置
|
||||
- 拖动窗口期间 xterm 网格按 150ms 节流更新,PTY 尺寸在停止后 100ms 生效
|
||||
- **PTY 输出经 `WriteBatcher` 批量进 xterm**(`src/renderer/src/terminal/writeBatcher.ts`,`tests/write-batcher.mjs`):高频 TUI 每个 node-pty 读块一条 IPC,每条都 `term.write` 会让 xterm 解析调度器每秒重启数百次、永远追不上生产者。批量化后每 ~16ms 窗口合并成一次 write(与 carry flush 同节奏),顺序不变。两个别忘了的接线点:① `markDead` 先 `batcher.flushNow()` 再置 dead 标志——PTY_EXIT 在数据之后、flush 定时器之前到达,dead 后写入会被丢弃;② effect cleanup 里 `batcher.dispose()`(重绑的 pane 靠 session replay 补回,不丢可见内容)
|
||||
- `scheduleFit`:fit 后 **100ms 去抖**再发 PTY,跳过相同尺寸(防全屏 TUI 中间尺寸反复重绘);fit 本身 **150ms 节流**(`fitThrottle.ts`,拖动窗口防主线程占满);**首次 fit 绝不等待**(PTY 以占位尺寸启动,shell 可能已在画),重绑时重置
|
||||
- PTY 输出经 `WriteBatcher` 批量进 xterm(~16ms 窗口合并,顺序不变)。两个接线点别漏:`markDead` 先 `flushNow()` 再置 dead 标志(PTY_EXIT 在数据之后、flush 定时器之前到达);effect cleanup 里 `batcher.dispose()`
|
||||
|
||||
## 性能与安全边界(M11 第二轮)
|
||||
## 性能与安全边界
|
||||
|
||||
- `TerminalView` 用 **5 组 `useShallow` 字段级订阅**(fontOpts/themeOpts/bgOpts/highlightOpts/toolbar + rendererMode),**不要再回到整对象订阅**——主进程 `mutateSettings` 回推整个 settings 对象,任何写入都会变身份,整对象订阅会让所有 pane 重写 options + refit。`useResolvedTheme` 也是 shallow + memo 后的稳定引用
|
||||
- `TerminalView` **已无 imperative handle**(`TerminalHandle`/forwardRef 全删,无人传 ref);要加「外部聚焦/清屏」需重新引入
|
||||
- `FilePanel` 虚拟化:自实现 windowing(>200 条目启用)。**行高与字号单一数据源在 `FilePanel.tsx`**:`BASE_ROW_HEIGHT = 26` × 缩放状态 `scale`(0.8–1.8,`localStorage` 的 `openterminal.sftpFontScale`)算出 `rowHeight`,经面板根元素 inline style 写成 CSS 变量 `--sftp-row-h` / `--sftp-fs` / `--sftp-fs-meta` / `--sftp-scale`;`windowStart(scrollTop, rowHeight)` 与 slice 计算都用这个动态值。**`sftp.css` 只消费变量、不得再写死行高或字号**(写死会让虚拟列表算术与真实行高脱节)。表头 `.sftp-head` 与 `.sftp-row` 共用同一套 grid 模板(列宽 `calc(Npx * var(--sftp-scale))`)才能对齐;行高写着 `box-sizing: border-box` 是有意的——本仓库没有全局 box-sizing reset,去掉会让 offset 算错。**别的坑**:antd 的 `Dropdown` 会把 `ant-dropdown-trigger` 类**克隆到子元素本身**(不是外面套一层),所以绝不能再写 `.sftp-list .ant-dropdown-trigger { display: block }` 这类后代选择器——特异性高于 `.sftp-row`,会把 `display: grid` 悄悄顶掉(旧代码正是如此);行与滚动容器各挂一个 contextMenu Dropdown,容器的 `onContextMenuCapture`(capture 早于 antd 的冒泡 trigger)记录「是否点在行内」,`onOpenChange` 据此拒绝打开,避免两个菜单叠加
|
||||
- 每个 dockview pane 内有 `PanelErrorBoundary`(`ErrorBoundary.tsx`):pane 崩溃只卸载自己,根部边界仍兜底
|
||||
- **本地路径准入**(`src/main/localPathGrants.ts`):SFTP 上传/下载与 zmodem 收发的本地路径必须来自系统对话框授权(pickFiles/pickDirectory 是唯一授权源,进程级内存注册表、不落盘),realpath+stat 双重校验、Windows 大小写折叠、解析后的路径才是要打开的路径。新增「渲染层构造本地路径传给主进程」的调用点时**必须过这道门**,别绕
|
||||
- `keyPath`(SSH 私钥):realpath → stat → 普通文件且 ≤1MB 才读(防设备文件永久阻塞 UI 线程/符号链接逃逸)
|
||||
- `src/shared/reservedAccelerators.ts`:设置页录制器与主进程 `applyGlobalShortcut` **共用同一张保留键表**(Ctrl+L、Ctrl+=/-/0/PgUp/PgDn),两处分表曾漂移出洞,加新全局快捷键时两边自动一致
|
||||
- SFTP 操作有 per-op 超时(`sftp.ts` 的 `bounded()`:元数据 30s / 传输块 60s / open 10s),超时按 transport 错误驱逐半死通道并重试一次;`setSftpTimeouts`/`setUpdateTimeouts` 是**测试缝**,生产无调用者,别接设置项
|
||||
- **PTY_EXIT 是一次性广播、不做重放**:绑定晚于退出的窗格靠 `SESSION_STATE` 查询通道(`sessionState()` + 退出码登记表 `sessionExits`,上限 512 条 FIFO)补偿,`TerminalView` 订阅完成后查一次。同理,SSH **飞行中连接**(`connectSsh` 未返回)登记在 `pendingOpens`(带 owner 与 `onClient` 最早引用),`killPtysByOwner`/`killAllPtys` 靠它中止握手,否则 renderer 崩溃后会留下无主孤儿会话
|
||||
- **zmodem 接收路径的背压靠 `pauseSource`/`resumeSource` 钩子**(`pty.ts` 注入,暂停喂入 sentry 的 ssh 流):fs WriteStream 缓冲无上限,慢盘 + 快对端会 OOM;`touchActivity` 只在写入被接受或 drain 恢复时计数,否则 stall 看门狗会把「堆在缓冲里」当进展。删除路径的 ENOENT 一律视为已删成功(`withSftp` 会整函数重跑,非幂等操作必须自己容忍「目标已消失」)
|
||||
- `webPreferences` 显式写死 `contextIsolation: true / nodeIntegration: false / webSecurity: true`(`index.ts` 唯一窗口创建点),防默认值被将来改动
|
||||
- 终端标签自动标题(「终端 N」)是**存储的显示文本**(进布局模板/会话快照/广播注册),`src/shared/terminalTitle.ts` 负责两个方向:反解用**全部 4 语言**的 pattern(任何语言生成的都能认出编号),渲染用当前语言;语言切换/快照恢复/模板应用时 `retitleAutoTitles` 原地重渲染(SSH 面板标题是用户起的连接名,永不动)。需要指定语言渲染时用 i18n 的 `tFor(lang, key, vars)`
|
||||
- `TerminalView` 用 **5 组 `useShallow` 字段级订阅**(fontOpts/themeOpts/bgOpts/highlightOpts/toolbar + rendererMode),**不要回到整对象订阅**(主进程任何设置写入都会让所有 pane 重写 options + refit);`useResolvedTheme` 同理 shallow + memo
|
||||
- `TerminalView` 已无 imperative handle;要「外部聚焦/清屏」需重新引入
|
||||
- `FilePanel` 虚拟化:行高/字号单一数据源在 `FilePanel.tsx`(`BASE_ROW_HEIGHT=26` × scale 0.8–1.8,`localStorage: openterminal.sftpFontScale`,写成 CSS 变量 `--sftp-row-h`/`--sftp-fs`/`--sftp-fs-meta`/`--sftp-scale`),`sftp.css` 只消费变量、不得写死行高字号;`box-sizing: border-box` 是有意的;**antd Dropdown 会把 `ant-dropdown-trigger` 类克隆到子元素本身,禁止写 `.xxx .ant-dropdown-trigger` 后代选择器**(特异性会顶掉 `display: grid`);行与滚动容器各挂一个 contextMenu Dropdown,容器的 `onContextMenuCapture` 记录「是否点在行内」、`onOpenChange` 据此拒绝打开,防两个菜单叠加
|
||||
- 每个 dockview pane 内有 `PanelErrorBoundary`,pane 崩溃只卸载自己
|
||||
- **本地路径准入**(`localPathGrants.ts`):SFTP/ZMODEM 的本地路径必须来自系统对话框授权(进程级内存注册表 + realpath/stat 双重校验);新增「渲染层构造本地路径传主进程」的调用点必须过这道门,别绕
|
||||
- `keyPath`(SSH 私钥):realpath → stat → 普通文件且 ≤1MB 才读;`src/shared/reservedAccelerators.ts` 是设置录制器与主进程 `applyGlobalShortcut` **共用**的保留键表,加新全局快捷键两边自动一致
|
||||
- SFTP 操作有 per-op 超时(`bounded()`:元数据 30s / 传输块 60s / open 10s),超时驱逐半死通道并重试一次;`setSftpTimeouts`/`setUpdateTimeouts` 是测试缝,生产无调用者,别接设置项
|
||||
- **PTY_EXIT 一次性广播不重放**:晚绑定的窗格靠 `sessionState()` + `sessionExits`(FIFO 上限 512)补偿;SSH 飞行中连接登记 `pendingOpens`,`killPtysByOwner`/`killAllPtys` 靠它中止握手
|
||||
- zmodem 接收路径背压靠 `pauseSource`/`resumeSource` 钩子(fs WriteStream 缓冲无上限);`touchActivity` 只在写入被接受或 drain 恢复时计数,否则 stall 看门狗会把「堆在缓冲里」当进展;删除路径 ENOENT 一律视为已删成功(重跑必须容忍「目标已消失」)
|
||||
- `webPreferences` 显式写死 `contextIsolation: true / nodeIntegration: false / webSecurity: true`
|
||||
- 标签自动标题是**存储的显示文本**:`shared/terminalTitle.ts` 用全部 4 语言 pattern 反解(任何语言生成的都认出),渲染用当前语言;SSH 面板标题是用户起的连接名,永不动
|
||||
|
||||
## 锁屏
|
||||
|
||||
- 只使用**主窗口内的不透明遮罩**(`src/renderer/src/lock/LockScreen.tsx` + `lock.css` 的 `.lock-screen`,z-index 4000),**不创建第二个 Electron 窗口**。锁定时 `App.tsx` 把 `.app-root` 设为 `inert` 并**保持挂载**——卸载会杀掉遮罩后面的本地/SSH 会话与传输列表;antd portal(Modal/Dropdown/Tooltip)挂在 `document.body` 上、不在 `#root` 内,锁定时**要把 body 下 `#root` 以外的子节点也设为 `inert`**,否则键盘 Tab 仍能走进遮罩后面的浮层。**这个一次性快照不够**:锁定之后才挂上的 portal 不在其中(典型场景是 SSH 连接发出后闲置自动锁屏,主机密钥弹窗此刻才弹出,antd 的 autoFocus 还会抢走密码框焦点),所以锁定期间 `App.tsx` 用 `MutationObserver` 盯着 `document.body` 的 childList,给新加的非 `#root` 子节点补 `inert`,解锁时断开 observer 并按记录恢复;observer 只在锁定时存在,平时零开销,且**锁屏遮罩自身在 `#root` 内,永远不参与 inert**
|
||||
- 密码 verifier 在 `<userData>/lock.json`(`src/main/lockStore.ts` 的 `LockStore`):scrypt(N=16384,r=8,p=1) + 每次写入重新生成的 16 字节 salt + `timingSafeEqual`,**不存明文**;缺 `version: 1`、salt/hash 尺寸不符一律当「未配置」(宁失效也不崩启动路径),但 `version` 不认识会**每进程 warn 一次**——将来改格式不许静默失锁
|
||||
- 锁状态由主进程独占(`src/main/lockController.ts`):`LockSettingsState` 只有 configured/enabled/autoLockMinutes/lockAtStartup/locked/cooldownMs,**salt/hash/密码永不出主进程**,渲染层从不自行判定锁定
|
||||
- 锁标志落盘在 `<userData>/lock-state.json`(`LockStateStore`),**locked/failures/cooldownUntil 每次变化立即写**,所以托盘退出、任务管理器强杀、崩溃后重启仍然是锁的——`lockAtStartup` 只是额外一层。没有 verifier 时启动会删掉该文件;从磁盘恢复的 `cooldownUntil` 夹紧到 `now+30s`,防系统时间回拨导致永久锁死
|
||||
- 冷却阶梯 1s→2s→5s→10s→30s,失败计数与冷却同样落盘;`setPassword`/`clearPassword`/`unlock` 走内部串行队列(`serialize`),否则并发调用会同时通过闸门绕过冷却
|
||||
- 闲置锁屏:`powerMonitor.getSystemIdleTime()`,15s 轮询;读不到(无会话/工作站已锁)一律当「不闲置」。`settings.lock.autoLockMinutes` 是白名单 `{0,1,5,15,30,60}`(`src/shared/settings.ts` 的 `LOCK_AUTO_DELAYS`),0 = 从不
|
||||
- **清除密码会一并把 `settings.lock.enabled`/`lockAtStartup` 置 false**(`LockControllerOptions.clearLockPreferences`,默认走 `mutateSettings`):设置页文案承诺「清除后锁屏会一并关闭」,留着会让用户下次设密码时被静默重新武装
|
||||
- 锁屏期间主进程在 `win.webContents.on('before-input-event')` 里吞掉 F5/Ctrl+R、Ctrl+±0(含 Shift 拼写)、Ctrl+Shift+I/J/C:遮罩是 DOM 层,拦不住浏览器进程处理的 Electron 默认菜单加速键,而重载会触发 `beforeunload` 把遮罩后面的会话全杀掉。**键盘判定抽在纯函数模块 `src/main/lockShortcuts.ts`(`isLockBlockedShortcut`/`isPanicLockChord`,无 Electron 依赖,表驱动测试 `tests/lock-shortcuts.mjs`)**——v1.0.17 的回归就是死在闭包里没法测。键盘之外还有鼠标路径:默认菜单按 Alt 就能唤出,菜单项点击不走 before-input-event,所以**锁定期间 `src/main/lockMenu.ts` 把整个应用菜单置 null(解锁时按 Electron 默认模板重建)**;菜单摘除挂在 `LockController` 的默认 publish 上,启动恢复锁定不经过 publish,由 `index.ts` 在 `initLockController()` 后按 `isLocked()` 直接补一次。渲染层的 `document.documentElement.dataset.locked` 守卫(字体快捷键、`Ctrl+PgUp/PgDn`)**只允许 `return` 跳过自身逻辑,绝不能 `preventDefault`**——keydown 的默认动作就是「往聚焦输入框插字符」,窗口级 preventDefault 会把锁屏密码框的全部输入杀掉(v1.0.17 就是这么坏的,v1.0.18 修复)
|
||||
- **Ctrl+L = 立即锁屏**(同一 `before-input-event` 里捕获,终端里也生效——这正是它的意义):仅在锁定真的生效时才 `preventDefault`,未设置密码的应用保留 Ctrl+L 给 shell 的清屏;已锁定时不再拦截。长按的自动重复要跳过(`input.isAutoRepeat`),否则未配置密码时每次重复都同步读 lock.json + settings.json。**Ctrl+L 是保留键**:全局唤起快捷键的录制器(`SettingsTabs.tsx` 的 `RESERVED_EXACT_ACCELERATORS`)拒绝它——globalShortcut 在 OS 层拦截,绑上去会让锁屏快捷键静默失效。窗口藏进托盘后 Ctrl+L 无效(before-input-event 只对聚焦窗口触发),这是刻意的取舍:全局注册会从所有应用手里抢走这个组合键。**主进程侧也拒绝注册它**:`applyGlobalShortcut`(globalShortcuts.ts)对 `Control+L` / `Ctrl+L` / `CommandOrControl+L`(大小写、修饰键别名都不敏感)直接跳过并 warn——录制器只拦得住它上线之后录入的值,老版本存下的 `Control+L` 仍会走到注册这一步
|
||||
- 启动时**不要**用 `locked: true` 作渲染层初值再直接画锁屏:`App.tsx` 用 `null` 表示「主进程还没答复」,此时只画 `.lock-screen-boot` 纯色层,否则每次启动都会给没设密码的用户闪一帧锁屏。`getLockState()` 失败时要落到「locked 且未配置」的状态,让输入框可达(主进程对无 verifier 的解锁请求直接放行)
|
||||
- 相关测试:`node tests/lock-store.mjs`(verifier + 状态存储)、`node tests/lock-controller.mjs`(冷却阶梯、并发串行化、落盘恢复、闲置触发、清除联动)、`node tests/lock-shortcuts.mjs`(键盘分类器表驱动用例)
|
||||
- 只用主窗口内不透明遮罩(`.lock-screen`,z-index 4000),**不建第二个窗口**;锁定时 `.app-root` 设 inert 并**保持挂载**(卸载会杀会话);antd portal 挂 body 不在 `#root` 内,锁定时 body 下 `#root` 以外子节点也要 inert,且用 `MutationObserver` 盯新挂 portal 补 inert(observer 只在锁定时存在,解锁恢复)
|
||||
- 密码 verifier 在 `<userData>/lock.json`(`lockStore.ts`):scrypt(N=16384,r=8,p=1) + 每次写入重新生成盐 + `timingSafeEqual`,不存明文;格式不对当「未配置」,`version` 不认识每进程 warn 一次
|
||||
- 锁状态由主进程独占(`lockController.ts`),salt/hash/密码永不出主进程;`lock-state.json` 每次变化立即写(强杀/崩溃重启仍锁);恢复的 cooldownUntil 夹紧 now+30s 防时间回拨;冷却阶梯 1s→2s→5s→10s→30s;`setPassword`/`clearPassword`/`unlock` 走串行队列
|
||||
- 闲置锁屏:`powerMonitor.getSystemIdleTime()` 15s 轮询,读不到当「不闲置」;`autoLockMinutes` 白名单 `{0,1,5,15,30,60}`,0 = 从不
|
||||
- 清除密码会一并关 `settings.lock.enabled`/`lockAtStartup`(设置页文案承诺,别留着静默重新武装)
|
||||
- 锁定期间:`before-input-event` 吞 F5/Ctrl+R、Ctrl+±0、Ctrl+Shift+I/J/C(判定抽在纯模块 `lockShortcuts.ts`,无 Electron 依赖可测);**应用菜单整块置 null**(菜单项点击不走 before-input-event;启动恢复锁定由 index.ts 直接补一次);渲染层 `dataset.locked` 守卫**只许 return 跳过自身逻辑,绝不 preventDefault**(窗口级 preventDefault 会把密码框输入杀掉,v1.0.17 回归)
|
||||
- **Ctrl+L = 立即锁屏**:仅锁定真生效时才 preventDefault,未设密码保留给 shell 清屏;长按 auto-repeat 跳过;已锁定不再拦截;窗口藏托盘后无效(刻意取舍:全局注册会抢所有应用的键);Ctrl+L 是保留键,录制器与主进程 `applyGlobalShortcut` **两侧都拒绝注册**
|
||||
- 启动初值用 `null` + `.lock-screen-boot` 纯色层(防给无密码用户闪一帧锁屏);`getLockState()` 失败落到「locked 且未配置」(主进程对无 verifier 的解锁直接放行)
|
||||
- 测试:`node tests/lock-store.mjs` / `lock-controller.mjs` / `lock-shortcuts.mjs`
|
||||
|
||||
## 布局模板(工作区)
|
||||
|
||||
- 应用模板(`Workspace.handleApplyTemplate`)读的是**外来 JSON**。`fromJSON` 一旦中途失败(别的版本写的模板、面板组件已不存在),dockview 会**先把目标 dockview 清空再抛错**(`failed to deserialize layout. Reverting changes`)。清空是逐面板走 `onDidRemovePanel` 的,所以**旧会话在抛错之前就已经被 `killSession` 杀掉了**——恢复出来的面板接不回它们,必须换新会话
|
||||
- 失败路径:应用前 `toJSON()` 快照两个 dockview → 抛错时只对**这次真的调用过 `fromJSON` 的** dockview 回灌快照(回灌本身会清空该 dockview;把快照灌进没被碰过的那个会连带杀掉它活着的会话)→ 对恢复出来的面板跑 `rebindRestoredPanels` → 再把「没有任何面板引用的 `previousSessions`」kill 掉 → `message.error`。`JSON.parse` 失败同样要提示,不能静默 return
|
||||
- 会话计数不靠累加器:`releaseSession` 直接扫 `api.panels` 判断还有没有面板在显示该会话——累加器与「`updateParameters` 原地换会话」「整块布局替换」这类无事件变化脱节,会漏杀或误杀
|
||||
- **旧版(M6.1 前单 dockview)模板只喂 terminal dockview**:成功路径的 rebind 与收尾 kill 都必须按 `sshTouched` / `live` 集合门控——rebind 会把面板参数改写成新**本地**会话,对没被 fromJSON 触碰的 ssh dockview 跑一遍等于把活 SSH 面板降级成终端再杀会话
|
||||
- 主机密钥队列只渲染队头,且主进程 `DEFAULT_TIMEOUTS.prompt`(120s)超时后**不再广播**:渲染层用同 TTL 的定时清扫(`HOST_KEY_PROMPT_TTL_MS`)移除过期条目,否则陈旧弹窗永远占住队头挡住后续主机;两处 TTL 必须同步改。`respondHostKey` 等 IPC 调用**不得写进 setState 的 updater**(React 可能重放 updater)
|
||||
- **tab 关闭二次确认**(`settings.terminal.confirmTabClose` / `confirmSshTabClose`,默认关):拦截点在 `TerminalTab.tsx` 的 `requestClose`——dockview 8.2.0 没有可否决的关闭前钩子,杀会话发生在 `onDidRemovePanel` 之后无法回滚,所以所有用户关闭入口(× 按钮、右键三关闭项、键盘 Delete/Backspace)必须在调用 `api.close()` 之前过它;键盘路径靠挂在 `.dv-tab` 外壳(自定义 div 的 dockview 祖先元素,DOM 焦点落点)上的原生捕获监听拦截。**新增用户可触发的关闭入口必须走 `requestClose`**,会话已死自动关闭/模板应用等程序性路径不走
|
||||
- 应用外来模板时 `fromJSON` 中途失败,dockview **先清空再抛错**——旧会话在抛错前已被 `onDidRemovePanel` 杀掉。失败路径:先 `toJSON()` 快照 → 只回灌**这次真的被 fromJSON 触碰的** dockview(回灌会清空,别灌没碰过的)→ `rebindRestoredPanels` → kill 没有面板引用的旧会话 → `message.error`;`JSON.parse` 失败同样要提示
|
||||
- 会话计数不靠累加器:`releaseSession` 直接扫 `api.panels`(累加器与「updateParameters 原地换会话」「整块布局替换」脱节)
|
||||
- 旧版(M6.1 前单 dockview)模板只喂 terminal dockview:rebind 与收尾 kill 按 `sshTouched`/`live` 门控,否则会把活 SSH 面板降级成终端再杀会话
|
||||
- 主机密钥队列只渲染队头;TTL `DEFAULT_TIMEOUTS.prompt`(120s)超时后主进程不再广播,渲染层同 TTL 定时清扫(`HOST_KEY_PROMPT_TTL_MS`,两处必须同步改);`respondHostKey` 等 IPC 调用**不得写进 setState updater**(React 可能重放)
|
||||
- **tab 关闭二次确认**(`settings.terminal.confirmTabClose`/`confirmSshTabClose`,默认关):dockview 8.2.0 没有可否决的关闭前钩子,杀会话在 `onDidRemovePanel` 之后无法回滚——所有用户关闭入口(× 按钮、右键菜单、Delete/Backspace)必须在 `api.close()` 之前过 `TerminalTab.requestClose`(键盘路径靠 `.dv-tab` 外壳上的原生捕获监听);程序性路径(会话已死自动关、模板应用)不走
|
||||
|
||||
## 关键词高亮
|
||||
|
||||
- 预设规则表在 `src/shared/settings.ts` 的 `DEFAULT_HIGHLIGHT_RULES`(22 条),引擎在 `src/renderer/src/terminal/highlightEngine.ts`;规则按 priority 升序应用,**先匹配到的 span 归先跑的规则,后续规则遇到重叠直接跳过**
|
||||
- 状态类预设(danger/okstate/warnstate/badstate)priority 排在 `shellkw` **之前**:`done` 既是 shell 关键字又是成功词、`if` 还在 `dd if=` 里,先跑谁就由谁着色
|
||||
- 状态符号(`✓ ✔ ✅ ✗ ✘ ✖ ❌ ⚠`)**不能放进 `\b…\b` 组**:`\b✓` 永不成立。预设把它们写在 `\b(?:…)\b|[✓✔✅]\uFE0F?` 的第二个分支里,尾随的 `\uFE0F?` 是为了把 emoji 变体选择符一起圈进着色范围
|
||||
- 严重级别按颜色拆开:成功(okstate 绿)/ 警告(warnstate 黄)/ 错误·致命(badstate 红,含 `CRITICAL`、`FATAL`、`PANIC`)/ 删除·移动·覆盖(delop 橙)/ 新建·创建·安装(createop 亮绿);`badstate` 里的 `NOT …` 分支负责 `not ok` / `not found`,`okstate` 的反向断言保证它不会被染绿
|
||||
- delop/createop 只列**操作动词**(delete/remove/rm/mkdir/touch/add/install/clone/…),`export` 等 shell 关键字仍归 `shellkw`,别把两边都写进去抢 span
|
||||
- **数值分级(`bands`)**:匹配里的第一个数字决定颜色(最后一个 `min <= 值` 的分级胜出),无数字或低于最小分级时回落到 `color.fg`;预设 `percent` 用它做百分比(<20% 红 / 20–50% 黄 / 50–80% 浅绿 / ≥80% 绿)。它的 priority 22 **必须早于 `numbers`(25)**,否则 `45%` 会先被数字规则整段吃掉;`numbers` 规则本身不含百分比分支
|
||||
- 词干要自带词尾(`DELET(?:E|ED|ES|ING|ION)` 而非 `DELETE(?:D|S|ING)?`,否则漏 `deleting`);不成词的词干(MOV/SAV/CLON/PURG/ERAS/WIP/REVOK)必须强制要求词尾
|
||||
- 上下文敏感的规则用 **lookbehind/lookahead 只圈住关键词本身**,否则分级会读到错误的数字:`(?<=\bHTTP/\d(?:\.\d)?\s)[1-5]\d\d\b` 让 `HTTP/1.1 404` 只着色 `404`(若把 `HTTP/1.1` 一起匹配,bands 会读到版本号 `1`)。同理 `[1-5]\d\d(?=\s+OK|…)` 靠先行断言限定"后面跟原因短语"才算状态码,避免把 `123` 这类普通数字当成 404
|
||||
- 裸 3 位数(`[1-5]\d\d`)**不能单独成规则**,必须有上下文锚点;同理 MAC、短哈希这类高误伤模式不进预设
|
||||
- 导入 / 导出与实时预览:`src/shared/highlightIO.ts` 管 JSON 信封(`kind`/`version`,外来 JSON 直接拒绝)与 replace/append 合并;渲染层用剪贴板 + FileReader + Blob 下载完成,**不新增 IPC**。编辑器预览走引擎的 `previewSpans`(跑真实高亮再解析它自己的 SGR 输出),并且**带上当前其它规则**,这样 span 被别的规则抢走时能一眼看出来
|
||||
- **锚定语义(改预设 `^…` 前必读)**:规则只作用于「转义序列之间的 text run」,且默认**没有 `m` flag**——裸 `^` 锚定 run 起点而非行首(`perm` 预设曾因此整类失效)。行首锚定用 lookbehind `(?<=^|\s)`;规则可开 `lineAnchor: true`(schema 字段,编译成 `gm`/`gim`,编辑器有「按行匹配」开关,徽标 `^$`),让用户的 `^ERROR$` 按行生效
|
||||
- **span 结束恢复应用 SGR**:引擎给命中 span 收尾时不再是裸 `\x1b[0m`,而是 `\x1b[0m` + 重放进入 span 前活跃的 SGR 序列。状态由 `HighlightStream` 持有并跨 push 传递——`hl-split-smoke` 的逐切分点字节一致依赖这一点,动 `applyHighlights` 第 4 参(sgr 状态)签名前先想清楚。四条护栏(全部踩过坑,别拆):
|
||||
- **reset 判定必须解析参数**,不能字符串全等。ECMA-48 里 `ESC[m`/`ESC[0m`/`ESC[00m`/`ESC[0;0m` 都是全量复位,`ESC[0;32m`(复位+设色,Go/Java 日志库常写)要先清空再记录新色。只认两种规范写法会让其它拼法被**追加**进累积器,每行涨几字节直到撞上上限,之后所有 span 的恢复永久失效
|
||||
- **回放闸门按序列条数**(`MAX_REPLAY_SEQUENCES=3`)而不是字节数。同一个「单个前景色」的写法长度差近 4 倍(`ESC[31m` 5B / `ESC[38;5;196m` 11B / 真彩色 13–19B),按字节卡会砍掉真彩色却放过索引色,语义不一致。`MAX_RESTORE_LEN=256` 只是累积器的内存上限
|
||||
- **恢复串只插在「可见间隙」前后**(`MIN_GAP_FOR_RESTORE=3` 个可见字符),不是每个 span 后。Kimi 这类全彩 TUI 一行 ~19 个 span,每 span 都重放会让 9.5KB 输出膨胀到 40.8KB(**4.30x**,无恢复时 2.78x),下游 xterm 解析 + WebGL 字形图集(背景图模式 MCR=4.5 逐字形算对比度,真彩色让缓存全 miss)被压垮,实测渲染线程卡 **18.4 秒**(日志 `[renderer] main thread stalled ~18384ms`)。现在的实现:最后一个 span 后必恢复,中间间隙够长才恢复,膨胀 3.05x 且长间隙着色正常
|
||||
- **改以上任何参数必须重跑 `[restore cost budget]` 基准段**(`tests/hl-rules.mjs`,断言密集真彩色 TUI 膨胀 < 3.2x)。功能性用例全绿不代表热路径没回归——`hl-split-smoke` 的样本每行以 reset 结尾,恰好绕过了这类膨胀
|
||||
- **CSI 参数类必须含私有前缀与中间字节**:`ESCAPE_RE` 的 CSI 分支是 `[0-9;:?<>=]*[ -/]*[A-Za-z]`。漏掉 `<`/`>`/`=` 和中间字节(`ESC[>4;1m` modifyOtherKeys、`ESC[<0;1;2M` SGR 鼠标、`ESC[=1;1$y` DECRQM)会让整行被当成含裸 ESC 的文本透传,**该行之后直到下一个换行的所有关键词全部失去高亮**——vim/tmux 在鼠标与按键上报时正好发这些序列
|
||||
- **ReDoS =「静态提示 + 主进程动态校验 + 编译期 fail-closed 闸门」**,三层各自职责别混:
|
||||
- `highlightEngine.ts` 的 `isReDoSProne` 仍是**编辑器即时提示**,有实测漏判/误判,绝不能当执行依据;文案里的 `passed` 也只能说「已通过当前探针」,不能承诺对任意输入安全
|
||||
- 执行闸门在 `compileRules` / `compileLinkRules`:内置高亮预设按「pattern+flags 精确命中 `DEFAULT_HIGHLIGHT_RULES` 派生白名单」放行;其它自定义规则必须持有 `RuleValidation.state='passed'` 且 **pattern、flags、probeVersion、runtime 四项全匹配**才编译。`pending`/`quarantined`/缺失/失配一律跳过,编辑器预览与链接 provider 都走同一闸门,没有旁路
|
||||
- 动态校验在 `src/main/redosValidator.ts` + `redosProbeWorker.ts`(electron-vite `?nodeWorker`,产物 `out/main/redosProbeWorker-*.js`):`src/shared/redosProbes.ts` 的 24 条固定合成探针(≤4KB、不含终端内容)在 Worker 里执行,**500ms 硬超时由主控侧计时并 terminate**,超时才 `quarantined`;Worker 崩溃重试一次后回 `pending`,Worker 不可用也回 `pending` 并在设置页明示。并发上限 2、同 key 去重、空闲回收;默认纯预设安装不启动 Worker,热路径零新增每块开销
|
||||
- runtime 指纹由 `src/main/ruleRuntime.ts` 安装、renderer 经 `AppInfo.runtimeKey` 对齐;Electron/V8 环境变化会让旧 `passed` 失效回 `pending`。validation 是规则内部元数据:主进程清洗形状,导入文件里的裁决一律不作数,导出剥离
|
||||
- 测试:`tests/redos-probes.mjs`(语料与已知病态样本)、`tests/rule-validation.mjs`(闸门真值表)、`tests/redos-validator.mjs`(真实 Worker/ terminate/并发/失败策略,经 build-bundles 产物);`tests/worker-artifact.mjs` 只在 build/dist 后跑,防 Worker 产物静默丢失
|
||||
- **fit 耗时埋点(只观测,不改变 fit 时机)**:`TerminalView.scheduleFit(trigger)` 记录 `fit.fit()` 的 trigger(mount/resize/font/scrollback/renderer-switch/unknown)、耗时、cols×rows、**实际 bufferLines** 与 scrollback;超 `FIT_SLOW_LOG_MS=200` 写 `PERF fit`,每 pane 30s 限频并带 `suppressed=N`。`fit` 是渲染层代价随会话历史增长的操作(reflow 全量 scrollback),但 stall 归因必须与 longtask/write 时间线对照,不能只凭一条日志下结论
|
||||
- **超长行按行降级**:run 无 >4KB 行时走整段扫描(保留跨行匹配);有则按 `\n` 逐行,>4KB 的行原样透传不上色(该 run 内跨行匹配放弃)。回溯防护不变式:**任何 >4KB 的行不进用户正则**
|
||||
- **统计按 pane 实例聚合**:`highlightStats.ts` 按 pane id(`crypto.randomUUID()`,**不是 sessionId**——SSH 分屏同一 session 挂两 pane)登记各自 sink,订阅方拿跨 pane 求和;卸载与关统计开关都要注销,重绑定(rebind)**不**注销(React 先跑完 cleanups 再 setups,在那里注销会让 pane 从汇总消失一拍)。重置清各 pane 的真实计数器
|
||||
- 分类(`category`,`safety|status|file|net|text|metric`):预设的分类放在 `PRESET_CATEGORIES` 一张表里,`basic` 集合必须始终是 safety+status 的子集(有测试守);`settings.terminal.highlightGroupByCategory` 只影响设置页(加分类列 + 按分类聚簇,组内仍按 priority),**热路径完全不涉及**
|
||||
- 跟随主题(`settings.terminal.highlightThemeColors`,默认关):`src/renderer/src/theme/highlightColors.ts` 按**色相分桶**把规则颜色映射到当前主题的 ANSI 调色板(不用"最近色",否则语义会漂移),亮度决定用普通色还是 bright 色(这样 percent 的两档绿仍能区分);饱和度低于 0.15 的中性色保持原样;**背景色不映射**(它是文字底块,不是语义信号)。映射在编译期一次性完成,热路径零成本;编辑器预览走同一函数,否则预览会与终端不一致
|
||||
- 统计(`settings.terminal.highlightStats`,默认关):引擎的 `applyHighlights`/`HighlightStream` 接受可选 `StatsSink`,**只在传了 sink 时才计数与计时**(默认路径不插桩);`TerminalView` 只在开关打开时挂 sink,并**每秒发布一次快照**(不是每块),否则忙碌的终端会把设置页重渲染到卡死;设置页通过 `subscribeHighlightStats` 订阅,多出「命中/耗时」两列(耗时按 µs/ms 格式化)
|
||||
- 性能护栏(别拆):`MAX_CHUNK` 512KB 整块跳过、`MAX_LINE_LEN` 4KB 超长行不跑规则(挡 `(a+)+b` 这类回溯)、`MAX_PER_RULE` 300 每条规则每块上限。实测 180–200KB 混合输出:**全预设 4–9ms/块**(0.02–0.05ms/KB,取决于转义序列密度;典型 4KB chunk ≈ 0.05–0.26ms)、**basic 档 ~1.3ms/块(≈49µs/chunk)**、**off 档 0**;单条规则最贵的是 `http`(0.82ms)、`percent`、`danger`
|
||||
- 总开关是**三档模式** `settings.terminal.highlightMode`(`all` / `basic` / `off`,读取一律过 `highlightModeOf` 兜底):`basic` 只跑带 `basic: true` 的规则(预设里是 danger/secret/okstate/warnstate/badstate 这 5 条;用户可在规则编辑器里用「基础规则」开关给自建规则打标),`off` 时 `TerminalView` 把规则集清空而不是绕过 `HighlightStream`——stream 会 hold 住尾部文本,绕过会丢字节;空规则集在 tokenize 之前就返回,几乎零成本
|
||||
- 按主机绑定规则集(`settings.terminal.highlightPerHost`,默认关):`HighlightProfile { id, name, ruleIds }` 存在 `AppSettings.highlightProfiles`(顶层数组,仿 `customThemes`),清洗在 `src/shared/highlightProfiles.ts`;`ruleIds` **为空 = 全部规则**,`rulesForProfile` 对未绑定 / 绑到不存在的 id / 空 profile 一律回落到全集,`excludedByProfile` 给设置页算「这个 profile 排除了哪些规则」。绑定存在 **`connections.json`**(`SshConnection.highlightProfileId`)而不是 settings,`TerminalView` 只在 mount 时查一次连接——改了绑定要重开会话才生效。新增连接字段时必须同时改三处:`PUBLIC_KEYS`(update 路径靠它回写)、`toPublic()`、`saveConnection()` 的新记录字面量,漏一处该字段会在某条路径上静默丢失
|
||||
- 编辑器预览的输入要**截断**(前 2000 字符):200KB 样本会产出 7000+ 个 span,React 每敲一个键重渲染会卡
|
||||
- `caseInsensitive: true` 的规则编译成 `gi`(规则级开关,默认关闭);预设里 `okstate` 带 `(?<!not\s)` 反向断言,把 `not ok` 让给 `badstate`
|
||||
- 词边界是这套预设的全部难点:新增关键词后请跑 `node tests/.hl-rules.cjs`(`invalid` 不能点亮 `valid`、`disabled` 不能点亮 `enabled` 等)
|
||||
- 升级旧安装:`settingsStore.refreshBuiltinRules` 只把**仍是出厂 pattern** 的内置规则升到新预设(保留用户的颜色/优先级/启停),且仅当规则集恰好等于旧预设集时才追加新增的内置规则——否则用户删掉的规则会每次加载都复活。改预设 pattern 时同步更新 `LEGACY_BUILTIN_PATTERNS` / `LEGACY_BUILTIN_IDS`
|
||||
- 预设 `DEFAULT_HIGHLIGHT_RULES`(22 条)在 `shared/settings.ts`,引擎 `highlightEngine.ts`;规则按 priority 升序,**先匹配先得 span**,重叠跳过
|
||||
- 词边界是全部难点(改关键词跑 `node tests/.hl-rules.cjs`):状态符号(✓✔✅✗❌⚠)不进 `\b` 组(`\b✓` 永不成立,写第二分支 + `\uFE0F?`);状态类 priority 在 `shellkw` 之前(`done`/`if` 归属);delop/createop 只列操作动词,shell 关键字仍归 shellkw;词干自带词尾(`DELET(?:E|ED|ES|ING|ION)`),不成词词干强制词尾;裸 3 位数 / MAC / 短哈希不能单独成规则(须上下文锚点);lookbehind/lookahead **只圈关键词本身**(圈多了 bands 会读错数字,如 `HTTP/1.1 404` 只该着色 `404`)
|
||||
- bands 数值分级:匹配里**第一个数字**定色,最后一个 `min <= 值` 胜出;`percent` priority 22 必须早于 `numbers`(25)
|
||||
- 锚定:规则作用于转义序列之间的 text run,默认无 m flag,裸 `^` 锚 run 起点非行首;行首锚定用 `(?<=^|\s)` 或 `lineAnchor: true`(编译成 gm/gim)
|
||||
- **SGR 回放四护栏**(全踩过坑,别拆):① reset 判定必须解析参数(`ESC[0;32m` = 复位+设色,只认两种规范写法会让累积器逐行膨胀直至恢复永久失效);② 回放闸门按序列条数 `MAX_REPLAY_SEQUENCES=3`,不是字节数(同义写法长度差 4 倍;`MAX_RESTORE_LEN=256` 只是累积器内存上限);③ 恢复串只插在可见间隙(`MIN_GAP_FOR_RESTORE=3` 个可见字符)——每 span 都恢复会让密集真彩 TUI 膨胀 4.3x、渲染线程卡 18s,现在 3.05x;④ 改以上任何参数必须重跑 `tests/hl-rules.mjs` 的 `[restore cost budget]` 段(断言 < 3.2x)
|
||||
- `ESCAPE_RE` 的 CSI 参数类必须含私有前缀与中间字节(`[0-9;:?<>=]*[ -/]*[A-Za-z]`):漏了 `<>=` 会让 `ESC[>4;1m`(modifyOtherKeys)/`ESC[<0;1;2M`(SGR 鼠标)这类序列所在行之后的关键词全部失高亮(vim/tmux 正好发这些)
|
||||
- 超长行降级:run 有 >4KB 行时按 `\n` 逐行,超长行原样透传不上色(该 run 放弃跨行匹配);不变式:**任何 >4KB 的行不进用户正则**。性能护栏:`MAX_CHUNK` 512KB 整块跳过 / `MAX_LINE_LEN` 4KB / `MAX_PER_RULE` 300;实测全预设 4–9ms/块,最贵的是 http/percent/danger
|
||||
- **ReDoS 三层别混**:① `isReDoSProne` 只是编辑器即时提示(有漏判误判,不当执行依据,文案只能说「通过当前探针」);② 执行闸门在 `compileRules`/`compileLinkRules`——内置预设按 pattern+flags 精确命中白名单放行,自定义规则须 `RuleValidation.state='passed'` 且 **pattern/flags/probeVersion/runtime 四项全匹配**,pending/quarantined/失配一律跳过,编辑器预览与链接 provider 同一闸门无旁路;③ 动态校验 `redosValidator` + `redosProbeWorker`(`?nodeWorker` 产物):24 条固定探针 ≤4KB、500ms 硬超时由主控计时并 terminate → quarantined,Worker 崩溃重试一次回 pending,不可用也回 pending;并发上限 2、同 key 去重、空闲回收;默认纯预设安装不启动 Worker。runtime 指纹由 `ruleRuntime` 安装、renderer 经 `AppInfo.runtimeKey` 对齐;validation 是规则内部元数据——导入的裁决不作数,导出剥离
|
||||
- 模式三档 `highlightMode` all/basic/off(读取一律过 `highlightModeOf` 兜底):basic 只跑 `basic: true` 的规则(预设 5 条:danger/secret/okstate/warnstate/badstate,用户可给自建规则打标;`basic` 集合必须是 safety+status 子集,有测试守);off = **清空规则集**而不是绕过 `HighlightStream`(stream 会 hold 尾部文本,绕过丢字节)
|
||||
- 开关键名:`highlightStats` / `highlightThemeColors` / `highlightPerHost`(均默认关)与 `highlightGroupByCategory`(只影响设置页分组展示,热路径不涉及);分类集合 `safety|status|file|net|text|metric` 单表在 `PRESET_CATEGORIES`
|
||||
- 按主机绑定:`HighlightProfile` 存 `AppSettings.highlightProfiles`(清洗在 `shared/highlightProfiles.ts`),`ruleIds` 空 = 全部规则;绑定存在 **connections.json**(`SshConnection.highlightProfileId`),`TerminalView` 只在 mount 查一次,改绑定要重开会话。**新增连接字段必须同时改三处**:`PUBLIC_KEYS`、`toPublic()`、`saveConnection()` 新记录字面量,漏一处该字段在某条路径静默丢失
|
||||
- 跟随主题 `highlightColors.ts`:按**色相分桶**映射到当前主题 ANSI 调色板(不用最近色,防语义漂移),亮度决定普通/bright 色,饱和度 <0.15 中性色保持原样,**背景色不映射**;编译期一次完成,编辑器预览走同一函数
|
||||
- 统计按 pane 实例聚合(`crypto.randomUUID()` 的 pane id,**不是 sessionId**);sink 只在开关开时挂(默认路径不插桩),**每秒发一次快照**不是每块;卸载与关开关都注销,重绑定不注销;编辑器预览输入截断 2000 字符
|
||||
- 导入/导出:`shared/highlightIO.ts` 管 JSON 信封(kind/version,外来 JSON 拒绝);渲染层用剪贴板 + FileReader + Blob 下载,**不新增 IPC**;预览走 `previewSpans` 且带上当前其它规则(span 被抢走能看出来)
|
||||
- `caseInsensitive: true` 编译成 `gi`;`okstate` 带 `(?<!not\s)` 把 `not ok` 让给 badstate
|
||||
- 升级旧安装:`settingsStore.refreshBuiltinRules` 只升级**仍是出厂 pattern** 的内置规则(保留用户颜色/优先级/启停),且仅当规则集恰好等于旧预设集才追加新增;改预设 pattern 同步更新 `LEGACY_BUILTIN_PATTERNS`/`LEGACY_BUILTIN_IDS`(当前已含 `url: 'https?://[^\\s]+'`)
|
||||
|
||||
## 可点击链接规则
|
||||
|
||||
- 内置链接(`src/renderer/src/terminal/urlLinks.ts` 的 `findUrls`,4 条写死的正则:http(s) / ftp / `www.` / 裸 host:port)**不动**;用户自定义规则是叠加在其上的第二来源
|
||||
- 引擎在 `src/shared/linkRules.ts`(可被 `tests/link-rules.mjs` 直连;它 import 共享 `ruleValidation.ts` / `redosProbes.ts`,说明符必须带 `.ts` + 局部 `@ts-expect-error TS5097`,否则 Node 原生类型剥离找不到模块——**别复制闸门/常量造成分表**):`sanitizeLinkRules` 清洗(id/pattern/template 必填、非法正则丢弃、模板空串丢弃、`enabled` 缺省为 true、note 截断 60、上限 100 条、validation 形状清洗)、`formatLinkTemplate` 展开模板(`$0` 整段 / `$1`–`$9` 分组 / `$$` 字面 `$`,**只吃一位数字**所以 `$10` = 分组 1 + 字面 `0`,未参与分组展开为空串而非 `undefined`)、`compileLinkRules`、`findRuleLinks`、`mergeLinkSpans`
|
||||
- **安全闸门有两层**:① ReDoS 执行闸门——`compileLinkRules` 与高亮规则共用 `validationAllows`,用户链接规则没有内置白名单,`pending`/`quarantined` 不编译;② `isSafeLinkTarget`——生成 span 时就把非 `http:`/`https:` 目标丢掉,`file://` / `javascript:` / `ms-settings:` / 相对路径连下划线都不会出现。主进程 `openExternal` 的协议白名单是第三道,别拆
|
||||
- 接线点在 `TerminalView.tsx` 的 link provider:`mergeLinkSpans(findRuleLinks(text, linkRulesRef.current), findUrls(text))`——**用户规则优先,重叠的内置链接让位**;hover 提示 `text: link.url` 显示的是展开后的真实目标
|
||||
- **热更走 `linkRulesRef`**(照 `streamRef.setRules` / `notifyRulesRef` 的模式):provider 随终端注册一次,改规则只换 ref 指向,绝不重建终端。`EMPTY_LINK_RULES` 是共享的稳定空数组——选择器返回新建的 `[]` 会让 zustand 每次拿到新身份,任何无关设置写入都会重渲染所有 pane
|
||||
- 性能护栏:`LINK_TEXT_MAX` 4KB 以上的逻辑行整行跳过(内置正则是固定集合,用户正则可回溯爆炸);每规则每行上限 200 个匹配;能匹配空串的 pattern 强制 `lastIndex` 前进,避免死循环
|
||||
- UI 在 `HighlightTab.tsx` 底部的 `ClickableLinkRules`(与高亮同属「终端输出的模式匹配」心智):表格(启用/正则/模板/操作)+ 编辑弹窗(pattern 非法正则内联报错、template 非 http(s) 内联报错、note、启用、探针状态)。导入导出与 profile 联动**尚未做**,属后续可加
|
||||
- 设置字段 `AppSettings.linkRules?: LinkRule[]`(可选,默认 `[]`);三处接线照 `highlightProfiles`:`src/main/settingsStore.ts` 的 `deepMerge` 分支 + `sanitizeLinkRules` 调用 + `defaultSettings()`,渲染层 `settings/store.ts` 的 `setLinkRules` + persist 白名单
|
||||
- 测试:`node tests/link-rules.mjs`;校验服务语义另见 `tests/redos-validator.mjs` 与「关键词高亮」的 ReDoS 条目
|
||||
- 内置链接 `urlLinks.ts` 的 `findUrls`(4 条写死正则)**不动**;用户自定义规则叠加其上
|
||||
- 引擎在 `shared/linkRules.ts`(可被 `tests/link-rules.mjs` 直连;它 import 共享 `ruleValidation.ts`/`redosProbes.ts`,说明符必须带 `.ts` + 局部 `@ts-expect-error TS5097`,**别复制闸门/常量造成分表**):`sanitizeLinkRules`(id/pattern/template 必填、非法正则丢弃、模板空串丢弃、enabled 缺省 true、note 截断 60、上限 100 条)、`formatLinkTemplate`(`$0` 整段 / `$1`–`$9` 分组 / `$$` 字面,只吃一位数字,未参与分组展开为空串)、`compileLinkRules`/`findRuleLinks`/`mergeLinkSpans`
|
||||
- **安全闸门**:① ReDoS 执行闸门——用户链接规则没有内置白名单,pending/quarantined 不编译;② `isSafeLinkTarget`——生成 span 时就把非 http/https 目标丢掉;主进程 `openExternal` 协议白名单是第三道
|
||||
- 接线在 `TerminalView` link provider:`mergeLinkSpans(findRuleLinks(...), findUrls(...))`,**用户规则优先**,重叠的内置链接让位;hover 提示显示展开后的真实目标;热更走 `linkRulesRef`(只换 ref 指向,绝不重建终端);`EMPTY_LINK_RULES` 是共享稳定空数组(选择器返回新建 `[]` 会让 zustand 每次拿到新身份)
|
||||
- 性能护栏:`LINK_TEXT_MAX` 4KB 以上逻辑行整行跳过;每规则每行上限 200;能匹配空串的 pattern 强制 `lastIndex` 前进
|
||||
- 设置字段 `AppSettings.linkRules?: LinkRule[]`(默认 `[]`);接线三处照 highlightProfiles:settingsStore `deepMerge` + sanitize 调用 + `defaultSettings()`,渲染层 `setLinkRules` + persist 白名单
|
||||
- UI 在 `HighlightTab.tsx` 底部 `ClickableLinkRules`;导入导出与 profile 联动**尚未做**,属后续可加
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
# OpenTerminal
|
||||
|
||||
[简体中文](README.md) | **English**
|
||||
|
||||
A modern, open-source terminal app: local terminal + SSH remote sessions + split-pane management + theme/font customization. Built with Electron + React + TypeScript, developed from scratch.
|
||||
|
||||
> Product website (GitHub Pages): <https://billowliu2.github.io/OpenTerminal/>, source in [`website/`](website/).
|
||||
|
||||
## Screenshots
|
||||
|
||||
| Multi-split parallel AI coding agents | Clean split view without background image |
|
||||
| --- | --- |
|
||||
|  |  |
|
||||
|
||||
| Themes & personalization (background image / opacity / dim layer) | Lock screen (Ctrl+L, sessions stay alive under the mask) |
|
||||
| --- | --- |
|
||||
|  |  |
|
||||
|
||||
## Highlights
|
||||
|
||||
- **Lightweight**: renderer dependencies are fully bundled and never duplicated into the installer — the app's asar is only 8.1MB, NSIS installer about 109MB; single-exe installer with in-app auto-update
|
||||
- **Privacy & security**: SSH credentials encrypted at rest via DPAPI, the renderer never touches plaintext; the lock-screen password stores only a scrypt-derived value; local paths for SFTP/ZMODEM must be granted through system dialogs, blocking constructed-path reads/writes
|
||||
- **Performance**: xterm.js WebGL rendering; keyword highlight engine measured at 0.02–0.05ms/KB (4–9ms for 200KB of mixed output); virtualized scrolling for large SFTP directories; debounced split-pane resizing so TUIs don't redraw repeatedly
|
||||
- **AI-coding friendly**: optimized for high-frequency TUIs like Kimi Code / Claude Code / Codex; IME candidate window follows the cursor for Chinese input
|
||||
- **Works in China**: update checks try GitHub first (following the system proxy), then automatically fall back to a direct-connect domestic channel, with a 30s overall timeout when both are unreachable
|
||||
- **Open & auditable**: MIT license; contracts for main/preload/renderer live in `src/shared/`, zero cross-module coupling; 36 offline tests covering storage, lock screen, highlighting, transfers and update fallback
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
npm install # skip if already installed
|
||||
npm run dev # development mode (hot reload)
|
||||
npm run build # production build to out/
|
||||
npm run typecheck # full type checking
|
||||
npm test # runs typecheck first, then rebuilds test bundles and runs 36 offline tests (see "Tests")
|
||||
```
|
||||
|
||||
> If the Electron binary download fails: `ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ node node_modules/electron/install.js`
|
||||
|
||||
## Features
|
||||
|
||||
**Terminal core**
|
||||
- Local terminal (node-pty; auto-detected Windows PowerShell / macOS zsh / Linux bash), advertises 256-color and truecolor support
|
||||
- xterm.js rendering: high-performance WebGL mode with automatic fallback to DOM on load failure/context loss
|
||||
- Session lifecycle: exit overlay shows the exit code; all sessions are reaped when the window closes
|
||||
- Search (Ctrl+F inline search bar, highlight all, n/N counters)
|
||||
- Clipboard (Ctrl+Shift+C/V, optional copy-on-selection)
|
||||
- Paste risk confirmation (multi-line/long text requires manual confirmation, can be disabled)
|
||||
- Font / size / weight / letter spacing / line height / scrollback / cursor style applied live
|
||||
- Keyword highlighting: 22 built-in rules (permissions, secret-leak alerts, paths, dangerous commands, delete/create operations, success/warning/error states, log levels, root prompts, exit codes, HTTP status codes, durations, percent progress, IPs, URLs, etc.). Status words are case-insensitive and cover symbols like ✓/✗; percentages / exit codes / HTTP codes / durations are colored by numeric bands. Rules support custom regex, "ignore case" and "numeric band" toggles, JSON import/export for sharing, live preview with test text in the editor, and a three-level highlight mode (all / basic / off — basic runs only key rules like errors/success/warnings/dangerous commands/secrets). Rules can be grouped by category (security/status/file/network/text/metric), with optional hit/latency stats (off by default, zero overhead when off), colors can follow the terminal theme palette, and rule sets can be created and bound per host (off by default; unbound hosts run all rules)
|
||||
|
||||
**SSH remote sessions**
|
||||
- Built on ssh2: password / private key (file path or pasted content) / SSH Agent authentication
|
||||
- Connection manager: bookmark sidebar (grouped accordions), double-click to connect, create/edit/delete dialogs
|
||||
- Credential security: passwords/keys/passphrases encrypted via Electron safeStorage (DPAPI) before hitting disk — the renderer never sees plaintext; "ask for password at connect time" mode supported
|
||||
- Host fingerprint verification (known-hosts pinning): confirm on first connect, red alert on fingerprint change, auto-reject after 120s without response
|
||||
- Server hardware monitoring: real-time CPU / memory / disk / network panels (no agent scripts needed on the server)
|
||||
- SFTP file management: dual-pane browsing, drag-and-drop transfers, queue/progress/cancel, permission and owner modification
|
||||
- ZMODEM (rz/sz) file transfers
|
||||
- Session data plane fully unified with the local terminal (write/resize/close/exit events share the same channels)
|
||||
|
||||
**Split panes & layouts**
|
||||
- dockview tab system: horizontal/vertical splits, drag to rearrange, tab context menu
|
||||
- Layout templates: save the current split as a template, apply with one click (sessions rebuilt automatically), deletable
|
||||
- Separate Terminal / SSH workspaces, never mixed
|
||||
|
||||
**Productivity**
|
||||
- Broadcast input: select ≥2 terminals and type in sync; tabs show a broadcast badge
|
||||
- Command palette: sidebar "Commands" tab with History / Library pages; history deduplicated per session, library supports grouping and one-click run
|
||||
- Input suggestions: sourced from history + command library, Tab to accept, Enter always executes directly (can be disabled entirely in settings)
|
||||
- Session logging: manual start/stop, plain-text output
|
||||
- Shortcuts: Ctrl+=/-/0 font size, Ctrl+PgUp/PgDn switch panes, Ctrl+L instant lock (effective when a password is set), global show/hide (configurable)
|
||||
|
||||
**Lock screen & privacy**
|
||||
- Locks via an opaque mask inside the main window (no second window); local/SSH sessions and transfer lists stay mounted underneath and resume on unlock
|
||||
- The password stores only a scrypt-derived value (`lock.json`, fresh random salt per write + timing-safe comparison); plaintext never hits disk; disabled entirely when no password is set
|
||||
- Locked state persists (`lock-state.json`) — still locked after tray quit, task-manager kill, or crash-restart
|
||||
- **Ctrl+L = instant lock**, works even inside the terminal; without a password set, Ctrl+L stays with the shell's clear-screen
|
||||
- Wrong passwords trigger a cooldown ladder (1s → 2s → 5s → 10s → 30s); failure count and cooldown also persist
|
||||
- Optional idle auto-lock (1/5/15/30/60 minutes) and lock-at-startup; repeated attempts during cooldown back off progressively
|
||||
- While locked, F5 / Ctrl+R / Ctrl+±0 / Ctrl+Shift+I/J/C are swallowed and the app menu is removed entirely, preventing menu-key bypass of the mask
|
||||
|
||||
**Appearance & window**
|
||||
- 11 built-in themes (Dracula / One Half / Solarized / Gruvbox / Nord / Monokai / GitHub, etc.)
|
||||
- Custom theme editor: 16-color ANSI palette + foreground/background/cursor/selection colors with live preview
|
||||
- Window title bar and background follow the terminal theme
|
||||
- System tray: close button can be set to "ask every time / minimize to tray / quit"; switchable from the tray menu
|
||||
- Single instance: launching again brings up the existing window
|
||||
- Auto-update: GitHub first (via system proxy), falling back to the domestic Gitea channel (forced direct connection); each check has a 30s overall timeout; NSIS exe only (no MSI since v1.0.22)
|
||||
- Settings persistence (userData/settings.json, atomic writes) + real-time sync across windows
|
||||
|
||||
**UI & language**
|
||||
- Four UI languages: 简体中文 / 繁體中文 / English / 日本語 (Settings → System → UI language; switches instantly and persists; antd built-in texts follow along)
|
||||
- Changelog bundled with the installer, readable offline on the About page, shown per current UI language (falls back to Simplified Chinese when a translation is missing)
|
||||
|
||||
**Tests**
|
||||
|
||||
`npm test` first runs `pretest` (= `npm run typecheck`), rebuilds the esbuild bundles, then runs 36 offline tests (no server or credentials needed — see the `test` script in `package.json` and `tests/`), roughly grouped as:
|
||||
|
||||
- **Storage**: command library / SSH bookmarks / settings sanitizing (incl. legacy highlight preset upgrades) / broadcast / known hosts
|
||||
- **Lock screen**: password verifier, lock controller (cooldown ladder, persisted recovery, idle trigger), shortcut classifier
|
||||
- **Terminal & rendering**: highlight engine (word boundaries, chunked regression, ReDoS probes and validation gates), theme semantic vars & contrast, font stack, resize debounce, link rules, tab titles, terminal CWD, mode-button gesture
|
||||
- **Transfer & sessions**: SFTP timeout & eviction, ZMODEM both ways, ssh2 loopback and session routing e2e, server monitoring e2e
|
||||
- **Platform**: IPC guard, update channel fallback, log sanitizer & diagnostics, reserved accelerators, local path grants
|
||||
|
||||
- Real-server tests (need `JD_HOST/JD_USER/JD_PASS`, **not** part of `npm test`, require network and real credentials): `tests/sftp-real.mjs`, `tests/sftp-chmod.mjs`
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
src/
|
||||
├── shared/ # Contracts shared by main/renderer (IPC channels, settings, theme model)
|
||||
│ ├── ipc.ts # channel names + payload types
|
||||
│ ├── api.ts # window.api interface (the preload exposure surface)
|
||||
│ ├── settings.ts # AppSettings / defaults
|
||||
│ └── theme.ts # TerminalTheme / built-in themes
|
||||
├── main/ # main process
|
||||
│ ├── pty.ts # PTY session pool (node-pty) → broadcasts PTY_DATA/PTY_EXIT
|
||||
│ ├── settingsStore.ts # JSON persistence + deep-merge fallback
|
||||
│ ├── lockController.ts # lock state machine (cooldown ladder, idle trigger, serialized unlock)
|
||||
│ ├── lockStore.ts # lock password verifier (scrypt, no plaintext)
|
||||
│ ├── layouts.ts # layout template storage (userData/layouts/*.json)
|
||||
│ ├── tray.ts # system tray + close behavior
|
||||
│ └── ipc.ts # all ipcMain registrations
|
||||
├── preload/index.ts # contextBridge → window.api
|
||||
└── renderer/src/
|
||||
├── terminal/ # TerminalView: xterm wrapper (renderer/search/clipboard/paste confirm)
|
||||
├── workspace/ # dockview workspace: toolbar/splits/session lifecycle/layout templates
|
||||
├── settings/ # zustand store + settings dialog
|
||||
└── theme/editor/ # custom theme editor
|
||||
```
|
||||
|
||||
Key design: **contracts first** — `src/shared/` is the single source of truth; main/preload/renderer each implement their side with zero cross-module coupling (terminal knows nothing about workspace, settings knows nothing about terminal).
|
||||
|
||||
## Data Locations
|
||||
|
||||
- Settings: `%APPDATA%/OpenTerminal/settings.json`
|
||||
- Layout templates: `%APPDATA%/OpenTerminal/layouts/*.json`
|
||||
- SSH connection bookmarks: `%APPDATA%/OpenTerminal/connections.json` (key fields DPAPI-encrypted)
|
||||
- Host fingerprints: `%APPDATA%/OpenTerminal/ssh_known_hosts.json`
|
||||
- Lock password verifier: `%APPDATA%/OpenTerminal/lock.json` (scrypt-derived, no plaintext)
|
||||
- Lock state & cooldown: `%APPDATA%/OpenTerminal/lock-state.json`
|
||||
|
||||
## License
|
||||
|
||||
[MIT](LICENSE)
|
||||
@@ -1,5 +1,7 @@
|
||||
# OpenTerminal
|
||||
|
||||
**简体中文** | [English](README.en.md)
|
||||
|
||||
开源的现代化终端应用:本地终端 + SSH 远程会话 + 分屏管理 + 主题/字体定制。Electron + React + TypeScript,从 0 到 1 独立开发。
|
||||
|
||||
> 产品官网(GitHub Pages):<https://billowliu2.github.io/OpenTerminal/>,源码在 [`website/`](website/)。
|
||||
@@ -21,7 +23,7 @@
|
||||
- **性能**:xterm.js WebGL 渲染;关键词高亮引擎实测 0.02–0.05ms/KB(200KB 混合输出 4–9ms);SFTP 大目录虚拟滚动;分屏尺寸去抖同步,TUI 不反复重绘
|
||||
- **AI 编程友好**:为 Kimi Code / Claude Code / Codex 等高频刷新 TUI 优化;中文输入法候选窗紧贴光标
|
||||
- **国内可用**:更新检查 GitHub 优先(跟随系统代理),失败自动回退国内直连通道,两边都拿不到时 30s 整体超时兜底
|
||||
- **开源可审计**:MIT 协议;主/预载/渲染三端契约集中在 `src/shared/`,模块间零横向依赖;19 个离线测试覆盖存储、锁屏、高亮、传输与更新回退
|
||||
- **开源可审计**:MIT 协议;主/预载/渲染三端契约集中在 `src/shared/`,模块间零横向依赖;36 个离线测试覆盖存储、锁屏、高亮、传输与更新回退
|
||||
|
||||
## 快速开始
|
||||
|
||||
@@ -30,7 +32,7 @@ npm install # 已安装可跳过
|
||||
npm run dev # 开发模式(热更新)
|
||||
npm run build # 生产构建到 out/
|
||||
npm run typecheck # 全量类型检查
|
||||
npm test # 先自动跑 typecheck,再重建测试 bundle 并跑 19 个离线测试(详见「测试」)
|
||||
npm test # 先自动跑 typecheck,再重建测试 bundle 并跑 36 个离线测试(详见「测试」)
|
||||
```
|
||||
|
||||
> Electron 二进制下载失败时:`ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ node node_modules/electron/install.js`
|
||||
@@ -51,7 +53,7 @@ npm test # 先自动跑 typecheck,再重建测试 bundle 并跑 19
|
||||
- ssh2 实现:密码 / 私钥(文件路径或粘贴内容)/ SSH Agent 三种认证
|
||||
- 连接管理器:左侧书签栏(分组手风琴)、双击连接、新建/编辑/删除对话框
|
||||
- 凭据安全:密码/私钥/口令经 Electron safeStorage(DPAPI)加密后落盘,渲染层永远接触不到明文密钥;支持「连接时询问密码」模式
|
||||
- 主机指纹校验(known hosts 固定):首次连接确认、指纹变更红色告警、30s 无响应自动拒绝
|
||||
- 主机指纹校验(known hosts 固定):首次连接确认、指纹变更红色告警、120s 无响应自动拒绝
|
||||
- 服务器硬件监控:CPU / 内存 / 磁盘 / 网络实时面板(无需在服务器部署任何脚本)
|
||||
- SFTP 文件管理:双栏浏览、拖拽传输、队列/进度/取消、权限与属主修改
|
||||
- ZMODEM(rz/sz)文件传输
|
||||
@@ -93,27 +95,13 @@ npm test # 先自动跑 typecheck,再重建测试 bundle 并跑 19
|
||||
|
||||
**测试**
|
||||
|
||||
`npm test` 会先跑 `pretest`(= `npm run typecheck`),再重建 esbuild bundle,最后依次跑下列 19 个测试(全部离线,无需服务器与凭据):
|
||||
`npm test` 会先跑 `pretest`(= `npm run typecheck`),再重建 esbuild bundle,最后依次跑 36 个离线测试(无需服务器与凭据,清单见 `package.json` 的 `test` 脚本与 `tests/`),按方向大致分为:
|
||||
|
||||
- `node tests/ssh-loopback.mjs` — ssh2 客户端/服务端回环(认证、shell、数据、resize、指纹)
|
||||
- `node tests/commands-store.mjs` — 命令库 / 历史 / 会话日志存储
|
||||
- `node tests/connections-store.mjs` — SSH 书签 CRUD、公开/密文字段切分、损坏文件备份
|
||||
- `node tests/settings-store.mjs` — 设置清洗器(closeAction/高亮规则修复/旧预设升级/告警日志)
|
||||
- `node tests/local-path-grants.mjs` — 本地路径准入(对话框授权、realpath+stat 双重校验、大小写折叠)
|
||||
- `node tests/lock-store.mjs` — 锁屏密码校验器(scrypt 往返、文件损坏处理)
|
||||
- `node tests/lock-controller.mjs` — 锁屏控制器(冷却阶梯、并发串行化、落盘恢复、闲置触发、清除联动)
|
||||
- `node tests/lock-shortcuts.mjs` — 锁屏快捷键分类器(表驱动:Ctrl+L 恐慌锁 / 锁定时禁用的组合键)
|
||||
- `node tests/reserved-accelerators.mjs` — 保留快捷键表(设置录制器与全局注册共用同一张表)
|
||||
- `node tests/.terminal-title.cjs` — 终端标签自动标题(四语言反解与重渲染)
|
||||
- `node tests/ipc-guard.mjs` — IPC 发送方守卫(仅信任本应用渲染帧)
|
||||
- `node tests/updater-fallback.mjs` — 更新通道回退(GitHub 优先 / Gitea 兜底、超时、防降级)
|
||||
- `node tests/log-sanitizer.mjs` — 会话日志脱敏(原始 PTY 字节流转可读文本)
|
||||
- `node tests/sftp-timeout.mjs` — SFTP 操作超时(半死通道驱逐与重试)
|
||||
- `node tests/.hl-split-smoke.cjs` — 关键词高亮流分块回归(bundle 由 `node tests/build-bundles.cjs` 生成)
|
||||
- `node tests/.hl-rules.cjs` — 内置高亮预设(词边界、大小写、负向词、危险命令)
|
||||
- `node tests/zmodem-e2e.mjs` — ZMODEM 双向传输(与第二个 zmodem.js Sentry 对接,内容一致性)
|
||||
- `node tests/ssh-session-e2e.mjs` — 会话路由层端到端(SSH 数据面、replay、resize、kill)
|
||||
- `node tests/sysinfo-e2e.mjs` — 服务器监控轮询端到端(META、采样速率、stopPolling)
|
||||
- **存储类**:命令库 / SSH 书签 / 设置清洗(含旧高亮预设升级)/ 广播 / 已知主机
|
||||
- **锁屏类**:密码校验器、锁屏控制器(冷却阶梯、落盘恢复、闲置触发)、快捷键分类器
|
||||
- **终端与渲染类**:高亮引擎(词边界、分块回归、ReDoS 探针与校验闸门)、主题语义变量与对比度、字体栈、尺寸同步去抖、链接规则、标签标题、终端 CWD、模式按钮手势
|
||||
- **传输与会话类**:SFTP 超时与驱逐、ZMODEM 双向传输、ssh2 回环与会话路由端到端、服务器监控端到端
|
||||
- **平台类**:IPC 守卫、更新通道回退、日志脱敏与诊断日志、保留快捷键表、本地路径准入
|
||||
|
||||
- 真实服务器测试(需 `JD_HOST/JD_USER/JD_PASS`,**不在** `npm test` 内,需联网与真实凭据):`tests/sftp-real.mjs`、`tests/sftp-chmod.mjs`
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# 字体与关键词高亮改进计划(2026-10-17)
|
||||
|
||||
> ⚠️ **已归档(2026-10-21)**:本计划大部分已实施(perm 锚定、SGR 状态重放、unicode11、CJK 字体栈、等宽徽标等已随 v1.0.2x 发布),未竟事项已并入 [`docs/PLAN-2026-10-21-backlog.md`](PLAN-2026-10-21-backlog.md)(W2 捡漏),本文仅作历史记录,不再维护。
|
||||
|
||||
基于 2026-10-17 的专项调研(字体两轮 + 高亮两轮,子代理实测探针交叉印证,主模型复核关键结论)。所有问题均给出证据位置与验收口径,按批次执行。
|
||||
|
||||
## 调研结论摘要
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
# OpenTerminal 下一轮迭代计划(v1.0.32+ 待办整理)
|
||||
|
||||
> 本计划整理自 2026-10-21 的三路代码审查(路线图梳理 / 代码缺口扫描 / 竞品对标),
|
||||
> 所有条目均经源码逐一核实(文件:行号见各项「现状」)。**本文件只是计划,不代表任何代码改动已开始。**
|
||||
> 执行时按波次进行,每个波次一个版本号;条目内的勾选框在执行阶段回填。
|
||||
>
|
||||
> 核实记录(2026-10-21 二轮):文档初稿写完后又逐条回源码验证,修正了 6 处凭印象写错的细节——
|
||||
> ① `pendingOpens` 在 `pty.ts` 而非 ssh.ts;② ssh2 无官方类型、项目用手写 shim `ssh2.d.ts` 且未声明
|
||||
> `forwardOut`;③ 本地 PTY 路径没有 ZMODEM sentry(W2-4 需新写检测而非复用);④ 剪贴板图片落临时文件
|
||||
> 不经过 `localPathGrants`(它只授权对话框选中的路径);⑤ 官网 SFTP 区并无「双栏」宣传;⑥ zh-TW
|
||||
> 「密碼」32 处 vs「口令」5 处(统一方向确认)。所有测试基建文件名(updater-fallback / settings-store /
|
||||
> link-rules / zmodem-e2e / ssh-loopback)已确认存在。
|
||||
|
||||
## 总览
|
||||
|
||||
| 波次 | 主题 | 内容 | 预估工作量 |
|
||||
|---|---|---|---|
|
||||
| W0 | 文档债清理 | 计划 checkbox 回填、ROADMAP 过期条目 | 0.5h |
|
||||
| W1 | 修复:更新器 + 发布脚本 + 预设升级 | 4 项小修,其中 2 项影响存量用户 | 1 天 |
|
||||
| W2 | 捡漏功能(半成品补全) | 链接规则导入导出、命令库参数、i18n 统一、ZMODEM 提示 | 1–2 天 |
|
||||
| W3 | 剪贴板与命令感知 | OSC 52、图片粘贴、OSC 133 marks、完成通知 | 2–3 天 |
|
||||
| W4 | Shell 与 SSH 迁移链 | 本地 shell 配置档、ssh_config 导入、ProxyJump + 端口转发 | 3–5 天 |
|
||||
| W5 | 差异化(择期) | 远程文件编辑、应用命令面板、终端内 AI 助理 | 各 2–4 天 |
|
||||
| — | 需决策的取舍项 | 代码签名、多窗口、双栏 SFTP、传输暂停续传 | 见 §6 |
|
||||
|
||||
**波次间的依赖**:W3 的「完成通知」依赖同波次的 OSC 133 marks;W4 的 ProxyJump 与端口转发共用 ssh2 通道基建必须同一波次;W5 各项独立。
|
||||
|
||||
---
|
||||
|
||||
## W0:文档债清理(无代码改动)
|
||||
|
||||
### W0-1 回填两份 superpowers 计划的 checkbox
|
||||
- 现状:`docs/superpowers/plans/2026-10-20-mode-button-double-click-sidebar.md` 12 个、
|
||||
`docs/superpowers/plans/2026-10-16-theme-overhaul.md` 40 个 `- [ ]` 全部未勾,但对应代码、测试、
|
||||
CHANGELOG 均已落地(v1.0.29 / v1.0.31)。
|
||||
- 动作:逐项核对后勾上。
|
||||
|
||||
### W0-2 修正 ROADMAP 过期陈述
|
||||
- 现状:`docs/ROADMAP.md:33`「SSH 面板暂不支持作为分屏参照」已过时——
|
||||
`src/renderer/src/workspace/Workspace.tsx:880` 起的 `handleSplit` 已实现 SSH dockview 内分屏。
|
||||
- 动作:删掉该条已知限制;顺带勾掉 M3-A / M5 已完成项的 checkbox,把 M4 真正未做的三项
|
||||
(双栏 / 压缩解压 / 远程编辑)单列。
|
||||
|
||||
---
|
||||
|
||||
## W1:修复(目标版本 v1.0.32)
|
||||
|
||||
### W1-1 更新器周期定时器不再被高频设置写入重置
|
||||
- 现状(已核实调用链):每次设置保存走 `mutateSettings`(settingsStore.ts:732)→ `applySystemSettings`(:736)→
|
||||
`updateScheduleApplier?.(system)`(:575)→ `applyUpdateSchedule`,而后者(`updater.ts:533-537`)无条件
|
||||
`clearInterval` + 重建。拖 ColorPicker / 打字会把 4h 倒计时无限推迟。
|
||||
- 改动:`applyUpdateSchedule` 记忆当前 interval 值,仅当 `updateCheckIntervalHours` 实际变化时重建定时器。
|
||||
- 测试:`tests/updater-fallback.mjs` 已有 schedule 测试段(第 10 节,:586 起),在其内补「连续调用不改变倒计时起点」用例。
|
||||
|
||||
### W1-2 「启动检查」中途拨开语义修正
|
||||
- 现状(已核实文案):设置页开关标签为「启动时自动检查」(`src/shared/i18n/dicts/zh-CN/settings.ts:357`),
|
||||
但 `updater.ts:543-547` `startupArmed` 只在首次武装时置位——启动时关闭过该开关的用户在会话中途打开,
|
||||
会在 5s 后立刻跑一次 `runCheck('startup')`,与「启动时」的文案语义矛盾。
|
||||
- 改动:启动时无论开关状态都消费 `startupArmed`(记录「启动检查已过」),
|
||||
中途打开只影响周期定时器。
|
||||
|
||||
### W1-3 修复 `--channel-only --skip-gitea` 静默上传
|
||||
- 现状:`scripts/release.cjs:437-441` `CHANNEL_ONLY` 分支在 `SKIP_GITEA` 警告(:442-446)之前 return——
|
||||
两个参数同用时警告永不打印,且 channel 照样上传,与操作者预期相反。
|
||||
- 改动:参数解析处直接拒绝该组合(`--channel-only` 的定义就是国内通道,叠加 `--skip-gitea` 无意义),
|
||||
或把警告移到分支之前并显式确认。
|
||||
- 测试:`release.cjs` 无测试基建,人工跑一次参数组合验证输出即可。
|
||||
|
||||
### W1-4 高亮预设升级机制补全(**影响存量用户,本波次重点**)
|
||||
- 现状(已核实判定链,settingsStore.ts:243-267):`refreshBuiltinRules` 只在
|
||||
`LEGACY_BUILTIN_PATTERNS[rule.id].includes(存储pattern)` 成立时把规则升级到新 preset;
|
||||
该表(:208-213)目前只有 perm/okstate/badstate 三条旧 pattern。追加新预设另有闸门
|
||||
`LEGACY_BUILTIN_IDS`(:215 起)的「存储集合恰好等于旧预设集」判定。结论:今后改任何预设的 pattern,
|
||||
若不在改动同时把旧 pattern 登记进 `LEGACY_BUILTIN_PATTERNS`,老用户将永远停在旧 pattern。
|
||||
- 改动:把历次发布过的预设 pattern 全量补进 `LEGACY_BUILTIN_PATTERNS`(从 git 历史逐版本提取),
|
||||
并在文档里写明「改预设 pattern 时必须同步登记旧 pattern」的规则(AGENTS.md 高亮一节)。
|
||||
- 测试:`tests/settings-store.mjs` 表驱动补「旧版 pattern 规则 → 升级到新 pattern、保留用户颜色/启停」用例。
|
||||
|
||||
---
|
||||
|
||||
## W2:捡漏功能(目标版本 v1.0.33)
|
||||
|
||||
### W2-1 可点击链接规则的导入 / 导出 + profile 联动
|
||||
- 现状:`src/renderer/src/settings/HighlightImportExport.tsx:44,52,155` 只处理 `highlightRules`;
|
||||
AGENTS.md「可点击链接规则」章末明确「尚未做,属后续可加」。
|
||||
- 改动:`src/shared/highlightIO.ts` 的信封机制扩展 `kind`(或新建 `linkRulesIO.ts` 同构模块),
|
||||
UI 侧在 `ClickableLinkRules` 区块复用剪贴板 + FileReader + Blob 方案,**不新增 IPC**;
|
||||
profile 联动参考 `excludedByProfile` 的现有做法。
|
||||
- 测试:`tests/link-rules.mjs` 补信封/合并用例。
|
||||
|
||||
### W2-2 命令库 `{{param}}` 参数变量落地
|
||||
- 现状(已核实执行链):`src/shared/commands.ts:11` 定义了 `params?: string[]`,全仓无消费代码。
|
||||
命令的实际执行路径是 `Workspace.tsx:1215` 的 `runCommand` → `writeBroadcast(sid, \`${cmd}\r\`)`(:1227)
|
||||
→ 激活面板。参数替换应发生在 `runCommand` 入口处,替换产物继续走 `writeBroadcast`,
|
||||
各 pane 的 line buffer 语义自然保持。
|
||||
- 改动:执行命令时若含 `{{name}}` 占位,弹参数输入(antd Modal 表单),替换后写入终端。
|
||||
- 测试:替换逻辑抽纯函数 + 表驱动测试(占位符转义、未填参数、含 `$` 字符等)。
|
||||
|
||||
### W2-3 i18n 两个统一专项
|
||||
- 现状(已核实计数):zh-TW 词典「密碼」32 处(settings.ts 26 + ssh.ts 4 + workspace.ts 2)为压倒性多数,
|
||||
「口令」仅 `dicts/zh-TW/ssh.ts` 5 处,统一为「密碼」并通读 指令/命令、伺服器/服务器、預設/默认;
|
||||
ja `dicts/ja/ssh.ts` 用「フォルダ」而 `settings.ts`/`terminal.ts` 用「フォルダー」。
|
||||
- 改动:纯文案批量替换 + 通读;不动键名。
|
||||
- 测试:无测试基建,四语言界面各过一遍设置页与 SSH 面板目检。
|
||||
|
||||
### W2-4 本地终端运行 rz/sz 的用户提示
|
||||
- 现状(已核实数据路径):ZMODEM sentry 只挂在 SSH 流上(`pty.ts:433-438` `handle.stream.on('data')` →
|
||||
`feedZmodem`);本地 node-pty 会话的数据路径**没有任何 chunk 检测**。本地终端跑 `rz/sz` 只会静默卡住。
|
||||
- 改动:在本地 PTY 数据路径加轻量 ZMODEM 头检测(只在看到 `rz`/`sz` 启动帧时生效,常态零开销),
|
||||
命中时在终端里输出一行醒目的不支持说明(走终端数据面,i18n 四语);不要接入完整 sentry。
|
||||
- 测试:`tests/zmodem-e2e.mjs` 补「本地会话触发 → 提示且不进入传输态」。
|
||||
|
||||
---
|
||||
|
||||
## W3:剪贴板与命令感知(目标版本 v1.0.34,对位「AI Agent 工作台」核心场景)
|
||||
|
||||
### W3-1 OSC 52 远程写剪贴板
|
||||
- 现状:只装了 fit/search/unicode11/webgl 四个 addon;远程 tmux/vim 复制无法直达本机剪贴板。
|
||||
- 改动:`term.parser.registerOscHandler(52, ...)` 自实现(比引 `@xterm/addon-clipboard` 可控):
|
||||
base64 → **UTF-8 安全解码**(`atob` 裸解中文会乱码,用 TextDecoder)→ `navigator.clipboard.writeText`;
|
||||
新增设置开关「允许远程程序写入剪贴板」(默认开,设上限如 1MB)。
|
||||
- 测试:解码函数抽纯模块,表驱动测中文/emoji/base64 边界。
|
||||
- 注意:新依赖规则——自实现则零依赖;若引 addon,渲染层依赖进 devDependencies。
|
||||
|
||||
### W3-2 剪贴板图片粘贴进终端
|
||||
- 场景:截图直接喂给 Claude Code / Kimi Code 这类支持贴图的 AI CLI。
|
||||
- 改动:渲染层拦截粘贴(现有粘贴逻辑在 `TerminalView.tsx:99-124` 确认弹窗与 :943-946 的 xterm paste 通道),
|
||||
剪贴板为图片时经 IPC 让主进程 `clipboard.readImage()` → 落临时 PNG(`os.tmpdir()`,定期清理)→
|
||||
把路径文本写入终端。**不涉及 `localPathGrants`**:该注册表只授权「系统对话框选中的路径」
|
||||
(`grantPickedFiles`/`grantPickedDirectory`,localPathGrants.ts:110,119),临时文件由主进程自己写入、
|
||||
应用自身不读它,无需授权。
|
||||
- 测试:临时文件命名/清理逻辑的单测;实际粘贴行为列入发版手测清单。
|
||||
|
||||
### W3-3 OSC 133 命令块感知(marks)
|
||||
- 现状:`src/main/shellIntegration.ts:8,39,60` 已注入 OSC 7(bash/zsh/PowerShell 三个适配器)——
|
||||
扩到 133 是同一处注入脚本 + xterm 侧 mark 注册。
|
||||
- 改动:注入脚本补 `OSC 133;A/B/C/D`(prompt start / command start / output start / finished+exitcode);
|
||||
渲染层监听 mark 事件建「命令块索引」,提供:
|
||||
- 滚动条刻度(marks gutter)
|
||||
- 快捷键跳上/下一条命令
|
||||
- 「复制本次命令输出」(整块选中复制——整段取 AI agent 输出是核心诉求)
|
||||
- 测试:注入脚本的字面量生成有现有测试模式可仿;mark 索引纯函数表驱动。
|
||||
- 风险:OSC 133 在不同 shell/嵌套 tmux 下行为差异大,首版只承诺 bash/zsh/pwsh,tmux 内标注「依赖外层终端透传」。
|
||||
|
||||
### W3-4 长任务完成通知(依赖 W3-3)
|
||||
- 场景:多路分屏跑 AI agent,跑完切走被叫回来——本产品核心场景。
|
||||
- 改动:OSC 133 D(命令结束)到达且该 pane **不可见**且命令耗时 > 阈值(设置项,默认 10s)时:
|
||||
托盘气泡(复用 `tray.ts notifyUpdate` 的通道)+ 标签强调态。退出码非零时气泡文案区分成功/失败。
|
||||
- 测试:判定逻辑抽纯函数(耗时阈值、可见性、退出码),表驱动。
|
||||
|
||||
---
|
||||
|
||||
## W4:Shell 与 SSH 迁移链(目标版本 v1.0.35,获客向)
|
||||
|
||||
### W4-1 本地 Shell 配置档
|
||||
- 现状:`src/main/pty.ts:220-228` win32 硬编码 `powershell.exe`;`createPty` 的 `opts.shell`/
|
||||
`opts.shellArgs` 已存在但**无任何调用方传参**——只差最后一公里。
|
||||
- 改动:设置新增「默认 Shell」下拉(自动探测:`wsl.exe -l -q` 枚举发行版、`git-bash.exe` 常见路径、
|
||||
cmd/pwsh/powershell),`Workspace.tsx` 的 `createPty()` 调用点传入;工作区「+ 新建终端」沿用默认,
|
||||
侧栏可提供「以其它 shell 新建」次级菜单。
|
||||
- 测试:探测函数接受注入的枚举源(测试缝),表驱动覆盖各解析分支;真实 WSL/Git Bash 列入手测清单。
|
||||
|
||||
### W4-2 `~/.ssh/config` 导入
|
||||
- 现状:无任何 ssh_config 解析代码;国内用户从 Xshell/PuTTY 迁移时「几十台机器重录」是弃用理由之首。
|
||||
- 改动:解析器抽纯模块(Host/HostName/User/Port/IdentityFile/ProxyJump,多 Host 与通配 Host `*`),
|
||||
映射到 `SshConnection` 并批量追加进 `connections.json`(新增字段时同步三处:
|
||||
`PUBLIC_KEYS` / `toPublic()` / `saveConnection()` 新记录字面量——AGENTS.md 明确要求)。
|
||||
ProxyJump 字段在 W4-3 之前先存起来不生效。
|
||||
- 测试:解析器表驱动(注释、大小写、引号、通配、Include 提示不支持)。
|
||||
|
||||
### W4-3 SSH ProxyJump + 端口转发(同一波次,共用通道基建)
|
||||
- 现状(已核实):`forwardOut|ProxyJump|jumpHost` 全仓零命中;企业内网 bastion 是刚需。
|
||||
**两个核实到的前置事实**:① 飞行中连接登记表 `pendingOpens` 在 `src/main/pty.ts:155,329,360`
|
||||
(不在 ssh.ts);② ssh2 不带类型定义,项目用手写 shim `src/main/ssh2.d.ts`,
|
||||
其中**只声明了 `connect()`,没有 `forwardOut`**(:85-86)——必须扩 shim。
|
||||
- 改动:
|
||||
- 连接模型:`SshConnection` 增加 `jumpHostId`(引用另一条连接)与 `forwards[]`(本地/远程/SOCKS)。
|
||||
- 主进程:`ssh2` 先连跳板 → `forwardOut` 建通道 → 在通道上再握手目标机;复用
|
||||
`pendingOpens` 的中止与超时体系(每跳独立超时);主机指纹逐跳走现有 host key 队列。
|
||||
- 类型:扩展 `src/main/ssh2.d.ts` 补 `forwardOut` 等声明。
|
||||
- 端口转发:连接建立后按 `forwards[]` 起本地监听,断开/失败时面板给出醒目状态。
|
||||
- 测试:ssh-loopback 测试基建可起两级跳板做端到端;真实服务器场景列入手测清单。
|
||||
- 风险:这是本计划里架构风险最高的一项,建议单独立项评审后再动。
|
||||
|
||||
---
|
||||
|
||||
## W5:差异化(择期,各独立立项)
|
||||
|
||||
### W5-1 远程文件内置编辑器
|
||||
- 双击 SFTP 文件 → 面板内打开编辑(轻量 textarea 起步,Monaco 体积与零依赖取向冲突需另行评估);
|
||||
保存自动回写;处理编码(UTF-8 优先)、CRLF、无写权限提示。改动点:`FilePanel.tsx` +
|
||||
`sftp.ts` 新增 read/write 文件 IPC(走 per-op 超时 `bounded()`)。
|
||||
|
||||
### W5-2 应用级命令面板 + 可自定义快捷键
|
||||
- `Ctrl+Shift+P`(已核实全仓无占用)动作注册表 + antd 模糊搜索 Modal;快捷键录制器复用
|
||||
`src/shared/reservedAccelerators.ts` 保留键表(`acceleratorParts`/`isReservedAccelerator`,防抢 Ctrl+L/Ctrl+F)。
|
||||
价值:功能密度已高,这是「后续功能不再堆 UI」的前置投资。
|
||||
|
||||
### W5-3 终端内 AI 助理
|
||||
- OpenAI 兼容端点(DeepSeek/Kimi/智谱均可接入),「`#` 开头自然语言 → 建议命令」,
|
||||
**默认只生成、回车前必须人工确认**;报错解释走显式按钮。**红线:任何终端内容不外发**,
|
||||
与现有「诊断日志不含内容」的隐私原则保持一致。
|
||||
- 注意:引入网络请求路径,需过安全评审;不绑账号、无云同步(相对 Warp 的国内优势)。
|
||||
|
||||
---
|
||||
|
||||
## §6 需决策的取舍项(不排期,等拍板)
|
||||
|
||||
| 项 | 取舍点 | 建议 |
|
||||
|---|---|---|
|
||||
| 代码签名证书 | 外部成本(~数百美元/年);无签名则 SmartScreen 拦截 + 通道完整性缺口 | 用户量到一定规模再做 |
|
||||
| 多窗口 / 标签拖出 | 与单实例设计冲突,`sessionState`/owner 归属需重构 | 等真实多显示器用户反馈 |
|
||||
| SFTP 本地↔远程双栏 | 大工程(本地文件树模型 + 传输层重做);官网 SFTP 区从未宣传「双栏」(i18n fs.* 文案只讲远程面板 + 上传下载),无宣传口径包袱 | 观望;拖拽上传先满足大半诉求 |
|
||||
| 传输暂停/续传 | `TransferState` 无 `paused` 态,需断点续传协议层 | 观望 |
|
||||
| 性能优化第二阶段(B3/B4) | 项目自定规则:没拿到真实 PERF 日志前不许凭感觉动 | 维持观测,等数据 |
|
||||
| xterm 解 pin 回 `^` | 锁 `6.1.0-beta.304` 因 IME 修复;解 pin 需同步 addon-unicode11 peer 并重测中文输入法 | 等上游 6.1.0 stable |
|
||||
| autoWrap=false 固定列宽 | xterm 无原生支持,等于自写列裁剪层 | 不做 |
|
||||
|
||||
## 执行约束(所有波次通用,摘自 AGENTS.md 铁律)
|
||||
|
||||
1. 新增纯逻辑一律抽零 import 纯模块 + Node 原生类型剥离表驱动测试,测试数随 AGENTS.md 清单同步更新
|
||||
2. 依赖分类:`src/main|preload` 实际 import 的才进 dependencies;渲染层一律 devDependencies
|
||||
3. i18n 四语言键必须同步(zh-CN/zh-TW/en/ja),缺键回落简中但属必须修
|
||||
4. 新增「渲染层构造本地路径传主进程」的调用点必须过 `localPathGrants`
|
||||
5. CSS 一律 `var(--ot-*)`,新代码禁止裸颜色字面量(`tests/css-theme-vars.mjs` 守门)
|
||||
6. 每个发版 commit 必须带 `package-lock.json`;发布流程照 AGENTS.md「发布新版本」六步
|
||||
7. 改 AGENTS.md 已文档化的行为时,同步更新 AGENTS.md
|
||||
+17
-17
@@ -30,17 +30,17 @@
|
||||
- [x] Host key 校验:known_hosts 固定、首次确认、变更告警、30s 超时拒绝
|
||||
- [x] 测试:tests/ssh-loopback.mjs(ssh2 回环)+ tests/ssh-session-e2e.mjs(真实会话路由层 e2e)
|
||||
- 待解锁后的 GUI 验收:双击书签连接、指纹弹窗交互、断线提示
|
||||
- 已知限制:SSH 面板暂不支持作为分屏参照(总在新标签打开);连接中 Modal 不可取消;agent 认证未在本机验证
|
||||
- 已知限制:连接中 Modal 不可取消;agent 认证未在本机验证(「SSH 面板不能作分屏参照」一条已失效:`Workspace.tsx` 的 `handleSplit` 已支持 SSH dockview 内按同 session 分屏)
|
||||
|
||||
## M3 — SSH 产品化:Terminal/SSH 分离 + 服务器硬件监控 ✅
|
||||
|
||||
### A. Terminal 与 SSH 功能分离(产品形态)
|
||||
|
||||
- [ ] 侧栏双分区:「终端」(本地)与「SSH 服务器」各自独立区块(独立折叠、独立头部、独立计数)
|
||||
- [ ] SSH 会话标识:标签页带服务器图标/主机名前缀,本地与 SSH 标签视觉区分
|
||||
- [ ] 新建入口分离:图标条「+」只建本地终端;SSH 会话仅从服务器列表发起(双击连接,右键:连接/编辑/删除/复制主机)
|
||||
- [ ] SSH 区增加「最近会话」(按 lastConnectedAt 排序,一键重连)
|
||||
- [ ] (可选增强)工作区模式:本地/SSH 两个独立工作区布局,互不混排
|
||||
- [x] 侧栏双分区:「终端」(本地)与「SSH 服务器」各自独立区块(独立折叠、独立头部、独立计数)
|
||||
- [x] SSH 会话标识:标签页带服务器图标/主机名前缀,本地与 SSH 标签视觉区分
|
||||
- [x] 新建入口分离:图标条「+」只建本地终端;SSH 会话仅从服务器列表发起(双击连接,右键:连接/编辑/删除/复制主机)
|
||||
- [x] SSH 区增加「最近会话」(按 lastConnectedAt 排序,一键重连)
|
||||
- [x] (可选增强)工作区模式:本地/SSH 两个独立工作区布局,互不混排(现为图标栏「终端工作区 / SSH 工作区」双模式 + 双 dockview)
|
||||
- 验收:不开侧栏也能一眼区分标签是本地还是 SSH;图标条「+」只出本地终端
|
||||
|
||||
### B. 服务器硬件监控(参考 Wave Terminal 的 sysinfo 块)
|
||||
@@ -52,25 +52,25 @@
|
||||
|
||||
数据面:ssh2 `conn.exec` → stdout JSON → 主进程解析 → `STATS:{id, stats}` 广播 → 渲染层
|
||||
|
||||
- [ ] `src/main/sysinfo.ts`:按会话轮询(默认 3s,可配置),会话关闭/断线自动停止;解析容错
|
||||
- [ ] 契约:`shared/sysinfo.ts`(SysinfoSample + STATS 通道 + 面板开关事件)
|
||||
- [ ] UI:SSH 会话右侧可折叠监控面板——CPU 多核条形、内存/磁盘用量条、网络上下行、负载/运行时间
|
||||
- [ ] (增强路线)进程 Top 列表 + 结束进程
|
||||
- [x] `src/main/sysinfo.ts`:按会话轮询(默认 3s,可配置),会话关闭/断线自动停止;解析容错
|
||||
- [x] 契约:`shared/sysinfo.ts`(SysinfoSample + STATS 通道 + 面板开关事件)
|
||||
- [x] UI:SSH 会话右侧可折叠监控面板——CPU 多核条形、内存/磁盘用量条、网络上下行、负载/运行时间
|
||||
- [ ] (增强路线)进程 Top 列表 + 结束进程(未做;依赖部署脚本路线,与「零部署」取向冲突,见 PLAN-2026-10-21 §6)
|
||||
- 验收:连接后 ≤3s 看到实时 CPU/内存;断开会话监控自动停止;服务器跑 `stress` 曲线明显上升
|
||||
|
||||
## M4 — SFTP 文件管理 ✅
|
||||
|
||||
- [ ] `src/main/sftp.ts`:ssh2 SFTP 封装(readdir/stat/rename/mkdir/rm/mv,大文件分块传输 + 进度事件)
|
||||
- [ ] 双栏界面(本地 ↔ 远程)+ 拖拽上传/下载 + 传输面板(队列/暂停/取消)
|
||||
- [ ] 右键菜单:压缩/解压(tar.gz)、编辑远程文件(Monaco,下载-编辑-回传)
|
||||
- [x] `src/main/sftp.ts`:ssh2 SFTP 封装(readdir/stat/rename/mkdir/rm/mv,大文件分块传输 + 进度事件)
|
||||
- [ ] 双栏界面(本地 ↔ 远程)+ 拖拽上传/下载 + 传输面板(队列/暂停/取消)——**部分完成**:传输面板与取消已有(TransferPanel/cancelTransfer);双栏、拖拽、暂停/续传未做(`TransferState` 无 paused 态),见 PLAN-2026-10-21 §6
|
||||
- [ ] 右键菜单:压缩/解压(tar.gz)、编辑远程文件(Monaco,下载-编辑-回传)——未做(远程编辑建议走轻量方案,见 PLAN W5-1)
|
||||
- 验收:100MB 文件传输进度流畅、可取消续传
|
||||
|
||||
## M5 — 命令系统与会话日志 ✅
|
||||
|
||||
- [ ] 命令历史(sqlite + better-sqlite3,按会话去重)
|
||||
- [ ] 命令库(分组 + 参数变量 `{{param}}` + 一键运行/粘贴)
|
||||
- [ ] 终端补全:历史 + 命令库 + 内置命令表(内置命令文档可从 tldr 页面生成)
|
||||
- [ ] 会话日志:手动启停、纯文本落盘、断线暂停指示
|
||||
- [x] 命令历史(按会话去重;实现为 `commands.json` 而非原计划的 sqlite,全仓零原生依赖)
|
||||
- [x] 命令库(分组 + 一键运行/粘贴;**参数变量 `{{param}}` 未做**——`shared/commands.ts` 的 `params` 字段已定义但无消费代码,见 PLAN W2-2)
|
||||
- [x] 终端补全:历史 + 命令库(**内置命令表未做**,见 PLAN §梳理)
|
||||
- [x] 会话日志:手动启停、纯文本落盘(**断线暂停指示未做**——pane 重绑时录制静默停止)
|
||||
- [x] ~~关键词高亮~~(✅ 已提前完成:highlightEngine 流式 ANSI 注入 + 22 条预设 + 设置页管理,见 M2.5;状态类预设不分大小写、覆盖 ✓/✗ 符号,删除/新建操作单独着色,百分比/退出码/HTTP 状态码/耗时按数值分级取色,含密钥泄露提醒与危险管道,旧安装的预设规则在加载时按「未被用户改动过」升级)
|
||||
- 验收:补全 ↑↓ 选择 / Tab 接受 / Esc 关闭 / 回车直接执行,交互流畅
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
> 用途:新会话/子代理从此文件恢复全部工作状态,无需对话历史。每次里程碑后更新。
|
||||
|
||||
> ⚠️ **本快照已过期(内容截至 v1.0.23,当前已发布 v1.0.31)**:里程碑总表以 [`ROADMAP.md`](ROADMAP.md) 为准,后续计划见 [`PLAN-2026-10-21-backlog.md`](PLAN-2026-10-21-backlog.md),日常开发规则以根目录 [`AGENTS.md`](../AGENTS.md) 为准。下文实现要点仍具参考价值,但版本号与细节可能与最新代码有出入。
|
||||
|
||||
## 当前版本与仓库
|
||||
|
||||
- v1.0.23,远程 `git.codingplan.site/admin/OpenTerminal.git`(国内仓)+ `github.com/billowliu2/OpenTerminal.git`(GitHub 镜像仓);凭据存于 `.env`(已 git 忽略),凭据助手按 host 自动读取
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# 主题系统全面改造(A+B+C)实施计划
|
||||
|
||||
> ⚠️ **已归档(2026-10-21)**:本计划已实施完毕(M14,2026-10-16 完成)并随正式版发布,仅作历史记录,不再维护。后续工作见 [`docs/PLAN-2026-10-21-backlog.md`](../../PLAN-2026-10-21-backlog.md)。
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or swarm waves as mapped in §11. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 浅色主题下终端与全部 UI 文字达到 WCAG AA 可读、底色柔化、194 处硬编码颜色全量收敛进语义变量层并以 fail-closed 测试锁死。
|
||||
@@ -37,7 +39,7 @@
|
||||
- `semanticVars(colors, light): Record<string, string>`(chrome.ts 逐个 `setProperty`;键不含 `--ot-` 前缀,写入时加)
|
||||
- `SEMANTIC`(锚点色常量表,CSS 静态兜底与 TSX 内联取色共用)
|
||||
|
||||
- [ ] **Step 1: 写失败测试 `tests/semantic-theme.mjs`**(完整文件)
|
||||
- [x] **Step 1: 写失败测试 `tests/semantic-theme.mjs`**(完整文件)
|
||||
|
||||
```js
|
||||
/**
|
||||
@@ -103,9 +105,9 @@ if (failed > 0) {
|
||||
console.log('semantic-theme: all ok')
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 跑测试确认失败**:`node tests/semantic-theme.mjs` → 期望 `ERR_MODULE_NOT_FOUND`(semantic.ts 不存在)。
|
||||
- [x] **Step 2: 跑测试确认失败**:`node tests/semantic-theme.mjs` → 期望 `ERR_MODULE_NOT_FOUND`(semantic.ts 不存在)。
|
||||
|
||||
- [ ] **Step 3: 写 `src/renderer/src/theme/semantic.ts`**(完整文件;**零 import**)
|
||||
- [x] **Step 3: 写 `src/renderer/src/theme/semantic.ts`**(完整文件;**零 import**)
|
||||
|
||||
```ts
|
||||
/**
|
||||
@@ -253,9 +255,9 @@ export function semanticVars(colors: ThemeLike, light: boolean): Record<string,
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 跑测试确认通过**:`node tests/semantic-theme.mjs` → `semantic-theme: all ok`。
|
||||
- [x] **Step 4: 跑测试确认通过**:`node tests/semantic-theme.mjs` → `semantic-theme: all ok`。
|
||||
|
||||
- [ ] **Step 5: 重构 `chrome.ts`**
|
||||
- [x] **Step 5: 重构 `chrome.ts`**
|
||||
- 删除 chrome.ts 内的 `parseHex/luminance/toHex/mix/contrast` 本地实现与 `chromeIsLight` 模块级变量,改为:
|
||||
```ts
|
||||
import { isLightTheme, luminance, mix, semanticVars, contrast } from './semantic'
|
||||
@@ -273,11 +275,11 @@ export function semanticVars(colors: ThemeLike, light: boolean): Record<string,
|
||||
- `applyTabAccent` 里原 `chromeIsLight` 判断改用 `isLightTheme` 需要背景色——改为在 `applyChromeTheme` 里把 `light` 存进模块级 `let chromeIsLight`(保留原变量名,只是赋值来源换成 `isLightTheme(colors.background)`),`applyTabAccent` 逻辑不变。
|
||||
- `tabLabel` 助手保留(仍用 contrast)。
|
||||
|
||||
- [ ] **Step 6: 接入测试链**:`package.json` 的 `"test"` 值末尾(`node tests/refit-throttle.mjs` 之后)追加 ` && node tests/semantic-theme.mjs`。
|
||||
- [x] **Step 6: 接入测试链**:`package.json` 的 `"test"` 值末尾(`node tests/refit-throttle.mjs` 之后)追加 ` && node tests/semantic-theme.mjs`。
|
||||
|
||||
- [ ] **Step 7: 验证**:`npm run typecheck` 通过;`node tests/semantic-theme.mjs` 通过。
|
||||
- [x] **Step 7: 验证**:`npm run typecheck` 通过;`node tests/semantic-theme.mjs` 通过。
|
||||
|
||||
- [ ] **Step 8: 提交**
|
||||
- [x] **Step 8: 提交**
|
||||
|
||||
```bash
|
||||
git add src/renderer/src/theme/semantic.ts src/renderer/src/theme/chrome.ts tests/semantic-theme.mjs package.json
|
||||
@@ -298,7 +300,7 @@ git commit -m "feat(theme): 语义变量层基座 --ot-*(深色锁现值/浅
|
||||
|
||||
**Interfaces:** 消费 T1 的 `isLightTheme`(`@renderer/theme/semantic`)。
|
||||
|
||||
- [ ] **Step 1: 写失败测试 `tests/theme-contrast.mjs`**(完整文件)
|
||||
- [x] **Step 1: 写失败测试 `tests/theme-contrast.mjs`**(完整文件)
|
||||
|
||||
```js
|
||||
/**
|
||||
@@ -349,9 +351,9 @@ if (failed > 0) {
|
||||
console.log('theme-contrast: all ok')
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 跑测试确认失败**:`node tests/theme-contrast.mjs` → 期望多条 FAIL(现三套浅色主题大量 <4.5)。
|
||||
- [x] **Step 2: 跑测试确认失败**:`node tests/theme-contrast.mjs` → 期望多条 FAIL(现三套浅色主题大量 <4.5)。
|
||||
|
||||
- [ ] **Step 3: 替换 `src/shared/theme.ts` 三套浅色主题**(算法生成:`mix(原值,#000,t)` 步进 0.05 至 ≥4.5,色相保留;`white/brightWhite` 同规则自动落到中灰)。**整体替换 `colors` 对象**,未列字段不变:
|
||||
- [x] **Step 3: 替换 `src/shared/theme.ts` 三套浅色主题**(算法生成:`mix(原值,#000,t)` 步进 0.05 至 ≥4.5,色相保留;`white/brightWhite` 同规则自动落到中灰)。**整体替换 `colors` 对象**,未列字段不变:
|
||||
|
||||
github-light(`background` 柔化 + `cursorAccent` 跟随):
|
||||
```
|
||||
@@ -377,9 +379,9 @@ brightBlack: '#677171', brightRed: '#c14715', brightGreen: '#586e75', brightYell
|
||||
brightBlue: '#626f71', brightMagenta: '#6166b0', brightCyan: '#677171', brightWhite: '#726f66'
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 跑测试确认通过**:`node tests/theme-contrast.mjs` → all ok。
|
||||
- [x] **Step 4: 跑测试确认通过**:`node tests/theme-contrast.mjs` → all ok。
|
||||
|
||||
- [ ] **Step 5: `TerminalView.tsx` 两处动态选项**
|
||||
- [x] **Step 5: `TerminalView.tsx` 两处动态选项**
|
||||
顶部导入:`import { isLightTheme } from '@renderer/theme/semantic'`。
|
||||
构造 effect(约 :855-873,`theme: withChromeColors(...)` 一带)改为:
|
||||
```ts
|
||||
@@ -402,7 +404,7 @@ brightBlue: '#626f71', brightMagenta: '#6166b0', brightCyan: '#677171', brightWh
|
||||
term.options.minimumContrastRatio = bgOpts.image || lightTheme ? 4.5 : 1
|
||||
```
|
||||
|
||||
- [ ] **Step 6: `main.tsx` ThemedConfigProvider**
|
||||
- [x] **Step 6: `main.tsx` ThemedConfigProvider**
|
||||
导入改为 `import { isLightTheme, mix } from '@renderer/theme/semantic'`(先确认现 `mix` 的导入源并迁移;若 chrome.ts 的兼容 re-export 仍被别处使用则保留)。函数体:
|
||||
```tsx
|
||||
const colors = getThemeById(themeId, customThemes).colors
|
||||
@@ -415,11 +417,11 @@ brightBlue: '#626f71', brightMagenta: '#6166b0', brightCyan: '#677171', brightWh
|
||||
colorBgElevated: mix(colors.background, colors.foreground, 0.1) ?? (light ? '#f5f5f5' : '#252525'),
|
||||
```
|
||||
|
||||
- [ ] **Step 7: `ErrorBoundary.tsx`** 两处内联色:`color: '#cccccc'` → `'var(--ot-text, #cccccc)'`;`color: '#ff6b6b'` → `'var(--ot-danger-fg, #ff6b6b)'`。
|
||||
- [x] **Step 7: `ErrorBoundary.tsx`** 两处内联色:`color: '#cccccc'` → `'var(--ot-text, #cccccc)'`;`color: '#ff6b6b'` → `'var(--ot-danger-fg, #ff6b6b)'`。
|
||||
|
||||
- [ ] **Step 8: 接链 + 验证**:`package.json` test 链末尾追加 ` && node tests/theme-contrast.mjs`;`npm run typecheck` + `node tests/theme-contrast.mjs` + `node tests/semantic-theme.mjs` 全过。
|
||||
- [x] **Step 8: 接链 + 验证**:`package.json` test 链末尾追加 ` && node tests/theme-contrast.mjs`;`npm run typecheck` + `node tests/theme-contrast.mjs` + `node tests/semantic-theme.mjs` 全过。
|
||||
|
||||
- [ ] **Step 9: 提交**
|
||||
- [x] **Step 9: 提交**
|
||||
|
||||
```bash
|
||||
git add src/shared/theme.ts src/renderer/src/terminal/TerminalView.tsx src/renderer/src/main.tsx src/renderer/src/ErrorBoundary.tsx tests/theme-contrast.mjs package.json
|
||||
@@ -454,7 +456,7 @@ git commit -m "fix(theme): 浅色主题 ANSI 调色板重做到 WCAG AA + MCR/bo
|
||||
|
||||
**铁律例外(terminal.css,保持硬编码):** `:34` 的 `#0d1117`(`.has-bg-image .terminal-view-dock` 深底衬,AGENTS.md 铁律①);`:390` 的 `#000`(`.term-bg-dim` 背景图压暗层,铁律③);`:409-410` 的 `rgba(0,0,0,0.55)` + `#fff`(`.term-dead-mask` 死终端遮罩——有意为之的深色 scrim,浅色主题下同样成立);所有 `#00000000` 透明色。
|
||||
|
||||
- [ ] **Step 1: global.css**
|
||||
- [x] **Step 1: global.css**
|
||||
`:root` 块(:3-15)现有 `--chrome-*` 静态兜底**保留不动**,其后追加(暗色默认, chrome.ts 启动后覆盖):
|
||||
```css
|
||||
--ot-text: #cccccc;
|
||||
@@ -481,13 +483,13 @@ git commit -m "fix(theme): 浅色主题 ANSI 调色板重做到 WCAG AA + MCR/bo
|
||||
```
|
||||
`:35/:48` 滚动条 → `var(--ot-scrollbar, rgba(125, 145, 170, 0.5))`;`:56` → `var(--ot-scrollbar-active, rgba(150, 172, 200, 0.7))`;`:137 #ff6b6b` → `var(--ot-danger-fg, #f85149)`。
|
||||
|
||||
- [ ] **Step 2: terminal.css 逐行替换**(除铁律例外外 0 硬编码残留):
|
||||
- [x] **Step 2: terminal.css 逐行替换**(除铁律例外外 0 硬编码残留):
|
||||
`:43/:55` 滚动条、`:63` active、`:150 rgba(0,0,0,.4)` → `--ot-shadow`、`:213` 深色面板底 → `--ot-surface-raised`、`:237 #8a8a8a` → `--ot-text-muted`、`:243 #ffffff` + `:244` 白色半透明 → `--ot-text` + `--ot-hover`、`:281` → `--ot-shadow`、`:311 rgba(59,142,234,.18)` → `--ot-focus-ring`、`:312 #ffffff` → `--ot-text`、`:330 #9a9a9a` → `--ot-text-muted`、`:331` → `--ot-hover`、`:337` → `--ot-text-muted`、`:419`(`.term-dead-btn` 的 `#fff`,底是 `--chrome-accent`)→ `var(--tab-accent-fg, #fff)`;`:409/:410` 见上方铁律例外保留。
|
||||
注意:**只改颜色声明值,不动选择器/布局/盒模型**;替换后逐条目检上下文确保语义正确(尤其 :409-419 一带)。
|
||||
|
||||
- [ ] **Step 3: 验证**:`npm run typecheck`;对两文件跑残留扫描(见 §10 命令)应为 0(铁律例外除外);`node tests/semantic-theme.mjs` 仍过。
|
||||
- [x] **Step 3: 验证**:`npm run typecheck`;对两文件跑残留扫描(见 §10 命令)应为 0(铁律例外除外);`node tests/semantic-theme.mjs` 仍过。
|
||||
|
||||
- [ ] **Step 4: 提交**
|
||||
- [x] **Step 4: 提交**
|
||||
|
||||
```bash
|
||||
git add src/renderer/src/global.css src/renderer/src/terminal/terminal.css
|
||||
@@ -505,17 +507,17 @@ git commit -m "refactor(theme): global/terminal 硬编码颜色收敛进 --ot-*
|
||||
|
||||
**Interfaces:** 同 T3 判定表与兜底规则。
|
||||
|
||||
- [ ] **Step 1: workspace.css**(22 处,逐行):
|
||||
- [x] **Step 1: workspace.css**(22 处,逐行):
|
||||
`:82 #1f1f1f` 实底 → `var(--ot-surface-raised, rgba(24, 26, 31, 0.85))`(若该选择器是弹层/工具条;读上下文,若是静态面板底改用 `--ot-surface`);`:85` → `--ot-shadow`;`:96 rgba(255,255,255,.85)` 文本 → `--ot-text`;`:105 #ffffff` → `--ot-text`;`:180 --dv-sash-color` → `var(--ot-border, rgba(148, 163, 184, 0.28))`;`:181 --dv-active-sash-color` → `var(--ot-info, rgba(59, 142, 234, 0.75))`;`:279/:342/:556/:562/:661/:669/:725/:739 #8a8a8a` → `--ot-text-muted`;`:285 #6a6a6a` → `--ot-text-muted`;`:347 #ffffff` → `--ot-text`;`:348` 白色半透明 .1 → `--ot-hover`;`:705 #ff4d4f` → `--ot-danger-fg`;`:750 #4cc38a` → `--ot-success-fg`;`:751 rgba(76,195,138,.14)` → `color-mix(in srgb, var(--ot-success, #3fb950) 14%, transparent)`;`:754 #6aa9ff` → `--ot-info-fg`;`:755 rgba(106,169,255,.14)` → `color-mix(in srgb, var(--ot-info, #58a6ff) 14%, transparent)`。
|
||||
|
||||
- [ ] **Step 2: connections.css**(26 处,逐行):
|
||||
- [x] **Step 2: connections.css**(26 处,逐行):
|
||||
所有 `#8a8a8a`(7 处)/`#9a9a9a`(3 处)/`#6a6a6a`(2 处) → `--ot-text-muted`;`:74/:147/:303/:374 rgba(255,255,255,.08)` 背景 → `--ot-hover`;`:81 rgba(255,255,255,.05)` 边框 → `--ot-border`;`:189` 边框 → `--ot-border`;`:166 #ffffff` + `:167` 白色半透明 .1 → `--ot-text` + `--ot-hover`;`:266 #ffffff` → `--ot-text`;`:196 rgba(255,255,255,.55)`、`:210 rgba(255,255,255,.88)`、`:215 rgba(255,255,255,.45)` ——**先读上下文**:若文本在语义色徽章/强调底上 → 对应 `--ot-*-fg`;普通底上 → `--ot-text-secondary`/`--ot-text-muted`;`:248/:64/:131 #cf6679` → `--ot-danger-fg`;`:335 rgba(121,121,121,.45)` 滚动条/滑块底 → `--ot-scrollbar`。
|
||||
|
||||
- [ ] **Step 3: commands.css**(8 处):`#8a8a8a`(5 处) → `--ot-text-muted`;`#9a9a9a` → `--ot-text-muted`;`#cf6679`(2 处,含 `!important`) → `var(--ot-danger-fg, #cf6679) !important`(保留 !important);`#f85149` → `--ot-danger-fg`。
|
||||
- [x] **Step 3: commands.css**(8 处):`#8a8a8a`(5 处) → `--ot-text-muted`;`#9a9a9a` → `--ot-text-muted`;`#cf6679`(2 处,含 `!important`) → `var(--ot-danger-fg, #cf6679) !important`(保留 !important);`#f85149` → `--ot-danger-fg`。
|
||||
|
||||
- [ ] **Step 4: 验证**:typecheck + 残留扫描 0 + 语义测试过。
|
||||
- [x] **Step 4: 验证**:typecheck + 残留扫描 0 + 语义测试过。
|
||||
|
||||
- [ ] **Step 5: 提交**
|
||||
- [x] **Step 5: 提交**
|
||||
|
||||
```bash
|
||||
git add src/renderer/src/workspace/workspace.css src/renderer/src/connections/connections.css src/renderer/src/commands/commands.css
|
||||
@@ -538,11 +540,11 @@ git commit -m "refactor(theme): 工作区/连接侧栏/命令面板颜色语义
|
||||
|
||||
**sftp 铁律(AGENTS.md):** 不动 grid 模板/`--sftp-row-h` 系变量/`box-sizing: border-box`/`.ant-dropdown-trigger` 相关选择器——只改颜色值。
|
||||
|
||||
- [ ] **Step 1: sftp.css**(18 处):`#8a8a8a`(10 处)/`#9a9a9a`/`#6a6a6a` → `--ot-text-muted`;`#e3b341` → `--ot-warning-fg`;`#3fb950`(2 处) → `--ot-success-fg`;`#f85149`(4 处) → `--ot-danger-fg`;`:155 rgba(59,142,234,.18)` → `--ot-focus-ring`;`:342` → `--ot-shadow`。
|
||||
- [x] **Step 1: sftp.css**(18 处):`#8a8a8a`(10 处)/`#9a9a9a`/`#6a6a6a` → `--ot-text-muted`;`#e3b341` → `--ot-warning-fg`;`#3fb950`(2 处) → `--ot-success-fg`;`#f85149`(4 处) → `--ot-danger-fg`;`:155 rgba(59,142,234,.18)` → `--ot-focus-ring`;`:342` → `--ot-shadow`。
|
||||
|
||||
- [ ] **Step 2: monitor.css**(15 处):`#8a8a8a`(9 处) → `--ot-text-muted`;`:22/:25 #f85149` → `--ot-danger-fg`;`:24 rgba(248,81,73,.12)` → `color-mix(in srgb, var(--ot-danger, #f85149) 12%, transparent)`;`:89 #3fb950` → `--ot-success-fg`;`:144 #d29922` → `--ot-warning-fg`;`:234 #58a6ff` → `--ot-info-fg`;`:238 #f0a55a` → `--ot-warning-fg`。
|
||||
- [x] **Step 2: monitor.css**(15 处):`#8a8a8a`(9 处) → `--ot-text-muted`;`:22/:25 #f85149` → `--ot-danger-fg`;`:24 rgba(248,81,73,.12)` → `color-mix(in srgb, var(--ot-danger, #f85149) 12%, transparent)`;`:89 #3fb950` → `--ot-success-fg`;`:144 #d29922` → `--ot-warning-fg`;`:234 #58a6ff` → `--ot-info-fg`;`:238 #f0a55a` → `--ot-warning-fg`。
|
||||
|
||||
- [ ] **Step 3: MonitorPanel.tsx**(7 处内联;HistoryChart 是 **canvas 绘制**——`ctx.strokeStyle/fillStyle` 赋值不解析 CSS 变量串,必须传实色):
|
||||
- [x] **Step 3: MonitorPanel.tsx**(7 处内联;HistoryChart 是 **canvas 绘制**——`ctx.strokeStyle/fillStyle` 赋值不解析 CSS 变量串,必须传实色):
|
||||
```tsx
|
||||
import { useResolvedTheme } from '@renderer/settings/store'
|
||||
import { isLightTheme, semanticVars } from '@renderer/theme/semantic'
|
||||
@@ -558,15 +560,15 @@ git commit -m "refactor(theme): 工作区/连接侧栏/命令面板颜色语义
|
||||
替换:usage 档位函数(约 :110-112)返回值 → `vars.dangerFg`/`vars.warningFg`/`vars.successFg`(**该函数若在组件外,移入组件或改为接收 vars 参数**);`:237` `lineColor={vars.successFg} fillColor={withAlpha(vars.success, 0.15)}`;`:249` `color={vars.warningFg}`;`:298` `lineColor={vars.infoFg} fillColor={withAlpha(vars.info, 0.15)}`;`:299` `lineColor={vars.warningFg} fillColor={withAlpha(vars.warning, 0.15)}`。
|
||||
效果:图表色随主题明暗自动达标(浅色吃加深变体),文件内不再有颜色字面量、无需白名单。
|
||||
|
||||
- [ ] **Step 4: settings.css**(11 处):`:203/:208 #fff` → 读上下文:强调底按钮上的文字 → `var(--tab-accent-fg, #fff)`,否则 `--ot-text`;`:302/:303 #3b8eea` → `--ot-focus-ring`(保留 inset box-shadow 结构);`:336 rgba(59,142,234,.2)` → `--ot-focus-ring`;`:513 #3fd06a` → `--ot-success-fg`;`:517/:603 #f85149` → `--ot-danger-fg`;`:599 #3fb950` → `--ot-success-fg`;`:630 #d29922` → `--ot-warning-fg`;`:49` 注释里的 `#ffffff` 不动(T6 扫描器剥注释)。
|
||||
- [x] **Step 4: settings.css**(11 处):`:203/:208 #fff` → 读上下文:强调底按钮上的文字 → `var(--tab-accent-fg, #fff)`,否则 `--ot-text`;`:302/:303 #3b8eea` → `--ot-focus-ring`(保留 inset box-shadow 结构);`:336 rgba(59,142,234,.2)` → `--ot-focus-ring`;`:513 #3fd06a` → `--ot-success-fg`;`:517/:603 #f85149` → `--ot-danger-fg`;`:599 #3fb950` → `--ot-success-fg`;`:630 #d29922` → `--ot-warning-fg`;`:49` 注释里的 `#ffffff` 不动(T6 扫描器剥注释)。
|
||||
|
||||
- [ ] **Step 5: highlight.css**(6 处):`#f85149`(4 处 color) → `--ot-danger-fg`;`:296/:301 border-color #f85149` → `--ot-danger`(描边用基色);`:302 rgba(248,81,73,.2)` → `color-mix(in srgb, var(--ot-danger, #f85149) 20%, transparent)`。
|
||||
- [x] **Step 5: highlight.css**(6 处):`#f85149`(4 处 color) → `--ot-danger-fg`;`:296/:301 border-color #f85149` → `--ot-danger`(描边用基色);`:302 rgba(248,81,73,.2)` → `color-mix(in srgb, var(--ot-danger, #f85149) 20%, transparent)`。
|
||||
|
||||
- [ ] **Step 6: lock.css**(2 处):`:40` → `--ot-shadow`;`:73 #f85149` → `--ot-danger-fg`。锁屏遮罩底色如为深色实底(读 lock.css 全文确认)**保持不动**(安全优先:锁屏必须不透明遮罩,z-index 4000 语义不变)。
|
||||
- [x] **Step 6: lock.css**(2 处):`:40` → `--ot-shadow`;`:73 #f85149` → `--ot-danger-fg`。锁屏遮罩底色如为深色实底(读 lock.css 全文确认)**保持不动**(安全优先:锁屏必须不透明遮罩,z-index 4000 语义不变)。
|
||||
|
||||
- [ ] **Step 7: 验证**:typecheck + 残留扫描(白名单:MonitorPanel.tsx 若走锚点路线的两行 fillColor)+ 语义测试。
|
||||
- [x] **Step 7: 验证**:typecheck + 残留扫描(白名单:MonitorPanel.tsx 若走锚点路线的两行 fillColor)+ 语义测试。
|
||||
|
||||
- [ ] **Step 8: 提交**
|
||||
- [x] **Step 8: 提交**
|
||||
|
||||
```bash
|
||||
git add src/renderer/src/sftp/sftp.css src/renderer/src/monitor/monitor.css src/renderer/src/monitor/MonitorPanel.tsx src/renderer/src/settings/settings.css src/renderer/src/settings/highlight.css src/renderer/src/lock/lock.css
|
||||
@@ -585,7 +587,7 @@ git commit -m "refactor(theme): SFTP/监控/设置/高亮/锁屏面板颜色语
|
||||
|
||||
**Interfaces:** 无新接口;消费 T1-T5 的最终状态。
|
||||
|
||||
- [ ] **Step 1: 写 `tests/css-theme-vars.mjs`**(完整文件;先跑一次看当前残留并按实况填 `WHITELIST`,基线必须为「白名单外 0」)
|
||||
- [x] **Step 1: 写 `tests/css-theme-vars.mjs`**(完整文件;先跑一次看当前残留并按实况填 `WHITELIST`,基线必须为「白名单外 0」)
|
||||
|
||||
```js
|
||||
/**
|
||||
@@ -685,11 +687,11 @@ if (violations > 0) {
|
||||
console.log('css-theme-vars: all ok')
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 基线清零**:跑 `node tests/css-theme-vars.mjs`,对每条 RAW/FALLBACK 输出:属于遗漏清扫 → 回对应文件改掉(**不扩大白名单来逃课**;若确属主题数据/铁律 → 加进 WHITELIST 精确值并在此步骤记录理由)。目标:白名单外 0 违规。
|
||||
- [x] **Step 2: 基线清零**:跑 `node tests/css-theme-vars.mjs`,对每条 RAW/FALLBACK 输出:属于遗漏清扫 → 回对应文件改掉(**不扩大白名单来逃课**;若确属主题数据/铁律 → 加进 WHITELIST 精确值并在此步骤记录理由)。目标:白名单外 0 违规。
|
||||
|
||||
- [ ] **Step 3: 接链 + 全量验证**:`package.json` test 链末尾追加 `&& node tests/css-theme-vars.mjs`;跑 **`npm test` 全链**(typecheck + 全部测试)必须全绿。
|
||||
- [x] **Step 3: 接链 + 全量验证**:`package.json` test 链末尾追加 `&& node tests/css-theme-vars.mjs`;跑 **`npm test` 全链**(typecheck + 全部测试)必须全绿。
|
||||
|
||||
- [ ] **Step 4: AGENTS.md「主题机制」增补**(追加 bullet,保留既有内容):
|
||||
- [x] **Step 4: AGENTS.md「主题机制」增补**(追加 bullet,保留既有内容):
|
||||
|
||||
```markdown
|
||||
- 语义变量层(2026-10 主题改造):`src/renderer/src/theme/semantic.ts`(零 import 纯模块,`isLightTheme`/`mix`/`darkenToRatio`/`SEMANTIC` 锚点)→ `chrome.ts` 的 `applyChromeTheme` 随主题派生 `--ot-*` 变量并写 `color-scheme`;**深色分支锁 SEMANTIC 锚点(零回归),浅色分支 darkenToRatio 按 WCAG 派生**。CSS 一律 `var(--ot-*, 锚点兜底)`,新代码禁止再写裸颜色字面量——`node tests/css-theme-vars.mjs` fail-closed 扫描(先剥注释、忽略 var() 兜底但有受控清单,白名单按「文件+精确值」登记)
|
||||
@@ -697,9 +699,9 @@ console.log('css-theme-vars: all ok')
|
||||
- 内置主题调色板是「主题数据」不是 chrome 颜色:改浅色主题色值用统一加深算法(`darkenToRatio`),不许手挑色
|
||||
```
|
||||
|
||||
- [ ] **Step 5: docs/STATE.md 追加条目**(一行式,随仓库现有格式):主题系统 A+B+C 落地——语义变量层 + 浅色 AA 调色板 + 防护测试(semantic-theme/theme-contrast/css-theme-vars)。
|
||||
- [x] **Step 5: docs/STATE.md 追加条目**(一行式,随仓库现有格式):主题系统 A+B+C 落地——语义变量层 + 浅色 AA 调色板 + 防护测试(semantic-theme/theme-contrast/css-theme-vars)。
|
||||
|
||||
- [ ] **Step 6: 提交**
|
||||
- [x] **Step 6: 提交**
|
||||
|
||||
```bash
|
||||
git add tests/css-theme-vars.mjs package.json AGENTS.md docs/STATE.md
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# 侧栏模式按钮双击收展侧栏 Implementation Plan
|
||||
|
||||
> ⚠️ **已归档(2026-10-21)**:本计划已实施完毕并随 v1.0.31 发布,仅作历史记录,不再维护。后续工作见 [`docs/PLAN-2026-10-21-backlog.md`](../../PLAN-2026-10-21-backlog.md)。
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 图标栏的「终端工作区 / SSH 工作区」模式按钮新增双击手势:双击当前工作区按钮 = 展开/收起侧栏;单击(含双击另一个按钮)只切换工作区。
|
||||
@@ -37,7 +39,7 @@
|
||||
**Interfaces:**
|
||||
- Produces: `decideModeClick(detail: number, preGestureMode: RailMode | null, buttonMode: RailMode): ModeClickAction`,`RailMode = 'terminal' | 'ssh'`,`ModeClickAction = 'switch' | 'toggle-sidebar' | 'noop'` —— Task 2 的 `ModeRailButtons` 消费此签名。
|
||||
|
||||
- [ ] **Step 1: 先写测试 `tests/mode-rail-gesture.mjs`**
|
||||
- [x] **Step 1: 先写测试 `tests/mode-rail-gesture.mjs`**
|
||||
|
||||
```js
|
||||
/**
|
||||
@@ -79,12 +81,12 @@ if (failed > 0) {
|
||||
console.log('mode-rail-gesture: all ok')
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 运行测试确认失败**
|
||||
- [x] **Step 2: 运行测试确认失败**
|
||||
|
||||
Run: `node tests/mode-rail-gesture.mjs`
|
||||
Expected: FAIL —— `ERR_MODULE_NOT_FOUND`(模块尚不存在)
|
||||
|
||||
- [ ] **Step 3: 实现 `src/renderer/src/workspace/modeRailGesture.ts`**
|
||||
- [x] **Step 3: 实现 `src/renderer/src/workspace/modeRailGesture.ts`**
|
||||
|
||||
```ts
|
||||
/**
|
||||
@@ -112,12 +114,12 @@ export function decideModeClick(detail: number, preGestureMode: RailMode | null,
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 运行测试确认通过**
|
||||
- [x] **Step 4: 运行测试确认通过**
|
||||
|
||||
Run: `node tests/mode-rail-gesture.mjs`
|
||||
Expected: PASS —— 10 行 `ok`,末尾 `mode-rail-gesture: all ok`,exit 0
|
||||
|
||||
- [ ] **Step 5: 登记测试到 package.json**
|
||||
- [x] **Step 5: 登记测试到 package.json**
|
||||
|
||||
`package.json:16` 的 `"test"` 链尾,在 `node tests/link-rules.mjs` 之后追加:
|
||||
|
||||
@@ -127,7 +129,7 @@ Expected: PASS —— 10 行 `ok`,末尾 `mode-rail-gesture: all ok`,exit 0
|
||||
|
||||
(新测试是零 import 直连型,不依赖 bundle,位置在链尾即可。)
|
||||
|
||||
- [ ] **Step 6: 同步 AGENTS.md 测试清单(项目规则:改 AGENTS.md 所载内容须同步)**
|
||||
- [x] **Step 6: 同步 AGENTS.md 测试清单(项目规则:改 AGENTS.md 所载内容须同步)**
|
||||
|
||||
两处编辑:
|
||||
1. 「测试」条目里 `可离线运行的 35 个测试` → `36 个测试`,枚举末尾 `font-stack、link-rules;` → `font-stack、link-rules、mode-rail-gesture;`
|
||||
@@ -141,7 +143,7 @@ Expected: PASS —— 10 行 `ok`,末尾 `mode-rail-gesture: all ok`,exit 0
|
||||
**Interfaces:**
|
||||
- Consumes: Task 1 的 `decideModeClick` / `ModeClickAction`;既有 `useWorkspaceModeStore`(含 `.getState()`)、`WorkspaceMode` 类型(已在 :42 导入)、`useRef`(已在用)、IconRail 已有的 `onToggleSidebar` prop。
|
||||
|
||||
- [ ] **Step 1: 加 import**
|
||||
- [x] **Step 1: 加 import**
|
||||
|
||||
在 Workspace.tsx:43 `import { useWorkspaceModeStore } from './workspaceModeStore'` 之后加:
|
||||
|
||||
@@ -149,7 +151,7 @@ Expected: PASS —— 10 行 `ok`,末尾 `mode-rail-gesture: all ok`,exit 0
|
||||
import { decideModeClick } from './modeRailGesture'
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 替换 ModeRailButtons(:1545–1564 整段)**
|
||||
- [x] **Step 2: 替换 ModeRailButtons(:1545–1564 整段)**
|
||||
|
||||
旧代码(原样,供 Edit 的 old_string):
|
||||
|
||||
@@ -217,21 +219,21 @@ function ModeRailButtons({ onToggleSidebar }: { onToggleSidebar: () => void }):
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: IconRail 内透传(:1532)**
|
||||
- [x] **Step 3: IconRail 内透传(:1532)**
|
||||
|
||||
`<ModeRailButtons />` → `<ModeRailButtons onToggleSidebar={onToggleSidebar} />`
|
||||
|
||||
- [ ] **Step 4: 渲染层类型检查**
|
||||
- [x] **Step 4: 渲染层类型检查**
|
||||
|
||||
Run: `npx tsc --noEmit -p tsconfig.web.json`
|
||||
Expected: 无输出,exit 0
|
||||
|
||||
- [ ] **Step 5: 全量测试**
|
||||
- [x] **Step 5: 全量测试**
|
||||
|
||||
Run: `npm test`
|
||||
Expected: pretest 类型检查 + 36 个离线测试全部通过,exit 0
|
||||
|
||||
- [ ] **Step 6: 自审 diff**
|
||||
- [x] **Step 6: 自审 diff**
|
||||
|
||||
Run: `git diff --stat` + `git status`
|
||||
Expected: 恰好 3 改 2 新:`Workspace.tsx`、`package.json`、`AGENTS.md` 修改;`modeRailGesture.ts`、`tests/mode-rail-gesture.mjs` 新增(共 5 个路径;本计划文档自身 untracked 属预期,无其它文件);无 CSS / i18n 词典改动
|
||||
@@ -1,7 +1,9 @@
|
||||
# 主题系统全面改造(A+B+C)设计文档
|
||||
|
||||
> ⚠️ **已归档(2026-10-21)**:本设计已随「主题系统全面改造」实施完毕并发布,仅作历史记录,不再维护。后续工作见 [`docs/PLAN-2026-10-21-backlog.md`](../../PLAN-2026-10-21-backlog.md)。
|
||||
|
||||
- 日期:2026-10-16
|
||||
- 状态:待交叉审核(未实现)
|
||||
- 状态:已实施(已归档)
|
||||
- 范围:浅色主题可读性(A)+ 底色柔化(B)+ 全量 CSS/TSX 颜色变量化(C)
|
||||
- 关联:`docs/ROADMAP.md`、AGENTS.md「主题机制」章节(M4 需同步更新)
|
||||
|
||||
|
||||
@@ -33,6 +33,18 @@ const SKIP_GH = process.argv.includes('--skip-github')
|
||||
const SKIP_GITEA = process.argv.includes('--skip-gitea')
|
||||
const SKIP_GITEA_RELEASE = process.argv.includes('--skip-gitea-release')
|
||||
const CHANNEL_ONLY = process.argv.includes('--channel-only')
|
||||
// Rejected before anything runs (no network, no uploads): --channel-only exists
|
||||
// to republish the Gitea update channel, so pairing it with --skip-gitea asks
|
||||
// for two opposite things. Without this check the CHANNEL_ONLY branch returns
|
||||
// before the SKIP_GITEA warning is ever printed and the channel silently
|
||||
// uploads anyway. (--skip-gitea-release is fine: channel-only never creates a
|
||||
// release object.)
|
||||
if (CHANNEL_ONLY && SKIP_GITEA) {
|
||||
console.error('*** --channel-only and --skip-gitea are contradictory: --channel-only exists to ***')
|
||||
console.error('*** publish the Gitea update channel. To republish just the channel, use ***')
|
||||
console.error('*** --channel-only alone; to skip Gitea entirely, drop --channel-only. ***')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
/** Strip one pair of matching quotes. A .env quotes its values for the shell
|
||||
that would source it, so the quotes are syntax, not part of the token. */
|
||||
|
||||
@@ -204,11 +204,19 @@ function coerceRule(value: unknown, index: number, warnings: Warnings): Highligh
|
||||
* Preset patterns shipped by earlier versions, keyed by rule id. A built-in rule
|
||||
* whose pattern is still one of these was never edited by the user, so the
|
||||
* load-time refresh below may swap in the current preset for it.
|
||||
*
|
||||
* MAINTENANCE RULE: whenever a built-in preset's pattern changes, the pattern it
|
||||
* replaces MUST be appended here in the same change — an unregistered old
|
||||
* pattern means every install that shipped it keeps the stale pattern forever
|
||||
* (the refresh only upgrades patterns it recognizes). The entries below are the
|
||||
* complete set of historically changed presets, extracted from git history.
|
||||
*/
|
||||
const LEGACY_BUILTIN_PATTERNS: Record<string, string[]> = {
|
||||
perm: ['^[-dlbcps][rwxstST-]{9}'],
|
||||
okstate: ['\\b(SUCCESS|PASS|OK|DONE|PASSED)\\b'],
|
||||
badstate: ['\\b(FAILED|ERROR|FAIL|FATAL|WARN|WARNING|DENIED)\\b']
|
||||
badstate: ['\\b(FAILED|ERROR|FAIL|FATAL|WARN|WARNING|DENIED)\\b'],
|
||||
// v0.x preset, widened in 237265e (ftp/www alternation)
|
||||
url: ['https?://[^\\s]+']
|
||||
}
|
||||
|
||||
/** Built-in ids of the preset set as it stood before `warnstate`/`danger` existed. */
|
||||
|
||||
+38
-13
@@ -76,12 +76,24 @@ let eventsWired = false
|
||||
*/
|
||||
let checkInFlight: Promise<UpdateState> | undefined
|
||||
let downloadInFlight: Promise<void> | undefined
|
||||
/** The periodic check timer; reassigned on every settings save. */
|
||||
/** The periodic check timer; rebuilt only when the configured interval changes. */
|
||||
let intervalTimer: NodeJS.Timeout | undefined
|
||||
/** The interval the current `intervalTimer` was armed with, so a settings save
|
||||
* that does not touch the interval leaves the countdown running. */
|
||||
let scheduledIntervalHours: number | undefined
|
||||
/** Once per run: the startup check is armed the first time the schedule is
|
||||
* applied, and a later settings save must not schedule a second one. */
|
||||
let startupArmed = false
|
||||
|
||||
/**
|
||||
* Test seam only: the arming is per-run state with no reset path in the app
|
||||
* itself. The self-test replays "the first apply of a run" with different
|
||||
* startup-toggle states, which a single process cannot do otherwise.
|
||||
*/
|
||||
export function __resetStartupArmForTests(): void {
|
||||
startupArmed = false
|
||||
}
|
||||
|
||||
/**
|
||||
* What has already been done for ONE release, so an app left running for days
|
||||
* does not deal with the same version over and over: every 4 hours the periodic
|
||||
@@ -517,34 +529,40 @@ export function configureAutoUpdater(): void {
|
||||
|
||||
// The startup check and the periodic one are armed by applyUpdateSchedule
|
||||
// (wired in index.ts through settingsStore's injected applier) instead of
|
||||
// here: a settings save must re-arm the schedule, not add a second timer.
|
||||
// here: a settings save feeds the same applier, which rebuilds the timer only
|
||||
// when the interval changed.
|
||||
}
|
||||
|
||||
/**
|
||||
* Arm (or re-arm) the update schedule from the current settings. Called by
|
||||
* settingsStore's injected applier on startup and after every settings save, so
|
||||
* it must be idempotent: the previous interval timer is dropped first, and the
|
||||
* startup check is armed at most once per run — a settings save two seconds into
|
||||
* the run must not schedule a second "startup" check.
|
||||
* it must be idempotent:
|
||||
*
|
||||
* - the startup check is armed at most once per run, on the process's FIRST
|
||||
* call — and the arming is consumed even when the user left 「启动时自动检
|
||||
* 查」off, so enabling it mid-session arms only the periodic timer (a
|
||||
* "startup" check hours into the session would contradict the label);
|
||||
* - the periodic timer is rebuilt only when the configured interval actually
|
||||
* changes. Every settings save re-runs this function, and an unconditional
|
||||
* rebuild would reset the countdown on keystroke-level writes (color
|
||||
* pickers, text fields) — a 4h check could be postponed forever.
|
||||
*
|
||||
* Dev builds schedule nothing (there is no feed), which is also why the startup
|
||||
* flag is not set on the way out.
|
||||
*/
|
||||
export function applyUpdateSchedule(system: SystemSettings): void {
|
||||
if (intervalTimer) {
|
||||
clearInterval(intervalTimer)
|
||||
intervalTimer = undefined
|
||||
}
|
||||
if (!app.isPackaged) return
|
||||
|
||||
// Startup check only when the user left it enabled (设置 → 关于). Left alone by
|
||||
// later calls, and unref'd like every other timer here: a pending check must
|
||||
// never keep the app (or a test process) alive.
|
||||
if (!startupArmed && system.autoCheckUpdate !== false) {
|
||||
// Startup check only when the user left it enabled (设置 → 关于). Unref'd like
|
||||
// every other timer here: a pending check must never keep the app (or a test
|
||||
// process) alive.
|
||||
if (!startupArmed) {
|
||||
startupArmed = true
|
||||
if (system.autoCheckUpdate !== false) {
|
||||
const startup = setTimeout(() => void runCheck('startup'), budgets.startupDelay)
|
||||
startup.unref?.()
|
||||
}
|
||||
}
|
||||
|
||||
// 0 = never: the startup and the manual check are all that is left. Any other
|
||||
// value is a whitelisted interval (settingsStore sanitizes it); the fallback
|
||||
@@ -552,11 +570,18 @@ export function applyUpdateSchedule(system: SystemSettings): void {
|
||||
const hours = isUpdateCheckInterval(system.updateCheckIntervalHours)
|
||||
? system.updateCheckIntervalHours
|
||||
: FALLBACK_CHECK_INTERVAL_HOURS
|
||||
if (hours !== scheduledIntervalHours) {
|
||||
if (intervalTimer) {
|
||||
clearInterval(intervalTimer)
|
||||
intervalTimer = undefined
|
||||
}
|
||||
scheduledIntervalHours = hours
|
||||
if (hours > 0) {
|
||||
const timer = setInterval(() => void runCheck('scheduled'), hours * budgets.hour)
|
||||
timer.unref?.()
|
||||
intervalTimer = timer
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-download switched on while an update was already found: start now, the
|
||||
// next tick could be hours away.
|
||||
|
||||
@@ -493,6 +493,15 @@ console.log('[preset refresh]')
|
||||
okstate?.priority === 10 && okstate?.color.fg === '#123456' && okstate?.enabled === true,
|
||||
'a refreshed rule keeps the priority, colour and switch the user left it at'
|
||||
)
|
||||
// The very first url preset (https?:// only, widened by ftp/www alternation
|
||||
// later) is a registered legacy pattern too — an early install upgrades
|
||||
// exactly like okstate above. This regressed silently before the url entry
|
||||
// was added to LEGACY_BUILTIN_PATTERNS.
|
||||
const url = upgraded.find((r) => r.id === 'url')
|
||||
ok(
|
||||
typeof url?.pattern === 'string' && url.pattern.includes('ftp://') && url.pattern !== V1.url,
|
||||
'the early url preset (https?:// only) is refreshed to the current one'
|
||||
)
|
||||
const addedSince = [
|
||||
'warnstate',
|
||||
'danger',
|
||||
@@ -524,6 +533,14 @@ console.log('[preset refresh]')
|
||||
'a hand-edited built-in rule is left alone'
|
||||
)
|
||||
|
||||
// The legacy url pattern on a NON-builtin rule must not be refreshed either.
|
||||
const nonBuiltin = v1Set().map((r) => (r.id === 'url' ? { ...r, builtin: false } : r))
|
||||
const afterNonBuiltin = (await roundTrip({ highlightRules: nonBuiltin })).highlightRules
|
||||
ok(
|
||||
afterNonBuiltin.find((r) => r.id === 'url')?.pattern === V1.url,
|
||||
'the legacy url pattern on a non-builtin rule is left alone'
|
||||
)
|
||||
|
||||
// ...and so must a rule the user deleted: it must not come back.
|
||||
const pruned = v1Set().filter((r) => r.id !== 'numbers')
|
||||
const afterPrune = (await roundTrip({ highlightRules: pruned })).highlightRules
|
||||
|
||||
@@ -591,9 +591,10 @@ console.log('install')
|
||||
* release at a time, is dropped by a manual check and is replaced wholesale
|
||||
* when a different version shows up — so each scenario uses its own version
|
||||
* number (7.x and 9.9.9 are spent by section 7), or starts from a manual check;
|
||||
* - the "startup check armed" flag is per run too: the first `applyUpdateSchedule`
|
||||
* that allows startup checks is the only one that can arm it, which is why
|
||||
* sections 10-13 run in this order.
|
||||
* - the "startup check armed" flag is consumed by the process's FIRST
|
||||
* `applyUpdateSchedule` call regardless of the startup toggle; the sections
|
||||
* below replay "the first apply of a run" through the __resetStartupArmForTests
|
||||
* seam, so their relative order no longer encodes the arming state.
|
||||
* `reset()` re-points the feeds and the check/download doubles, so those do not
|
||||
* leak between sections.
|
||||
*/
|
||||
@@ -677,6 +678,9 @@ console.log('interval 0: the periodic check is off, the startup check still runs
|
||||
ctl.checkForUpdates = recordCheck
|
||||
fetchImpl = () => Promise.resolve(errResponse(503))
|
||||
|
||||
// Replay the first apply of a run with the toggle already on (the section
|
||||
// above consumed the real one with the toggle off).
|
||||
updater.__resetStartupArmForTests()
|
||||
const from = checkLog.length
|
||||
updater.applyUpdateSchedule({ autoCheckUpdate: true, updateCheckIntervalHours: 0 })
|
||||
await sleep(150)
|
||||
@@ -686,6 +690,64 @@ console.log('interval 0: the periodic check is off, the startup check still runs
|
||||
ok(startupChecks() === 1, 'the run has seen its startup check exactly once')
|
||||
}
|
||||
|
||||
console.log('a settings save that keeps the interval does not restart the countdown')
|
||||
{
|
||||
reset()
|
||||
stub.__setPackaged(true)
|
||||
updater.setUpdateTimeouts({ startupDelay: 20, hour: 5 })
|
||||
writeSystem({ autoCheckUpdate: false, updateCheckIntervalHours: 24, autoDownloadUpdate: false })
|
||||
ctl.checkForUpdates = recordCheck
|
||||
fetchImpl = () => Promise.resolve(errResponse(503))
|
||||
|
||||
// 24h at 5ms/hour = a 120ms period. The first apply starts the countdown;
|
||||
// 50ms in — mid-period — an unrelated settings save re-runs the applier with
|
||||
// the very same interval. If that rebuilt the timer, the next tick would land
|
||||
// a full period after the re-apply (~120ms); if the countdown survives, it
|
||||
// lands ~70ms after it.
|
||||
const from = checkLog.length
|
||||
updater.applyUpdateSchedule({ autoCheckUpdate: false, updateCheckIntervalHours: 24 })
|
||||
await sleep(50)
|
||||
const reappliedAt = checkLog.length // === from: mid-period, nothing fired yet
|
||||
const reappliedWall = Date.now()
|
||||
updater.applyUpdateSchedule({ autoCheckUpdate: false, updateCheckIntervalHours: 24 })
|
||||
let guard = 0
|
||||
while (checkLog.length === reappliedAt && guard++ < 200) await sleep(5)
|
||||
const gap = Date.now() - reappliedWall
|
||||
ok(checkLog.length > reappliedAt, 'the periodic timer kept ticking across the re-apply')
|
||||
ok(gap < 100, `the countdown was not restarted (next tick ${gap}ms after the re-apply; a restart would be ~120ms)`)
|
||||
|
||||
// Leave the section clean: stop the timer so its phase cannot bleed into the
|
||||
// cadence section below.
|
||||
updater.applyUpdateSchedule({ autoCheckUpdate: false, updateCheckIntervalHours: 0 })
|
||||
}
|
||||
|
||||
console.log('enabling the startup check mid-session does not fire a startup check')
|
||||
{
|
||||
reset()
|
||||
stub.__setPackaged(true)
|
||||
updater.setUpdateTimeouts({ startupDelay: 20, hour: 5 })
|
||||
writeSystem({ autoCheckUpdate: false, updateCheckIntervalHours: 0, autoDownloadUpdate: false })
|
||||
ctl.checkForUpdates = recordCheck
|
||||
fetchImpl = () => Promise.resolve(errResponse(503))
|
||||
|
||||
// Replay a run whose FIRST apply sees the toggle off: the arming is consumed
|
||||
// silently. Flipping the toggle on later must arm only future behavior (the
|
||||
// periodic timer), never retroactively fire a "startup" check hours into the
|
||||
// session — the settings label promises a check *at startup*.
|
||||
updater.__resetStartupArmForTests()
|
||||
const before = checkLog.length
|
||||
updater.applyUpdateSchedule({ autoCheckUpdate: false, updateCheckIntervalHours: 0 })
|
||||
await sleep(60) // well past the 20ms startup delay
|
||||
ok(checkLog.length === before, 'the toggle-off first apply scheduled nothing')
|
||||
|
||||
updater.applyUpdateSchedule({ autoCheckUpdate: true, updateCheckIntervalHours: 0 })
|
||||
await sleep(80)
|
||||
ok(
|
||||
checkLog.length === before && startupChecks() === 1,
|
||||
'enabling mid-session fires no startup check (the earlier section still owns the only one)'
|
||||
)
|
||||
}
|
||||
|
||||
console.log('re-applying the schedule replaces the cadence (and 0 stops it)')
|
||||
{
|
||||
reset()
|
||||
|
||||
+1
-1
@@ -98,7 +98,7 @@ website/
|
||||
2. 下载表格里的文件大小(`.mono` 单元格,如 `109 MB`)
|
||||
3. 「近期更新」小节:新增一条 `<article>`(最新的在最前面,结构保持
|
||||
`<h3><span class="mono">版本</span><span class="rel-date">日期</span></h3>` + `<ul><li>`),
|
||||
并删掉最旧的一条以控制长度(当前保留最近 5 条:1.0.30 / 1.0.29 / 1.0.28 / 1.0.27 / 1.0.26)
|
||||
并删掉最旧的一条以控制长度(当前保留最近 5 条:1.0.31 / 1.0.30 / 1.0.29 / 1.0.28 / 1.0.27)
|
||||
4. `assets/i18n.js`:给新版本加 `changelog.<新版本>.li.N` 键(**四个语言块都要加**),
|
||||
并删掉被移除那条的 `changelog.<旧版本>.li.*` 键(同样是四个语言块)
|
||||
|
||||
|
||||
+12
-12
@@ -155,6 +155,9 @@ window.OPENTERMINAL_I18N = {
|
||||
'cl.eyebrow': 'Changelog',
|
||||
'cl.title': '近期更新',
|
||||
|
||||
'changelog.1.0.31.li.1': '双击图标栏的「终端工作区 / SSH 工作区」当前按钮,可快速展开或收起侧栏',
|
||||
'changelog.1.0.31.li.2': '单击仍然只切换工作区,双击另一个工作区按钮也只切换,侧栏状态不受影响',
|
||||
'changelog.1.0.31.li.3': '键盘(Enter / 空格)触发保持单击语义,无障碍操作不受影响',
|
||||
'changelog.1.0.30.li.1': '中文显示更准:终端启用 Unicode 11 宽度表,中文、全角符号与表格线对齐正确;设置新增「中文字体」回退槽位',
|
||||
'changelog.1.0.30.li.2': '新增自定义可点击链接规则:正则捕获组可展开为 http/https 链接,Ctrl+点击打开;高亮规则新增「按行匹配」开关',
|
||||
'changelog.1.0.30.li.3': '修复彩色 TUI 输出在高亮区段后掉色、ls -l 权限位高亮在多行下失效、含空格或括号的字体名静默失效;非法配置导致窗格崩溃会在启动时自动修复',
|
||||
@@ -171,9 +174,6 @@ window.OPENTERMINAL_I18N = {
|
||||
'changelog.1.0.27.li.2': '覆盖标签 × 按钮、右键「关闭 / 关闭其他 / 关闭右侧」与键盘 Delete / Backspace 误关——点击标签后按 Backspace 不再直接关闭',
|
||||
'changelog.1.0.27.li.3': '批量关闭整批只确认一次;开关保存后立即生效,无需重启',
|
||||
|
||||
'changelog.1.0.26.li.1': '修复拖动缩放窗口或终端高频输出时界面冻结数秒(网格重排节流 + 输出批量写入)',
|
||||
'changelog.1.0.26.li.2': '新增落盘诊断日志:默认开启,按天一个文件保留 7 天,设置 → 系统可关闭',
|
||||
'changelog.1.0.26.li.3': '反馈卡顿问题时附上当天的日志文件即可精确定位',
|
||||
|
||||
'cl.morePre': '完整日志见仓库',
|
||||
'cl.morePost': '。',
|
||||
@@ -329,6 +329,9 @@ window.OPENTERMINAL_I18N = {
|
||||
'cl.eyebrow': 'Changelog',
|
||||
'cl.title': '近期更新',
|
||||
|
||||
'changelog.1.0.31.li.1': '雙擊圖示列的「終端工作區 / SSH 工作區」目前按鈕,可快速展開或收起側欄',
|
||||
'changelog.1.0.31.li.2': '單擊仍然只切換工作區,雙擊另一個工作區按鈕也只切換,側欄狀態不受影響',
|
||||
'changelog.1.0.31.li.3': '鍵盤(Enter / 空白鍵)觸發保持單擊語義,無障礙操作不受影響',
|
||||
'changelog.1.0.30.li.1': '中文顯示更準:終端機啟用 Unicode 11 寬度表,中文、全形符號與表格線對齊正確;設定新增「中文字型」回退欄位',
|
||||
'changelog.1.0.30.li.2': '新增自訂可點擊連結規則:正規表達式捕獲組可展開為 http/https 連結,Ctrl+點擊開啟;高亮規則新增「逐行比對」開關',
|
||||
'changelog.1.0.30.li.3': '修復彩色 TUI 輸出在高亮區段後掉色、ls -l 權限位高亮在多行下失效、含空格或括號的字型名稱默默失效;非法設定導致窗格崩潰會在啟動時自動修復',
|
||||
@@ -345,9 +348,6 @@ window.OPENTERMINAL_I18N = {
|
||||
'changelog.1.0.27.li.2': '涵蓋分頁 × 按鈕、右鍵「關閉 / 關閉其他 / 關閉右側」與鍵盤 Delete / Backspace 誤關——點擊分頁後按 Backspace 不再直接關閉',
|
||||
'changelog.1.0.27.li.3': '批量關閉整批只確認一次;開關儲存後立即生效,無需重啟',
|
||||
|
||||
'changelog.1.0.26.li.1': '修復拖動縮放視窗或終端機高頻輸出時介面凍結數秒(網格重排節流 + 輸出批量寫入)',
|
||||
'changelog.1.0.26.li.2': '新增落盤診斷日誌:預設開啟,按天一個檔案保留 7 天,設定 → 系統可關閉',
|
||||
'changelog.1.0.26.li.3': '回報卡頓問題時附上當天的日誌檔案即可精確定位',
|
||||
|
||||
'cl.morePre': '完整日誌請見倉庫',
|
||||
'cl.morePost': '。',
|
||||
@@ -503,6 +503,9 @@ window.OPENTERMINAL_I18N = {
|
||||
'cl.eyebrow': 'Changelog',
|
||||
'cl.title': 'Recent updates',
|
||||
|
||||
'changelog.1.0.31.li.1': "Double-clicking the active \"Terminal workspace / SSH workspace\" button on the icon rail quickly expands or collapses the sidebar",
|
||||
'changelog.1.0.31.li.2': 'A single click still only switches workspaces; double-clicking the other workspace button also just switches, leaving the sidebar untouched',
|
||||
'changelog.1.0.31.li.3': 'Keyboard activation (Enter / Space) keeps single-click semantics, so accessibility is unaffected',
|
||||
'changelog.1.0.30.li.1': 'Better CJK rendering: the terminal now uses the Unicode 11 width table so Chinese, full-width punctuation and box-drawing characters align correctly; a CJK font fallback slot was added to Settings',
|
||||
'changelog.1.0.30.li.2': 'Added custom clickable link rules: a regex capture group can expand into an http/https link, opened with Ctrl+click; highlight rules gained a line-anchored matching switch',
|
||||
'changelog.1.0.30.li.3': 'Fixed coloured TUI output losing its colour after a highlighted span, ls -l permission bits not highlighting across wrapped lines, and font names with spaces or parentheses silently failing; panes no longer crash on invalid settings, which are repaired at startup',
|
||||
@@ -519,9 +522,6 @@ window.OPENTERMINAL_I18N = {
|
||||
'changelog.1.0.27.li.2': 'Covers the tab × button, the context menu\'s Close / Close others / Close right, and accidental Delete / Backspace — pressing Backspace after clicking a tab no longer closes it',
|
||||
'changelog.1.0.27.li.3': 'Batch closes ask once for the whole batch; switches take effect immediately, no restart needed',
|
||||
|
||||
'changelog.1.0.26.li.1': 'Fixed the UI freezing for seconds while resizing the window or under high-frequency terminal output (reflow throttling + batched writes)',
|
||||
'changelog.1.0.26.li.2': 'Added on-disk diagnostic logs: on by default, one file per day kept for 7 days, can be disabled in Settings → System',
|
||||
'changelog.1.0.26.li.3': 'Attach the day\'s log file when reporting freezes for precise diagnosis',
|
||||
|
||||
'cl.morePre': 'Full changelog in',
|
||||
'cl.morePost': '.',
|
||||
@@ -677,6 +677,9 @@ window.OPENTERMINAL_I18N = {
|
||||
'cl.eyebrow': 'Changelog',
|
||||
'cl.title': '最近の更新',
|
||||
|
||||
'changelog.1.0.31.li.1': 'アイコンレールの「ターミナルワークスペース / SSHワークスペース」のアクティブなボタンをダブルクリックすると、サイドバーをすばやく開閉できます',
|
||||
'changelog.1.0.31.li.2': 'シングルクリックはこれまでどおりワークスペースの切替のみ。もう一方のボタンをダブルクリックした場合も切替だけで、サイドバーの状態は変わりません',
|
||||
'changelog.1.0.31.li.3': 'キーボード(Enter / スペース)での操作はシングルクリック相当のまま保たれ、アクセシビリティに影響しません',
|
||||
'changelog.1.0.30.li.1': '中国語表示の精度を改善:ターミナルが Unicode 11 の文字幅テーブルを使うようになり、中国語・全角記号・罫線の位置が揃います。設定に「中国語フォント」のフォールバック欄を追加',
|
||||
'changelog.1.0.30.li.2': 'カスタムのクリック可能リンク規則を追加:正規表現のキャプチャグループを http/https リンクに展開し、Ctrl+クリックで開けます。ハイライト規則に「行単位で照合」スイッチを追加',
|
||||
'changelog.1.0.30.li.3': '色付き TUI 出力がハイライト区間の後で色を失う問題、ls -l のパーミッション表示が折り返し時にハイライトされない問題、空白や括弧を含むフォント名が黙って無効になる問題を修正。不正な設定でペインが落ちる問題は起動時に自動修復',
|
||||
@@ -693,9 +696,6 @@ window.OPENTERMINAL_I18N = {
|
||||
'changelog.1.0.27.li.2': 'タブの × ボタン、右クリックメニューの「閉じる / ほかを閉じる / 右側を閉じる」、および Delete / Backspace による誤クローズに対応——タブクリック後の Backspace で閉じなくなります',
|
||||
'changelog.1.0.27.li.3': 'バッチクローズは全体で 1 回だけ確認;スイッチは保存後すぐに反映され、再起動不要',
|
||||
|
||||
'changelog.1.0.26.li.1': 'ウィンドウのリサイズや高頻度ターミナル出力時に UI が数秒固まる問題を修正(リフローのスロットリング + 書き込みバッチ化)',
|
||||
'changelog.1.0.26.li.2': '診断ログを追加:既定で有効、日ごとに 1 ファイルを 7 日間保持、設定 → システムで無効化可能',
|
||||
'changelog.1.0.26.li.3': '固まりの報告時に当日のログファイルを添付すれば正確に特定できます',
|
||||
|
||||
'cl.morePre': '完全な変更履歴は',
|
||||
'cl.morePost': ' をご覧ください。',
|
||||
|
||||
+13
-12
@@ -118,14 +118,14 @@
|
||||
内置 SFTP 文件管理与广播输入。安装即用,支持自动更新。
|
||||
</p>
|
||||
<div class="hero-actions">
|
||||
<a class="btn btn-primary" id="dl-exe" href="https://github.com/billowliu2/OpenTerminal/releases/download/v1.0.30/OpenTerminal-1.0.30-setup.exe" data-i18n="hero.download">
|
||||
<a class="btn btn-primary" id="dl-exe" href="https://github.com/billowliu2/OpenTerminal/releases/download/v1.0.31/OpenTerminal-1.0.31-setup.exe" data-i18n="hero.download">
|
||||
下载 Windows 安装包
|
||||
</a>
|
||||
<a class="btn btn-ghost" href="https://github.com/billowliu2/OpenTerminal" target="_blank" rel="noopener" data-i18n="hero.source">
|
||||
查看源码
|
||||
</a>
|
||||
</div>
|
||||
<p class="hero-note"><span data-i18n="hero.notePre">当前版本</span> <span class="mono">v1.0.30</span> <span data-i18n="hero.notePost">· NSIS exe 安装包,支持应用内自动更新。</span></p>
|
||||
<p class="hero-note"><span data-i18n="hero.notePre">当前版本</span> <span class="mono">v1.0.31</span> <span data-i18n="hero.notePost">· NSIS exe 安装包,支持应用内自动更新。</span></p>
|
||||
</div>
|
||||
<figure class="hero-shot">
|
||||
<img src="assets/shot-terminal.jpg" alt="OpenTerminal 主界面:三路分屏并行运行 Kimi Code、Claude Code 与 Codex" width="1739" height="870" data-i18n-attr="alt:hero.shot.alt" />
|
||||
@@ -133,7 +133,7 @@
|
||||
</figure>
|
||||
</div>
|
||||
<div class="container spec-strip">
|
||||
<span><span data-i18n="spec.version">当前版本</span> <strong class="mono">v1.0.30</strong></span>
|
||||
<span><span data-i18n="spec.version">当前版本</span> <strong class="mono">v1.0.31</strong></span>
|
||||
<span data-i18n="spec.os">Windows 10 / 11 · x64</span>
|
||||
<span data-i18n="spec.license">MIT 开源协议</span>
|
||||
<span data-i18n="spec.update">exe 支持应用内自动更新</span>
|
||||
@@ -341,11 +341,11 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="mono">OpenTerminal-1.0.30-setup.exe</td>
|
||||
<td class="mono">OpenTerminal-1.0.31-setup.exe</td>
|
||||
<td data-i18n="dl.table.exe">NSIS 安装包,支持应用内自动更新(推荐)</td>
|
||||
<td class="mono">109 MB</td>
|
||||
<td>
|
||||
<a href="https://github.com/billowliu2/OpenTerminal/releases/download/v1.0.30/OpenTerminal-1.0.30-setup.exe" data-i18n="dl.table.download">下载</a>
|
||||
<a href="https://github.com/billowliu2/OpenTerminal/releases/download/v1.0.31/OpenTerminal-1.0.31-setup.exe" data-i18n="dl.table.download">下载</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
@@ -375,6 +375,14 @@
|
||||
<p class="section-eyebrow" data-i18n="cl.eyebrow">Changelog</p>
|
||||
<h2 data-i18n="cl.title">近期更新</h2>
|
||||
<div class="changelog-list">
|
||||
<article>
|
||||
<h3><span class="mono">1.0.31</span><span class="rel-date">2026-10-21</span></h3>
|
||||
<ul>
|
||||
<li data-i18n="changelog.1.0.31.li.1">双击图标栏的「终端工作区 / SSH 工作区」当前按钮,可快速展开或收起侧栏</li>
|
||||
<li data-i18n="changelog.1.0.31.li.2">单击仍然只切换工作区,双击另一个工作区按钮也只切换,侧栏状态不受影响</li>
|
||||
<li data-i18n="changelog.1.0.31.li.3">键盘(Enter / 空格)触发保持单击语义,无障碍操作不受影响</li>
|
||||
</ul>
|
||||
</article>
|
||||
<article>
|
||||
<h3><span class="mono">1.0.30</span><span class="rel-date">2026-10-18</span></h3>
|
||||
<ul>
|
||||
@@ -407,13 +415,6 @@
|
||||
<li data-i18n="changelog.1.0.27.li.3">批量关闭整批只确认一次;开关保存后立即生效,无需重启</li>
|
||||
</ul>
|
||||
</article>
|
||||
<article>
|
||||
<h3><span class="mono">1.0.26</span><span class="rel-date">2026-10-11</span></h3>
|
||||
<ul>
|
||||
<li data-i18n="changelog.1.0.26.li.1">修复拖动缩放窗口或终端高频输出时界面冻结数秒(网格重排节流 + 输出批量写入)</li>
|
||||
<li data-i18n="changelog.1.0.26.li.2">新增落盘诊断日志:默认开启,按天一个文件保留 7 天,设置 → 系统可关闭</li>
|
||||
<li data-i18n="changelog.1.0.26.li.3">反馈卡顿问题时附上当天的日志文件即可精确定位</li>
|
||||
</ul>
|
||||
</article>
|
||||
</div>
|
||||
<p class="changelog-more">
|
||||
|
||||
Reference in new issue
Block a user