@aiti-ai/aiti-editor 1.0.9 → 1.0.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (162) hide show
  1. package/README.md +14 -22
  2. package/README.zh-CN.md +31 -0
  3. package/client/src/disk-refresh.test.mjs +69 -0
  4. package/client/src/disk-refresh.ts +64 -0
  5. package/client/src/editor-app.tsx +117 -16
  6. package/client/src/file-toolbar.tsx +5 -1
  7. package/dist/aiti-editor-client.mjs +3243 -3163
  8. package/index.ts +0 -1
  9. package/monaco/.version +1 -0
  10. package/monaco/vs/abap-D-t0cyap.js +1 -0
  11. package/monaco/vs/apex-CcIm7xu6.js +1 -0
  12. package/monaco/vs/assets/css.worker-URu8fCFR.js +97 -0
  13. package/monaco/vs/assets/editor.worker-lj3bdIIn.js +30 -0
  14. package/monaco/vs/assets/editorWebWorkerMain-CA_vMoUU.js +30 -0
  15. package/monaco/vs/assets/html.worker-D1SL3iM8.js +474 -0
  16. package/monaco/vs/assets/json.worker-CoJx_OPf.js +62 -0
  17. package/monaco/vs/assets/ts.worker-BWKtMYOk.js +67722 -0
  18. package/monaco/vs/azcli-BA0tQDCg.js +1 -0
  19. package/monaco/vs/basic-languages/monaco.contribution.js +1 -0
  20. package/monaco/vs/bat-C397hTD6.js +1 -0
  21. package/monaco/vs/bicep-DF5aW17k.js +2 -0
  22. package/monaco/vs/cameligo-plsz8qhj.js +1 -0
  23. package/monaco/vs/clojure-Y2auQMzK.js +1 -0
  24. package/monaco/vs/coffee-Bu45yuWE.js +1 -0
  25. package/monaco/vs/cpp-CkKPQIni.js +1 -0
  26. package/monaco/vs/csharp-CX28MZyh.js +1 -0
  27. package/monaco/vs/csp-D8uWnyxW.js +1 -0
  28. package/monaco/vs/css-CaeNmE3S.js +3 -0
  29. package/monaco/vs/css.worker-CyhWkhHo.js +1 -0
  30. package/monaco/vs/cssMode-CV6Ay48H.js +1 -0
  31. package/monaco/vs/cypher-DVThT8BS.js +1 -0
  32. package/monaco/vs/dart-CmGfCvrO.js +1 -0
  33. package/monaco/vs/dockerfile-CZqqYdch.js +1 -0
  34. package/monaco/vs/ecl-30fUercY.js +1 -0
  35. package/monaco/vs/editor/editor.main.css +1 -0
  36. package/monaco/vs/editor/editor.main.js +1 -0
  37. package/monaco/vs/editor/editor.worker.js +1 -0
  38. package/monaco/vs/editor-KLE6jdfb.js +831 -0
  39. package/monaco/vs/editor.js +1 -0
  40. package/monaco/vs/editorWorkerHost-fVE1cjcC.js +35 -0
  41. package/monaco/vs/elixir-xjPaIfzF.js +1 -0
  42. package/monaco/vs/flow9-DqtmStfK.js +1 -0
  43. package/monaco/vs/freemarker2-FWreY7v7.js +3 -0
  44. package/monaco/vs/fsharp-BOMdg4U1.js +1 -0
  45. package/monaco/vs/go-D_hbi-Jt.js +1 -0
  46. package/monaco/vs/graphql-CKUU4kLG.js +1 -0
  47. package/monaco/vs/handlebars-CZi4pHCH.js +1 -0
  48. package/monaco/vs/hcl-DTaboeZW.js +1 -0
  49. package/monaco/vs/html-j_7ZTNRU.js +1 -0
  50. package/monaco/vs/html.worker-CA3iAimZ.js +1 -0
  51. package/monaco/vs/htmlMode-27by_KM4.js +1 -0
  52. package/monaco/vs/index-049xrXrp.js +1 -0
  53. package/monaco/vs/index-CBVt3dzv.js +5 -0
  54. package/monaco/vs/index.js +1 -0
  55. package/monaco/vs/ini-CsNwO04R.js +1 -0
  56. package/monaco/vs/initialize-DL0l1TGY.js +1 -0
  57. package/monaco/vs/java-CI4ZMsH9.js +1 -0
  58. package/monaco/vs/javascript-DTJl4Jn1.js +1 -0
  59. package/monaco/vs/json.worker-BizpAl9O.js +1 -0
  60. package/monaco/vs/jsonMode-DLUiMUrI.js +1 -0
  61. package/monaco/vs/julia-BwzEvaQw.js +1 -0
  62. package/monaco/vs/kotlin-IUYPiTV8.js +1 -0
  63. package/monaco/vs/language/css/css.worker.js +66 -0
  64. package/monaco/vs/language/css/monaco.contribution.js +1 -0
  65. package/monaco/vs/language/html/html.worker.js +443 -0
  66. package/monaco/vs/language/html/monaco.contribution.js +1 -0
  67. package/monaco/vs/language/json/json.worker.js +17 -0
  68. package/monaco/vs/language/json/monaco.contribution.js +1 -0
  69. package/monaco/vs/language/typescript/monaco.contribution.js +1 -0
  70. package/monaco/vs/language/typescript/ts.worker.js +67693 -0
  71. package/monaco/vs/less-C0eDYdqa.js +2 -0
  72. package/monaco/vs/lexon-iON-Kj97.js +1 -0
  73. package/monaco/vs/liquid-CLJYelW6.js +1 -0
  74. package/monaco/vs/loader.js +1368 -0
  75. package/monaco/vs/lspLanguageFeatures-BIkJOWLw.js +2 -0
  76. package/monaco/vs/lua-DtygF91M.js +1 -0
  77. package/monaco/vs/m3-CsR4AuFi.js +1 -0
  78. package/monaco/vs/main-BEx-Fmlo.js +15 -0
  79. package/monaco/vs/main-DsK8pnKg.js +3 -0
  80. package/monaco/vs/markdown-C_rD0bIw.js +1 -0
  81. package/monaco/vs/mdx-CpzZIPHF.js +1 -0
  82. package/monaco/vs/mips-CiYP61RB.js +1 -0
  83. package/monaco/vs/monaco.contribution-9cKT3C7t.js +1 -0
  84. package/monaco/vs/monaco.contribution-BE88ZNGY.js +1 -0
  85. package/monaco/vs/monaco.contribution-BPhsneLd.js +1 -0
  86. package/monaco/vs/monaco.contribution-BgRy6xDf.js +1 -0
  87. package/monaco/vs/msdax-C38-sJlp.js +1 -0
  88. package/monaco/vs/mysql-CdtbpvbG.js +1 -0
  89. package/monaco/vs/nls/lang/cs.d.ts +1 -0
  90. package/monaco/vs/nls/lang/cs.js +9 -0
  91. package/monaco/vs/nls/lang/de.d.ts +1 -0
  92. package/monaco/vs/nls/lang/de.js +9 -0
  93. package/monaco/vs/nls/lang/es.d.ts +1 -0
  94. package/monaco/vs/nls/lang/es.js +9 -0
  95. package/monaco/vs/nls/lang/fr.d.ts +1 -0
  96. package/monaco/vs/nls/lang/fr.js +9 -0
  97. package/monaco/vs/nls/lang/it.d.ts +1 -0
  98. package/monaco/vs/nls/lang/it.js +9 -0
  99. package/monaco/vs/nls/lang/ja.d.ts +1 -0
  100. package/monaco/vs/nls/lang/ja.js +9 -0
  101. package/monaco/vs/nls/lang/ko.d.ts +1 -0
  102. package/monaco/vs/nls/lang/ko.js +9 -0
  103. package/monaco/vs/nls/lang/pl.d.ts +1 -0
  104. package/monaco/vs/nls/lang/pl.js +9 -0
  105. package/monaco/vs/nls/lang/pt-br.d.ts +1 -0
  106. package/monaco/vs/nls/lang/pt-br.js +9 -0
  107. package/monaco/vs/nls/lang/ru.d.ts +1 -0
  108. package/monaco/vs/nls/lang/ru.js +9 -0
  109. package/monaco/vs/nls/lang/tr.d.ts +1 -0
  110. package/monaco/vs/nls/lang/tr.js +9 -0
  111. package/monaco/vs/nls/lang/zh-cn.d.ts +1 -0
  112. package/monaco/vs/nls/lang/zh-cn.js +9 -0
  113. package/monaco/vs/nls/lang/zh-tw.d.ts +1 -0
  114. package/monaco/vs/nls/lang/zh-tw.js +9 -0
  115. package/monaco/vs/nls.messages-loader.js +1 -0
  116. package/monaco/vs/objective-c-CntZFaHX.js +1 -0
  117. package/monaco/vs/pascal-r6kuqfl_.js +1 -0
  118. package/monaco/vs/pascaligo-BiXoTmXh.js +1 -0
  119. package/monaco/vs/perl-DABw_TcH.js +1 -0
  120. package/monaco/vs/pgsql-me_jFXeX.js +1 -0
  121. package/monaco/vs/php-D_kh-9LK.js +1 -0
  122. package/monaco/vs/pla-VfZjczW0.js +1 -0
  123. package/monaco/vs/postiats-BBSzz8Pk.js +1 -0
  124. package/monaco/vs/powerquery-Dt-g_2cc.js +1 -0
  125. package/monaco/vs/powershell-B-7ap1zc.js +1 -0
  126. package/monaco/vs/protobuf-BmtuEB1A.js +2 -0
  127. package/monaco/vs/pug-BRpRNeEb.js +1 -0
  128. package/monaco/vs/python-CqWUUgfu.js +1 -0
  129. package/monaco/vs/qsharp-BzsFaUU9.js +1 -0
  130. package/monaco/vs/r-f8dDdrp4.js +1 -0
  131. package/monaco/vs/razor-hok5y9Km.js +1 -0
  132. package/monaco/vs/redis-fvZQY4PI.js +1 -0
  133. package/monaco/vs/redshift-45Et0LQi.js +1 -0
  134. package/monaco/vs/restructuredtext-C7UUFKFD.js +1 -0
  135. package/monaco/vs/ruby-CZO8zYTz.js +1 -0
  136. package/monaco/vs/rust-Bfetafyc.js +1 -0
  137. package/monaco/vs/sb-3GYllVck.js +1 -0
  138. package/monaco/vs/scala-foMgrKo1.js +1 -0
  139. package/monaco/vs/scheme-CHdMtr7p.js +1 -0
  140. package/monaco/vs/scss-C1cmLt9V.js +3 -0
  141. package/monaco/vs/shell-ClXCKCEW.js +1 -0
  142. package/monaco/vs/solidity-MZ6ExpPy.js +1 -0
  143. package/monaco/vs/sophia-DWkuSsPQ.js +1 -0
  144. package/monaco/vs/sparql-AUGFYSyk.js +1 -0
  145. package/monaco/vs/sql-32GpJSV2.js +1 -0
  146. package/monaco/vs/st-CuDFIVZ_.js +1 -0
  147. package/monaco/vs/swift-t-PfMj7W.js +1 -0
  148. package/monaco/vs/systemverilog-Ch4vA8Yt.js +1 -0
  149. package/monaco/vs/tcl-D74tq1nH.js +1 -0
  150. package/monaco/vs/toggleHighContrast-qGX7E9o7.js +61 -0
  151. package/monaco/vs/ts.worker-2QLmBukE.js +1 -0
  152. package/monaco/vs/tsMode-B0S_kp2q.js +11 -0
  153. package/monaco/vs/twig-C6taOxMV.js +1 -0
  154. package/monaco/vs/typescript-_2t_511t.js +1 -0
  155. package/monaco/vs/typespec-8OfoLt6R.js +1 -0
  156. package/monaco/vs/vb-Dyb2648j.js +1 -0
  157. package/monaco/vs/wgsl-BhLXMOR0.js +298 -0
  158. package/monaco/vs/workers-BBttULjf.js +1 -0
  159. package/monaco/vs/xml-cZjNDqDT.js +1 -0
  160. package/monaco/vs/yaml-A1fOIdH6.js +1 -0
  161. package/package.json +6 -5
  162. package/tools/materialize-monaco.mjs +126 -0
