@dimina-kit/fs-core 0.2.0-dev.20260710085051

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 (39) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +45 -0
  3. package/dist/agent-tools.js +52 -0
  4. package/dist/client.js +234 -0
  5. package/dist/disk-mirror.js +97 -0
  6. package/dist/fs-core.worker.js +1001 -0
  7. package/dist/fs-query.worker.js +115 -0
  8. package/dist/sync/sync-engine.js +381 -0
  9. package/dist/sync/truth-port.js +1 -0
  10. package/dist/worker-lib/engine-shared.js +25 -0
  11. package/dist/worker-lib/paths.js +17 -0
  12. package/dist/worker-lib/rpc-types.js +1 -0
  13. package/dist/worker-lib/wal-codec.js +111 -0
  14. package/dist/zip.js +60 -0
  15. package/package.json +75 -0
  16. package/src/__checks__/types-smoke.ts +23 -0
  17. package/src/agent-tools.test.ts +151 -0
  18. package/src/agent-tools.ts +117 -0
  19. package/src/client.test.ts +110 -0
  20. package/src/client.ts +284 -0
  21. package/src/disk-mirror.test.ts +190 -0
  22. package/src/disk-mirror.ts +119 -0
  23. package/src/fs-core-recovery.ts +280 -0
  24. package/src/fs-core-write-ops.ts +402 -0
  25. package/src/fs-core.worker.ts +182 -0
  26. package/src/fs-query.worker.ts +152 -0
  27. package/src/import-smoke.test.ts +40 -0
  28. package/src/worker-lib/engine-shared.test.ts +30 -0
  29. package/src/worker-lib/engine-shared.ts +74 -0
  30. package/src/worker-lib/paths.test.ts +39 -0
  31. package/src/worker-lib/paths.ts +14 -0
  32. package/src/worker-lib/rpc-types.ts +67 -0
  33. package/src/worker-lib/wal-codec.test.ts +83 -0
  34. package/src/worker-lib/wal-codec.ts +130 -0
  35. package/src/zip.test.ts +124 -0
  36. package/src/zip.ts +60 -0
  37. package/sync/sync-engine.test.ts +695 -0
  38. package/sync/sync-engine.ts +459 -0
  39. package/sync/truth-port.ts +72 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 EchoTechFE and dimina-kit contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,45 @@
