Compare commits

...
7 Commits
Author SHA1 Message Date
Bill f026400676 fix(release): GitHub publish survives partial runs and flaky proxies
CI / typecheck + test + build (windows) (push) Canceled after 0s
Skip assets a previous partial run already uploaded (the POST 422s on
duplicates, so a retry could never get past them), and retry transient
network errors on large proxied uploads.
2026-09-28 13:58:51 +08:00
Bill dd08f8054a chore: release v1.0.19
CI / typecheck + test + build (windows) (push) Canceled after 0s
2026-09-28 13:39:27 +08:00
Bill dba2b14c33 feat(updater): GitHub-first update checks with 20s connectivity probe
checkWithFallback now probes api.github.com through a dedicated system-proxy
session with a 20s AbortSignal timeout before choosing the feed: reachable ->
GitHub releases (with Gitea as the error fallback), unreachable/timeout ->
straight to the domestic Gitea channel (forced direct), so proxy-less users
never hang on a dead proxy. Feed is decided before touching the updater, so
there is never a raced concurrent checkForUpdates. Docs updated: AGENTS.md
update-mechanism section and the release flow (GitHub assets ship per version
again, release.cjs runs with no skip flags).
2026-09-28 13:39:26 +08:00
Bill 680254cad6 fix(release): drop stray TS annotation in .cjs GitHub path
The hardening-round edit left `(): Promise<Response> =>` in a plain .cjs
file; every run since that commit died at parse time before reaching any flag
handling.
2026-09-28 13:39:25 +08:00
Bill b7c7c5cd76 fix(highlight): settings UI fixes from review — grouped view, import guard, basic flag
CI / typecheck + test + build (windows) (push) Canceled after 0s
- grouped view actually clusters: drop the priority column's defaultSortOrder
  that made antd re-sort the dataSource and undo the category clustering
- replace import is Popconfirm-guarded and the import mode resets to append
  each time the dialog opens (it stayed on the destructive choice)
- rule editor exposes the basic flag (basic-mode membership) with a switch;
  clearing it on edit now actually removes the flag
- rule/profile writes read the store at call time instead of the render-scoped
  array, so two writes in one React batch no longer drop the first
- profile editor lists the rules a non-empty profile leaves out (uses the
  previously dead excludedByProfile helper)
- bands editor keeps rows sorted by min; band preview keys by index (duplicate
  min no longer collides); clear-background also closes the bg picker
- TerminalView pushes compiled rules into the HighlightStream from an effect
  instead of during render
2026-09-28 13:06:40 +08:00
Bill 462da60977 perf(highlight): precompute SGR at compile, sorted span claiming, stream ESC-free fast path
- compileRules precomputes the full SGR open sequence per rule and per band;
  wrap() is now pure concatenation, bandOpen() a table lookup (output bytes
  unchanged, bg keeps its unvalidated white-fallback)
- claim() replaces the linear overlaps() scan: claimed spans stay sorted by
  start, O(1) append on the common left-to-right sweep plus one binary search
  otherwise (match-dense 200KB payload: -19% one-shot, -56% streamed)
- HighlightStream: bufferHasEsc flag skips the O(buffer) escape rescans when
  neither the held text nor the chunk contains ESC, fixing quadratic rescan on
  escape-free floods (plain 200KB streamed: -54%)
- applyHighlights tracks the consumed match budget incrementally instead of
  two budgets.reduce() passes per text token
- ESCAPE_RE now covers DCS/APC/PM/SOS (BEL or ST terminated) and single-char
  Fe escapes (DECSC/DECRC/NEL/RI/RIS, orphan ST); isIncompleteEscape holds cut
  tails of the new families; split-smoke exercises every cut point through them
2026-09-28 13:06:29 +08:00
Bill 4e5377f49c fix: hardening round from code review (4 P1 + 15 P2)
CI / typecheck + test + build (windows) (push) Canceled after 0s
P1:
- settingsStore: back up an unparseable settings.json to .bak before
  falling back to defaults, so the next mutation can no longer silently
  wipe custom themes/highlight rules
- ptyDispatcher: fan out per-session data/exit handlers (Set instead of
  a single slot) so SSH split panes stop stealing each other's stream
- FilePanel: monotonic refresh token keeps stale listings from painting
  over a newer navigation; upload finish no longer yanks the panel back
- settings.css: active settings-tab label derives from --chrome-fg so it
  stays visible on the shipped light themes

P2 (main/renderer):
- paste guard: a paste ending in a newline always confirms
- Workspace: closing an SSH pane no longer seeds the local cwd with a
  remote path
- tray: skip close-dialog continuation on a destroyed window
- commands: close zombie 'in-progress' session logs at hydrate
- zmodem: clear the stale offer timer before arming a new one
- connectionsStore: coerce/validate renderer input before persisting
- sftp: OperationError marker class keeps translated errors out of the
  transport-retry classifier
- CommandsPanel: surface save failures inside the dialog
- ConnectionSidebar: drop a tautological tooltip condition

P2 (i18n/tooling/tests):
- localize the 16 ANSI color labels and the highlight sample text
  (21 new keys across zh-CN/zh-TW/en/ja)
- sync-changelog: keep ### subheadings, normalize CRLF notes
- release.cjs: GitHub release reuse-by-tag (idempotent re-runs); fail
  loudly on a failed Gitea asset listing
- commands-store test: absent historyEnabled now truly tests absence
2026-09-27 22:25:03 +08:00
39 changed files with 850 additions and 190 deletions

No files matched your search