package/README.md CHANGED
@@ -1,38 +1,30 @@
1
1
  # @aiti-ai/aiti-editor
2
2
 
3
- > 可扩展编辑器 —— Monaco 编辑接管 + 保存(mtime 冲突 409 + 白名单/大小校验)+ 压缩包解析 + 统一图片查看器(消息图片/文件图片合一,缩放·捏合·信息面板)
3
+ > Extensible editor — Monaco editing take over + save (mtime conflict 409 + whitelist/size checks) + archive extraction + unified image viewer (message images and file images unified, zoom · pinch · info panel)
4
4
 
5
- ## 来源 · Origin
5
+ English · [简体中文](./README.zh-CN.md)
6
6
 
7
- 本包是 **aiti** 生态的组成部分——以 [pi](https://pi.dev) 为引擎的个人 AI 开发工作台与本地调试注入生态:**aiti-agent**(自托管 Web 工作台,基于开源项目 [pi-web](https://github.com/aiti-ai/pi-web) 构建,浏览器为主操作面)+ 各开发工具内的调试注入(aiti-devtools)+ 丰富的扩展体系。
7
+ ## About aiti
8
8
 
9
9
  This package is part of the **aiti** ecosystem — a personal AI development workbench powered by [pi](https://pi.dev): **aiti-agent** (a self-hosted web workbench built on the open-source [pi-web](https://github.com/aiti-ai/pi-web)) plus debug injection into mainstream dev tools (aiti-devtools) and a rich extension system.
10
10
 
11
- - 工作台 / workbench: <https://github.com/aiti-ai/pi-web>
12
- - 生态仓库 / monorepo: <https://github.com/aiti-ai/aiti-ai>
13
- - 官网 / site: <https://aiti.pw>
11
+ - Workbench (aiti-agent, built on pi-web): <https://github.com/aiti-ai/pi-web>
12
+ - Ecosystem monorepo: <https://github.com/aiti-ai/aiti-ai>
13
+ - Site: <https://aiti.pw>
14
14
 
15
- ## 功能定位(中文)
15
+ Category: Developer tools · Tags: `editor` · `monaco` · `save` · `file-viewer` · `archive`
16
16
 
17
- 可扩展编辑器 —— Monaco 编辑接管 + 保存(mtime 冲突 409 + 白名单/大小校验)+ 压缩包解析 + 统一图片查看器(消息图片/文件图片合一,缩放·捏合·信息面板)
17
+ ## Install
18
18
 
19
- 开发工具 · tags: `editor` · `monaco` · `save` · `file-viewer` · `archive`
19
+ Bundled with **aiti-agent** (`@aiti-ai/pi-web`) — enable it from the in-app extension manager (ExtMan / App Center).
20
20
 
21
- ## Positioning (English)
21
+ Manual install: place this package at `~/.pi/web/extensions/aiti-editor/` and reload pi-web.
22
22
 
23
- Extensible editor — Monaco editing take over + save (mtime conflict 409 + whitelist/size checks) + archive extraction + unified image viewer (message images and file images unified, zoom · pinch · info panel)
23
+ ## Documentation
24
24
 
25
- ## 安装 / 部署
26
-
27
- ```bash
28
- # 部署到 ~/.pi/web/extensions/aiti-editor/ 后 reload
29
- ```
30
-
31
- ## 文档 · Documentation
32
-
33
- - 中文完整文档(aiti-docs): <https://aiti.pw/extensions/aiti-editor.html>
34
- - English docs: <https://aiti.pw/en/extensions/aiti-editor.html>
35
- - 扩展注册表 / registry: <https://aiti.pw/extensions.html> · machine-readable <https://aiti.pw/extensions.json>
25
+ - Documentation (English): <https://aiti.pw/en/extensions/aiti-editor.html>
26
+ - 文档(中文): <https://aiti.pw/extensions/aiti-editor.html>
27
+ - Extension registry: <https://aiti.pw/extensions.html> · machine-readable <https://aiti.pw/extensions.json>
36
28
 
37
29
  ## License
38
30
 
@@ -0,0 +1,31 @@
1
+ # @aiti-ai/aiti-editor
2
+
3
+ > 可扩展编辑器 —— Monaco 编辑接管 + 保存(mtime 冲突 409 + 白名单/大小校验)+ 压缩包解析 + 统一图片查看器(消息图片/文件图片合一,缩放·捏合·信息面板)
4
+
5
+ [English](./README.md) · 简体中文
6
+
7
+ ## 关于 aiti
8
+
9
+ 本包是 **aiti** 生态的组成部分——以 [pi](https://pi.dev) 为引擎的个人 AI 开发工作台与本地调试注入生态:**aiti-agent**(自托管 Web 工作台,基于开源项目 [pi-web](https://github.com/aiti-ai/pi-web) 构建,浏览器为主操作面)+ 各开发工具内的调试注入(aiti-devtools)+ 丰富的扩展体系。
10
+
11
+ - 工作台(aiti-agent,基于 pi-web): <https://github.com/aiti-ai/pi-web>
12
+ - 生态仓库: <https://github.com/aiti-ai/aiti-ai>
13
+ - 官网: <https://aiti.pw>
14
+
15
+ 分类: 开发工具 · tags: `editor` · `monaco` · `save` · `file-viewer` · `archive`
16
+
17
+ ## 安装
18
+
19
+ 随 **aiti-agent**(`@aiti-ai/pi-web`)捆绑发行——在工作台内的扩展管理器(ExtMan / 应用中心)启用即可。
20
+
21
+ 手动安装:把本包放到 `~/.pi/web/extensions/aiti-editor/` 后 reload。
22
+
23
+ ## 文档
24
+
25
+ - 中文文档: <https://aiti.pw/extensions/aiti-editor.html>
26
+ - Documentation (English): <https://aiti.pw/en/extensions/aiti-editor.html>
27
+ - 扩展注册表: <https://aiti.pw/extensions.html> · machine-readable <https://aiti.pw/extensions.json>
28
+
29
+ ## 许可证
30
+
31
+ ISC
@@ -0,0 +1,69 @@
1
+ /**
2
+ * disk-refresh.ts tests — disk-change auto-refresh decision layer (pure functions).
3
+ *
4
+ * Plan: docs/aiti-editor-preview-watch-refresh-plan.md §3.4
5
+ * Run: node --experimental-strip-types --test pi-web-ext/aiti-editor/client/src/disk-refresh.test.mjs
6
+ */
7
+ import assert from "node:assert/strict";
8
+ import test from "node:test";
9
+
10
+ const { sameDiskSig, shouldAutoReload, throttleOk, sigOfFrame } = await import("./disk-refresh.ts");
11
+
12
+ // ── sameDiskSig(保存回环抑制)──
13
+ test("sameDiskSig: identical sigs match", () => {
14
+ assert.equal(sameDiskSig({ mtimeMs: 1000, size: 5 }, { mtimeMs: 1000, size: 5 }), true);
15
+ });
16
+
17
+ test("sameDiskSig: mtime within ±2ms tolerance matches (float save response vs floored frame)", () => {
18
+ assert.equal(sameDiskSig({ mtimeMs: 1000.4, size: 5 }, { mtimeMs: 1000, size: 5 }), true);
19
+ assert.equal(sameDiskSig({ mtimeMs: 1002, size: 5 }, { mtimeMs: 1000, size: 5 }), true);
20
+ });
21
+
22
+ test("sameDiskSig: mtime beyond tolerance does not match", () => {
23
+ assert.equal(sameDiskSig({ mtimeMs: 1003, size: 5 }, { mtimeMs: 1000, size: 5 }), false);
24
+ });
25
+
26
+ test("sameDiskSig: size mismatch does not match even with same mtime", () => {
27
+ assert.equal(sameDiskSig({ mtimeMs: 1000, size: 6 }, { mtimeMs: 1000, size: 5 }), false);
28
+ });
29
+
30
+ test("sameDiskSig: null/undefined/missing-mtime never match", () => {
31
+ assert.equal(sameDiskSig(null, { mtimeMs: 1, size: 1 }), false);
32
+ assert.equal(sameDiskSig({ mtimeMs: 1, size: 1 }, undefined), false);
33
+ assert.equal(sameDiskSig({ mtimeMs: undefined, size: 1 }, { mtimeMs: 1, size: 1 }), false);
34
+ });
35
+
36
+ // ── shouldAutoReload(视图分派)──
37
+ test("shouldAutoReload: binary views always reload", () => {
38
+ assert.equal(shouldAutoReload("binary", true), true);
39
+ assert.equal(shouldAutoReload("binary", false), true);
40
+ });
41
+
42
+ test("shouldAutoReload: text view reloads only when clean", () => {
43
+ assert.equal(shouldAutoReload("text", false), true);
44
+ assert.equal(shouldAutoReload("text", true), false);
45
+ });
46
+
47
+ // ── throttleOk(自动重载节流)──
48
+ test("throttleOk: first reload (last=0) always allowed", () => {
49
+ assert.equal(throttleOk(0, 5000, 1000), true);
50
+ });
51
+
52
+ test("throttleOk: within interval denied, at/past interval allowed", () => {
53
+ assert.equal(throttleOk(5000, 5999, 1000), false);
54
+ assert.equal(throttleOk(5000, 6000, 1000), true);
55
+ });
56
+
57
+ // ── sigOfFrame(帧解析)──
58
+ test("sigOfFrame: valid frame parsed", () => {
59
+ assert.deepEqual(sigOfFrame({ mtimeMs: 123, size: 7 }), { mtimeMs: 123, size: 7 });
60
+ });
61
+
62
+ test("sigOfFrame: malformed frames → null", () => {
63
+ assert.equal(sigOfFrame(null), null);
64
+ assert.equal(sigOfFrame(undefined), null);
65
+ assert.equal(sigOfFrame({}), null);
66
+ assert.equal(sigOfFrame({ mtimeMs: NaN, size: 1 }), null);
67
+ assert.equal(sigOfFrame({ mtimeMs: 1, size: -1 }), null);
68
+ assert.equal(sigOfFrame({ mtimeMs: "123", size: 1 }), null);
69
+ });
@@ -0,0 +1,64 @@
1
+ /**
2
+ * aiti-editor · disk-change auto-refresh decision layer (pure functions, zero window/ctx deps).
3
+ *
4
+ * Plan: docs/aiti-editor-preview-watch-refresh-plan.md §3.1
5
+ *
6
+ * Why a standalone module: the ed-watch frame used to be consumed only by media files
7
+ * (isMediaFile hardcoded in the watch effect). Generalizing watch to all file types widens
8
+ * the decision surface (own-save echo suppression / throttle / per-view dispatch), so the
9
+ * decisions live here where node --test can pin them down.
10
+ */
11
+
12
+ /** Signature of the file on disk as seen by an ed-watch frame or a /stat response. */
13
+ export interface DiskSig { mtimeMs: number; size: number }
14
+
15
+ /**
16
+ * Echo suppression: does this frame signature match the last disk state we already know?
17
+ * Used to ignore frames caused by our own save (the /save response reports the new
18
+ * mtimeMs+size, which we record in diskSigRef). mtime tolerance ±2ms absorbs the
19
+ * float→floor conversion done server-side (`Math.floor(mtimeMs)` in the watch frame).
20
+ */
21
+ export function sameDiskSig(a: DiskSig | null | undefined, b: DiskSig | null | undefined): boolean {
22
+ if (!a || !b) return false;
23
+ if (typeof a.mtimeMs !== "number" || typeof b.mtimeMs !== "number") return false;
24
+ return Math.abs(a.mtimeMs - b.mtimeMs) <= 2 && a.size === b.size;
25
+ }
26
+
27
+ /** Which kind of view is currently shown for this file. */
28
+ export type ViewKind = "binary" | "text";
29
+
30
+ /**
31
+ * Should an incoming disk-change frame trigger an automatic reload?
32
+ * - binary views (image / video / audio / pdf / archive): always — they are read-only,
33
+ * there is no unsaved buffer to lose.
34
+ * - text views (edit / split / preview): only when clean. With unsaved edits we never
35
+ * silently overwrite — the caller shows the existing "disk modified" bar instead
36
+ * (three-way merge / load from disk / keep local).
37
+ */
38
+ export function shouldAutoReload(kind: ViewKind, dirty: boolean): boolean {
39
+ if (kind === "binary") return true;
40
+ return !dirty;
41
+ }
42
+
43
+ /**
44
+ * Throttle gate for automatic text reloads: agents streaming into a file can produce a
45
+ * frame every 300ms (server debounce); reloading + re-rendering Monaco that often is
46
+ * wasteful, so automatic reloads keep a minimum interval. Skipped frames still update
47
+ * the known signature (the next frame or the manual Reload menu item catches up).
48
+ */
49
+ export function throttleOk(lastReloadAt: number, now: number, minIntervalMs = 1000): boolean {
50
+ return now - lastReloadAt >= minIntervalMs;
51
+ }
52
+
53
+ /**
54
+ * Parse an ed-watch frame into a DiskSig. Returns null for malformed frames
55
+ * (missing/NaN fields) and for "gone" frames — the caller handles gone separately.
56
+ */
57
+ export function sigOfFrame(frame: { mtimeMs?: unknown; size?: unknown } | null | undefined): DiskSig | null {
58
+ if (!frame) return null;
59
+ const mtimeMs = frame.mtimeMs;
60
+ const size = frame.size;
61
+ if (typeof mtimeMs !== "number" || !isFinite(mtimeMs)) return null;
62
+ if (typeof size !== "number" || !isFinite(size) || size < 0) return null;
63
+ return { mtimeMs, size };
64
+ }
@@ -11,6 +11,7 @@ import { ENCODINGS, encDisplay, formatSize, mdSlug, sourceLinesForText, isDarkTh
11
11
  import { detectLanguage, mdFenceDescriptors } from "./registry";
12
12
  import { markdownPreviewHtml, markdownRenderHtml } from "./markdown";
13
13
  import { previewKindOf, defaultViewModeOf, previewModeItems } from "./preview";
14
+ import { sameDiskSig, shouldAutoReload, throttleOk, sigOfFrame, type DiskSig } from "./disk-refresh";
14
15
  import { getPopover, previewClickInfo, closePreviewPopover, mdSmartEnabled } from "./md-smart";
15
16
  import { openViewerWindow } from "../../../_shared/open-viewer-window";
16
17
  import { openNewSessionWithExtSvc } from "../../../_shared/new-session-cwd";
@@ -171,12 +172,20 @@ export function EditorApp(props: Record<string, any>) {
171
172
  return () => { alive = false; };
172
173
  }, [filePath]);
173
174
  // watch 走 pi-web 页面事件流(globalThis.__piPageEvents 广播的 ed-watch 帧,帧带
174
- // 随机 watchId,浏览器按 id 过滤;路径/句柄不进 URL)。仅播放中启用(连接池保护);
175
- // 页面事件流客户端缺席(core 过旧)→ 静态预览降级。
175
+ // 随机 watchId,浏览器按 id 过滤;路径/句柄不进 URL)。全类型文件打开期间常开
176
+ // (总线共享连接,零新增 socket;服务端 30min 无事件自动停兜底)。
177
+ // 页面事件流客户端缺席(core 过旧)→ 静态预览降级 + ⋯菜单「重新加载」手动兜底。
178
+ // 帧分派决策(回环抑制/节流/视图路由)见 ./disk-refresh(方案 §3.1)。
179
+ const diskSigRef = useRef<DiskSig | null>(null); // 最近已知磁盘签名(自己保存的回声抑制)
180
+ const lastDiskReloadRef = useRef(0); // 文本自动重载节流游标
181
+ const archiveTickRef = useRef(0); // 压缩包列表刷新 tick(帧到 → 重拉列表)
182
+ const [archiveTick, setArchiveTick] = useState(0);
176
183
  useEffect(() => {
177
184
  setMediaBust(0);
178
185
  setMediaDuration(null);
179
- if (!isMediaFile || props.watchEnabled === false) return; // 总线共享连接,不占额外 socket——媒体打开期间常开
186
+ diskSigRef.current = null;
187
+ lastDiskReloadRef.current = 0;
188
+ if (props.watchEnabled === false) return;
180
189
  let dead = false;
181
190
  let unsub: (() => void) | null = null;
182
191
  let watchId: string | null = null;
@@ -199,14 +208,74 @@ export function EditorApp(props: Record<string, any>) {
199
208
  if (!watchId) return;
200
209
  unsub = client.subscribePageFrames((frame: any) => {
201
210
  if (dead || frame.type !== "ed-watch" || frame.id !== watchId) return;
202
- setMediaBust((v: number) => v + 1);
211
+ try { diskFrameRef.current(frame); } catch { /* 帧处理异常不扩散 */ }
203
212
  });
204
213
  setWatchLive(true);
205
214
  } catch { /* 静默降级为静态预览 */ }
206
215
  };
207
216
  open();
208
217
  return () => { dead = true; setWatchLive(false); stop(); try { unsub?.(); } catch { /* noop */ } };
209
- }, [filePath, isMediaFile, props.watchEnabled]);
218
+ }, [filePath, props.watchEnabled]);
219
+
220
+ // 从磁盘重读文本并重置编辑缓冲(watch 帧 clean 自动刷新 / ⋯菜单「重新加载」共用)。
221
+ // setValue → onDidChangeModelContent → bumpPreview 自动联动预览刷新;基线签名同步
222
+ // diskSigRef(下一次自己保存前的帧不再误报)。
223
+ const reloadFromDisk = useCallback((sig?: DiskSig | null) => {
224
+ const encU = meta && meta.encoding ? "&encoding=" + encodeURIComponent(meta.encoding) : "";
225
+ const statP = api("/stat?filePath=" + encodeURIComponent(filePath)).then((r) => r.json()).catch(() => null);
226
+ const rawP = api("/raw?filePath=" + encodeURIComponent(filePath) + encU).then((r) => r.json());
227
+ Promise.all([statP, rawP]).then(([m, d]) => {
228
+ if (d.error) { setNotice({ kind: "warn", text: "刷新失败:" + d.error }); return; }
229
+ const content = d.content || "";
230
+ const mtimeMs = (m && m.ok && typeof m.mtimeMs === "number") ? m.mtimeMs : (sig ? sig.mtimeMs : Date.now());
231
+ const size = (m && m.ok && typeof m.size === "number") ? m.size : (sig ? sig.size : content.length);
232
+ const ed = editorRef.current;
233
+ if (ed) ed.setValue(content);
234
+ initialContentRef.current = content;
235
+ setMeta((prev: any) => (prev ? { ...prev, content, size } : prev));
236
+ const cache = (window as any).__aitiEditor._cacheGet(filePath);
237
+ if (cache) { cache.content = content; cache.original = content; cache.baseMtimeMs = mtimeMs; cache.size = size; }
238
+ baseMtimeRef.current = mtimeMs;
239
+ diskSigRef.current = { mtimeMs, size };
240
+ setDirty(false);
241
+ (window as any).__aitiEditor._setDirty(filePath, false);
242
+ setExtChange(null);
243
+ }).catch(() => { /* 下一次帧/渲染期磁盘检查兜底 */ });
244
+ }, [filePath, meta, bumpPreview]);
245
+
246
+ // ⋯菜单「重新加载」:clean 直接重读磁盘;dirty 不丢编辑 → 拉真实磁盘签名后弹
247
+ // 「磁盘已修改」条(三路合并/加载磁盘版/保留当前);二进制 → bust 强制重载。
248
+ const doReload = useCallback(() => {
249
+ if (fileType !== "text") { setMediaBust((v: number) => v + 1); if (fileType === "archive") { archiveTickRef.current += 1; setArchiveTick(archiveTickRef.current); } return; }
250
+ if (dirty) {
251
+ api("/stat?filePath=" + encodeURIComponent(filePath)).then((r) => r.json())
252
+ .then((m) => setExtChange({ diskMtimeMs: (m && m.ok && typeof m.mtimeMs === "number") ? m.mtimeMs : Date.now(), diskSize: (m && m.ok && typeof m.size === "number") ? m.size : 0 }))
253
+ .catch(() => setExtChange({ diskMtimeMs: Date.now(), diskSize: 0 }));
254
+ return;
255
+ }
256
+ reloadFromDisk(null);
257
+ }, [filePath, fileType, dirty, reloadFromDisk]);
258
+
259
+ // 帧分派(每渲染重挂 ref——闭包始终拿最新 dirty/meta/fileType;订阅本身不随渲染重启)
260
+ const diskFrameRef = useRef<(frame: any) => void>(() => {});
261
+ useEffect(() => {
262
+ diskFrameRef.current = (frame: any) => {
263
+ if (frame.gone) { setNotice({ kind: "warn", text: "磁盘上的文件已不存在(可能被移动或删除)" }); return; }
264
+ const sig = sigOfFrame(frame);
265
+ if (!sig) return;
266
+ if (sameDiskSig(sig, diskSigRef.current)) return; // 自己保存的回声 / 已知状态,忽略
267
+ diskSigRef.current = sig;
268
+ if (fileType !== "text") {
269
+ setMediaBust((v: number) => v + 1); // 图片/PDF/媒体:bust 换缓存指纹重载
270
+ if (fileType === "archive") { archiveTickRef.current += 1; setArchiveTick(archiveTickRef.current); } // 压缩包列表重拉
271
+ return;
272
+ }
273
+ if (!shouldAutoReload("text", dirty)) { setExtChange({ diskMtimeMs: sig.mtimeMs, diskSize: sig.size }); return; }
274
+ if (!throttleOk(lastDiskReloadRef.current, Date.now(), 1000)) return; // 高频写节流,下一帧/菜单兜底
275
+ lastDiskReloadRef.current = Date.now();
276
+ reloadFromDisk(sig);
277
+ };
278
+ }); // 无依赖数组:每渲染更新
210
279
  const isMarkdown = language === "markdown";