1
+ # @dimina-kit/fs-core
2
+
3
+ 浏览器端 OPFS WAL 文件系统内核:单写者权威(Web Locks 选主)、WAL-first 写序、checkpoint/restore 回滚、agent 写权 turn 门控,零运行时依赖。
4
+
5
+ ## 安装
6
+
7
+ ```bash
8
+ npm i @dimina-kit/fs-core
9
+ ```
10
+
11
+ ## 入口
12
+
13
+ | 子路径 | 用途 |
14
+ | --- | --- |
15
+ | `@dimina-kit/fs-core/client` | `ProjectFsClient.connect({ projectId })`:读写/快照/grep/glob、`mode`/`onModeChange`(多标签页单写者可见性)、turn API |
16
+ | `@dimina-kit/fs-core/agent-tools` | fs_read/fs_write/fs_restore 等 agent 工具面(fs-core 侧 turn 执法) |
17
+ | `@dimina-kit/fs-core/disk-mirror` | File System Access 目录镜像(防抖增量写盘,`pick(handle)` 可注入已授权句柄) |
18
+ | `@dimina-kit/fs-core/sync` | 磁盘↔账本同步引擎(TruthPort 适配外部真相源) |
19
+ | `@dimina-kit/fs-core/zip` | 快照打包为 ZIP |
20
+
21
+ ## 使用
22
+
23
+ ```js
24
+ import { ProjectFsClient } from '@dimina-kit/fs-core/client'
25
+
26
+ // 宿主需把 dist/fs-core.worker.js 与 dist/fs-query.worker.js 部署到可访问 URL
27
+ // (默认约定 /ide/fs/ 下),client 以 module worker 加载它们。
28
+ const fs = await ProjectFsClient.connect({
29
+ projectId: 'my-project',
30
+ coreUrl: '/ide/fs/fs-core.worker.js',
31
+ queryUrl: '/ide/fs/fs-query.worker.js',
32
+ })
33
+ await fs.write('app.json', '{}')
34
+ const { content } = await fs.read('app.json')
35
+ ```
36
+
37
+ ## Worker 产物约束
38
+
39
+ `dist/fs-core.worker.js` 与 `dist/fs-query.worker.js` 是单文件自包含 ESM(无 import 语句、零依赖),宿主按字面文件名从 `dist/` 拷贝/托管即可;以 module worker(`{ type: 'module' }`)加载。
40
+
41
+ 需要 OPFS(`navigator.storage.getDirectory`)与 `createSyncAccessHandle`(worker 内),即 Chromium ≥ 102 一类环境;不依赖 COI。
42
+
43
+ ## License
44
+
45
+ MIT
@@ -0,0 +1,52 @@
1
+ export function createAgentTools(fs) {
2
+ let turnSeq = 0;
3
+ let activeTurn = null;
4
+ /** 铸造一轮写能力:fs-core 侧自动打 checkpoint 锚(返回 {cpId, expiresAt})。 */
5
+ async function beginTurn(opts = {}) {
6
+ const turnId = 't-' + Date.now().toString(36) + '-' + ++turnSeq;
7
+ const r = await fs.turnBegin(turnId, opts);
8
+ activeTurn = turnId;
9
+ return { turnId, ...r };
10
+ }
11
+ /** 结束(撤销)当前 turn;之后任何 agent 写都会被 fs-core 拒绝(turn-closed)。 */
12
+ async function endTurn() {
13
+ if (!activeTurn)
14
+ return { closed: false };
15
+ const turnId = activeTurn;
16
+ activeTurn = null;
17
+ return fs.turnEnd(turnId);
18
+ }
19
+ const agentOpts = () => ({ actor: 'agent', turnId: activeTurn });
20
+ // E is inferred fresh per call from whatever concrete `execute` is passed
21
+ // (e.g. `({ path }: { path: string }) => …`) rather than checked against one
22
+ // fixed function type, so each tool below keeps its own narrow parameter
23
+ // shape; assigning it into the returned object's `execute` member is safe
24
+ // because AgentTool.execute is a method (bivariant), not an arrow-typed
25
+ // property.
26
+ function T(name, description, inputSchema, execute, extra) {
27
+ return { name, description, inputSchema, execute, ...extra };
28
+ }
29
+ const tools = [
30
+ // 读/查询(路由 fs-query,不占写路径)
31
+ T('fs_read', '读文件,返回完整内容与版本 rev(绝不截断)', { path: 'string' }, ({ path }) => fs.read(path)),
32
+ T('fs_ls', '列出项目全部文件路径', {}, () => fs.ls()),
33
+ T('fs_glob', '按 glob 模式匹配文件路径', { pattern: 'string' }, ({ pattern }) => fs.glob(pattern)),
34
+ T('fs_grep', '按正则搜索文件内容,返回 {path, lineNo, line} 命中行', { pattern: 'string', glob: 'string?', limit: 'number?' }, ({ pattern, ...o }) => fs.grep(pattern, o)),
35
+ // 写(fs-core 执法:必须在有效 turn 内)
36
+ T('fs_write', '写整文件;可带 ifMatch(rev) 做 CAS 前置校验', { path: 'string', content: 'string', ifMatch: 'number?' }, ({ path, content, ifMatch }) => fs.write(path, content, { ...agentOpts(), ifMatch })),
37
+ T('fs_edit', '精确替换:old 在文件中必须唯一匹配', { path: 'string', old: 'string', next: 'string' }, ({ path, old, next }) => fs.edit(path, old, next, agentOpts())),
38
+ T('fs_mv', '移动/重命名文件', { from: 'string', to: 'string' }, ({ from, to }) => fs.mv(from, to, agentOpts())),
39
+ T('fs_rm', '删除文件', { path: 'string' }, ({ path }) => fs.rm(path, agentOpts())),
40
+ // 回滚面
41
+ T('fs_checkpoint', '手动创建快照锚点(turn 开始时已自动有一个)', {}, () => fs.checkpoint(agentOpts())),
42
+ T('fs_restore', '回滚到指定 checkpoint(本身是一条可再撤销的写);若 checkpoint 之后有人类编辑,默认拒绝并返回 restore-conflict{humanPaths},需 force:true 确认覆盖', { cpId: 'string', force: 'boolean?' }, ({ cpId, force }) => fs.restore(cpId, { ...agentOpts(), force }), { dangerous: true }),
43
+ T('fs_diff', '列出某轮 turn 的全部改动(默认当前 turn)', { turnId: 'string?' }, ({ turnId } = {}) => fs.diff(turnId ?? activeTurn ?? undefined)),
44
+ ];
45
+ return {
46
+ tools,
47
+ byName: Object.fromEntries(tools.map((t) => [t.name, t])),
48
+ beginTurn,
49
+ endTurn,
50
+ get activeTurn() { return activeTurn; },
51
+ };
52
+ }
package/dist/client.js ADDED
@@ -0,0 +1,234 @@
1
+ /**
2
+ * ProjectFsClient — 主线程侧 ProjectFS 封装(P0,同 origin)。
3
+ * 起 fs-core / fs-query 两个 worker,牵好 core→query 的 diff 端口,
4
+ * 暴露 Promise API;写请求自动带 opId,超时重试幂等(同 opId 重发)。
5
+ */
6
+ const WRITE_TIMEOUT_MS = 8000;
7
+ export class ProjectFsClient {
8
+ projectId;
9
+ clientId;
10
+ core;
11
+ query;
12
+ pending;
13
+ qPending;
14
+ changeCbs;
15
+ modeCbs;
16
+ _mode;
17
+ seq;
18
+ welcome = null;
19
+ pingTimer;
20
+ lastPong;
21
+ _retried;
22
+ /** 测试用:抹掉一个项目的全部持久层(只能在无 core 运行时调用)。 */
23
+ static async wipe(projectId) {
24
+ const root = await navigator.storage.getDirectory();
25
+ try {
26
+ await root.removeEntry(projectId, { recursive: true });
27
+ }
28
+ catch { }
29
+ }
30
+ static async connect({ projectId, coreUrl = '/ide/fs/fs-core.worker.js', queryUrl = '/ide/fs/fs-query.worker.js', clientId = 'c-' + Math.random().toString(36).slice(2, 10), }) {
31
+ const c = new ProjectFsClient();
32
+ c.projectId = projectId;
33
+ c.clientId = clientId;
34
+ c.core = new Worker(coreUrl, { type: 'module' });
35
+ c.query = new Worker(queryUrl, { type: 'module' });
36
+ c.pending = new Map(); // id -> {resolve, reject, timer}
37
+ c.qPending = new Map();
38
+ c.changeCbs = new Set();
39
+ c.modeCbs = new Set();
40
+ c._mode = 'starting';
41
+ c.seq = 0;
42
+ c.welcome = null;
43
+ const chan = new MessageChannel();
44
+ const welcomed = new Promise((resolve, reject) => {
45
+ c.core.onmessage = (e) => c._onCoreMessage(e.data, resolve, reject);
46
+ c.core.onerror = (e) => reject(new Error('fs-core worker error: ' + e.message));
47
+ });
48
+ c.query.onmessage = (e) => c._onQueryMessage(e.data);
49
+ c.query.postMessage({ type: 'init', corePort: chan.port2 }, [chan.port2]);
50
+ c.core.postMessage({ type: 'HELLO', projectId, clientId, capabilityRequest: { mode: 'rw' }, queryPort: chan.port1 }, [chan.port1]);
51
+ c.welcome = await welcomed;
52
+ c._setMode(c.welcome.mode || (c.welcome.readonly ? 'readonly' : 'writer'));
53
+ c.pingTimer = setInterval(() => { try {
54
+ c.core.postMessage({ type: 'PING', t: Date.now() });
55
+ }
56
+ catch { } }, 10000);
57
+ c.lastPong = Date.now();
58
+ return c;
59
+ }
60
+ _onCoreMessage(msg, resolveWelcome, rejectWelcome) {
61
+ if (msg.type === 'WELCOME') {
62
+ this._handleWelcome(msg, resolveWelcome);
63
+ return;
64
+ }
65
+ if (msg.type === 'FATAL') {
66
+ this._handleFatal(msg, rejectWelcome);
67
+ return;
68
+ }
69
+ if (msg.type === 'PONG') {
70
+ this.lastPong = Date.now();
71
+ return;
72
+ }
73
+ if (msg.evt) {
74
+ this._handleEvt(msg);
75
+ return;
76
+ }
77
+ if (msg.id !== undefined)
78
+ this._handleRpcReply(msg);
79
+ }
80
+ _handleWelcome(msg, resolveWelcome) {
81
+ if (resolveWelcome)
82
+ resolveWelcome(msg);
83
+ }
84
+ _handleFatal(msg, rejectWelcome) {
85
+ this._setMode('dead');
86
+ const err = new Error('fs-core fatal: ' + msg.error);
87
+ if (rejectWelcome)
88
+ rejectWelcome(err);
89
+ for (const [, p] of this.pending)
90
+ p.reject(err);
91
+ this.pending.clear();
92
+ }
93
+ _handleEvt(msg) {
94
+ if (msg.evt === 'writer-granted')
95
+ this._setMode('writer');
96
+ else if (msg.evt === 'writer-lost')
97
+ this._setMode('readonly');
98
+ for (const cb of this.changeCbs) {
99
+ try {
100
+ cb(msg);
101
+ }
102
+ catch { }
103
+ }
104
+ }
105
+ _handleRpcReply(msg) {
106
+ const p = this.pending.get(msg.id);
107
+ if (!p)
108
+ return;
109
+ this.pending.delete(msg.id);
110
+ if (p.timer)
111
+ clearTimeout(p.timer);
112
+ if (msg.ok)
113
+ p.resolve(msg.result);
114
+ else
115
+ p.reject(Object.assign(new Error(msg.error), { code: msg.code, ...(msg.humanPaths !== undefined ? { humanPaths: msg.humanPaths } : {}), ...(msg.auditGap !== undefined ? { auditGap: msg.auditGap } : {}) }));
116
+ }
117
+ _onQueryMessage(msg) {
118
+ if (msg.id === undefined)
119
+ return;
120
+ const p = this.qPending.get(msg.id);
121
+ if (!p)
122
+ return;
123
+ this.qPending.delete(msg.id);
124
+ if (msg.ok)
125
+ p.resolve(msg.result);
126
+ else
127
+ p.reject(Object.assign(new Error(msg.error), { code: msg.code }));
128
+ }
129
+ _rpc(op, args, { opId, timeout } = {}) {
130
+ const id = ++this.seq;
131
+ return new Promise((resolve, reject) => {
132
+ const entryResolve = resolve;
133
+ const timer = timeout
134
+ ? setTimeout(() => {
135
+ // 超时重试一次:同 opId 幂等(fs-core 对已知 opId 返回既有结果)
136
+ if (opId && !this._retried) {
137
+ const retryId = ++this.seq;
138
+ this.pending.set(retryId, { resolve: entryResolve, reject, timer: setTimeout(() => { this.pending.delete(retryId); reject(new Error(op + ' timeout (after retry)')); }, timeout) });
139
+ this.pending.delete(id);
140
+ this.core.postMessage({ id: retryId, op, args, opId });
141
+ }
142
+ else {
143
+ this.pending.delete(id);
144
+ reject(new Error(op + ' timeout'));
145
+ }
146
+ }, timeout)
147
+ : null;
148
+ this.pending.set(id, { resolve: entryResolve, reject, timer });
149
+ this.core.postMessage({ id, op, args, opId });
150
+ });
151
+ }
152
+ _qrpc(op, args) {
153
+ const id = ++this.seq;
154
+ return new Promise((resolve, reject) => {
155
+ this.qPending.set(id, { resolve: resolve, reject });
156
+ this.query.postMessage({ id, op, args });
157
+ });
158
+ }
159
+ _writeOp(op, args) {
160
+ return this._rpc(op, args, { opId: crypto.randomUUID(), timeout: WRITE_TIMEOUT_MS });
161
+ }
162
+ // ── 写 API({actor:'human'|'agent', turnId, ifMatch} 透传)──
163
+ write(path, content, opts = {}) { return this._writeOp('write', { path, content, ...opts }); }
164
+ edit(path, old, next, opts = {}) { return this._writeOp('edit', { path, old, next, ...opts }); }
165
+ rm(path, opts = {}) { return this._writeOp('rm', { path, ...opts }); }
166
+ mv(from, to, opts = {}) { return this._writeOp('mv', { from, to, ...opts }); }
167
+ mkdir(path, opts = {}) { return this._writeOp('mkdir', { path, ...opts }); }
168
+ checkpoint(opts = {}) { return this._writeOp('checkpoint', { ...opts }); }
169
+ /** opts: {baseGen?, force?} 透传给 fs-core(§4.7 restore 冲突策略);baseGen 缺省时
170
+ * fs-core 从 checkpoint 自身记录的 gen 推导。 */
171
+ restore(cpId, opts = {}) { return this._writeOp('restore', { cpId, ...opts }); }
172
+ compact() { return this._rpc('compact', {}, { timeout: 30000 }); }
173
+ // ── P4 turn 能力:铸造(附带 checkpoint 锚)→ agent 写执法 → 撤销 ──
174
+ turnBegin(turnId, opts = {}) { return this._writeOp('turnBegin', { turnId, ...opts }); }
175
+ turnEnd(turnId) { return this._writeOp('turnEnd', { turnId }); }
176
+ /** 该 turn 的改动清单(WAL actor/turnId 审计标注免费提供)。 */
177
+ diff(turnId) { return this._rpc('diff', { turnId }); }
178
+ /** W4 纵深加固令牌门(docs/k3-terminal-split-plan.md §6 替代方案 A / §8.3):特权方法,只应
179
+ * 由内核(kernel.js createKernel)在 boot 早期调用一次,把只有内核持有的随机令牌交给 fs-core;
180
+ * 此后 actor:'agent' 的写类 op 必须在 opts 里携带匹配的 agentToken(fs-core 侧强制,见
181
+ * fs-core.worker.js checkTurn/armAgentToken)。无 opId/超时重试——一次性 admin 调用,非写路径
182
+ * 幂等本就由 fs-core 的 armAgentToken 自身保证(同令牌重放 ok,不同令牌拒绝)。 */
183
+ armAgentTokenGate(token) { return this._rpc('armAgentTokenGate', { token }); }
184
+ // ── 读 API:小读走 core(权威),查询/快照走 query(不占写路径)──
185
+ read(path) { return this._rpc('read', { path }); }
186
+ ls() { return this._rpc('ls', {}); }
187
+ status() { return this._rpc('status', {}); }
188
+ snapshot(opts = {}) { return this._qrpc('snapshot', opts); }
189
+ grep(pattern, opts = {}) { return this._qrpc('grep', { pattern, ...opts }); }
190
+ glob(pattern, opts = {}) { return this._qrpc('glob', { pattern, ...opts }); }
191
+ queryRead(path, opts = {}) { return this._qrpc('read', { path, ...opts }); }
192
+ /** 空项目时批量写入种子文件;非空则跳过。返回 {seeded, count}。 */
193
+ async seed(files) {
194
+ const { paths } = await this.ls();
195
+ if (paths.length)
196
+ return { seeded: false, count: paths.length };
197
+ const entries = Object.entries(files);
198
+ for (let i = 0; i < entries.length; i += 50) {
199
+ await Promise.all(entries.slice(i, i + 50).map(([p, c]) => this.write(p, c, { actor: 'human' })));
200
+ }
201
+ return { seeded: true, count: entries.length };
202
+ }
203
+ onChange(cb) {
204
+ this.changeCbs.add(cb);
205
+ return () => this.changeCbs.delete(cb);
206
+ }
207
+ /** 当前单写者状态:starting(HELLO 未回)|writer(持写者租约)|readonly(排队中或已交出)|
208
+ * draining(worker 侧过渡态,client 不会长时间观察到——见 fs-core.worker.js onBroadcast,
209
+ * 收敛为 readonly 再广播事件)|dead(FATAL,worker 已不可用)。由 WELCOME 与
210
+ * writer-granted/writer-lost 事件驱动,供宿主向用户呈现"另一个标签页持有写权"之类提示。 */
211
+ get mode() { return this._mode; }
212
+ _setMode(mode) {
213
+ if (this._mode === mode)
214
+ return;
215
+ this._mode = mode;
216
+ for (const cb of this.modeCbs) {
217
+ try {
218
+ cb(mode);
219
+ }
220
+ catch { }
221
+ }
222
+ }
223
+ /** 订阅 mode 变化(writer ⇄ readonly ⇄ dead);返回退订函数。 */
224
+ onModeChange(cb) {
225
+ this.modeCbs.add(cb);
226
+ return () => this.modeCbs.delete(cb);
227
+ }
228
+ destroy() {
229
+ clearInterval(this.pingTimer);
230
+ this._setMode('dead');
231
+ this.core.terminate();
232
+ this.query.terminate();
233
+ }
234
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * 本地磁盘镜像(P5,Chromium only)—— showDirectoryPicker() 授权一个真实磁盘目录,
3
+ * 把项目树 write-through 镜像过去:用户在访达/资源管理器里能看到自己的项目。
4
+ * 这是"持久化到磁盘"的字面回答(架构文档 §4 出口 1)。
5
+ *
6
+ * 策略:fs-change 防抖 2s 全量比对同步(内容不变的文件跳过 —— 内容比对在内存,
7
+ * 磁盘只写变更);删除按上次镜像记录清理。授权需要用户手势,无授权时静默不动。
8
+ */
9
+ export function createDiskMirror(fs) {
10
+ let dir = null;
11
+ let last = new Map(); // path -> content(上次已镜像的内容)
12
+ let timer;
13
+ let syncing = false;
14
+ let pending = false; // syncAll 运行期间又发生了变更,结束后需要再镜像一轮
15
+ /** @param handle 已获取授权的目录句柄;传了就直接用(跳过
16
+ * showDirectoryPicker 弹窗),调用方自行负责获取授权(例如复用宿主已有的句柄)。 */
17
+ async function pick(handle) {
18
+ if (handle) {
19
+ dir = handle;
20
+ }
21
+ else {
22
+ if (!window.showDirectoryPicker)
23
+ throw new Error('showDirectoryPicker 不可用(需要 Chromium)');
24
+ dir = await window.showDirectoryPicker({ mode: 'readwrite' });
25
+ }
26
+ last = new Map();
27
+ const r = await syncAll();
28
+ return { name: dir.name, ...r };
29
+ }
30
+ /** 停用镜像:取消挂起的防抖定时器,清空目录句柄与镜像记账,使后续任何 syncAll
31
+ * 调用(包括仍在飞行中的定时器回调)在入口处天然短路。 */
32
+ function dispose() {
33
+ clearTimeout(timer);
34
+ dir = null;
35
+ last = new Map();
36
+ }
37
+ async function syncAll() {
38
+ if (!dir || syncing)
39
+ return null;
40
+ syncing = true;
41
+ try {
42
+ const snap = await fs.snapshot();
43
+ let written = 0;
44
+ for (const [p, content] of Object.entries(snap.files)) {
45
+ if (last.get(p) === content)
46
+ continue;
47
+ const parts = p.split('/');
48
+ let d = dir;
49
+ for (const seg of parts.slice(0, -1))
50
+ d = await d.getDirectoryHandle(seg, { create: true });
51
+ const fh = await d.getFileHandle(parts[parts.length - 1], { create: true });
52
+ const w = await fh.createWritable();
53
+ await w.write(content);
54
+ await w.close();
55
+ last.set(p, content);
56
+ written++;
57
+ }
58
+ let removed = 0;
59
+ for (const p of [...last.keys()]) {
60
+ if (p in snap.files)
61
+ continue;
62
+ try {
63
+ const parts = p.split('/');
64
+ let d = dir;
65
+ for (const seg of parts.slice(0, -1))
66
+ d = await d.getDirectoryHandle(seg);
67
+ await d.removeEntry?.(parts[parts.length - 1]);
68
+ }
69
+ catch { /* 目录已不在等:忽略 */ }
70
+ last.delete(p);
71
+ removed++;
72
+ }
73
+ return { written, removed, gen: snap.gen };
74
+ }
75
+ finally {
76
+ syncing = false;
77
+ if (pending && dir) {
78
+ pending = false;
79
+ schedule();
80
+ }
81
+ }
82
+ }
83
+ function schedule() {
84
+ if (!dir)
85
+ return;
86
+ if (syncing) {
87
+ // 一轮 syncAll 正在进行中:这次变更不能被这轮同步捕获(快照已在读取途中),
88
+ // 也不能立刻并发再跑一轮(syncAll 自身会在入口处丢弃)。记一个 pending,
89
+ // 交给上面 syncAll 的 finally 在这轮结束后重新排期,保证不丢更新。
90
+ pending = true;
91
+ return;
92
+ }
93
+ clearTimeout(timer);
94
+ timer = setTimeout(() => { syncAll().catch(() => { }); }, 2000);
95
+ }
96
+ return { pick, syncAll, schedule, dispose, get active() { return !!dir; } };
97
+ }