+7 -7
View File
@@ -28,18 +28,18 @@ Electron + electron-vite + React 终端工具(本地终端 / SSH / SFTP)。
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` 已含该版本号
3. `npm run dist` 构建
4. `node scripts/release.cjs <版本号> --skip-github`,例如 `node scripts/release.cjs 1.0.14 --skip-github`
- **只发国内**(2026-09-15 起的产品决定):Gitea release(msi + exe 资产)→ Gitea 更新通道(`api/packages/admin/generic/openterminal-update/stable`:exe.blockmap → exe → release-notes.md → **latest.yml 最后**)
4. `node scripts/release.cjs <版本号>`,例如 `node scripts/release.cjs 1.0.14`(**不带 skip 参数**,Gitea 与 GitHub 一起发)
- Gitea:release(msi + 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 复用、已传资产跳过)
- 只补通道:`node scripts/release.cjs <版本号> --channel-only`(不建 release、不发 GitHub)
- 国内通道全程直连,**不需要设代理**;代理只在显式补发 GitHub 时才用(`HTTPS_PROXY=http://127.0.0.1:7897`,脚本只把它用于 GitHub 请求)
- GitHub 那一步的状态:`README` 与更新机制里仍保留 GitHub 作为更新回退源,但**release 资产不再随版本发布同步**;若某天需要补,跑一次 `node scripts/release.cjs <版本号> --skip-gitea` 即可
- 国内通道全程直连,**不需要设代理**;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 资产不发 GitHub)
6. `git tag v<版本号>` 并推送两个远程(代码/tag 与 release 资产的镜像保持同步)
## 更新机制
- 检查更新:先 Gitea 更新通道(强制直连,不走系统代理),失败回退 GitHub(走系统代理)。 electron-updater 用独立 session(partition `electron-updater`),代理模式在 `useFeed` 里按源切换
- 检查更新:**GitHub 优先**(走系统代理;前置 20 秒连通性探测 `probeGithub`——探测失败/超时直接兜底 Gitea,不给 electron-updater 挂起的机会),失败回退国内 Gitea 通用包通道(强制直连,不走系统代理)。 electron-updater 用独立 session(partition `electron-updater`),代理模式在 `useFeed` 里按源切换
- 更新日志:Gitea 仓库是私有的(匿名 API 404),改为从更新通道的 `release-notes.md` 读取(直连 session `openterminal-update-direct`),再回退 Gitea/GitHub releases API
- electron-updater 不支持 MSI 自动更新,自动更新只走 NSIS exe
@@ -83,7 +83,7 @@ Electron + electron-vite + React 终端工具(本地终端 / SSH / SFTP)。
- 跟随主题(`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.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`
+17
View File
@@ -1,5 +1,22 @@
# OpenTerminal Changelog
## v1.0.19 - 2026-09-28
### Update mechanism change
- **GitHub is now the primary update source**: update checks go to GitHub releases first (following the system proxy), gated by a 20-second connectivity probe — without a proxy, or when GitHub is unreachable, the app does not hang but falls back to the domestic update channel (forced direct connection).
- **GitHub releases carry the installer assets again** (exe + blockmap + latest.yml), in step with the domestic channel.
### Terminal highlighting: performance and compatibility
- **Faster engine**: colour sequences are precomputed at compile time (no per-span hex parsing); span claiming moved from a linear scan to sorted insertion (binary search); escape-free text floods (e.g. cat of a large file) skip redundant rescans. Measured on 200KB payloads: ~19–56% faster on match-dense output, ~54% faster on plain streamed text, with output byte-identical to the previous version.
- **Broader escape-sequence coverage**: DCS/APC/PM/SOS (tmux passthrough, sixel, kitty graphics protocol) and DEC single-character sequences (save/restore cursor, etc.) are now recognised, so normal text on the same line no longer loses its highlighting in those scenarios.
### Highlight settings page fixes
- The "group by category" view now actually clusters by category (it was previously flattened back to priority order).
- New "basic rule" switch in the rule editor: your own rules can join the set that stays active in "basic only" mode.
- The profile editor lists the rules a partial selection leaves out.
- "Replace current rules" imports now ask for confirmation, and the dialog defaults back to "append" each time it opens.
- Assorted small fixes: rapid toggles could lose a change, value-band rows were not kept sorted by threshold, the colour field lingered after clearing a background, and more.
## v1.0.18 - 2026-09-24
- **Fixed a serious v1.0.17 regression: the password could not be typed on the lock screen**. v1.0.17 swallowed all keyboard input at the renderer level while locked (meant to block zoom/reload shortcuts), but the default action of a key press is exactly "insert the character into the focused field" — so the lock screen's password box received nothing and the app could not be unlocked.
+17
View File
@@ -1,5 +1,22 @@
# OpenTerminal 更新履歴
## v1.0.19 - 2026-09-28
### アップデート機構の変更
- **更新元を GitHub 優先に変更**:更新チェックはまず GitHub releases を使い(システムプロキシに追随)、20 秒の接続プローブ付き——プロキシが無い、または GitHub に届かない場合は待たされず、国内更新チャンネル(常時直连)へ自動でフォールバックします。
- **GitHub releases でインストーラー同梱を再開**(exe + blockmap + latest.yml)。国内チャンネルと同じ内容です。
### ターミナルハイライト:性能と互換性
- **エンジン高速化**:色シーケンスをコンパイル時に事前計算(span ごとの hex 解析を廃止)、span 確定を線形スキャンからソート済み挿入(二分探索)へ、エスケープを含まないテキスト洪水(大きなファイルの cat 等)では再スキャンをスキップ。200KB 負荷の実測:マッチ密集シーンで約 19–56%、プレーンテキストのストリームで約 54% 高速化。出力は旧版とバイト単位で同一です。
- **エスケープシーケンスのカバー拡大**:DCS/APC/PM/SOS(tmux パススルー、sixel、kitty グラフィクス)と DEC 単一文字シーケンス(カーソル保存/復帰など)を認識し、これらの場面で同じ行の通常テキストがハイライトを失わなくなりました。
### ハイライト設定ページの修正
- 「分類でグループ化」ビューが実際に分類ごとにまとまるように(以前は優先度ソートで打散されていました)。
- ルールエディターに「基本ルール」スイッチを追加:自作ルールを「基本のみ」モードの有効セットに加えられます。
- プロファイルエディターで一部のルールだけを選んだ場合、含まれないルールの一覧を表示。
- 「現在のルールを置き換える」インポートに確認ダイアログを追加し、ダイアログを開くたびに「追加」モードへ戻るように。
- その他の小さな修正:連続トグルで変更が失われる問題、値バンド行がしきい値順にならない問題、背景色クリア後にカラー欄が残る問題など。
## v1.0.18 - 2026-09-24
- **v1.0.17 の重大なリグレッションを修正:ロック画面でパスワードを入力できない**。v1.0.17 ではロック中、レンダラー側ですべてのキーボード入力を握りつぶしていました(ズーム/再読み込みのショートカットを無効化する意図でした)。しかしキー入力の既定動作は「フォーカスされたフィールドへの文字挿入」であり、その結果、ロック画面のパスワード欄に何も入力できず、ロック解除もできなくなっていました。
+17
View File
@@ -1,5 +1,22 @@
# OpenTerminal 更新日志
## v1.0.19 - 2026-09-28
### 更新机制调整
- **更新源改为 GitHub 优先**:检查更新先走 GitHub releases(跟随系统代理),并带 20 秒连通性探测——没有代理或连不通时不会干等,自动改用国内更新通道(强制直连)兜底。
- **GitHub releases 恢复随版本发布安装包**(exe + blockmap + latest.yml),与国内通道保持一致。
### 终端高亮:性能与兼容性
- **引擎提速**:颜色序列改为编译期预计算(不再逐处解析 hex);高亮区间认领从线性扫描改为有序插入(二分查找);纯文本洪流(如 cat 大文件)跳过重扫描。实测 200KB 负载:密集匹配场景提速约 19–56%,纯文本流式提速约 54%,输出与旧版逐字节一致。
- **转义序列覆盖更全**:新增 DCS/APC/PM/SOS(tmux 透传、sixel、kitty 图形协议)与 DEC 单字符序列(保存/恢复光标等)的识别,这些场景下同行的正常文本不再丢失高亮。
### 高亮设置页修复
- 「按分类分组」视图现在真正按分类聚簇(此前会被优先级排序打散)。
- 规则编辑器新增「基础规则」开关:可将自建规则加入「仅基础」模式的生效集合。
- 规则集(profile)编辑器勾选部分规则时,会列出未包含的规则。
- 「替换现有规则」导入增加确认提示,且每次打开弹窗默认回到「追加」。
- 修复若干小问题:快速连续开关规则可能丢改动、数值分级行不按阈值排序、清除背景色后色块残留等。
## v1.0.18 - 2026-09-24
- **修复 v1.0.17 的严重回归:锁屏后无法输入密码**。v1.0.17 在锁屏期间由渲染层拦截了全部键盘输入(本意是挡住缩放/刷新快捷键),但按键的默认行为正是「向聚焦的输入框插入字符」,导致锁屏界面密码框收不到任何输入、无法解锁。
+17
View File
@@ -1,5 +1,22 @@
# OpenTerminal 更新日誌
## v1.0.19 - 2026-09-28
### 更新機制調整
- **更新來源改為 GitHub 優先**:檢查更新先走 GitHub releases(跟隨系統代理),並帶 20 秒連通性探測——沒有代理或連不通時不會空等,自動改用國內更新通道(強制直連)兜底。
- **GitHub releases 恢復隨版本發佈安裝包**(exe + blockmap + latest.yml),與國內通道保持一致。
### 終端高亮:效能與相容性
- **引擎提速**:顏色序列改為編譯期預計算(不再逐處解析 hex);高亮區間認領從線性掃描改為有序插入(二分搜尋);純文字洪流(如 cat 大檔案)跳過重複掃描。實測 200KB 負載:密集匹配場景提速約 19–56%,純文字流式提速約 54%,輸出與舊版逐位元組一致。
- **跳脫序列覆蓋更全**:新增 DCS/APC/PM/SOS(tmux 透傳、sixel、kitty 圖形協定)與 DEC 單字元序列(儲存/恢復游標等)的辨識,這些場景下同行的正常文字不再遺失高亮。
### 高亮設定頁修復
- 「依分類分組」檢視現在真正依分類聚簇(此前會被優先級排序打散)。
- 規則編輯器新增「基礎規則」開關:可將自建規則加入「僅基礎」模式的生效集合。
- 規則集(profile)編輯器勾選部分規則時,會列出未包含的規則。
- 「取代現有規則」匯入增加確認提示,且每次開啟視窗預設回到「附加」。
- 修復若干小問題:快速連續開關規則可能遺失改動、數值分級列不按閾值排序、清除背景色後色塊殘留等。
## v1.0.18 - 2026-09-24
- **修復 v1.0.17 的嚴重回歸:鎖定後無法輸入密碼**。v1.0.17 在鎖定期間由繪製程序攔截了全部鍵盤輸入(本意是擋住縮放/重新整理快捷鍵),但按鍵的預設行為正是「向聚焦的輸入框插入字元」,導致鎖定畫面密碼框收不到任何輸入、無法解鎖。
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "open-terminal",
"productName": "OpenTerminal",
"version": "1.0.18",
"version": "1.0.19",
"description": "Open-source terminal with SSH, split panes, themes and fonts",
"main": "out/main/index.js",
"author": "CodingPlan.Site",
+60 -16
View File
@@ -124,7 +124,12 @@ async function gitea() {
}
}
const listed = await fetch(`${base}/releases/${rel.id}/assets`, { headers: auth })
const attached = new Set(listed.ok ? (await listed.json()).map((a) => a.name) : [])
// A failed listing must not read as "nothing attached": that would re-POST
// every already-uploaded installer and abort the run on the duplicate-name
// rejection, before the channel update could run. Fail loudly instead — a
// re-run then resumes correctly.
if (!listed.ok) throw new Error(`Gitea asset listing failed: HTTP ${listed.status}`)
const attached = new Set((await listed.json()).map((a) => a.name))
for (const f of files) {
const name = path.basename(f)
if (attached.has(name)) { console.log(` asset ${name}: already attached, skipped`); continue }
@@ -237,23 +242,62 @@ async function giteaChannel() {
async function github() {
console.log('=== GitHub release ===')
const resp = await fetch('https://api.github.com/repos/billowliu2/OpenTerminal/releases', {
method: 'POST',
headers: {
Authorization: `Bearer ${env.GH_TOKEN}`,
'Content-Type': 'application/json',
'User-Agent': 'OpenTerminal',
Accept: 'application/vnd.github+json'
},
body: JSON.stringify({ tag_name: `v${V}`, name: `OpenTerminal v${V}`, body: notes, draft: false, prerelease: false }),
...(proxyAgent ? { dispatcher: proxyAgent } : {})
})
if (!resp.ok) { throw new Error(`GitHub release create failed: ${resp.status} ${(await resp.text()).slice(0, 300)}`) }
const rel = await resp.json()
console.log('release created, id =', rel.id)
const api = 'https://api.github.com/repos/billowliu2/OpenTerminal'
const ghHeaders = {
Authorization: `Bearer ${env.GH_TOKEN}`,
'Content-Type': 'application/json',
'User-Agent': 'OpenTerminal',
Accept: 'application/vnd.github+json'
}
const gh = (url, init) =>
fetch(url, { ...init, headers: ghHeaders, ...(proxyAgent ? { dispatcher: proxyAgent } : {}) })
// Idempotent like gitea(): a re-run after a partial publish must reuse the
// release it created last time instead of dying on 422 already_exists and
// leaving GitHub permanently half-uploaded until someone deletes it by hand.
const byTag = () => gh(`${api}/releases/tags/v${V}`)
let resp = await byTag()
let rel
if (resp.ok) {
rel = await resp.json()
console.log('release already exists, reusing id =', rel.id)
} else {
resp = await gh(`${api}/releases`, {
method: 'POST',
body: JSON.stringify({ tag_name: `v${V}`, name: `OpenTerminal v${V}`, body: notes, draft: false, prerelease: false })
})
if (resp.ok) {
rel = await resp.json()
console.log('release created, id =', rel.id)
} else {
const detail = (await resp.text()).slice(0, 300)
// 422 already_exists = the tag carries a release from an earlier partial run.
const again = resp.status === 422 ? await byTag() : undefined
if (!again || !again.ok) throw new Error(`GitHub release create failed: ${resp.status} ${detail}`)
rel = await again.json()
console.log('release already exists, reusing id =', rel.id)
}
}
const up = `https://uploads.github.com/repos/billowliu2/OpenTerminal/releases/${rel.id}/assets`
// Skip assets a previous partial run already uploaded — the upload POST
// 422s on duplicates, so without this a retry could never get past them.
const assetsResp = await gh(`${api}/releases/${rel.id}/assets?per_page=100`)
const existing = new Set(assetsResp.ok ? (await assetsResp.json()).map((a) => a.name) : [])
for (const f of [...files, path.join(R, `OpenTerminal-${V}-setup.exe.blockmap`), path.join(R, 'latest.yml')]) {
await upload(up, f, env.GH_TOKEN, 'Bearer', true)
if (existing.has(path.basename(f))) {
console.log(` skip ${path.basename(f)} (already uploaded)`)
continue
}
// Large uploads through a proxy flake; retry transient network errors.
for (let attempt = 1; ; attempt++) {
try {
await upload(up, f, env.GH_TOKEN, 'Bearer', true)
break
} catch (e) {
if (attempt >= 3) throw e
console.log(` upload ${path.basename(f)} failed (${e.message}), retrying in 5s…`)
await new Promise((r) => setTimeout(r, 5000))
}
}
}
}
+9 -4
View File
@@ -33,10 +33,15 @@ const sync = ({ notes, changelog, header, required }) => {
const body = fs
.readFileSync(notesPath, 'utf8')
// Drop the notes' own title / `## vX` heading: the section heading is built
// here so every changelog stays uniform (`## vX.Y.Z - date`).
.replace(/^#.*\n/gm, '')
.replace(new RegExp(`^## v${pkgVersion}.*\\n`), '')
// Normalize newlines first: a CRLF notes file would otherwise keep its `\r`
// in every line, and the join below re-adds the changelog's ending on top —
// every line of the merged section rendering as `\r\r\n` (doubled blanks).
.replace(/\r\n/g, '\n')
// Drop only the notes' own title and the `## v<pkgVersion>` heading: the
// section heading is built here so every changelog stays uniform
// (`## vX.Y.Z - date`). Stripping every `#`-line (the old behaviour) also
// removed `###` subheadings, merging 新功能/修复 groups into one flat list.
.replace(new RegExp(`^# .*\\n|^## v${pkgVersion}.*\\n`, 'gm'), '')
.replace(/^\s+/, '')
.trimEnd()
+11 -1
View File
@@ -229,6 +229,7 @@ export class CommandsStore {
if (!existsSync(this.indexFile)) return
const raw: unknown = JSON.parse(readFileSync(this.indexFile, 'utf8'))
if (!Array.isArray(raw)) return
let closed = false
for (const x of raw) {
if (
x &&
@@ -239,10 +240,19 @@ export class CommandsStore {
// index.json is data, not trust: a tampered or hand-edited `file`
// must never turn logWrite into an arbitrary-path append.
if (!this.isLoggableFile(meta.file)) continue
// A log still "in progress" here was interrupted by a crash or a
// force-kill: its session id belonged to the previous run, so no
// logStop will ever match it and its sanitizer is gone (logWrite
// would be a permanent no-op). Close it instead of reviving it as
// an active entry nothing can ever end.
if (meta.endedAt === undefined) {
meta.endedAt = Date.now()
closed = true
}
this.metasByFile.set(meta.file, meta)
if (meta.endedAt === undefined) this.activeBySession.set(meta.sessionId, meta)
}
}
if (closed) this.persistIndex()
} catch {
// index missing / corrupt -> rebuild on next write
}
+37 -1
View File
@@ -20,6 +20,41 @@ import { writeJson } from './store'
const ENC_SUFFIX = '_enc'
const PLAIN_PREFIX = 'plain:'
const SECRET_KEYS = ['password', 'keyContent', 'passphrase'] as const
const AUTH_METHODS: ReadonlySet<string> = new Set(['password', 'privateKey', 'agent'])
/**
* Runtime coercion for renderer-supplied connection fields. saveConnection
* copies input values verbatim, and they flow straight into ssh2's
* ConnectConfig (port, keepalive) — a malformed save or a renderer regression
* must not persist `port: "22"` or a NaN-bound keepalive. Repair over drop,
* like every other store: strings stay strings, numbers are range-checked,
* unknown auth falls back to password, optional fields collapse to undefined.
*/
function coerceConnectionInput(input: SshConnectionInput): SshConnectionInput {
const out = { ...input } as Record<string, unknown>
out.name = typeof input.name === 'string' ? input.name : ''
out.group = typeof input.group === 'string' && input.group !== '' ? input.group : undefined
out.host = typeof input.host === 'string' ? input.host.trim() : ''
out.username = typeof input.username === 'string' ? input.username : ''
const port = Number(input.port)
out.port = Number.isInteger(port) && port >= 1 && port <= 65535 ? port : 22
out.auth = AUTH_METHODS.has(input.auth as string) ? input.auth : 'password'
out.askPasswordAtConnect = input.askPasswordAtConnect === true
out.askPassphraseAtConnect = input.askPassphraseAtConnect === true
out.keyPath =
typeof input.keyPath === 'string' && input.keyPath !== '' ? input.keyPath : undefined
const keepalive = Number(input.keepaliveIntervalSec)
out.keepaliveIntervalSec =
Number.isFinite(keepalive) && keepalive >= 0 ? Math.round(keepalive) : 0
out.highlightProfileId =
typeof input.highlightProfileId === 'string' && input.highlightProfileId !== ''
? input.highlightProfileId
: undefined
for (const key of SECRET_KEYS) {
if (out[key] !== undefined && typeof out[key] !== 'string') out[key] = undefined
}
return out as unknown as SshConnectionInput
}
/** Internal persisted record: public fields + encrypted secret fields. */
interface StoredConnection {
@@ -150,7 +185,8 @@ export class ConnectionsStore {
return stored ? decrypt(stored[encField] as string | undefined) : undefined
}
saveConnection(input: SshConnectionInput): SshConnection {
saveConnection(rawInput: SshConnectionInput): SshConnection {
const input = coerceConnectionInput(rawInput)
const list = this.load()
let stored: StoredConnection | undefined
+25 -2
View File
@@ -1,5 +1,5 @@
import { app, ipcMain, powerSaveBlocker } from 'electron'
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
import { join } from 'path'
import { Ipc } from '../shared/ipc'
import {
@@ -435,13 +435,36 @@ function reportWarnings(warnings: string[]): void {
}
}
/**
* A file that exists but cannot be parsed must never be silently destroyed:
* loadSettings falls back to defaults, and the next mutation (any settings save,
* even the tray persisting its close action) would rewrite settings.json from
* those defaults — taking the user's custom themes and highlight rules with it.
* Keep a one-time copy the user can recover from before that can happen.
*/
function backupUnparseableSettings(err: unknown): void {
try {
const file = settingsPath()
if (!existsSync(file)) return
const bak = `${file}.bak`
if (existsSync(bak)) return // keep the first backup; later loads must not clobber it
copyFileSync(file, bak)
reportWarnings([
`settings.json unparseable (${err instanceof Error ? err.message : String(err)}) — original kept at settings.json.bak`
])
} catch {
// best effort: the backup must never break settings loading
}
}
export function loadSettings(): AppSettings {
try {
const raw: unknown = JSON.parse(readFileSync(settingsPath(), 'utf8'))
const { settings, errors } = deepMerge(raw)
reportWarnings(errors)
return settings
} catch {
} catch (err) {
backupUnparseableSettings(err)
return {
terminal: { ...DEFAULT_SETTINGS.terminal },
customThemes: [...DEFAULT_SETTINGS.customThemes],
+18 -3
View File
@@ -65,6 +65,14 @@ const sftpCache = new Map<string, SFTPWrapper>()
*/
class SessionGoneError extends Error {}
/**
* Our own "operation failed for a non-transport reason" error (server-side
* failure summary, user cancellation). isTransportError rejects these outright:
* their messages are translated and can embed remote paths/output, so text
* matching would make the retry decision locale- and filename-dependent.
*/
class OperationError extends Error {}
async function sftpOf(sessionId: string): Promise<SFTPWrapper> {
const cached = sftpCache.get(sessionId)
if (cached) return cached
@@ -100,9 +108,16 @@ function evictSftp(sessionId: string): void {
* already exists) mean the CHANNEL IS ALIVE — retrying them would just open
* another subsystem channel, which strict sshd (MaxSessions 2) punishes by
* dropping the whole connection. Only transport-level death retries.
*
* Classification is class-based for our own errors (SessionGoneError /
* OperationError) and matches only the ssh2 library's own message strings for
* the rest: ssh2 is English-only and never translated, so the decision cannot
* drift with the UI locale (our translated messages arrive as OperationError
* and are rejected before any text is inspected).
*/
function isTransportError(err: unknown): boolean {
if (err instanceof SessionGoneError) return true
if (err instanceof OperationError) return false
const msg = (err as Error | undefined)?.message ?? ''
return /not connected|ECONNRESET|EPIPE|timed out|disconnected|channel|read past end|no response/i.test(msg)
}
@@ -211,7 +226,7 @@ export function deleteRemote(sessionId: string, paths: string[]): Promise<void>
}
}
if (failures.length > 0) {
throw new Error(t('main.sftp.deleteFailed', { detail: failures.join('; ') }))
throw new OperationError(t('main.sftp.deleteFailed', { detail: failures.join('; ') }))
}
})
}
@@ -329,7 +344,7 @@ export function uploadRemote(
let pos = 0
let lastEmit = 0
for (;;) {
if (transfer.cancelled) throw new Error(t('main.sftp.cancelled'))
if (transfer.cancelled) throw new OperationError(t('main.sftp.cancelled'))
if (firstError) throw firstError
while (inflight.size >= UPLOAD_WINDOW) {
await Promise.race(inflight)
@@ -420,7 +435,7 @@ export function downloadRemote(
let lastEmit = 0
const buf = Buffer.alloc(CHUNK)
for (;;) {
if (transfer.cancelled) throw new Error(t('main.sftp.cancelled'))
if (transfer.cancelled) throw new OperationError(t('main.sftp.cancelled'))
const { bytesRead } = await p<{ bytesRead: number; buffer: Buffer }>(cb =>
sftp.read(handle, buf, 0, CHUNK, pos, cb)
)
+8
View File
@@ -87,6 +87,9 @@ export function initTray(showOrCreate: () => void): void {
}
function hideToTray(win: BrowserWindow): void {
// A quit started while the close dialog was pending destroys the window
// before its resolution runs; touching a destroyed window throws.
if (win.isDestroyed()) return
win.hide()
if (process.platform === 'win32' && tray && !balloonShown) {
balloonShown = true
@@ -126,6 +129,11 @@ export async function onMainWindowClose(win: BrowserWindow, e: Electron.Event, s
checkboxChecked: false,
noLink: true
})
// The dialog resolves when the window is destroyed underneath it (tray exit
// or OS shutdown called app.quit() while it was open): the quit is already
// underway, so neither branch may run — and must not persist a choice the
// user never made.
if (win.isDestroyed()) return
if (response === 0) {
if (checkboxChecked) persistCloseAction('tray')
hideToTray(win)
+53 -14
View File
@@ -8,9 +8,11 @@ import { loadSettings } from './settingsStore'
import { markQuitting } from './tray'
/**
* Update feeds: domestic Gitea generic package is tried first (fast in
* China); GitHub releases is the fallback. Both feeds carry the NSIS .exe —
* electron-updater does NOT support MSI auto-updates, MSI is manual only.
* Update feeds: GitHub releases is tried first (system proxy, gated by a
* 20s connectivity probe so proxy-less users don't hang); the domestic
* Gitea generic package is the fallback (forced direct). Both feeds carry
* the NSIS .exe — electron-updater does NOT support MSI auto-updates, MSI
* is manual only.
*/
const GITEA_FEED =
'https://git.codingplan.site/api/packages/admin/generic/openterminal-update/stable/'
@@ -19,6 +21,9 @@ const GITEA_RELEASES_API =
const GITHUB_REPO = { owner: 'billowliu2', repo: 'OpenTerminal' }
const GITHUB_RELEASES_API =
'https://api.github.com/repos/billowliu2/OpenTerminal/releases?per_page=10'
const GITHUB_PROBE_URL =
'https://api.github.com/repos/billowliu2/OpenTerminal/releases/latest'
const GITHUB_PROBE_TIMEOUT_MS = 20_000
let state: UpdateState = { status: 'idle', currentVersion: app.getVersion() }
let activeFeed: 'gitea' | 'github' = 'gitea'
@@ -70,23 +75,38 @@ function wireEvents(): void {
)
}
/** Check the active feed; on failure switch Gitea → GitHub and retry once. */
/**
* Check updates: GitHub releases first (system proxy, gated by a
* connectivity probe), domestic Gitea generic package as fallback.
*
* The feed is decided BEFORE touching the updater: checkForUpdates has no
* timeout, and racing it while mid-flight risks two concurrent checks
* polluting state.
*/
async function checkWithFallback(): Promise<void> {
// The feed choice is per attempt: a transient Gitea failure must not pin
// every later check to GitHub (and its system proxy) for the whole session.
// The feed choice is per attempt: a transient GitHub failure must not pin
// every later check to Gitea (and its forced direct connection) — or vice
// versa — for the whole session.
let githubErr: string | undefined
if (await probeGithub()) {
useFeed('github')
try {
await autoUpdater.checkForUpdates()
return
} catch (err) {
console.warn('[updater] github feed failed, falling back to gitea:', err)
githubErr = err instanceof Error ? err.message : String(err)
}
} else {
console.warn('[updater] github probe failed or timed out, using gitea feed directly')
}
useFeed('gitea')
try {
await autoUpdater.checkForUpdates()
} catch (err) {
console.warn('[updater] gitea feed failed, falling back to github:', err)
if (githubErr === undefined) throw err
const giteaErr = err instanceof Error ? err.message : String(err)
useFeed('github')
try {
await autoUpdater.checkForUpdates()
} catch (err2) {
const ghErr = err2 instanceof Error ? err2.message : String(err2)
throw new Error(t('main.updater.feedFailed', { gitea: giteaErr, github: ghErr }))
}
throw new Error(t('main.updater.feedFailed', { gitea: giteaErr, github: githubErr }))
}
}
@@ -121,6 +141,25 @@ async function directFetch(url: string): Promise<Response> {
return s.fetch(url, { headers: { 'User-Agent': 'OpenTerminal' } })
}
/**
* Connectivity probe for the GitHub feed: dedicated session in the same
* proxy mode as the feed (system), hard timeout so proxy-less users fall
* back to Gitea instead of hanging on a dead proxy/DNS.
*/
async function probeGithub(): Promise<boolean> {
try {
const s = session.fromPartition('openterminal-github-probe', { cache: false })
await s.setProxy({ mode: 'system' })
const resp = await s.fetch(GITHUB_PROBE_URL, {
headers: { 'User-Agent': 'OpenTerminal' },
signal: AbortSignal.timeout(GITHUB_PROBE_TIMEOUT_MS)
})
return resp.ok
} catch {
return false
}
}
/**
* Changelog sources, in order:
* 1. update channel's release-notes.md — the Gitea repo itself is private
+5
View File
@@ -342,6 +342,11 @@ export function attachZmodem(sessionId: string, deps: ZmodemDeps): void {
} catch {
// never crash the event loop
}
// Clear any previous offer timer first: a re-detect while an earlier
// offer is still pending overwrites engine.offerTimer, and the orphaned
// timer would fire at its original deadline and abort a legitimate
// pending offer ahead of its own timeout.
if (engine.offerTimer) clearTimeout(engine.offerTimer)
engine.offerTimer = setTimeout(() => {
if (engine.detection && !engine.confirmed) {
abortWithoutSession(engine, t('main.zmodem.offerTimedOut'))
+19 -8
View File
@@ -31,6 +31,8 @@ export function CommandsPanel({ onRun }: CommandsPanelProps): React.JSX.Element
const [name, setName] = useState('')
const [command, setCommand] = useState('')
const [note, setNote] = useState('')
/** Save failure surfaced inside the dialog instead of an unhandled rejection. */
const [saveError, setSaveError] = useState('')
/** M6.1: current workspace group — the command panel's 发送目标 belongs to it. */
const workspaceMode = useWorkspaceModeStore((s) => s.mode)
@@ -68,20 +70,28 @@ export function CommandsPanel({ onRun }: CommandsPanelProps): React.JSX.Element
setName('')
setCommand('')
setNote('')
setSaveError('')
setAddOpen(true)
}, [])
const handleSave = useCallback(async (): Promise<void> => {
const trimmed = command.trim()
if (!trimmed) return
await window.api.saveLibraryItem({
id: window.crypto.randomUUID(),
name: name.trim() || undefined,
command: trimmed,
note: note.trim() || undefined,
createdAt: Date.now(),
lastUsedAt: Date.now()
})
try {
await window.api.saveLibraryItem({
id: window.crypto.randomUUID(),
name: name.trim() || undefined,
command: trimmed,
note: note.trim() || undefined,
createdAt: Date.now(),
lastUsedAt: Date.now()
})
} catch (err) {
// Keep the dialog open with the typed values; a silent no-op would look
// like the save button doing nothing.
setSaveError((err as Error)?.message || t('common.saveFailed'))
return
}
setAddOpen(false)
refresh()
}, [name, command, note, refresh])
@@ -182,6 +192,7 @@ export function CommandsPanel({ onRun }: CommandsPanelProps): React.JSX.Element
className="commands-modal"
>
<div className="commands-form">
{saveError && <div className="commands-save-error">{saveError}</div>}
<label className="commands-field">
<span className="commands-field-label">{t('panels.commands.name')}</span>
<input
+8
View File
@@ -138,6 +138,14 @@
gap: 12px;
}
/* IPC/store failure during save: shown inside the dialog (in the app's danger
colour) so a failed save is visible instead of a silent no-op. */
.commands-save-error {
color: #f85149;
font-size: 12px;
word-break: break-all;
}
.commands-field {
display: flex;
flex-direction: column;
@@ -289,7 +289,7 @@ function Row({
<div className="connections-row-main">
<div className="connections-row-name">
<span className="connections-row-label">{conn.name}</span>
<Tooltip title={conn.host === conn.host ? t('ssh.sidebar.dblClickConnect', { host: conn.host }) : undefined}>
<Tooltip title={t('ssh.sidebar.dblClickConnect', { host: conn.host })}>
<AuthTags auth={conn.auth} />
</Tooltip>
</div>
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react'
import { Alert, Button, Input, Modal, Radio, Space } from 'antd'
import { Alert, Button, Input, Modal, Popconfirm, Radio, Space } from 'antd'
import { t } from '@shared/i18n'
import { exportHighlightRules, mergeRules, parseHighlightRules } from '@shared/highlightIO'
import type { ImportError } from '@shared/highlightIO'
@@ -40,6 +40,7 @@ export function HighlightImportExport({
setDraft('')
setResult(null)
setCopied(false)
setMode('append')
}, [open])
const handleCopy = async (): Promise<void> => {
@@ -83,7 +84,9 @@ export function HighlightImportExport({
setResult({ warnings: parsed.warnings, count: 0 })
return
}
await setHighlightRules(mergeRules(highlightRules, parsed.rules, mode))
// read at call time: the render-scoped array goes stale inside a React batch
const current = useSettingsStore.getState().settings.highlightRules
await setHighlightRules(mergeRules(current, parsed.rules, mode))
setResult({ warnings: parsed.warnings, count: parsed.rules.length })
}
@@ -145,14 +148,30 @@ export function HighlightImportExport({
<Radio.Button value="append">{t('settings.highlight.importAppend')}</Radio.Button>
<Radio.Button value="replace">{t('settings.highlight.importReplace')}</Radio.Button>
</Radio.Group>
<Button
size="small"
type="primary"
disabled={draft.trim() === ''}
onClick={() => void handleImport()}
>
{t('settings.highlight.importOk')}
</Button>
{mode === 'replace' ? (
// replace wipes the whole set, so it gets the same guard as deletes
<Popconfirm
title={t('settings.highlight.importReplaceTitle')}
description={t('settings.highlight.importReplaceDesc')}
okText={t('settings.highlight.importOk')}
cancelText={t('common.cancel')}
okButtonProps={{ danger: true }}
onConfirm={() => void handleImport()}
>
<Button size="small" type="primary" disabled={draft.trim() === ''}>
{t('settings.highlight.importOk')}
</Button>
</Popconfirm>
) : (
<Button
size="small"
type="primary"
disabled={draft.trim() === ''}
onClick={() => void handleImport()}
>
{t('settings.highlight.importOk')}
</Button>
)}
</Space>
{result && (
<Alert
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
import { Button, Checkbox, Input, Modal, Popconfirm, Switch, Table, Tooltip } from 'antd'
import type { ColumnsType } from 'antd/es/table'
import { t } from '@shared/i18n'
import { excludedByProfile } from '@shared/highlightProfiles'
import type { HighlightProfile } from '@shared/settings'
import { ruleLabel } from './ruleLabel'
import { useSettingsStore } from './store'
@@ -39,11 +40,15 @@ export function HighlightProfiles(): React.JSX.Element {
if (name.trim() === '') return
setSaving(true)
try {
const next = editing
? profiles.map((profile) =>
profile.id === editing.id ? { ...profile, name: name.trim(), ruleIds } : profile
// read at call time: the render-scoped array goes stale inside a React
// batch, and two writes in one batch would silently drop the first
const current = useSettingsStore.getState().settings.highlightProfiles
const target = editingId !== null && editingId !== '' ? current.find((profile) => profile.id === editingId) : undefined
const next = target
? current.map((profile) =>
profile.id === target.id ? { ...profile, name: name.trim(), ruleIds } : profile
)
: [...profiles, { id: crypto.randomUUID(), name: name.trim(), ruleIds }]
: [...current, { id: crypto.randomUUID(), name: name.trim(), ruleIds }]
await setHighlightProfiles(next)
setEditingId(null)
} finally {
@@ -51,6 +56,16 @@ export function HighlightProfiles(): React.JSX.Element {
}
}
/**
* What the current selection leaves out, shown under the checkboxes so the
* user sees which rules a host bound to this profile will NOT run. An empty
* selection means "all rules", so nothing is excluded by definition.
*/
const excluded =
ruleIds.length > 0 && ruleIds.length < rules.length
? excludedByProfile(rules, { id: editingId ?? '', name, ruleIds })
: []
const columns: ColumnsType<HighlightProfile> = [
{
title: t('settings.highlight.profileName'),
@@ -84,7 +99,11 @@ export function HighlightProfiles(): React.JSX.Element {
okText={t('common.delete')}
cancelText={t('common.cancel')}
okButtonProps={{ danger: true }}
onConfirm={() => void setHighlightProfiles(profiles.filter((p) => p.id !== record.id))}
onConfirm={() =>
void setHighlightProfiles(
useSettingsStore.getState().settings.highlightProfiles.filter((p) => p.id !== record.id)
)
}
>
<Button size="small" danger>
{t('common.delete')}
@@ -154,6 +173,14 @@ export function HighlightProfiles(): React.JSX.Element {
onChange={(values) => setRuleIds(values as string[])}
options={rules.map((rule) => ({ value: rule.id, label: ruleLabel(rule) }))}
/>
{excluded.length > 0 && (
<span className="hl-editor-hint">
{t('settings.highlight.profileExcluded', {
n: excluded.length,
list: excluded.map((rule) => ruleLabel(rule)).join('; ')
})}
</span>
)}
</div>
</div>
</div>
+39 -12
View File
@@ -35,8 +35,6 @@ import {
import { applyThemeColors } from '../theme/highlightColors'
import './highlight.css'
const ORDERED_SORT: 'ascend' = 'ascend'
/** preset color swatches for the highlighting editor. */
const PRESET_COLORS = ['#3fb950', '#f85149', '#e3b341', '#58a6ff', '#d2a8ff', '#79c0ff', '#bc8cff', '#f2cc60']
@@ -92,6 +90,7 @@ interface RuleDraft {
caseInsensitive: boolean
note?: string
enabled: boolean
basic: boolean
}
interface EditorState {
@@ -115,6 +114,8 @@ export function HighlightTab(): React.JSX.Element {
const rows = [...highlightRules].sort((a, b) => a.priority - b.priority)
// Grouped view: cluster by category, keeping priority order inside a group
// (Array.prototype.sort is stable, so the priority sort above survives).
// The table must not carry a defaultSortOrder — antd would re-sort the
// dataSource globally and undo this clustering.
return groupByCategory ? rows.sort((a, b) => categoryRank(a) - categoryRank(b)) : rows
}, [highlightRules, groupByCategory])
@@ -123,7 +124,10 @@ export function HighlightTab(): React.JSX.Element {
const closeEditor = (): void => setEditor((prev) => (prev.open ? { ...prev, open: false } : prev))
const replaceRule = async (updated: HighlightRule): Promise<void> => {
const next = highlightRules.map((r) => (r.id === updated.id ? updated : r))
// read at call time: the render-scoped array goes stale inside a React
// batch, and two writes in one batch would silently drop the first
const current = useSettingsStore.getState().settings.highlightRules
const next = current.map((r) => (r.id === updated.id ? updated : r))
await setHighlightRules(next)
}
@@ -132,7 +136,8 @@ export function HighlightTab(): React.JSX.Element {
}
const handleDelete = async (id: string): Promise<void> => {
await setHighlightRules(highlightRules.filter((r) => r.id !== id))
const current = useSettingsStore.getState().settings.highlightRules
await setHighlightRules(current.filter((r) => r.id !== id))
}
const handleReset = async (): Promise<void> => {
@@ -206,7 +211,6 @@ export function HighlightTab(): React.JSX.Element {
width: 76,
align: 'center',
sorter: (a, b) => a.priority - b.priority,
defaultSortOrder: ORDERED_SORT,
render: (priority: number) => <span className="hl-cell-priority">{priority}</span>
},
{
@@ -217,8 +221,8 @@ export function HighlightTab(): React.JSX.Element {
render: (color: HighlightRule['color'], record) =>
record.bands && record.bands.length > 0 ? (
<div className="hl-band-preview">
{record.bands.map((band) => (
<Tooltip key={band.min} title={`≥ ${band.min}`}>
{record.bands.map((band, index) => (
<Tooltip key={index} title={`≥ ${band.min}`}>
<span className="hl-preview-text" style={{ color: band.fg }}>
≥{band.min}
</span>
@@ -233,7 +237,7 @@ export function HighlightTab(): React.JSX.Element {
...(color.bg ? { background: color.bg, borderRadius: 3, padding: '0 4px' } : {})
}}
>
Highlight
{t('settings.highlight.sampleText')}
</span>
)
},
@@ -423,7 +427,8 @@ function HighlightEditor({
bg: undefined,
caseInsensitive: false,
note: undefined,
enabled: true
enabled: true,
basic: false
})
const [saving, setSaving] = useState(false)
const [showBg, setShowBg] = useState(false)
@@ -485,9 +490,10 @@ function HighlightEditor({
const note = editing ? editing.note : undefined
const enabled = editing ? editing.enabled : true
const caseInsensitive = editing ? editing.caseInsensitive === true : false
const basic = editing ? editing.basic === true : false
const bands = editing?.bands
const category = editing?.category
setDraft({ pattern, priority, fg, bg, bands, category, caseInsensitive, note, enabled })
setDraft({ pattern, priority, fg, bg, bands, category, caseInsensitive, note, enabled, basic })
setShowBg(bg != null)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open])
@@ -507,7 +513,12 @@ function HighlightEditor({
const updateBand = (index: number, partial: Partial<{ min: number; fg: string }>): void => {
const bands = draft.bands
if (!bands) return
patch({ bands: bands.map((band, i) => (i === index ? { ...band, ...partial } : band)) })
const next = bands.map((band, i) => (i === index ? { ...band, ...partial } : band))
// the engine applies bands in ascending min order (compileBands sorts), so
// keep the editor rows in that same order — a stable sort keeps equal mins
// in their entry order
next.sort((a, b) => a.min - b.min)
patch({ bands: next })
}
const removeBand = (index: number): void => {
@@ -535,6 +546,7 @@ function HighlightEditor({
bands,
category: draft.category,
caseInsensitive,
...(draft.basic ? { basic: true as const } : {}),
note: draft.note?.trim() ? draft.note.trim() : undefined
}
await setHighlightRules([...highlightRules, rule])
@@ -548,6 +560,9 @@ function HighlightEditor({
bands,
category: draft.category,
caseInsensitive,
// explicit override (not a conditional spread): unchecking must clear
// a basic flag the edited rule may already carry
basic: draft.basic ? true : undefined,
note: draft.note?.trim() ? draft.note.trim() : undefined
}
await setHighlightRules(highlightRules.map((r) => (r.id === rule.id ? rule : r)))
@@ -646,7 +661,13 @@ function HighlightEditor({
value={draft.bg ?? PRESET_COLORS[0]}
onChange={(v) => patch({ bg: v })}
/>
<Button size="small" onClick={() => patch({ bg: undefined })}>
<Button
size="small"
onClick={() => {
patch({ bg: undefined })
setShowBg(false)
}}
>
{t('settings.highlight.clearBg')}
</Button>
</>
@@ -742,6 +763,12 @@ function HighlightEditor({
/>
</div>
<div className="hl-editor-row">
<span className="hl-editor-label">{t('settings.highlight.basic')}</span>
<Switch checked={draft.basic} onChange={(c) => patch({ basic: c })} />
<span className="hl-editor-hint">{t('settings.highlight.basicHint')}</span>
</div>
<div className="hl-editor-row">
<span className="hl-editor-label">{t('settings.highlight.enabled')}</span>
<Switch checked={draft.enabled} onChange={(c) => patch({ enabled: c })} />
+5 -3
View File
@@ -45,10 +45,12 @@
}
/* antd paints the active tab label in the primary blue, which is hard to read
on the accent-tinted row; keep it identical to the inactive labels. The
extra .ant-tabs-tab class out-specifies antd's runtime-injected rule. */
on the accent-tinted row; keep it identical to the inactive labels. Theme-
derived (not #ffffff): on the shipped light themes a white label on the
near-white tinted row was invisible. The extra .ant-tabs-tab class
out-specifies antd's runtime-injected rule. */
.settings-body .ant-tabs-left > .ant-tabs-nav .ant-tabs-tab.ant-tabs-tab-active .ant-tabs-tab-btn {
color: #ffffff;
color: var(--chrome-fg, #cccccc);
}
.settings-body .ant-tabs-left > .ant-tabs-nav .ant-tabs-ink-bar {
+21 -4
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
ArrowUpOutlined,
CopyOutlined,
@@ -332,19 +332,32 @@ export function FilePanel({ sessionId }: FilePanelProps): React.JSX.Element {
/** the entry the context menu is operating on (selected when right-clicked) */
const menuTarget = menuEntry ?? selected
/** Monotonic token: only the most recently issued listing may paint state. */
const refreshSeqRef = useRef(0)
/** Mirrors `dir` for callbacks created before a navigation (upload finish). */
const dirRef = useRef(dir)
const refresh = useCallback(
async (target: string): Promise<void> => {
const seq = ++refreshSeqRef.current
setLoading(true)
setError('')
try {
const list = await window.api.listRemote(sessionId, target)
// A slower earlier listing (breadcrumb click racing a double-click) must
// never overwrite a newer one; only the latest request may paint.
if (seq !== refreshSeqRef.current) return
dirRef.current = target
setEntries(list)
setDir(target)
setSelected(null)
} catch (err) {
if (seq !== refreshSeqRef.current) return
setError((err as Error).message)
} finally {
setLoading(false)
// The spinner belongs to the latest request; a stale one completing
// first must not hide it while the latest is still in flight.
if (seq === refreshSeqRef.current) setLoading(false)
}
},
[sessionId]
@@ -377,7 +390,9 @@ export function FilePanel({ sessionId }: FilePanelProps): React.JSX.Element {
if (settled) return
settled = true
off()
void refresh(dir)
// `dir` here is the directory the upload was started from; if the user
// has navigated away since, refreshing it would yank the panel back.
if (dirRef.current === dir) void refresh(dir)
}
const off = window.api.onTransferProgress((e: TransferProgressEvent) => {
if (e.state !== 'done' && e.state !== 'error' && e.state !== 'cancelled') return
@@ -604,8 +619,10 @@ export function FilePanel({ sessionId }: FilePanelProps): React.JSX.Element {
}
}
}
// `entries` is read by doUpload's overwrite check — a stale snapshot could
// skip the "file exists" confirmation that protects main's truncating 'w'.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [menuTarget, dir, refresh, language])
}, [menuTarget, dir, entries, refresh, language])
return (
<div className="sftp-panel">
+18 -6
View File
@@ -39,16 +39,24 @@ function toFontWeight(n: number): 'normal' | 'bold' | number {
/**
* Paste-risk heuristic, split by shape rather than by raw length:
* - a paste ending in a newline always confirms — the trailing \r is the
* shell's run keystroke, so it executes before the user can review it;
* - two or more lines always confirm — a stray newline executes a command
* the user never reviewed;
* - a single line pastes straight through, unless it is unusually long
* (those are pasted scripts rather than something typed by hand).
* - a single line without a trailing newline pastes straight through, unless
* it is unusually long (those are pasted scripts rather than something
* typed by hand).
*/
const SINGLE_LINE_CONFIRM_LENGTH = 1000
function needsPasteConfirm(text: string): boolean {
const normalized = text.replace(/\r\n?/g, '\n').replace(/\n$/, '')
if (normalized.includes('\n')) return true
return normalized.length > SINGLE_LINE_CONFIRM_LENGTH
const normalized = text.replace(/\r\n?/g, '\n')
// Decided on the raw shape: stripping the trailing newline first would let
// the most common copy shape (`curl … | sh\n`, one line + newline from a
// doc) straight through to immediate execution.
if (normalized.endsWith('\n')) return true
const trimmed = normalized.replace(/\n$/, '')
if (trimmed.includes('\n')) return true
return trimmed.length > SINGLE_LINE_CONFIRM_LENGTH
}
/**
@@ -378,7 +386,11 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
const timer = window.setInterval(() => publishHighlightStats(new Map(sink)), 1000)
return () => window.clearInterval(timer)
}, [settings.terminal.highlightStats])
streamRef.current?.setRules(compiledRules)
// Push the latest compiled rules into the stream from an effect — assigning
// during render would be a side effect in the render body.
useEffect(() => {
streamRef.current?.setRules(compiledRules)
}, [compiledRules])
// M5: load completion candidates once per session (history + library cached).
const ensureCompletionCache = useCallback((): void => {
+158 -54
View File
@@ -14,14 +14,18 @@ export interface CompiledRule {
id: string
/** compiled with the 'g' flag (plus 'i' for case-insensitive rules) */
regex: RegExp
/** hex fg, e.g. "#3fb950" — converted to rgb only at wrap time */
/** hex fg, e.g. "#3fb950" — kept for introspection; `open` is precomputed from it */
fg: string
/** optional hex bg */
/** optional hex bg (not re-validated; a malformed value folds to white, as before) */
bg?: string
/** optional value bands, ascending by `min` — see HighlightRule.bands */
bands?: { min: number; fg: string }[]
bands?: { min: number; fg: string; open: string }[]
/** copied from the source rule for stable priority ordering */
priority: number
/** precomputed truecolor fg fragment, e.g. "38;2;63;185;80" */
fgSgr: string
/** full SGR open sequence `\x1b[38;2;…(;48;2;…)?m` incl. optional bg, built once here */
open: string
}
/** Per-rule counters, only filled when a stats sink is passed in (see `StatsSink`). */
@@ -39,11 +43,26 @@ export interface RuleStat {
export type StatsSink = Map<string, RuleStat>
type Token = { kind: 'seq' | 'text'; value: string }
type Span = { start: number; end: number; fg: string; bg?: string }
/** A claimed span carries its precomputed SGR open sequence, never a hex colour. */
type Span = { start: number; end: number; open: string }
/** ANSI escape tokens: CSI, OSC, two-char (ESC ( / ) ..), and ESC = / > . */
/**
* ANSI escape tokens:
* - CSI `ESC [ … final`
* - OSC `ESC ] … (BEL | ST)`
* - DCS/APC/PM/SOS `ESC P|X|^|_ … (BEL | ST)` — tmux, sixel, kitty graphics.
* The `[^\x1b]*` body means an unterminated one stops at the next ESC
* instead of swallowing the rest of the buffer (it then stays a text run,
* which applyHighlights passes through raw).
* - two-char charset `ESC ( x` / `ESC ) x`, keypad modes `ESC =` / `ESC >`
* - single-char Fe escapes: `ESC` + one 0x30–0x7E byte (DECSC `\x1b7`,
* DECRC `\x1b8`, NEL `\x1bE`, RI `\x1bM`, RIS `\x1bc`, orphan ST `\x1b\`, …).
* Last in the alternation, and the initiators of the longer forms
* (`[ ] ( ) P X ^ _ = >`) are excluded from the class so it can never steal
* the leading bytes of a longer sequence.
*/
const ESCAPE_RE =
/(\x1b\[[0-9;:?]*[A-Za-z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[()][0-9A-B]|\x1b[=>])/g
/(\x1b\[[0-9;:?]*[A-Za-z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[PX^_][^\x1b]*(?:\x07|\x1b\\)|\x1b[()][0-9A-B]|\x1b[=>]|\x1b[0-9:;<?@A-OQ-WYZ\\`a-z{|}~])/g
/** Matches a single rule may inject per chunk (bomb guard). */
const MAX_PER_RULE = 300
@@ -69,6 +88,12 @@ export function hexToRgb(hex: string): { r: number; g: number; b: number } {
return { r: (n >> 16) & 0xff, g: (n >> 8) & 0xff, b: n & 0xff }
}
/** "r;g;b" decimal SGR fragment for one hex colour (white fallback via hexToRgb). */
function rgbFragment(hex: string): string {
const { r, g, b } = hexToRgb(hex)
return `${r};${g};${b}`
}
/** Split a chunk into escape-sequence tokens and plain-text runs. */
export function tokenize(input: string): Token[] {
const out: Token[] = []
@@ -110,63 +135,94 @@ export function compileRules(rules: HighlightRule[]): CompiledRule[] {
const fg = typeof rule.color?.fg === 'string' ? rule.color.fg : ''
if (!/^#[0-9a-fA-F]{3}$|^#[0-9a-fA-F]{6}$/.test(fg)) continue
const bg = typeof rule.color?.bg === 'string' ? rule.color.bg : undefined
out.push({ id: rule.id, regex, fg, bg, bands: compileBands(rule.bands), priority: rule.priority })
// bg stays unvalidated on purpose (as before): a malformed hex folds to
// white inside hexToRgb, byte-identical to the old wrap-time conversion.
const bgPart = bg ? `;48;2;${rgbFragment(bg)}` : ''
const fgSgr = rgbFragment(fg)
out.push({
id: rule.id,
regex,
fg,
bg,
bands: compileBands(rule.bands, bgPart),
priority: rule.priority,
fgSgr,
open: `\x1b[38;2;${fgSgr}${bgPart}m`
})
}
out.sort((a, b) => a.priority - b.priority)
return out
}
/** Keep only well-formed value bands, ascending — the engine walks them in order. */
function compileBands(bands: HighlightRule['bands']): CompiledRule['bands'] {
function compileBands(bands: HighlightRule['bands'], bgPart: string): CompiledRule['bands'] {
if (!Array.isArray(bands)) return undefined
const kept: { min: number; fg: string }[] = []
const kept: NonNullable<CompiledRule['bands']> = []
for (const band of bands) {
if (!band || !Number.isFinite(band.min)) continue
if (typeof band.fg !== 'string' || !/^#[0-9a-fA-F]{3}$|^#[0-9a-fA-F]{6}$/.test(band.fg)) continue
kept.push({ min: band.min, fg: band.fg })
kept.push({ min: band.min, fg: band.fg, open: `\x1b[38;2;${rgbFragment(band.fg)}${bgPart}m` })
}
if (kept.length === 0) return undefined
kept.sort((a, b) => a.min - b.min)
return kept
}
/** Wrap one plain-text span with truecolor SGR. */
function wrap(text: string, fgHex: string, bgHex?: string): string {
const f = hexToRgb(fgHex)
let s = `\x1b[38;2;${f.r};${f.g};${f.b}`
if (bgHex) {
const b = hexToRgb(bgHex)
s += `;48;2;${b.r};${b.g};${b.b}`
}
return `${s}m${text}\x1b[0m`
}
/** True when [s,e) overlaps any claimed span. */
function overlaps(claimed: Span[], s: number, e: number): boolean {
for (const c of claimed) {
if (s < c.end && e > c.start) return true
}
return false
/** Wrap one plain-text span with its precomputed SGR open sequence. */
function wrap(text: string, open: string): string {
return `${open}${text}\x1b[0m`
}
/**
* Colour for one match: with value bands the match's first number picks the
* band (last `min` that is <= the value wins); everything else keeps the rule's
* own foreground.
* Try to claim [s,e) for a span. Claimed spans are pairwise disjoint and kept
* sorted by start (ends ascend with starts), which turns the old linear scan
* over every claimed span (O(spans²) on match-dense runs) into:
* - an O(1) append when the candidate lands past every claim — the common
* case, since rule scans sweep a run left to right; otherwise
* - one binary search: the only possible clash is with the nearest span left
* of the insertion point.
* The span colour is only computed on accept. Returns true when claimed.
*/
function bandColor(rule: CompiledRule, matched: string): string {
function claim(claimed: Span[], s: number, e: number, rule: CompiledRule, matched: string): boolean {
const n = claimed.length
if (n === 0 || claimed[n - 1].end <= s) {
claimed.push({ start: s, end: e, open: bandOpen(rule, matched) })
return true
}
let lo = 0
let hi = n
while (lo < hi) {
const mid = (lo + hi) >> 1
if (claimed[mid].start < e) lo = mid + 1
else hi = mid
}
if (lo > 0 && claimed[lo - 1].end > s) return false
claimed.splice(lo, 0, { start: s, end: e, open: bandOpen(rule, matched) })
return true
}
/** First-number finder for band selection; no /g, so the shared literal is stateless. */
const BAND_NUMBER_RE = /-?\d+(?:\.\d+)?/
/**
* SGR open sequence for one match: with value bands the match's first number
* picks the band (last `min` that is <= the value wins); everything else keeps
* the rule's own precomputed open sequence. Pure table lookup — no colour math
* on the hot path.
*/
function bandOpen(rule: CompiledRule, matched: string): string {
const bands = rule.bands
if (!bands || bands.length === 0) return rule.fg
const number = /-?\d+(?:\.\d+)?/.exec(matched)
if (number === null) return rule.fg
if (!bands || bands.length === 0) return rule.open
const number = BAND_NUMBER_RE.exec(matched)
if (number === null) return rule.open
const value = Number(number[0])
if (!Number.isFinite(value)) return rule.fg
let fg = rule.fg
if (!Number.isFinite(value)) return rule.open
let open = rule.open
for (const band of bands) {
if (value < band.min) break
fg = band.fg
open = band.open
}
return fg
return open
}
/** True when `text` holds a newline-free segment longer than MAX_LINE_LEN. */
@@ -189,10 +245,17 @@ function hasLongLine(text: string): boolean {
/** Millisecond clock for the optional stats sink; `performance` when present. */
const now = (): number => (typeof performance !== 'undefined' ? performance.now() : Date.now())
function applyRun(text: string, rules: CompiledRule[], budgets: number[], stats?: StatsSink): string {
if (text.length === 0) return ''
if (hasLongLine(text)) return text
function applyRun(text: string, rules: CompiledRule[], budgets: number[], stats?: StatsSink, spent?: { n: number }): string {
if (text.length === 0) {
if (spent !== undefined) spent.n = 0
return ''
}
if (hasLongLine(text)) {
if (spent !== undefined) spent.n = 0
return text
}
const claimed: Span[] = []
let totalMade = 0
for (let ri = 0; ri < rules.length; ri++) {
const rule = rules[ri]
if (budgets[ri] <= 0) continue
@@ -208,9 +271,9 @@ function applyRun(text: string, rules: CompiledRule[], budgets: number[], stats?
rule.regex.lastIndex++
continue
}
if (!overlaps(claimed, start, end)) {
claimed.push({ start, end, fg: bandColor(rule, m[0]), bg: rule.bg })
if (claim(claimed, start, end, rule, m[0])) {
made++
totalMade++
budgets[ri]--
if (budgets[ri] <= 0) break
}
@@ -223,13 +286,14 @@ function applyRun(text: string, rules: CompiledRule[], budgets: number[], stats?
stats.set(rule.id, stat)
}
}
if (spent !== undefined) spent.n = totalMade
if (claimed.length === 0) return text
claimed.sort((a, b) => a.start - b.start)
// `claim` keeps the spans sorted by start — no final sort needed here.
let out = ''
let pos = 0
for (const c of claimed) {
if (c.start > pos) out += text.slice(pos, c.start)
out += wrap(text.slice(c.start, c.end), c.fg, c.bg)
out += wrap(text.slice(c.start, c.end), c.open)
pos = c.end
}
if (pos < text.length) out += text.slice(pos)
@@ -249,6 +313,9 @@ export function applyHighlights(chunk: string, rules: CompiledRule[], stats?: St
let budgets = rules.map(() => MAX_PER_RULE)
let usedBudget = false
let out = ''
// applyRun reports how many matches it claimed, replacing the old two
// budgets.reduce() passes per text token.
const spent = { n: 0 }
for (const token of tokenize(chunk)) {
if (token.kind === 'seq') {
out += token.value
@@ -260,10 +327,9 @@ export function applyHighlights(chunk: string, rules: CompiledRule[], stats?: St
out += token.value
continue
}
const before = budgets.reduce((a, b) => a + b, 0)
out += applyRun(token.value, rules, budgets, stats)
const after = budgets.reduce((a, b) => a + b, 0)
if (after < before) usedBudget = true
spent.n = 0
out += applyRun(token.value, rules, budgets, stats, spent)
if (spent.n > 0) usedBudget = true
}
return usedBudget ? out : chunk
}
@@ -308,15 +374,30 @@ export function previewSpans(text: string, rules: CompiledRule[]): PreviewSpan[]
return spans
}
/** True when `seg` is a dangling, unfinished escape fragment (must not be emitted). */function isIncompleteEscape(seg: string): boolean {
/**
* True when `seg` is a dangling, unfinished escape fragment (must not be
* emitted before its terminator arrives).
*/
function isIncompleteEscape(seg: string): boolean {
// `seg` is expected to start at (or be) a lone ESC.
// A lone ESC — the leading half of any CSI/OSC/two-char/ST sequence.
// A lone ESC — the leading half of any CSI/OSC/DCS/two-char/ST/Fe sequence.
if (seg === '\x1b') return true
// Unfinished CSI: ESC [ plus parameter bytes but no final byte in 0x40..0x7E.
if (/^\x1b\[[0-9;:?]*$/.test(seg)) return true
// Unfinished OSC: ESC ] ... with no BEL (\x07) or ST (\x1b\ ) terminator.
// (A trailing half-ST is covered by the lone-ESC case above.)
if (seg.startsWith('\x1b]') && !seg.includes('\x07') && !seg.includes('\x1b\\')) return true
// Unfinished OSC / DCS / APC / PM / SOS: opened but no BEL (\x07) or ST
// (\x1b\) terminator yet, so the fragment could still grow into any of them.
// (A trailing half-ST — the ESC of `\x1b\` with the `\` still to come — is
// covered by the lone-ESC case above, since findIncompleteIndex slices from
// the *last* ESC.)
// NB: `[]` would be an *empty* class in JS regex, so `]` is kept in a
// startsWith instead of a merged character class.
if (
(seg.startsWith('\x1b]') || /^\x1b[PX^_]/.test(seg)) &&
!seg.includes('\x07') &&
!seg.includes('\x1b\\')
) {
return true
}
return false
}
@@ -356,6 +437,8 @@ export class HighlightStream {
private rules: CompiledRule[]
private stats?: StatsSink
private buffer = ''
/** Whether `buffer` holds any ESC — lets push skip the O(buffer) escape scans on escape-free floods. */
private bufferHasEsc = false
constructor(rules: CompiledRule[]) {
this.rules = rules
@@ -378,6 +461,20 @@ export class HighlightStream {
/** Join held bytes with `chunk`, return content safe to hand to xterm. */
push(chunk: string): string {
// Fast path: neither the held text nor the new chunk contains a single ESC,
// so findIncompleteIndex/lastEscapeEnd would both come back -1. Skip their
// O(buffer) rescans — they made escape-free floods (cat of a large text
// file) quadratic in the held buffer — and go straight to the trailing-text
// hold. Decision-identical to the full path below.
if (!this.bufferHasEsc && !chunk.includes('\x1b')) {
this.buffer += chunk
if (this.buffer.length > MAX_CHUNK) {
const raw = this.buffer
this.buffer = ''
return applyHighlights(raw, this.rules, this.stats)
}
return ''
}
this.buffer += chunk
// (a) trailing incomplete escape -> hold it, emit the closed prefix.
@@ -389,9 +486,11 @@ export class HighlightStream {
// Give up waiting on a pathologically long unterminated sequence: emit
// the fragment raw (applyHighlights skips raw \x1b in text runs).
this.buffer = ''
this.bufferHasEsc = false
return applyHighlights(head, this.rules, this.stats) + tail
}
this.buffer = tail
this.bufferHasEsc = true // the held tail starts at the dangling ESC
return applyHighlights(head, this.rules, this.stats)
}
@@ -404,12 +503,16 @@ export class HighlightStream {
if (this.buffer.length > MAX_CHUNK) {
const raw = this.buffer
this.buffer = ''
this.bufferHasEsc = false
return applyHighlights(raw, this.rules, this.stats)
}
// The buffer (or the chunk that just joined it) brought the ESC in.
this.bufferHasEsc = true
return ''
}
const head = this.buffer.slice(0, lastEnd)
this.buffer = this.buffer.slice(lastEnd)
this.bufferHasEsc = this.buffer.includes('\x1b')
return applyHighlights(head, this.rules, this.stats)
}
@@ -417,6 +520,7 @@ export class HighlightStream {
flush(): string {
const b = this.buffer
this.buffer = ''
this.bufferHasEsc = false
return applyHighlights(b, this.rules, this.stats)
}
}
+29 -11
View File
@@ -6,43 +6,61 @@ import type { PtyDataEvent, PtyExitEvent } from '@shared/ipc'
* listeners (N = pane count) and N-1 of them discarded it after an id
* compare. With many terminals under heavy output that per-chunk fan-out is
* pure overhead. Here one IPC listener dispatches through a Map keyed by
* sessionId: one lookup per chunk, and only the owning pane runs.
* sessionId: one lookup per chunk, and only the owning session's panes run.
*
* The per-id value is a Set, not a single handler: an SSH split deliberately
* binds two panes to the same sessionId, so the second pane must not steal
* (and its teardown must not revoke) the first pane's stream.
*/
type DataHandler = (data: string) => void
type ExitHandler = (exitCode: number) => void
const dataHandlers = new Map<string, DataHandler>()
const exitHandlers = new Map<string, ExitHandler>()
const dataHandlers = new Map<string, Set<DataHandler>>()
const exitHandlers = new Map<string, Set<ExitHandler>>()
let bound = false
function ensureBound(): void {
if (bound) return
bound = true
window.api.onPtyData((e: PtyDataEvent) => {
dataHandlers.get(e.id)?.(e.data)
dataHandlers.get(e.id)?.forEach((cb) => cb(e.data))
})
window.api.onPtyExit((e: PtyExitEvent) => {
exitHandlers.get(e.id)?.(e.exitCode)
exitHandlers.get(e.id)?.forEach((cb) => cb(e.exitCode))
})
}
/** Subscribe to one session's output. Returns the unsubscribe function. */
export function subscribePtyData(id: string, cb: DataHandler): () => void {
ensureBound()
dataHandlers.set(id, cb)
let set = dataHandlers.get(id)
if (!set) {
set = new Set()
dataHandlers.set(id, set)
}
set.add(cb)
return () => {
// A rebind (template apply) may have replaced the handler already; only
// delete when this subscription is still the registered one.
if (dataHandlers.get(id) === cb) dataHandlers.delete(id)
const set = dataHandlers.get(id)
if (!set) return
set.delete(cb)
if (set.size === 0) dataHandlers.delete(id)
}
}
/** Subscribe to one session's exit. Returns the unsubscribe function. */
export function subscribePtyExit(id: string, cb: ExitHandler): () => void {
ensureBound()
exitHandlers.set(id, cb)
let set = exitHandlers.get(id)
if (!set) {
set = new Set()
exitHandlers.set(id, set)
}
set.add(cb)
return () => {
if (exitHandlers.get(id) === cb) exitHandlers.delete(id)
const set = exitHandlers.get(id)
if (!set) return
set.delete(cb)
if (set.size === 0) exitHandlers.delete(id)
}
}
+19 -18
View File
@@ -30,23 +30,24 @@ const headerKeys = (): FieldSpec[] => [
{ key: 'selectionBackground', label: t('settings.color.selection') }
]
const ANSI_KEYS: FieldSpec[] = [
{ key: 'black', label: 'Black' },
{ key: 'red', label: 'Red' },
{ key: 'green', label: 'Green' },
{ key: 'yellow', label: 'Yellow' },
{ key: 'blue', label: 'Blue' },
{ key: 'magenta', label: 'Magenta' },
{ key: 'cyan', label: 'Cyan' },
{ key: 'white', label: 'White' },
{ key: 'brightBlack', label: 'Bright Black' },
{ key: 'brightRed', label: 'Bright Red' },
{ key: 'brightGreen', label: 'Bright Green' },
{ key: 'brightYellow', label: 'Bright Yellow' },
{ key: 'brightBlue', label: 'Bright Blue' },
{ key: 'brightMagenta', label: 'Bright Magenta' },
{ key: 'brightCyan', label: 'Bright Cyan' },
{ key: 'brightWhite', label: 'Bright White' }
/** Resolved per call (like headerKeys) so a language switch re-translates. */
const ansiKeys = (): FieldSpec[] => [
{ key: 'black', label: t('settings.color.ansi.black') },
{ key: 'red', label: t('settings.color.ansi.red') },
{ key: 'green', label: t('settings.color.ansi.green') },
{ key: 'yellow', label: t('settings.color.ansi.yellow') },
{ key: 'blue', label: t('settings.color.ansi.blue') },
{ key: 'magenta', label: t('settings.color.ansi.magenta') },
{ key: 'cyan', label: t('settings.color.ansi.cyan') },
{ key: 'white', label: t('settings.color.ansi.white') },
{ key: 'brightBlack', label: t('settings.color.ansi.brightBlack') },
{ key: 'brightRed', label: t('settings.color.ansi.brightRed') },
{ key: 'brightGreen', label: t('settings.color.ansi.brightGreen') },
{ key: 'brightYellow', label: t('settings.color.ansi.brightYellow') },
{ key: 'brightBlue', label: t('settings.color.ansi.brightBlue') },
{ key: 'brightMagenta', label: t('settings.color.ansi.brightMagenta') },
{ key: 'brightCyan', label: t('settings.color.ansi.brightCyan') },
{ key: 'brightWhite', label: t('settings.color.ansi.brightWhite') }
]
/** The native colour input only accepts #rrggbb: clamp every seeded value so a
@@ -164,7 +165,7 @@ export function ThemeEditor({ open, onClose, themeId }: ThemeEditorProps): React
<ColorField key={spec.key} spec={spec} value={colors[spec.key]} onChange={setColor} />
))}
<div className="theme-editor-divider" />
{ANSI_KEYS.map((spec) => (
{ansiKeys().map((spec) => (
<ColorField key={spec.key} spec={spec} value={colors[spec.key]} onChange={setColor} />
))}
</div>
+7 -2
View File
@@ -490,8 +490,13 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
const cleanups = [
api.onDidRemovePanel((panel: IDockviewPanel) => {
// Closing a pane keeps its directory as the seed for the next one.
const closing = getSessionCwd(sessionIdOf(panel))
if (closing) lastLocalCwdRef.current = closing
// SSH panes are excluded: their tracked cwd is a *remote* path (OSC 7
// from the remote shell), and seeding a new local terminal with it
// would silently break "start where I left off".
if ((panel.params as TerminalParams | undefined)?.sessionKind !== 'ssh') {
const closing = getSessionCwd(sessionIdOf(panel))
if (closing) lastLocalCwdRef.current = closing
}
releaseSession(panel)
recomputeLocalPanels()
recountAll()
+1
View File
@@ -4,6 +4,7 @@ const common: Record<string, string> = {
'common.cancel': 'Cancel',
'common.close': 'Close',
'common.save': 'Save',
'common.saveFailed': 'Save failed, please try again',
'common.delete': 'Delete',
'common.edit': 'Edit',
'common.add': 'Add',
+23
View File
@@ -16,6 +16,22 @@ const settings: Record<string, string> = {
'settings.color.background': 'Background',
'settings.color.cursor': 'Cursor',
'settings.color.selection': 'Selection',
'settings.color.ansi.black': 'Black',
'settings.color.ansi.red': 'Red',
'settings.color.ansi.green': 'Green',
'settings.color.ansi.yellow': 'Yellow',
'settings.color.ansi.blue': 'Blue',
'settings.color.ansi.magenta': 'Magenta',
'settings.color.ansi.cyan': 'Cyan',
'settings.color.ansi.white': 'White',
'settings.color.ansi.brightBlack': 'Bright Black',
'settings.color.ansi.brightRed': 'Bright Red',
'settings.color.ansi.brightGreen': 'Bright Green',
'settings.color.ansi.brightYellow': 'Bright Yellow',
'settings.color.ansi.brightBlue': 'Bright Blue',
'settings.color.ansi.brightMagenta': 'Bright Magenta',
'settings.color.ansi.brightCyan': 'Bright Cyan',
'settings.color.ansi.brightWhite': 'Bright White',
'settings.font.label': 'Font',
'settings.font.desc': 'The built-in default or a system monospace font',
'settings.font.placeholder': 'Select a font',
@@ -162,6 +178,7 @@ const settings: Record<string, string> = {
'settings.highlight.clearBg': 'Clear',
'settings.highlight.addBg': 'Add',
'settings.highlight.palette': 'Palette',
'settings.highlight.sampleText': 'Highlight',
'settings.highlight.note': 'Note',
'settings.highlight.notePlaceholder': 'Rule description (optional)',
'settings.highlight.caseInsensitive': 'Ignore case',
@@ -183,6 +200,9 @@ const settings: Record<string, string> = {
'settings.highlight.importMode': 'Mode',
'settings.highlight.importReplace': 'Replace current rules',
'settings.highlight.importAppend': 'Append to current rules',
'settings.highlight.importReplaceTitle': 'Replace current rules?',
'settings.highlight.importReplaceDesc':
'Importing overwrites every current rule with this file. This cannot be undone.',
'settings.highlight.importOk': 'Import',
'settings.highlight.importEmpty': 'No usable rules found',
'settings.highlight.importBadJson': 'Not valid JSON',
@@ -197,6 +217,8 @@ const settings: Record<string, string> = {
'settings.highlight.modeOff': 'Off',
'settings.highlight.modeDesc':
'Basic only: keeps just the key rules (errors, success, warnings, dangerous commands, secrets) — cheaper and quieter. Off: no colour injection at all (rules and colours are kept).',
'settings.highlight.basic': 'Basic rule',
'settings.highlight.basicHint': 'keep this rule active when the highlight mode is "Basic only"',
'settings.highlight.category': 'Category',
'settings.highlight.groupByCategory': 'Group by category',
'settings.highlight.category.safety': 'Safety',
@@ -228,6 +250,7 @@ const settings: Record<string, string> = {
'settings.highlight.profileNamePlaceholder': 'e.g. production (errors only)',
'settings.highlight.profileRules': 'Included rules',
'settings.highlight.profileRulesHint': 'Unchecked rules stay off on that host',
'settings.highlight.profileExcluded': 'Excluded {n}: {list}',
'settings.highlight.profileEmpty': 'No rule sets yet',
'settings.highlight.profileDeleteTitle': 'Delete rule set',
'settings.highlight.actions': 'Actions',
+1
View File
@@ -4,6 +4,7 @@ const common: Record<string, string> = {
'common.cancel': 'キャンセル',
'common.close': '閉じる',
'common.save': '保存',
'common.saveFailed': '保存に失敗しました。もう一度お試しください',
'common.delete': '削除',
'common.edit': '編集',
'common.add': '追加',
+23
View File
@@ -16,6 +16,22 @@ const settings: Record<string, string> = {
'settings.color.background': '背景',
'settings.color.cursor': 'カーソル',
'settings.color.selection': '選択範囲',
'settings.color.ansi.black': '黒',
'settings.color.ansi.red': '赤',
'settings.color.ansi.green': '緑',
'settings.color.ansi.yellow': '黄',
'settings.color.ansi.blue': '青',
'settings.color.ansi.magenta': 'マゼンタ',
'settings.color.ansi.cyan': 'シアン',
'settings.color.ansi.white': '白',
'settings.color.ansi.brightBlack': '明るい黒',
'settings.color.ansi.brightRed': '明るい赤',
'settings.color.ansi.brightGreen': '明るい緑',
'settings.color.ansi.brightYellow': '明るい黄',
'settings.color.ansi.brightBlue': '明るい青',
'settings.color.ansi.brightMagenta': '明るいマゼンタ',
'settings.color.ansi.brightCyan': '明るいシアン',
'settings.color.ansi.brightWhite': '明るい白',
'settings.font.label': 'フォント',
'settings.font.desc': '組み込みの既定フォント、またはシステムの等幅フォント',
'settings.font.placeholder': 'フォントを選択',
@@ -159,6 +175,7 @@ const settings: Record<string, string> = {
'settings.highlight.clearBg': 'クリア',
'settings.highlight.addBg': '追加',
'settings.highlight.palette': 'パレット',
'settings.highlight.sampleText': 'ハイライト',
'settings.highlight.note': 'メモ',
'settings.highlight.notePlaceholder': 'ルールの説明(任意)',
'settings.highlight.caseInsensitive': '大文字小文字を無視',
@@ -179,6 +196,9 @@ const settings: Record<string, string> = {
'settings.highlight.importMode': '取り込み方法',
'settings.highlight.importReplace': '現在のルールを置き換える',
'settings.highlight.importAppend': '現在のルールに追加する',
'settings.highlight.importReplaceTitle': '現在のルールを置き換えますか?',
'settings.highlight.importReplaceDesc':
'インポートすると現在のすべてのルールがこのファイルの内容で上書きされます。元に戻せません。',
'settings.highlight.importOk': 'インポート',
'settings.highlight.importEmpty': '有効なルールが見つかりません',
'settings.highlight.importBadJson': '有効な JSON ではありません',
@@ -193,6 +213,8 @@ const settings: Record<string, string> = {
'settings.highlight.modeOff': 'オフ',
'settings.highlight.modeDesc':
'基本のみ:エラー/成功/警告/危険なコマンド/シークレットなど重要なルールだけを適用(軽量・低ノイズ)。オフ:色をまったく注入しません(ルールと色は保持)。',
'settings.highlight.basic': '基本ルール',
'settings.highlight.basicHint': 'オンにすると、ハイライトが「基本のみ」モードのときもこのルールが適用されます',
'settings.highlight.category': '分類',
'settings.highlight.groupByCategory': '分類でグループ化',
'settings.highlight.category.safety': '安全',
@@ -223,6 +245,7 @@ const settings: Record<string, string> = {
'settings.highlight.profileNamePlaceholder': '例:本番機(エラーのみ)',
'settings.highlight.profileRules': '含めるルール',
'settings.highlight.profileRulesHint': 'チェックを外したルールはそのホストでは効きません',
'settings.highlight.profileExcluded': '{n} 件を除外:{list}',
'settings.highlight.profileEmpty': 'ルールセットがまだありません',
'settings.highlight.profileDeleteTitle': 'ルールセットを削除',
'settings.highlight.actions': '操作',
+1
View File
@@ -4,6 +4,7 @@ const common: Record<string, string> = {
'common.cancel': '取消',
'common.close': '关闭',
'common.save': '保存',
'common.saveFailed': '保存失败,请重试',
'common.delete': '删除',
'common.edit': '编辑',
'common.add': '新增',
+22
View File
@@ -16,6 +16,22 @@ const settings: Record<string, string> = {
'settings.color.background': '背景',
'settings.color.cursor': '光标',
'settings.color.selection': '选区',
'settings.color.ansi.black': '黑',
'settings.color.ansi.red': '红',
'settings.color.ansi.green': '绿',
'settings.color.ansi.yellow': '黄',
'settings.color.ansi.blue': '蓝',
'settings.color.ansi.magenta': '品红',
'settings.color.ansi.cyan': '青',
'settings.color.ansi.white': '白',
'settings.color.ansi.brightBlack': '亮黑',
'settings.color.ansi.brightRed': '亮红',
'settings.color.ansi.brightGreen': '亮绿',
'settings.color.ansi.brightYellow': '亮黄',
'settings.color.ansi.brightBlue': '亮蓝',
'settings.color.ansi.brightMagenta': '亮品红',
'settings.color.ansi.brightCyan': '亮青',
'settings.color.ansi.brightWhite': '亮白',
'settings.font.label': '字体',
'settings.font.desc': '内置默认字体或系统等宽字体',
'settings.font.placeholder': '选择字体',
@@ -153,6 +169,7 @@ const settings: Record<string, string> = {
'settings.highlight.clearBg': '清空',
'settings.highlight.addBg': '添加',
'settings.highlight.palette': '色板',
'settings.highlight.sampleText': '高亮',
'settings.highlight.note': '备注',
'settings.highlight.notePlaceholder': '规则说明(可选)',
'settings.highlight.caseInsensitive': '忽略大小写',
@@ -173,6 +190,8 @@ const settings: Record<string, string> = {
'settings.highlight.importMode': '导入方式',
'settings.highlight.importReplace': '替换现有规则',
'settings.highlight.importAppend': '追加到现有规则',
'settings.highlight.importReplaceTitle': '替换现有规则?',
'settings.highlight.importReplaceDesc': '导入后当前全部规则将被这份文件覆盖,无法恢复。',
'settings.highlight.importOk': '导入',
'settings.highlight.importEmpty': '没有解析到可用规则',
'settings.highlight.importBadJson': '不是有效的 JSON',
@@ -186,6 +205,8 @@ const settings: Record<string, string> = {
'settings.highlight.modeBasic': '仅基础',
'settings.highlight.modeOff': '关闭',
'settings.highlight.modeDesc': '仅基础:只保留错误/成功/警告/危险命令/密钥等关键规则,开销更小;关闭:完全不注入颜色(规则与颜色都保留)',
'settings.highlight.basic': '基础规则',
'settings.highlight.basicHint': '开启后,终端高亮为「仅基础」模式时该规则仍会生效',
'settings.highlight.category': '分类',
'settings.highlight.groupByCategory': '按分类分组',
'settings.highlight.category.safety': '安全',
@@ -213,6 +234,7 @@ const settings: Record<string, string> = {
'settings.highlight.profileNamePlaceholder': '例如:生产机(只看错误)',
'settings.highlight.profileRules': '包含的规则',
'settings.highlight.profileRulesHint': '未勾选的规则在这台主机上不生效',
'settings.highlight.profileExcluded': '未包含 {n} 条:{list}',
'settings.highlight.profileEmpty': '尚未创建规则集',
'settings.highlight.profileDeleteTitle': '删除规则集',
'settings.highlight.actions': '操作',
+1
View File
@@ -4,6 +4,7 @@ const common: Record<string, string> = {
'common.cancel': '取消',
'common.close': '關閉',
'common.save': '儲存',
'common.saveFailed': '儲存失敗,請重試',
'common.delete': '刪除',
'common.edit': '編輯',
'common.add': '新增',
+22
View File
@@ -16,6 +16,22 @@ const settings: Record<string, string> = {
'settings.color.background': '背景',
'settings.color.cursor': '游標',
'settings.color.selection': '選取區',
'settings.color.ansi.black': '黑',
'settings.color.ansi.red': '紅',
'settings.color.ansi.green': '綠',
'settings.color.ansi.yellow': '黃',
'settings.color.ansi.blue': '藍',
'settings.color.ansi.magenta': '洋紅',
'settings.color.ansi.cyan': '青',
'settings.color.ansi.white': '白',
'settings.color.ansi.brightBlack': '亮黑',
'settings.color.ansi.brightRed': '亮紅',
'settings.color.ansi.brightGreen': '亮綠',
'settings.color.ansi.brightYellow': '亮黃',
'settings.color.ansi.brightBlue': '亮藍',
'settings.color.ansi.brightMagenta': '亮洋紅',
'settings.color.ansi.brightCyan': '亮青',
'settings.color.ansi.brightWhite': '亮白',
'settings.font.label': '字型',
'settings.font.desc': '內建預設字型或系統等寬字型',
'settings.font.placeholder': '選擇字型',
@@ -153,6 +169,7 @@ const settings: Record<string, string> = {
'settings.highlight.clearBg': '清除',
'settings.highlight.addBg': '新增',
'settings.highlight.palette': '色盤',
'settings.highlight.sampleText': '高亮',
'settings.highlight.note': '備註',
'settings.highlight.notePlaceholder': '規則說明(選填)',
'settings.highlight.caseInsensitive': '忽略大小寫',
@@ -173,6 +190,8 @@ const settings: Record<string, string> = {
'settings.highlight.importMode': '匯入方式',
'settings.highlight.importReplace': '取代現有規則',
'settings.highlight.importAppend': '附加到現有規則',
'settings.highlight.importReplaceTitle': '取代現有規則?',
'settings.highlight.importReplaceDesc': '匯入後目前全部規則將被這份檔案覆蓋,無法復原。',
'settings.highlight.importOk': '匯入',
'settings.highlight.importEmpty': '沒有解析到可用規則',
'settings.highlight.importBadJson': '不是有效的 JSON',
@@ -186,6 +205,8 @@ const settings: Record<string, string> = {
'settings.highlight.modeBasic': '僅基礎',
'settings.highlight.modeOff': '關閉',
'settings.highlight.modeDesc': '僅基礎:只保留錯誤/成功/警告/危險命令/密鑰等關鍵規則,開銷更小;關閉:完全不注入顏色(規則與顏色都保留)',
'settings.highlight.basic': '基礎規則',
'settings.highlight.basicHint': '開啟後,終端機高亮為「僅基礎」模式時該規則仍會生效',
'settings.highlight.category': '分類',
'settings.highlight.groupByCategory': '依分類分組',
'settings.highlight.category.safety': '安全',
@@ -213,6 +234,7 @@ const settings: Record<string, string> = {
'settings.highlight.profileNamePlaceholder': '例如:正式機(只看錯誤)',
'settings.highlight.profileRules': '包含的規則',
'settings.highlight.profileRulesHint': '未勾選的規則在這台主機上不生效',
'settings.highlight.profileExcluded': '未包含 {n} 條:{list}',
'settings.highlight.profileEmpty': '尚未建立規則集',
'settings.highlight.profileDeleteTitle': '刪除規則集',
'settings.highlight.actions': '操作',
+7 -4
View File
@@ -144,10 +144,13 @@ writeSettings({ historyLimit: 50, historyEnabled: true })
store.recordCommand('recorded-again')
ok(store.listHistory()[0].command === 'recorded-again', 'historyEnabled=true resumes recording')
// Default (keys absent from settings.json) records, matching a fresh install.
writeSettings({})
store.recordCommand('default-on')
ok(store.listHistory()[0].command === 'default-on', 'absent switch defaults to recording')
// Default (key absent from settings.json): the shipped default is recording OFF
// (DEFAULT_SETTINGS.terminal.historyEnabled = false, gate is `=== true`), and a
// fresh install has no key at all. The writeSettings helper always injects
// `historyEnabled: true`, so the absence case writes the file directly.
writeFileSync(join(userData, 'settings.json'), JSON.stringify({ terminal: {}, system: {} }), 'utf8')
store.recordCommand('default-off')
ok(!store.listHistory().some((h) => h.command === 'default-off'), 'absent switch defaults to NOT recording (shipped default)')
store.clearHistory()
// ---- 4. Library CRUD + ordering ------------------------------------------------
+48 -1
View File
@@ -12,7 +12,7 @@
* --outfile=tests/.hl-rules.cjs --alias:@shared=./src/shared
* node tests/hl-rules.mjs
*/
import { compileRules, applyHighlights } from '../src/renderer/src/terminal/highlightEngine.ts'
import { compileRules, applyHighlights, __testHooks as hooks } from '../src/renderer/src/terminal/highlightEngine.ts'
import { previewSpans } from '../src/renderer/src/terminal/highlightEngine.ts'
import { DEFAULT_HIGHLIGHT_RULES } from '../src/shared/settings.ts'
import { exportHighlightRules, mergeRules, parseHighlightRules } from '../src/shared/highlightIO.ts'
@@ -739,5 +739,52 @@ console.log('[profiles]')
)
}
// ---- 19. escape sequences pass through untouched ---------------------------
console.log('[escape sequences]')
{
// DCS / APC / PM / SOS bodies and single-char Fe escapes must keep their
// bytes raw — never wrapped in SGR — while text around them stays highlighted.
const families = [
['DCS (ST)', '\x1bP0;1;0q#0;2;0;0;0~..\x1b\\'],
['DCS (BEL)', '\x1bP1;2|9/9\x07'],
['APC (kitty graphics)', '\x1b_Gf=32,s=10,v=10,m=1\x1b\\'],
['PM', '\x1b^weather\x1b\\'],
['SOS', '\x1bXsos-frame\x1b\\']
]
// one coloured span, exactly as the engine emits it (ESC-free span text)
const SPAN_RE = /\x1b\[38;2;\d+;\d+;\d+(?:;48;2;\d+;\d+;\d+)?m[^\x1b]*\x1b\[0m/g
for (const [name, seq] of families) {
const out = applyHighlights(`DONE ${seq} ERROR`, rules)
ok(out.includes(`${sgr(OK_GREEN)}DONE\x1b[0m`), `${name}: text before it keeps its highlight`)
ok(out.includes(`${sgr(BAD_RED)}ERROR\x1b[0m`), `${name}: text after it keeps its highlight`)
ok(
out.replace(SPAN_RE, '').includes(seq),
`${name}: sequence bytes pass through raw, never wrapped in SGR`
)
}
const fe = applyHighlights('\x1b7DONE\x1b8 1', rules)
ok(fe.includes(`${sgr(OK_GREEN)}DONE\x1b[0m`), 'text between DECSC/DECRC keeps its highlight')
ok(fe.includes('\x1b7') && fe.includes('\x1b8'), 'DECSC/DECRC bytes pass through byte-for-byte')
// backstop: a text run holding a residual (unterminated) ESC passes through
// raw byte-for-byte — never partially highlighted, never corrupted
const raw = 'DONE \x1bP0;1;0qcut ERROR'
ok(applyHighlights(raw, rules) === raw, 'an unterminated DCS keeps its run byte-for-byte raw')
// the stream treats a cut DCS/APC/PM/SOS tail as incomplete, like OSC
ok(hooks.isIncompleteEscape('\x1b') === true, 'a lone trailing ESC stays incomplete')
ok(hooks.isIncompleteEscape('\x1bP0;1;0qcut') === true, 'a cut DCS tail counts as incomplete')
ok(hooks.isIncompleteEscape('\x1b_Gf=32') === true, 'a cut APC tail counts as incomplete')
ok(
hooks.isIncompleteEscape('\x1b^w') === true && hooks.isIncompleteEscape('\x1bXs') === true,
'cut PM/SOS tails count as incomplete'
)
ok(hooks.isIncompleteEscape('\x1bP0;1;0qx\x1b\\') === false, 'a terminated DCS is complete')
ok(hooks.isIncompleteEscape('\x1b7') === false, 'a complete two-byte Fe escape is complete')
ok(hooks.findIncompleteIndex('DONE \x1bP0;1;0qcut') === 5, 'the stream holds from the DCS initiator')
ok(hooks.findIncompleteIndex('DONE \x1b7') === -1, 'a trailing DECSC is not held as incomplete')
}
console.log(failed === 0 ? '\n[hl-rules] ALL CHECKS PASSED' : `\n[hl-rules] ${failed} CHECK(S) FAILED`)
process.exit(failed === 0 ? 0 : 1)
+14 -2
View File
@@ -7,12 +7,24 @@ import { DEFAULT_HIGHLIGHT_RULES } from '../src/shared/settings.ts'
const rules = compileRules(DEFAULT_HIGHLIGHT_RULES)
// kimi-like: truecolor SGR, OSC title, box drawing, per-line repaints
// kimi-like: truecolor SGR, OSC title, box drawing, per-line repaints.
// The tail line exercises the string-terminated families (DCS/sixel, APC/kitty
// graphics, PM, SOS), a BEL-terminated DCS, and DECSC/DECRC (`\x1b7`/`\x1b8`)
// so every cut point through them is checked against the reference.
const banner =
'\x1b]0;kimi — session\x07' +
'\x1b[38;2;79;168;255m╭───────╮\x1b[0m\r\n' +
'\x1b[38;2;136;136;136mWelcome to Kimi Code!\x1b[0m \x1b[38;2;79;168;255m│\x1b[0m send /help\r\n' +
'\x1b[38;2;224;224;224mModel: MiniMax-M3 0.41.038 ERROR 99% https://example.com/x\x1b[0m\r\n'
'\x1b[38;2;224;224;224mModel: MiniMax-M3 0.41.038 ERROR 99% https://example.com/x\x1b[0m\r\n' +
'\x1b7' +
'\x1b[38;2;224;224;224mERROR 85% exit code 1\x1b[0m' +
'\x1bP0;1;0q#0;2;0;0;0~-..\x1b\\' +
'\x1bP1;2|9/9\x07' +
'\x1b_Gf=32,s=10,v=10,m=1\x1b\\' +
'\x1b^pm-note\x1b\\' +
'\x1bXsos-frame\x1b\\' +
'\x1b[38;2;136;136;136mdone \u2713\x1b[0m' +
'\x1b8\r\n'
const reference = applyHighlights(banner, rules)