@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.
- package/README.md +14 -22
- package/README.zh-CN.md +31 -0
- package/client/src/disk-refresh.test.mjs +69 -0
- package/client/src/disk-refresh.ts +64 -0
- package/client/src/editor-app.tsx +117 -16
- package/client/src/file-toolbar.tsx +5 -1
- package/dist/aiti-editor-client.mjs +3243 -3163
- package/index.ts +0 -1
- package/monaco/.version +1 -0
- package/monaco/vs/abap-D-t0cyap.js +1 -0
- package/monaco/vs/apex-CcIm7xu6.js +1 -0
- package/monaco/vs/assets/css.worker-URu8fCFR.js +97 -0
- package/monaco/vs/assets/editor.worker-lj3bdIIn.js +30 -0
- package/monaco/vs/assets/editorWebWorkerMain-CA_vMoUU.js +30 -0
- package/monaco/vs/assets/html.worker-D1SL3iM8.js +474 -0
- package/monaco/vs/assets/json.worker-CoJx_OPf.js +62 -0
- package/monaco/vs/assets/ts.worker-BWKtMYOk.js +67722 -0
- package/monaco/vs/azcli-BA0tQDCg.js +1 -0
- package/monaco/vs/basic-languages/monaco.contribution.js +1 -0
- package/monaco/vs/bat-C397hTD6.js +1 -0
- package/monaco/vs/bicep-DF5aW17k.js +2 -0
- package/monaco/vs/cameligo-plsz8qhj.js +1 -0
- package/monaco/vs/clojure-Y2auQMzK.js +1 -0
- package/monaco/vs/coffee-Bu45yuWE.js +1 -0
- package/monaco/vs/cpp-CkKPQIni.js +1 -0
- package/monaco/vs/csharp-CX28MZyh.js +1 -0
- package/monaco/vs/csp-D8uWnyxW.js +1 -0
- package/monaco/vs/css-CaeNmE3S.js +3 -0
- package/monaco/vs/css.worker-CyhWkhHo.js +1 -0
- package/monaco/vs/cssMode-CV6Ay48H.js +1 -0
- package/monaco/vs/cypher-DVThT8BS.js +1 -0
- package/monaco/vs/dart-CmGfCvrO.js +1 -0
- package/monaco/vs/dockerfile-CZqqYdch.js +1 -0
- package/monaco/vs/ecl-30fUercY.js +1 -0
- package/monaco/vs/editor/editor.main.css +1 -0
- package/monaco/vs/editor/editor.main.js +1 -0
- package/monaco/vs/editor/editor.worker.js +1 -0
- package/monaco/vs/editor-KLE6jdfb.js +831 -0
- package/monaco/vs/editor.js +1 -0
- package/monaco/vs/editorWorkerHost-fVE1cjcC.js +35 -0
- package/monaco/vs/elixir-xjPaIfzF.js +1 -0
- package/monaco/vs/flow9-DqtmStfK.js +1 -0
- package/monaco/vs/freemarker2-FWreY7v7.js +3 -0
- package/monaco/vs/fsharp-BOMdg4U1.js +1 -0
- package/monaco/vs/go-D_hbi-Jt.js +1 -0
- package/monaco/vs/graphql-CKUU4kLG.js +1 -0
- package/monaco/vs/handlebars-CZi4pHCH.js +1 -0
- package/monaco/vs/hcl-DTaboeZW.js +1 -0
- package/monaco/vs/html-j_7ZTNRU.js +1 -0
- package/monaco/vs/html.worker-CA3iAimZ.js +1 -0
- package/monaco/vs/htmlMode-27by_KM4.js +1 -0
- package/monaco/vs/index-049xrXrp.js +1 -0
- package/monaco/vs/index-CBVt3dzv.js +5 -0
- package/monaco/vs/index.js +1 -0
- package/monaco/vs/ini-CsNwO04R.js +1 -0
- package/monaco/vs/initialize-DL0l1TGY.js +1 -0
- package/monaco/vs/java-CI4ZMsH9.js +1 -0
- package/monaco/vs/javascript-DTJl4Jn1.js +1 -0
- package/monaco/vs/json.worker-BizpAl9O.js +1 -0
- package/monaco/vs/jsonMode-DLUiMUrI.js +1 -0
- package/monaco/vs/julia-BwzEvaQw.js +1 -0
- package/monaco/vs/kotlin-IUYPiTV8.js +1 -0
- package/monaco/vs/language/css/css.worker.js +66 -0
- package/monaco/vs/language/css/monaco.contribution.js +1 -0
- package/monaco/vs/language/html/html.worker.js +443 -0
- package/monaco/vs/language/html/monaco.contribution.js +1 -0
- package/monaco/vs/language/json/json.worker.js +17 -0
- package/monaco/vs/language/json/monaco.contribution.js +1 -0
- package/monaco/vs/language/typescript/monaco.contribution.js +1 -0
- package/monaco/vs/language/typescript/ts.worker.js +67693 -0
- package/monaco/vs/less-C0eDYdqa.js +2 -0
- package/monaco/vs/lexon-iON-Kj97.js +1 -0
- package/monaco/vs/liquid-CLJYelW6.js +1 -0
- package/monaco/vs/loader.js +1368 -0
- package/monaco/vs/lspLanguageFeatures-BIkJOWLw.js +2 -0
- package/monaco/vs/lua-DtygF91M.js +1 -0
- package/monaco/vs/m3-CsR4AuFi.js +1 -0
- package/monaco/vs/main-BEx-Fmlo.js +15 -0
- package/monaco/vs/main-DsK8pnKg.js +3 -0
- package/monaco/vs/markdown-C_rD0bIw.js +1 -0
- package/monaco/vs/mdx-CpzZIPHF.js +1 -0
- package/monaco/vs/mips-CiYP61RB.js +1 -0
- package/monaco/vs/monaco.contribution-9cKT3C7t.js +1 -0
- package/monaco/vs/monaco.contribution-BE88ZNGY.js +1 -0
- package/monaco/vs/monaco.contribution-BPhsneLd.js +1 -0
- package/monaco/vs/monaco.contribution-BgRy6xDf.js +1 -0
- package/monaco/vs/msdax-C38-sJlp.js +1 -0
- package/monaco/vs/mysql-CdtbpvbG.js +1 -0
- package/monaco/vs/nls/lang/cs.d.ts +1 -0
- package/monaco/vs/nls/lang/cs.js +9 -0
- package/monaco/vs/nls/lang/de.d.ts +1 -0
- package/monaco/vs/nls/lang/de.js +9 -0
- package/monaco/vs/nls/lang/es.d.ts +1 -0
- package/monaco/vs/nls/lang/es.js +9 -0
- package/monaco/vs/nls/lang/fr.d.ts +1 -0
- package/monaco/vs/nls/lang/fr.js +9 -0
- package/monaco/vs/nls/lang/it.d.ts +1 -0
- package/monaco/vs/nls/lang/it.js +9 -0
- package/monaco/vs/nls/lang/ja.d.ts +1 -0
- package/monaco/vs/nls/lang/ja.js +9 -0
- package/monaco/vs/nls/lang/ko.d.ts +1 -0
- package/monaco/vs/nls/lang/ko.js +9 -0
- package/monaco/vs/nls/lang/pl.d.ts +1 -0
- package/monaco/vs/nls/lang/pl.js +9 -0
- package/monaco/vs/nls/lang/pt-br.d.ts +1 -0
- package/monaco/vs/nls/lang/pt-br.js +9 -0
- package/monaco/vs/nls/lang/ru.d.ts +1 -0
- package/monaco/vs/nls/lang/ru.js +9 -0
- package/monaco/vs/nls/lang/tr.d.ts +1 -0
- package/monaco/vs/nls/lang/tr.js +9 -0
- package/monaco/vs/nls/lang/zh-cn.d.ts +1 -0
- package/monaco/vs/nls/lang/zh-cn.js +9 -0
- package/monaco/vs/nls/lang/zh-tw.d.ts +1 -0
- package/monaco/vs/nls/lang/zh-tw.js +9 -0
- package/monaco/vs/nls.messages-loader.js +1 -0
- package/monaco/vs/objective-c-CntZFaHX.js +1 -0
- package/monaco/vs/pascal-r6kuqfl_.js +1 -0
- package/monaco/vs/pascaligo-BiXoTmXh.js +1 -0
- package/monaco/vs/perl-DABw_TcH.js +1 -0
- package/monaco/vs/pgsql-me_jFXeX.js +1 -0
- package/monaco/vs/php-D_kh-9LK.js +1 -0
- package/monaco/vs/pla-VfZjczW0.js +1 -0
- package/monaco/vs/postiats-BBSzz8Pk.js +1 -0
- package/monaco/vs/powerquery-Dt-g_2cc.js +1 -0
- package/monaco/vs/powershell-B-7ap1zc.js +1 -0
- package/monaco/vs/protobuf-BmtuEB1A.js +2 -0
- package/monaco/vs/pug-BRpRNeEb.js +1 -0
- package/monaco/vs/python-CqWUUgfu.js +1 -0
- package/monaco/vs/qsharp-BzsFaUU9.js +1 -0
- package/monaco/vs/r-f8dDdrp4.js +1 -0
- package/monaco/vs/razor-hok5y9Km.js +1 -0
- package/monaco/vs/redis-fvZQY4PI.js +1 -0
- package/monaco/vs/redshift-45Et0LQi.js +1 -0
- package/monaco/vs/restructuredtext-C7UUFKFD.js +1 -0
- package/monaco/vs/ruby-CZO8zYTz.js +1 -0
- package/monaco/vs/rust-Bfetafyc.js +1 -0
- package/monaco/vs/sb-3GYllVck.js +1 -0
- package/monaco/vs/scala-foMgrKo1.js +1 -0
- package/monaco/vs/scheme-CHdMtr7p.js +1 -0
- package/monaco/vs/scss-C1cmLt9V.js +3 -0
- package/monaco/vs/shell-ClXCKCEW.js +1 -0
- package/monaco/vs/solidity-MZ6ExpPy.js +1 -0
- package/monaco/vs/sophia-DWkuSsPQ.js +1 -0
- package/monaco/vs/sparql-AUGFYSyk.js +1 -0
- package/monaco/vs/sql-32GpJSV2.js +1 -0
- package/monaco/vs/st-CuDFIVZ_.js +1 -0
- package/monaco/vs/swift-t-PfMj7W.js +1 -0
- package/monaco/vs/systemverilog-Ch4vA8Yt.js +1 -0
- package/monaco/vs/tcl-D74tq1nH.js +1 -0
- package/monaco/vs/toggleHighContrast-qGX7E9o7.js +61 -0
- package/monaco/vs/ts.worker-2QLmBukE.js +1 -0
- package/monaco/vs/tsMode-B0S_kp2q.js +11 -0
- package/monaco/vs/twig-C6taOxMV.js +1 -0
- package/monaco/vs/typescript-_2t_511t.js +1 -0
- package/monaco/vs/typespec-8OfoLt6R.js +1 -0
- package/monaco/vs/vb-Dyb2648j.js +1 -0
- package/monaco/vs/wgsl-BhLXMOR0.js +298 -0
- package/monaco/vs/workers-BBttULjf.js +1 -0
- package/monaco/vs/xml-cZjNDqDT.js +1 -0
- package/monaco/vs/yaml-A1fOIdH6.js +1 -0
- package/package.json +6 -5
- package/tools/materialize-monaco.mjs +126 -0
package/README.md
CHANGED
|
@@ -1,38 +1,30 @@
|
|
|
1
1
|
# @aiti-ai/aiti-editor
|
|
2
2
|
|
|
3
|
-
>
|
|
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
|
-
|
|
5
|
+
English · [简体中文](./README.zh-CN.md)
|
|
6
6
|
|
|
7
|
-
|
|
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
|
-
-
|
|
12
|
-
-
|
|
13
|
-
-
|
|
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
|
-
|
|
17
|
+
## Install
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
Bundled with **aiti-agent** (`@aiti-ai/pi-web`) — enable it from the in-app extension manager (ExtMan / App Center).
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
Manual install: place this package at `~/.pi/web/extensions/aiti-editor/` and reload pi-web.
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
## Documentation
|
|
24
24
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
|
package/README.zh-CN.md
ADDED
|
@@ -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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
415
|
-
|
|
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:
|
|
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 =
|
|
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
|
-
{
|
|
1932
|
-
? <ImageViewer src={
|
|
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={
|
|
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 })}
|