211
280
  // 预览面能力判定(方案 §3.1):`isMarkdown` 此后只管 markdown 专属功能(md-smart 弹层、TOC
212
281
  // 滚动同步、表格格式化、导出 HTML/PDF),预览壳/分栏/模式切换器一律看 canPreview
@@ -221,7 +290,7 @@ export function EditorApp(props: Record<string, any>) {
221
290
  .then((r) => r.json())
222
291
  .then((d) => { if (d.ok) setArchiveFiles(d.files); else setArchiveErr(d.error || "解析失败"); })
223
292
  .catch((e) => setArchiveErr(String(e)));
224
- }, [filePath, fileType]);
293
+ }, [filePath, fileType, archiveTick]); // archiveTick:ed-watch 帧到 → 重拉列表(磁盘上包被换了)
225
294
 
226
295
  // ── 同目录文件遍历(‹ ›):服务端深度遍历(DFS、按名排序),仅文件不含文件夹 ──
227
296
  // 遍历根:优先项目根 cwd(子树走到头自然接上父级的下一个子树,不会卡在叶节点);
@@ -349,6 +418,7 @@ export function EditorApp(props: Record<string, any>) {
349
418
  .then((m) => {
350
419
  if (m && m.ok && typeof m.mtimeMs === "number") {
351
420
  baseMtimeRef.current = m.mtimeMs;
421
+ diskSigRef.current = { mtimeMs: m.mtimeMs, size: m.size || 0 };
352
422
  if (Math.abs(m.mtimeMs - cache.baseMtimeMs) > 1) {
353
423
  // 磁盘变了,静默合并
354
424
  const original = cache.original || "";
@@ -400,6 +470,10 @@ export function EditorApp(props: Record<string, any>) {
400
470
  return;
401
471
  }
402
472
  const u = new URLSearchParams({ encoding: openEncoding });
473
+ // stat 与 /raw 并行:缓存基线(baseMtimeMs)必须是【磁盘真实 mtime】而非 Date.now()——
474
+ // 否则服务端缓存的基线永远「比磁盘新」,磁盘新鲜度门卫(下方 stale 判定)永远失效。
475
+ const diskStatP = api("/stat?filePath=" + encodeURIComponent(filePath), { method: "GET" })
476
+ .then((r) => r.json()).catch(() => null);
403
477
  api("/raw?filePath=" + encodeURIComponent(filePath) + "&" + u.toString())
404
478
  .then((r) => r.json())
405
479
  .then((d) => {
@@ -408,13 +482,31 @@ export function EditorApp(props: Record<string, any>) {
408
482
  // 先检查服务端缓存(多开恢复):必须早于 setMeta/setStatus——否则 Monaco create
409
483
  // 触发 onDidChangeModelContent 写回原版覆盖服务端编辑
410
484
  let _recoveredSrv: any = null;
411
- (window as any).__aitiEditor._cacheFetchFromServer(filePath, true).then((srv: any) => {
485
+ (window as any).__aitiEditor._cacheFetchFromServer(filePath, true).then(async (srv: any) => {
412
486
  _recoveredSrv = srv;
413
487
  if (srv && srv.contentHash && srv.contentHash !== (window as any).__aitiEditor._hashContent(next.content)) {
414
- next.content = srv.content;
415
- next.encoding = srv.encoding || next.encoding;
488
+ // 磁盘新鲜度门卫(重开显示最新版):用【基线内容 hash】而非 mtime——
489
+ // 缓存的 original = 上次会话看到的磁盘版;它与当前磁盘内容不一致 = 文件已被
490
+ // 外部更新(agent/shell),旧「未保存编辑」不再值得恢复 → 放弃恢复(磁盘版为准)
491
+ // 并清掉过期服务端缓存,避免下次打开再误判 dirty。(mtime 维度不可靠:精度/回拨/旧版污染)
492
+ try {
493
+ const stale = srv && srv.original != null
494
+ && (window as any).__aitiEditor._hashContent(String(srv.original))
495
+ !== (window as any).__aitiEditor._hashContent(d.content || "");
496
+ if (stale) {
497
+ _recoveredSrv = null;
498
+ api("/cache-delete", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ filePath }) }).catch(() => {});
499
+ next.content = d.content || "";
500
+ next.encoding = d.encoding || "utf-8";
501
+ next.hasBOM = d.hasBOM === true;
502
+ next.size = d.size || next.content.length;
503
+ } else {
504
+ next.content = srv.content;
505
+ next.encoding = srv.encoding || next.encoding;
506
+ }
507
+ } catch { next.content = srv.content; next.encoding = srv.encoding || next.encoding; }
416
508
  }
417
- }).finally(() => {
509
+ }).finally(async () => {
418
510
  const isRecovered = _recoveredSrv && _recoveredSrv.contentHash && _recoveredSrv.contentHash !== (window as any).__aitiEditor._hashContent(d.content || "");
419
511
  const baseline = isRecovered ? (_recoveredSrv.original || _recoveredSrv.content) : next.content;
420
512
  initialContentRef.current = baseline;
@@ -422,10 +514,13 @@ export function EditorApp(props: Record<string, any>) {
422
514
  setStatus({ phase: "ready", error: null });
423
515
  (window as any).__aitiEditor._setDirty(filePath, isRecovered);
424
516
  setDirty(isRecovered);
425
- // 建缓存:恢复时 original=服务端基线(磁盘原版)→ dirty 判定正确
517
+ // 建缓存:恢复时 original=服务端基线(磁盘原版)→ dirty 判定正确;
518
+ // baseMtimeMs = 磁盘真实 mtime(新鲜度门卫的基线信号,绝不能用 Date.now())
519
+ const _ds: any = await diskStatP.catch(() => null);
520
+ const _diskMtime = _ds && _ds.ok && typeof _ds.mtimeMs === "number" ? _ds.mtimeMs : Date.now();
426
521
  (window as any).__aitiEditor._cacheSet(filePath, {
427
522
  content: next.content, original: baseline,
428
- baseMtimeMs: Date.now(), language: next.language, size: next.size,
523
+ baseMtimeMs: _diskMtime, language: next.language, size: next.size,
429
524
  encoding: next.encoding, hasBOM: next.hasBOM,
430
525
  });
431
526
  if (isRecovered) {
@@ -437,6 +532,7 @@ export function EditorApp(props: Record<string, any>) {
437
532
  .then((m) => {
438
533
  const mtimeMs = m && m.ok && typeof m.mtimeMs === "number" ? m.mtimeMs : Date.now();
439
534
  baseMtimeRef.current = mtimeMs;
535
+ diskSigRef.current = { mtimeMs, size: (m && m.ok && typeof m.size === "number") ? m.size : 0 };
440
536
  // 缓存已建 → 只更新 baseMtime,不覆盖 content/original
441
537
  const ec = (window as any).__aitiEditor._cacheGet(filePath);
442
538
  if (ec) { ec.baseMtimeMs = mtimeMs; }
@@ -993,6 +1089,7 @@ export function EditorApp(props: Record<string, any>) {
993
1089
  }
994
1090
  if (!r.ok) { setNotice({ kind: "err", text: d.error || "\u4FDD\u5B58\u5931\u8D25 (" + r.status + ")" }); return; }
995
1091
  baseMtimeRef.current = d.mtimeMs;
1092
+ diskSigRef.current = { mtimeMs: d.mtimeMs, size: typeof d.size === "number" ? d.size : editor.getValue().length }; // 回环抑制:自己保存触发的帧不再误报
996
1093
  if (encOverride) {
997
1094
  const c2 = (window as any).__aitiEditor._cacheGet(filePath);
998
1095
  if (c2) { c2.encoding = encOverride.encoding; c2.hasBOM = encOverride.hasBOM === true; }
@@ -1789,7 +1886,7 @@ useEffect(() => {
1789
1886
  ) : null;
1790
1887
 
1791
1888
  const renderBinaryBar = (typeLabel: string) => {
1792
- const live = isMediaFile && watchLive;
1889
+ const live = watchLive; // 全类型二进制视图都显示「实时」徽标(watch 已泛化到所有文件)
1793
1890
  const info = (
1794
1891
  <span style={{ display: "inline-flex", alignItems: "center", gap: 6, minWidth: 0, flexShrink: 1, overflow: "hidden",
1795
1892
  color: "var(--text-dim,#888)", fontSize: 11, fontFamily: "var(--font-mono, ui-monospace, monospace)" }}>
@@ -1821,6 +1918,7 @@ useEffect(() => {
1821
1918
  onMove={doMove}
1822
1919
  onNewWindow={openInNewWindow}
1823
1920
  onTerminal={openTerminal}
1921
+ onReload={doReload}
1824
1922
  menuTop={[...extMenuTop, ...menuTopExtra]}
1825
1923
  infoAfterNav={info}
1826
1924
  />
@@ -1928,8 +2026,8 @@ useEffect(() => {
1928
2026
  <div style={{ display: "flex", flexDirection: "column", height: "100%", overflow: "hidden", background: "var(--bg,#fff)", position: "relative" }}>
1929
2027
  {renderBinaryBar(typeLabel)}
1930
2028
  <div style={{ flex: 1, minHeight: 0, minWidth: 0, position: "relative" }}>
1931
- {fileUrl
1932
- ? <ImageViewer src={fileUrl} filePath={filePath} fileSize={meta ? meta.size : undefined} mtimeMs={meta ? meta.mtimeMs : undefined} onSrcError={recoverMedia} />
2029
+ {mediaSrc
2030
+ ? <ImageViewer src={mediaSrc} filePath={filePath} fileSize={meta ? meta.size : undefined} mtimeMs={meta ? meta.mtimeMs : undefined} onSrcError={recoverMedia} />
1933
2031
  : <div style={{ position: "absolute", inset: 0, display: "flex", alignItems: "center", justifyContent: "center", color: "var(--text-dim,#888)", fontSize: 13 }}>正在加载…</div>}
1934
2032
  {hasNav && (
1935
2033
  <>
@@ -1950,7 +2048,7 @@ useEffect(() => {
1950
2048
  // 调用会抛 ReferenceError 使整个视频/音频预览崩溃(2026-09-06 实测)。
1951
2049
  : fileType === "video" ? <video key={mediaSrc} src={mediaSrc} controls playsInline preload="metadata" onPlay={() => { mediaErrRef.current = 0; }} onLoadedMetadata={(e: any) => { mediaErrRef.current = 0; setMediaDuration(e.currentTarget.duration); }} onError={recoverMedia} style={{ maxWidth: "100%", maxHeight: "100%" }} />
1952
2050
  : fileType === "audio" ? <audio key={mediaSrc} src={mediaSrc} controls preload="metadata" onPlay={() => { mediaErrRef.current = 0; }} onLoadedMetadata={(e: any) => { mediaErrRef.current = 0; setMediaDuration(e.currentTarget.duration); }} onError={recoverMedia} style={{ width: "90%", maxWidth: 480 }} />
1953
- : fileType === "pdf" ? <iframe src={fileUrl} style={{ width: "100%", height: "100%", border: "none" }} />
2051
+ : fileType === "pdf" ? <iframe key={mediaSrc} src={mediaSrc} style={{ width: "100%", height: "100%", border: "none" }} />
1954
2052
  : <div style={{ color: "var(--text-dim,#888)", fontSize: 14, textAlign: "center", padding: 40 }}>暂不支持预览此类型文件</div>;
1955
2053
  return (
1956
2054
  <div style={{ display: "flex", flexDirection: "column", height: "100%", overflow: "hidden", background: "var(--bg,#fff)", position: "relative" }}>
@@ -1974,6 +2072,7 @@ useEffect(() => {
1974
2072
  about={aboutProp}
1975
2073
  onSave={!isReadOnly ? () => doSave(false) : undefined}
1976
2074
  onSaveAs={!isReadOnly ? () => doSaveAsText() : undefined}
2075
+ onReload={doReload}
1977
2076
  dot={{ title: dirty ? "\u6709\u672A\u4FDD\u5B58\u4FEE\u6539" : "\u5DF2\u540C\u6B65", color: dirty ? "var(--warn, #f59e0b)" : "var(--border,#e0e0e0)" }}
1978
2077
  nav={navProps}
1979
2078
  closeActions={closeActions}
@@ -2224,6 +2323,7 @@ useEffect(() => {
2224
2323
  const c = (window as any).__aitiEditor._cacheGet(filePath);
2225
2324
  if (c) { c.content = merged; c.original = merged; c.baseMtimeMs = (extChange && extChange.diskMtimeMs) || Date.now(); }
2226
2325
  baseMtimeRef.current = (extChange && extChange.diskMtimeMs) || Date.now();
2326
+ diskSigRef.current = { mtimeMs: baseMtimeRef.current, size: (extChange && extChange.diskSize) || merged.length };
2227
2327
  setDirty(false); (window as any).__aitiEditor._setDirty(filePath, false);
2228
2328
  setExtChange(null);
2229
2329
  const msg = failCount > 0 ? "\u5408\u5E76\u5B8C\u6210\uFF08" + (results.length - failCount) + "/" + results.length + " \u6210\u529F\uFF09" : "\u5DF2\u5408\u5E76\uFF08\u7528\u6237\u6539\u52A8\u5DF2\u5E94\u7528\u4E8E\u65B0\u7248\u672C\uFF09";
@@ -2247,6 +2347,7 @@ useEffect(() => {
2247
2347
  const cache = (window as any).__aitiEditor._cacheGet(filePath);
2248
2348
  if (cache) { cache.content = content; cache.original = content; cache.baseMtimeMs = (extChange && extChange.diskMtimeMs) || Date.now(); }
2249
2349
  baseMtimeRef.current = (extChange && extChange.diskMtimeMs) || Date.now();
2350
+ diskSigRef.current = { mtimeMs: baseMtimeRef.current, size: (extChange && extChange.diskSize) || content.length };
2250
2351
  setDirty(false); (window as any).__aitiEditor._setDirty(filePath, false);
2251
2352
  setExtChange(null); setNotice({ kind: "ok", text: "\u5DF2\u52A0\u8F7D\u78C1\u76D8\u7248\u672C" });
2252
2353
  })
@@ -55,6 +55,7 @@ export const MiCloseRight = mkMenuIcon(() => <><line x1="21" y1="4" x2="21" y2="
55
55
  export const MiCloseAll = mkMenuIcon(() => <><circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/></>);
56
56
  export const MiSave = mkMenuIcon(() => <><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></>);
57
57
  export const MiSaveAs = mkMenuIcon(() => <><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><line x1="12" y1="16" x2="12" y2="10"/></>);
58
+ export const MiRefresh = mkMenuIcon(() => <><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></>);
58
59
 
59
60
  const S: Record<string, any> = {
60
61
  bar: { display: "flex", alignItems: "center", gap: 8, padding: "5px 12px",
@@ -129,6 +130,8 @@ export function FileToolbar(props: {
129
130
  onMove?: () => void;
130
131
  /** 在独立窗口打开(pi-web /viewer:只含文件查看器的浏览器窗口);不传则不显示 */
131
132
  onNewWindow?: () => void;
133
+ /** 重新加载(⋯ 菜单项):从磁盘重读最新内容;不传则不显示(aiti-folder 等其他使用方零影响) */
134
+ onReload?: () => void;
132
135
  /** 关闭动作组(2026-09-15 拍板):提供即渲染一排 4 图标(关闭左侧/自己/右侧/全部);
133
136
  * 大屏在工具条右端常驻,compact 收进 ⋯ 菜单(同款图标排,名称+图标右对齐)。 */
134
137
  closeActions?: { left?: () => void; self?: () => void; right?: () => void; all?: () => void };
@@ -251,10 +254,11 @@ export function FileToolbar(props: {
251
254
  {divider}
252
255
  </>
253
256
  )}
254
- {/* 保存组:保存 / 另存为 / 下载 */}
257
+ {/* 保存组:保存 / 另存为 / 下载 / 重新加载 */}
255
258
  {onSave && menuItem({ label: "保存", title: "保存当前编辑内容(Ctrl+S)", icon: <MiSave />, onClick: onSave })}
256
259
  {onSaveAs && menuItem({ label: "另存为…", title: "把当前内容保存为新文件", icon: <MiSaveAs />, onClick: onSaveAs })}
257
260
  {props.onDownload && menuItem({ label: "下载文件", icon: <MiDownload />, onClick: props.onDownload })}
261
+ {props.onReload && menuItem({ label: "重新加载", title: "从磁盘重读最新内容(有未保存编辑时会先询问:合并/加载磁盘/保留)", icon: <MiRefresh />, onClick: props.onReload })}
258
262
  {(onSave || onSaveAs || props.onDownload) && divider}
259
263
  {/* @ 组:当前会话 / 新会话 */}
260
264
  {onMention && menuItem({ label: "@ 提及", title: mentionTitle || "提及文件——将当前文件写入聊天输入框", icon: <MiAt />, onClick: onMention })}