dsh-markdown-typora 0.3.5 → 0.3.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/lib/client.js CHANGED
@@ -6,8 +6,8 @@
6
6
  // · 一个 tab body(sidebar.right.pane.tab, key=dsh-markdown-typora/editor)承载 iframe;
7
7
  // · iframe 生命周期:bridge-ready/init 后先发 theme 再 open;文档身份=sessionId+path+generation,
8
8
  // 过期回执直接丢弃;切 tab / 关 tab / path 变化时 abort 或置 dead;
9
- // · 保存:change{len} 脏标 → 1500ms 防抖 → requestText(2s 超时)拉全文 → 串行保存,
10
- // 每次 save 必带 baseVersion(关 tab 的 keepalive flush 也一样);FS_STALE_VERSION 只给
9
+ // · 保存:引擎回执确认文档版本,连续增量在父窗口保留,防抖后按基准版本串行写盘。
10
+ // 关闭时将最后编辑保存在恢复日志。FS_STALE_VERSION 只给
11
11
  // 冲突横幅(重载磁盘版本 / 复制我的内容),绝不覆盖;
12
12
  // · 手动保存:iframe 内 Ctrl+S(引擎 keymap)经 dsh-bridge 的 mpHost.__save 上行
13
13
  // manual-save;焦点在本 tab 的壳上时由父窗口 keydown 兜住(同时 preventDefault,
@@ -40,15 +40,351 @@ window.__ModuleLoader__.load({
40
40
  const FRAME_SRC = "/dsh-markdown-typora/renderer-cm6/index.html";
41
41
  const SAVE_DEBOUNCE_MS = 1500; // 与桌面壳 _autoSaveTimer 同拍
42
42
  const PULL_TIMEOUT_MS = 2000; // requestText 超时:超时就不保存,绝不拿旧缓存盖新内容
43
- const FLUSH_WAIT_MS = 120; // 关 tab 前等全文的窗口
43
+ const FLUSH_WAIT_MS = 120; // 关闭后接收已排队输入消息的宽限期
44
44
  const RECOVERY_PREFIX = "dsh-markdown-typora:recovery:";
45
45
  const POLL_FAST_MS = 2000; // SSE 断开后的首档轮询
46
46
  const POLL_SLOW_MS = 6000; // 连续失败后的降频档
47
47
  const MAX_TEXT_CHARS = 512 * 1024; // 恢复槽上限:localStorage 5MB 配额,超限不写并告警
48
+ const LIVE_FLUSH_TIMEOUT_MS = 4000; // live flush(引擎 facade 拉齐未确认编辑)的等待上限
48
49
 
49
50
  /** 当前打开的文档登记表(@mp 候选源用)。key = sessionId + '\n' + absPath */
50
51
  const openDocs = new Map();
51
52
 
53
+ // Browser-only recovery: IndexedDB owns large checkpoints and ordered deltas;
54
+ // Web Storage owns only the bounded tail not yet acknowledged by IndexedDB.
55
+ function createDraftStore(options = {}) {
56
+ const idb = options.indexedDB ?? globalThis.indexedDB;
57
+ let storage = options.storage;
58
+ if (storage === undefined) { try { storage = globalThis.localStorage; } catch {} }
59
+ const onError = typeof options.onError === 'function' ? options.onError : () => {};
60
+ const dbName = options.dbName || 'dsh-markdown-typora-drafts-v2';
61
+ const prefix = options.prefix || 'dsh-markdown-typora:journal:v2:';
62
+ const maxSyncChars = options.maxSyncChars ?? 512 * 1024;
63
+ const states = new Map(), jobs = new Map();
64
+ let database, opening, serial = 0;
65
+ const pathKey = value => /^[a-z]:[\\/]/i.test(value) || /^[/\\]{2}/.test(value)
66
+ ? String(value).replace(/\\/g, '/').toLowerCase() : String(value);
67
+ const sessionOK = s => !!s && Number.isSafeInteger(s.epoch) && Number.isSafeInteger(s.sourceRevision) && s.sourceRevision >= 0;
68
+ const same = (a,b) => sessionOK(a) && sessionOK(b) && a.epoch === b.epoch && a.sourceRevision === b.sourceRevision;
69
+ const failure = (code, message) => Object.assign(new Error(message), {code});
70
+ const cloneSession = s => ({epoch:s.epoch,sourceRevision:s.sourceRevision});
71
+ const slotKey = token => prefix + encodeURIComponent(token);
72
+ function notify(state, error) {
73
+ const code=error?.code || error?.name || 'RECOVERY_STORAGE';
74
+ if(state)state.error={code,message:String(error?.message||error)};
75
+ try {onError({path:state?.path,docToken:state?.docToken,code,message:String(error?.message||error)});} catch {}
76
+ return {ok:false,durable:false,code,error:String(error?.message||error)};
77
+ }
78
+ function open() {
79
+ if(database)return Promise.resolve(database);
80
+ if(opening)return opening;
81
+ opening=new Promise((resolve,reject)=>{
82
+ if(!idb?.open){reject(failure('RECOVERY_UNAVAILABLE','IndexedDB is unavailable'));return;}
83
+ let ended=false,request;
84
+ const done=(error,db)=>{if(ended){db?.close();return;}ended=true;clearTimeout(timer);error?reject(error):resolve(database=db);};
85
+ const timer=setTimeout(()=>done(failure('RECOVERY_OPEN_TIMEOUT','Recovery database open timed out')),5000);
86
+ try {request=idb.open(dbName,1);}catch(error){done(error);return;}
87
+ request.onupgradeneeded=()=>{
88
+ const db=request.result;
89
+ if(!db.objectStoreNames.contains('records')){
90
+ const records=db.createObjectStore('records',{keyPath:'id'});
91
+ records.createIndex('token','docToken',{unique:false});
92
+ records.createIndex('path','pathKey',{unique:false});
93
+ }
94
+ };
95
+ request.onerror=()=>done(request.error||failure('RECOVERY_OPEN','Recovery database failed to open'));
96
+ request.onsuccess=()=>{
97
+ const db=request.result;db.onversionchange=()=>{db.close();if(database===db)database=null;};
98
+ done(null,db);
99
+ };
100
+ request.onblocked=()=>done(failure('RECOVERY_BLOCKED','Another page is blocking recovery storage'));
101
+ }).finally(()=>{opening=null;});
102
+ return opening;
103
+ }
104
+ async function transaction(mode,body) {
105
+ const db=await open();
106
+ return new Promise((resolve,reject)=>{
107
+ let tx,value,explicitError,timer;
108
+ try {
109
+ tx=db.transaction('records',mode);
110
+ timer=setTimeout(()=>{explicitError=failure('RECOVERY_TRANSACTION_TIMEOUT','Recovery storage transaction timed out');try{tx.abort();}catch{reject(explicitError);}},options.transactionTimeoutMs??10000);
111
+ tx.oncomplete=()=>{clearTimeout(timer);resolve(value);};
112
+ tx.onerror=()=>{};
113
+ tx.onabort=()=>{clearTimeout(timer);reject(explicitError||tx.error||failure('RECOVERY_TRANSACTION','Recovery transaction aborted'));};
114
+ body(tx.objectStore('records'),v=>{value=v;},error=>{explicitError=error;tx.abort();});
115
+ } catch(error){clearTimeout(timer);try{tx?.abort();}catch{}reject(error);}
116
+ });
117
+ }
118
+ function enqueue(token,action,state) {
119
+ const task=(jobs.get(token)||Promise.resolve()).catch(()=>{}).then(action).catch(error=>notify(state,error));
120
+ jobs.set(token,task);
121
+ task.then(()=>{if(jobs.get(token)===task)jobs.delete(token);});
122
+ return task;
123
+ }
124
+ function metadata(state,session=state.session) {
125
+ return {id:'m:'+state.docToken,type:'meta',path:state.path,pathKey:state.pathKey,
126
+ docToken:state.docToken,checkpointId:state.checkpointId,checkpointOrder:state.checkpointOrder,session:cloneSession(session),
127
+ baseVersion:state.baseVersion,dirty:state.dirty,composing:state.composing,ts:state.ts};
128
+ }
129
+ function addPiece(out,piece) {
130
+ if(piece.from===piece.to)return;
131
+ const prev=out.at(-1);
132
+ if(prev&&prev.text===piece.text&&prev.to===piece.from)prev.to=piece.to;
133
+ else out.push(piece);
134
+ }
135
+ function patchPieces(state,event) {
136
+ if(!same(state.session,event.before)||!sessionOK(event.after)||event.after.epoch!==event.before.epoch||event.after.sourceRevision!==event.before.sourceRevision+1||event.beforeLength!==state.length||!Array.isArray(event.changes))throw failure('RECOVERY_GAP','Recovery delta is not continuous');
137
+ let end=0,length=state.length;
138
+ for(const c of event.changes){
139
+ if(!c||!Number.isSafeInteger(c.from)||!Number.isSafeInteger(c.to)||c.from<end||c.to<c.from||c.to>state.length||typeof c.insert!=='string')throw failure('RECOVERY_DELTA','Invalid recovery delta');
140
+ end=c.to;length+=c.insert.length-(c.to-c.from);
141
+ }
142
+ if(length!==event.afterLength)throw failure('RECOVERY_LENGTH','Recovery delta length mismatch');
143
+ const out=[];let position=0,index=0,offset=0;
144
+ function walk(to,keep){while(position<to){const p=state.pieces[index];if(!p)throw failure('RECOVERY_PIECES','Recovery pieces are incomplete');const count=Math.min(to-position,p.to-p.from-offset);if(keep)addPiece(out,{text:p.text,from:p.from+offset,to:p.from+offset+count});position+=count;offset+=count;if(offset===p.to-p.from){index++;offset=0;}}}
145
+ for(const c of event.changes){walk(c.from,true);walk(c.to,false);addPiece(out,{text:c.insert,from:0,to:c.insert.length});}
146
+ walk(state.length,true);
147
+ return {...state,pieces:out,length,session:cloneSession(event.after)};
148
+ }
149
+ const materialize = state => state.pieces.map(p=>p.text.slice(p.from,p.to)).join('');
150
+ function flush({docToken,path}={}) {
151
+ const state=states.get(docToken);
152
+ if(!state||pathKey(path||state.path)!==state.pathKey)return {ok:false,durable:false,code:'RECOVERY_OWNER'};
153
+ if(!state.dirty){try{storage?.removeItem(slotKey(docToken));}catch{}return {ok:true,durable:state.checkpointDurable&&state.pending.length===0,synchronous:true,pending:0};}
154
+ const durable=state.checkpointDurable&&state.pending.length===0;
155
+ if(durable){try{storage?.removeItem(slotKey(docToken));}catch{}return {ok:true,durable:true,synchronous:true,pending:0};}
156
+ const size=state.pendingChars+1024+(state.checkpointDurable?0:state.checkpointText.length);
157
+ if(size>maxSyncChars)return {ok:false,durable:false,synchronous:false,pending:state.pending.length,code:'RECOVERY_TAIL_TOO_LARGE'};
158
+ const value={schema:2,...metadata(state),baseSession:cloneSession(state.durableSession||state.checkpointSession),
159
+ events:state.pending,...(!state.checkpointDurable?{checkpoint:{text:state.checkpointText,session:state.checkpointSession}}:{})};
160
+ try {
161
+ if(!storage?.setItem)throw failure('RECOVERY_SYNC_UNAVAILABLE','Synchronous recovery storage is unavailable');
162
+ const json=JSON.stringify(value);
163
+ if(json.length>maxSyncChars)throw failure('RECOVERY_TAIL_TOO_LARGE','Recovery tail exceeds synchronous storage budget');
164
+ storage.setItem(slotKey(docToken),json);
165
+ return {ok:true,durable:false,synchronous:true,pending:state.pending.length,checkpointDurable:state.checkpointDurable};
166
+ } catch(error){return {...notify(state,error),synchronous:false,pending:state.pending.length};}
167
+ }
168
+ function checkpoint(slot) {
169
+ if(!slot||typeof slot.path!=='string'||!slot.path||typeof slot.docToken!=='string'||!slot.docToken||typeof slot.text!=='string'||!sessionOK(slot.session))return Promise.resolve({ok:false,durable:false,code:'RECOVERY_CHECKPOINT'});
170
+ const old=states.get(slot.docToken);if(old?.compactTimer)clearTimeout(old.compactTimer);
171
+ const state={...slot,pathKey:pathKey(slot.path),session:cloneSession(slot.session),dirty:!!slot.dirty,composing:!!slot.composing,ts:slot.ts||Date.now(),
172
+ checkpointId:Date.now().toString(36)+'-'+(++serial).toString(36)+'-'+Math.random().toString(36).slice(2),
173
+ checkpointOrder:(old?.checkpointOrder||0)+1,
174
+ checkpointText:slot.text,checkpointSession:cloneSession(slot.session),checkpointDurable:false,durableSession:null,
175
+ pieces:slot.text.length?[{text:slot.text,from:0,to:slot.text.length}]:[],length:slot.text.length,pending:[],pendingChars:0,events:0,error:null};
176
+ states.set(slot.docToken,state);flush({docToken:slot.docToken});
177
+ const meta=metadata(state),record={...meta,id:'c:'+state.docToken+':'+state.checkpointId,type:'checkpoint',text:slot.text};
178
+ return enqueue(state.docToken,async()=>{
179
+ await transaction('readwrite',(records,done)=>{
180
+ const keys=records.index('token').getAllKeys(state.docToken);
181
+ keys.onsuccess=()=>{for(const key of keys.result)records.delete(key);records.put(record);records.put(meta);done({ok:true});};
182
+ });
183
+ state.checkpointDurable=true;state.durableSession=cloneSession(state.checkpointSession);
184
+ if(states.get(state.docToken)===state)flush({docToken:state.docToken});
185
+ return {ok:true,durable:true,session:cloneSession(state.checkpointSession)};
186
+ },state);
187
+ }
188
+ function append(event) {
189
+ const state=states.get(event?.docToken);
190
+ if(!state||event.path&&pathKey(event.path)!==state.pathKey)return {ok:false,durable:Promise.resolve({ok:false}),code:'RECOVERY_OWNER'};
191
+ try {
192
+ const next=patchPieces(state,event);
193
+ const delta={before:cloneSession(event.before),after:cloneSession(event.after),beforeLength:event.beforeLength,afterLength:event.afterLength,
194
+ changes:event.changes.map(c=>({from:c.from,to:c.to,insert:c.insert})),composing:!!event.composing,ts:Date.now()};
195
+ Object.assign(state,{pieces:next.pieces,length:next.length,session:next.session,dirty:true,composing:delta.composing,ts:delta.ts});
196
+ if(typeof event.baseVersion==='string')state.baseVersion=event.baseVersion;
197
+ const cost=delta.changes.reduce((n,c)=>n+c.insert.length+128,256);
198
+ state.pending.push(delta);state.pendingChars+=cost;state.events++;
199
+ const meta=metadata(state),record={...meta,id:'d:'+state.docToken+':'+state.checkpointId+':'+delta.after.sourceRevision,type:'delta',delta};
200
+ const synchronous=flush({docToken:state.docToken});
201
+ const durable=enqueue(state.docToken,async()=>{
202
+ await transaction('readwrite',(records,done,abort)=>{
203
+ const get=records.get('m:'+state.docToken);
204
+ get.onsuccess=()=>{
205
+ const old=get.result;
206
+ if(!old||old.checkpointId!==record.checkpointId){abort(failure('RECOVERY_GAP','Stored recovery journal is not continuous'));return;}
207
+ let head=old.session;
208
+ // Retry an earlier failed write with the next delta, without ever
209
+ // skipping the missing revision or serializing the full document.
210
+ for(const pending of state.pending){
211
+ if(pending.after.sourceRevision>delta.after.sourceRevision)break;
212
+ if(pending.after.sourceRevision<=head.sourceRevision)continue;
213
+ if(!same(head,pending.before)){abort(failure('RECOVERY_GAP','Stored recovery journal has a missing revision'));return;}
214
+ records.put({...record,id:'d:'+state.docToken+':'+state.checkpointId+':'+pending.after.sourceRevision,delta:pending});head=pending.after;
215
+ }
216
+ if(!same(head,delta.after)){abort(failure('RECOVERY_GAP','Stored recovery head does not match the delta'));return;}
217
+ records.put(meta);done({ok:true});
218
+ };
219
+ });
220
+ state.pending=state.pending.filter(p=>p.after.sourceRevision>delta.after.sourceRevision);
221
+ state.pendingChars=state.pending.reduce((sum,p)=>sum+p.changes.reduce((n,c)=>n+c.insert.length+128,256),0);
222
+ state.durableSession=cloneSession(delta.after);
223
+ if(states.get(state.docToken)===state)flush({docToken:state.docToken});
224
+ return {ok:true,durable:true,session:cloneSession(delta.after)};
225
+ },state);
226
+ if(state.events>=128&&!state.compactTimer&&!state.releaseRequested){
227
+ state.compactTimer=setTimeout(()=>{
228
+ state.compactTimer=null;if(states.get(state.docToken)!==state||state.releaseRequested)return;
229
+ void checkpoint({...metadata(state),text:materialize(state)});
230
+ },1000);
231
+ }
232
+ return {ok:true,synchronous:synchronous.synchronous===true,durable};
233
+ } catch(error){const answer=notify(state,error);return {...answer,durable:Promise.resolve(answer)};}
234
+ }
235
+ function markSaved(slot) {
236
+ const state=states.get(slot?.docToken);
237
+ if(!state||pathKey(slot.path)!==state.pathKey||!sessionOK(slot.session)||slot.session.epoch!==state.session.epoch||slot.session.sourceRevision>state.session.sourceRevision)return Promise.resolve({ok:false,code:'RECOVERY_OWNER'});
238
+ state.baseVersion=slot.baseVersion;state.dirty=!same(slot.session,state.session);state.ts=Date.now();
239
+ flush({docToken:state.docToken});
240
+ return enqueue(state.docToken,()=>transaction('readwrite',(records,done,abort)=>{
241
+ const request=records.get('m:'+state.docToken);
242
+ request.onsuccess=()=>{
243
+ const old=request.result;
244
+ if(!old||old.checkpointId!==state.checkpointId||old.session.epoch!==slot.session.epoch||old.session.sourceRevision<slot.session.sourceRevision){abort(failure('RECOVERY_OWNER','Save does not belong to the recovery checkpoint'));return;}
245
+ records.put({...old,baseVersion:slot.baseVersion,dirty:!same(old.session,slot.session),ts:state.ts});done({ok:true,durable:true});
246
+ };
247
+ }),state);
248
+ }
249
+ function clear(path,docToken,expectedSession) {
250
+ const state=states.get(docToken);
251
+ if(state&&(state.pathKey!==pathKey(path)||expectedSession&&!same(state.session,expectedSession)))return Promise.resolve({ok:false,code:'RECOVERY_NEWER'});
252
+ // Another page may already own a newer synchronous tail on this baseline.
253
+ try {const raw=storage?.getItem(slotKey(docToken));if(raw){const tail=JSON.parse(raw);if(tail.pathKey!==pathKey(path)||expectedSession&&!same(tail.session,expectedSession))return Promise.resolve({ok:false,code:'RECOVERY_NEWER'});}}catch{}
254
+ if(state){states.delete(docToken);clearTimeout(state.compactTimer);}
255
+ try {const raw=storage?.getItem(slotKey(docToken));if(raw){const old=JSON.parse(raw);if(old.pathKey===pathKey(path)&&(!expectedSession||same(old.session,expectedSession)))storage.removeItem(slotKey(docToken));}}catch{}
256
+ return enqueue(docToken,()=>transaction('readwrite',(records,done)=>{
257
+ const get=records.get('m:'+docToken);
258
+ get.onsuccess=()=>{
259
+ const old=get.result;
260
+ if(old&&(old.pathKey!==pathKey(path)||expectedSession&&!same(old.session,expectedSession))){done({ok:false,code:'RECOVERY_NEWER'});return;}
261
+ const keys=records.index('token').getAllKeys(docToken);keys.onsuccess=()=>{for(const key of keys.result)records.delete(key);done({ok:true});};
262
+ };
263
+ }),state);
264
+ }
265
+ function replay(base,events) {
266
+ let result={session:cloneSession(base.session),length:base.text.length,pieces:base.text.length?[{text:base.text,from:0,to:base.text.length}]:[]};
267
+ for(const event of events){if(event.after.epoch===result.session.epoch&&event.after.sourceRevision<=result.session.sourceRevision)continue;result=patchPieces(result,event);}
268
+ return {...result,text:materialize(result)};
269
+ }
270
+ async function readLatest(path) {
271
+ const key=pathKey(path),candidates=new Map(),errors=[];
272
+ let rows=[];
273
+ try {rows=await transaction('readonly',(records,done)=>{const request=records.index('path').getAll(key);request.onsuccess=()=>done(request.result);});}
274
+ catch(error){errors.push(notify(null,error));}
275
+ for(const meta of rows.filter(r=>r.type==='meta')){
276
+ const base=rows.find(r=>r.type==='checkpoint'&&r.docToken===meta.docToken&&r.checkpointId===meta.checkpointId);
277
+ if(!base)continue;
278
+ try {
279
+ const events=rows.filter(r=>r.type==='delta'&&r.docToken===meta.docToken&&r.checkpointId===meta.checkpointId).map(r=>r.delta).sort((a,b)=>a.after.sourceRevision-b.after.sourceRevision);
280
+ const value=replay(base,events);if(!same(value.session,meta.session))throw failure('RECOVERY_GAP','Stored recovery metadata has a journal gap');
281
+ candidates.set(meta.docToken,{...meta,text:value.text,durable:true});
282
+ }catch(error){errors.push(notify(null,error));}
283
+ }
284
+ try {
285
+ for(let i=0;i<(storage?.length||0);i++){
286
+ const k=storage.key(i);if(!k?.startsWith(prefix))continue;
287
+ try {
288
+ const tail=JSON.parse(storage.getItem(k));if(tail?.schema!==2||tail.pathKey!==key||!Array.isArray(tail.events))continue;
289
+ const stored=candidates.get(tail.docToken);
290
+ const base=stored?.checkpointId===tail.checkpointId?stored:tail.checkpoint&&typeof tail.checkpoint.text==='string'?tail.checkpoint:null;
291
+ if(!base)continue;
292
+ const value=replay(base,tail.events);
293
+ if(!same(value.session,tail.session))continue;
294
+ // A stale synchronous tail never replaces a later IndexedDB checkpoint.
295
+ if(stored&&stored.checkpointId!==tail.checkpointId&&stored.checkpointOrder>=tail.checkpointOrder)continue;
296
+ candidates.set(tail.docToken,{...tail,text:value.text,durable:true,synchronous:true});
297
+ }catch(error){errors.push(notify(null,error));}
298
+ }
299
+ }catch(error){errors.push(notify(null,error));}
300
+ for(const state of states.values())if(state.pathKey===key){
301
+ candidates.set(state.docToken,{...metadata(state),text:materialize(state),durable:state.checkpointDurable&&state.pending.length===0,error:state.error});
302
+ }
303
+ const dirty=[...candidates.values()].filter(r=>r.dirty).sort((a,b)=>b.ts-a.ts);
304
+ return dirty.length?{...dirty[0],alternatives:dirty.slice(1).map(r=>({docToken:r.docToken,session:r.session,ts:r.ts})),storageErrors:errors}:null;
305
+ }
306
+ function status(docToken) {
307
+ const state=states.get(docToken);
308
+ return state?{docToken,path:state.path,session:cloneSession(state.session),dirty:state.dirty,pending:state.pending.length,
309
+ durable:state.checkpointDurable&&state.pending.length===0,checkpointDurable:state.checkpointDurable,retained:!!state.releaseRequested,error:state.error}:null;
310
+ }
311
+ async function release(value) {
312
+ const docToken=typeof value==='string'?value:value?.docToken,path=typeof value==='object'?value.path:null;
313
+ let state=states.get(docToken);
314
+ if(!state)return {ok:true,released:true};
315
+ if(path&&pathKey(path)!==state.pathKey)return {ok:false,released:false,code:'RECOVERY_OWNER'};
316
+ state.releaseRequested=true;clearTimeout(state.compactTimer);state.compactTimer=null;
317
+ // Close grace finishes message admission before calling release. Still
318
+ // follow any already-enqueued checkpoint/delta, including its replacement.
319
+ for(;;){const job=jobs.get(docToken);if(!job)break;await job;}
320
+ state=states.get(docToken);
321
+ if(!state)return {ok:true,released:true};
322
+ clearTimeout(state.compactTimer);
323
+ if(!state.checkpointDurable||state.pending.length){
324
+ const sync=flush({docToken});
325
+ return {...notify(state,failure('RECOVERY_RETAINED','Recovery storage is incomplete; the closed draft remains in memory')),
326
+ released:false,retained:true,synchronous:!!sync.synchronous};
327
+ }
328
+ if(!state.dirty){
329
+ const result=await clear(state.path,docToken,state.session);
330
+ return {...result,released:!states.has(docToken),durable:true};
331
+ }
332
+ states.delete(docToken);
333
+ return {ok:true,durable:true,released:true};
334
+ }
335
+ function close() {for(const state of states.values()){clearTimeout(state.compactTimer);flush({docToken:state.docToken});}database?.close();database=null;}
336
+ return {checkpoint,append,flush,readLatest,markSaved,clear,status,release,close};
337
+ }
338
+
339
+ const recoveryStore = window.__dshDraftStore || createDraftStore({onError(error) {
340
+ for (const document of openDocs.values()) document.storageError?.(error);
341
+ }});
342
+ window.__dshDraftStore = recoveryStore;
343
+
344
+
345
+ // The parent keeps a delta mirror for recovery; editor revisions remain authoritative.
346
+ // Session truth always comes from mpEngine.documentSession(); these functions
347
+ // never create a second editor revision counter.
348
+ function sameSession(a,b){return !!a&&!!b&&a.epoch===b.epoch&&a.sourceRevision===b.sourceRevision;}
349
+ function validSession(s){return !!s&&Number.isSafeInteger(s.epoch)&&Number.isSafeInteger(s.sourceRevision)&&s.sourceRevision>=0;}
350
+ function mirrorOf(text,session,token){
351
+ if(typeof text!=='string'||!validSession(session))throw Error('Invalid mirror checkpoint');
352
+ return{token,session:{...session},length:text.length,pieces:text.length?[{text,from:0,to:text.length}]:[],continuous:true};
353
+ }
354
+ function materialize(mirror){return mirror.pieces.map(p=>p.text.slice(p.from,p.to)).join('');}
355
+ function appendPiece(out,piece){
356
+ if(piece.from===piece.to)return;
357
+ const prev=out[out.length-1];
358
+ if(prev&&prev.text===piece.text&&prev.to===piece.from)prev.to=piece.to;
359
+ else out.push(piece);
360
+ }
361
+ function applyMirrorDelta(mirror,event){
362
+ if(!mirror.continuous||event.docToken!==mirror.token||!sameSession(mirror.session,event.before)||!validSession(event.after)||event.after.epoch!==event.before.epoch||event.after.sourceRevision!==event.before.sourceRevision+1)return{ok:false,reason:'gap-or-owner'};
363
+ if(event.beforeLength!==mirror.length||!Array.isArray(event.changes))return{ok:false,reason:'shape'};
364
+ let previousEnd=0,length=mirror.length;
365
+ for(const c of event.changes){
366
+ if(!c||!Number.isSafeInteger(c.from)||!Number.isSafeInteger(c.to)||c.from<previousEnd||c.to<c.from||c.to>mirror.length||typeof c.insert!=='string')return{ok:false,reason:'ranges'};
367
+ previousEnd=c.to;length+=c.insert.length-(c.to-c.from);
368
+ }
369
+ if(length!==event.afterLength)return{ok:false,reason:'length'};
370
+ // Walk existing piece boundaries. Unchanged source text stays shared; only the
371
+ // inserted strings and a small piece array are retained. No full text clone.
372
+ const pieces=[];let sourcePos=0,pieceIndex=0,pieceOffset=0;
373
+ function walkTo(target,keep){
374
+ while(sourcePos<target){
375
+ const p=mirror.pieces[pieceIndex];if(!p)throw Error('Corrupt mirror pieces');
376
+ const count=Math.min(target-sourcePos,p.to-p.from-pieceOffset);
377
+ if(keep)appendPiece(pieces,{text:p.text,from:p.from+pieceOffset,to:p.from+pieceOffset+count});
378
+ sourcePos+=count;pieceOffset+=count;
379
+ if(pieceOffset===p.to-p.from){pieceIndex++;pieceOffset=0;}
380
+ }
381
+ }
382
+ for(const c of event.changes){walkTo(c.from,true);walkTo(c.to,false);appendPiece(pieces,{text:c.insert,from:0,to:c.insert.length});}
383
+ walkTo(mirror.length,true);
384
+ return{ok:true,mirror:{...mirror,session:{...event.after},length,pieces}};
385
+ }
386
+
387
+
52
388
  /** sessions 服务旁路:apply 里写入取值器(每次 boot 现取,服务晚到也能拿到 cwd;不进 cordis inject) */
53
389
  const SessionsBridge = { getSessions: null };
54
390
 
@@ -215,13 +551,84 @@ window.__ModuleLoader__.load({
215
551
  return base;
216
552
  }
217
553
 
218
- /** 保存请求体:path/text/baseVersion 三者齐全才放行(baseVersion 永不省略)。 */
554
+ /** 保存请求体:path/text/baseVersion 三者齐全才放行(baseVersion 永不省略)。
555
+ * live 会话激活时附带守卫票 {client, epoch, version}(房间版本来自 flush 回执);
556
+ * 票不完整按拒绝处理,绝不降级成无票盲写。 */
219
557
  function buildSaveRequest(payload) {
220
558
  const p = payload || {};
221
559
  if (typeof p.path !== "string" || p.path.length === 0) return { ok: false, reason: "missing-path" };
222
560
  if (typeof p.text !== "string") return { ok: false, reason: "missing-text" };
223
561
  if (typeof p.baseVersion !== "string" || p.baseVersion.length === 0) return { ok: false, reason: "missing-base-version" };
224
- return { ok: true, body: { path: p.path, text: p.text, baseVersion: p.baseVersion } };
562
+ const body = { path: p.path, text: p.text, baseVersion: p.baseVersion };
563
+ const ticket = p.live;
564
+ if (ticket !== undefined && ticket !== null) {
565
+ // 房间 epoch 是 LiveDocuments rooms.epoch 的 UUID 字符串;version/savedVersion 才是整数。
566
+ // 引擎 documentSession 的数字 epoch 是另一条契约,两者不得混用。
567
+ if (!ticket || typeof ticket !== "object" || typeof ticket.client !== "string" || !ticket.client
568
+ || typeof ticket.epoch !== "string" || !ticket.epoch
569
+ || !Number.isSafeInteger(ticket.version)) {
570
+ return { ok: false, reason: "malformed-live-ticket" };
571
+ }
572
+ body.live = { client: ticket.client, epoch: ticket.epoch, version: ticket.version };
573
+ }
574
+ return { ok: true, body: body };
575
+ }
576
+
577
+ /** live 保存回执并入本地会话状态(纯函数):房间侧字段缺失时保留 flush 对齐值。 */
578
+ function applyLiveReceipt(live, value) {
579
+ if (!live || !value || typeof value !== "object") return live;
580
+ const next = { ...live };
581
+ if (Number.isSafeInteger(value.savedVersion)) next.savedVersion = value.savedVersion;
582
+ if (typeof value.diskHash === "string" && value.diskHash) next.diskHash = value.diskHash;
583
+ if (typeof value.epoch === "string" && value.epoch) next.epoch = value.epoch; // UUID 字符串
584
+ if (Number.isSafeInteger(value.liveVersion)) next.version = value.liveVersion;
585
+ return next;
586
+ }
587
+
588
+ /** live 错误分类(纯函数):unsupported=引擎无 facade(回落单窗口);conflict=会话已死
589
+ * (保留草稿 + 冲突横幅,API.md §4 halt 集);notice=瞬态(facade 自行退避重试)。 */
590
+ function liveErrorAction(code) {
591
+ const c = String(code || "");
592
+ if (c === "MP_LIVE_FACADE_MISSING") return "unsupported";
593
+ if (["MP_LIVE_LEASE", "MP_LIVE_SESSION", "MP_LIVE_CLOSED", "MP_LIVE_CONFLICT",
594
+ "MP_LIVE_CHANGE", "MP_LIVE_UNREACHABLE", "MP_DOC_MISMATCH", "MP_FILE_CONFLICT",
595
+ "MP_LIVE_STALE"].indexOf(c) !== -1) return "conflict";
596
+ return "notice";
597
+ }
598
+
599
+ /** MP_LIVE_STALE 保存路径决策(纯函数;runSave 与真实客户端测试共用同一实现):
600
+ * · 该码只表示「同一房间对端还在写」的版本竞争(API.md §4:保存路径、不 halt);
601
+ * · 快重试 3 轮(0ms)→ 退避 ≤3 轮(2000ms,与输入 1500ms 防抖自然合并)→
602
+ * 有界停止(blocked:不再自动发请求,保草稿等显式 Ctrl+S);
603
+ * · 显式保存(manual)立即重试并重置预算;保存成功同样重置;
604
+ * · 其它冲突码(换代/歧义替换/外盘冲突)不走此函数,仍由 liveErrorAction 明确停。 */
605
+ function liveStaleAction(state) {
606
+ const s = state || {};
607
+ const attempt = Number(s.attempt) || 1;
608
+ const rounds = Number(s.rounds) || 0;
609
+ if (s.manual) return { action: "retry-now", rounds: 0, blocked: false }; // 显式保存:重置预算
610
+ if (s.blocked) return { action: "stopped", rounds }; // 已停:自动路径不得再发请求
611
+ if (attempt < 3) return { action: "retry-immediate", rounds };
612
+ if (rounds < 3) return { action: "retry-delayed", rounds: rounds + 1, delayMs: 2000 };
613
+ return { action: "stopped", rounds, blocked: true };
614
+ }
615
+
616
+ /** join 票 recovered 字段的展示投影(纯函数):只保留带字符串 recoveryPath 的条目,
617
+ * 截前 5 条、其余字段原样透传(sha256/at 供定位与时间显示);宿主与插件都**不读**这些文件。
618
+ * 提示按票(client)去重:同一 client 只提示一次。 */
619
+ function liveRecoveredItems(ticket) {
620
+ const raw = ticket && Array.isArray(ticket.recovered) ? ticket.recovered : [];
621
+ const items = [];
622
+ for (const entry of raw) {
623
+ if (!entry || typeof entry !== "object" || typeof entry.recoveryPath !== "string" || !entry.recoveryPath) continue;
624
+ items.push({
625
+ recoveryPath: entry.recoveryPath,
626
+ ...(typeof entry.sha256 === "string" && entry.sha256 ? { sha256: entry.sha256 } : {}),
627
+ ...(Number.isFinite(Number(entry.at)) ? { at: Number(entry.at) } : {}),
628
+ });
629
+ if (items.length >= 5) break;
630
+ }
631
+ return items;
225
632
  }
226
633
 
227
634
  /** 轮询档位:SSE 健康/页面隐藏/后台 tab 时返回 null(不调度);断开首档 2s,三次后 6s。 */
@@ -360,6 +767,7 @@ window.__ModuleLoader__.load({
360
767
  }
361
768
 
362
769
  const __test = {
770
+ createDraftStore,
363
771
  constants: {
364
772
  TAB_TYPE_ID: TAB_TYPE_ID,
365
773
  FILE_ADDRESS_PREFIX: FILE_ADDRESS_PREFIX,
@@ -384,6 +792,10 @@ window.__ModuleLoader__.load({
384
792
  saveGate: saveGate,
385
793
  manualSaveGate: manualSaveGate,
386
794
  buildSaveRequest: buildSaveRequest,
795
+ applyLiveReceipt: applyLiveReceipt,
796
+ liveErrorAction: liveErrorAction,
797
+ liveStaleAction: liveStaleAction,
798
+ liveRecoveredItems: liveRecoveredItems,
387
799
  nextPollDelay: nextPollDelay,
388
800
  shouldAcceptExternal: shouldAcceptExternal,
389
801
  insertSelectionToChat: insertSelectionToChat,
@@ -464,13 +876,17 @@ window.__ModuleLoader__.load({
464
876
  const inputActionsRef = React.useRef(props.inputActions);
465
877
  inputActionsRef.current = props.inputActions;
466
878
 
467
- const [ui, setUi] = React.useState({ phase: "loading", detail: "", notice: "", recovery: null });
879
+ const [ui, setUi] = React.useState({ phase: "loading", detail: "", notice: "", recovery: null, recovered: null });
468
880
  const setUiIfChanged = React.useCallback((next) => {
469
881
  setUi((prev) => {
470
882
  if (prev.phase === next.phase && prev.detail === next.detail && prev.notice === next.notice) return prev;
471
883
  return { ...prev, ...next };
472
884
  });
473
885
  }, []);
886
+ // recovered 横幅独立于 phase(dirty/ready 切换不清除;按票一次,用户「知道了」才收起)。
887
+ const setUiIfChangedWithRecovered = React.useCallback((items) => {
888
+ setUi((prev) => (prev.recovered === items ? prev : { ...prev, recovered: items }));
889
+ }, []);
474
890
 
475
891
  /* ── 文档生命周期(每个 depKey 一代;异步回执都验票) ── */
476
892
  React.useEffect(() => {
@@ -496,15 +912,18 @@ window.__ModuleLoader__.load({
496
912
  saveQueued: false,
497
913
  manualPending: false,
498
914
  editSeq: 0,
915
+ token: "dshdoc-" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2) + "-" + generationCounter,
916
+ mirror: null, pendingApply: null, applyUnknown: false, composing: false,
917
+ wireSeq: 0, pulls: new Map(), gapDeltas: [], closeUntil: 0,
918
+ loadSeq: 0,
499
919
  text: "",
500
920
  textFresh: false,
501
921
  recovery: null,
502
922
  saveTimer: null,
503
- pullResolve: null,
504
- pullTimer: null,
505
- flushResolve: null,
506
- flushTimer: null,
507
923
  flushed: false,
924
+ live: null, liveUnsupported: false, liveFlushSeq: 0, liveFlushWaiters: new Map(), liveStatus: null,
925
+ liveStaleRounds: 0, liveStaleBlocked: false,
926
+ liveRecoveredShownFor: null, liveRecoveredItems: null,
508
927
  es: null,
509
928
  esHealthy: false,
510
929
  pollTimer: null,
@@ -520,18 +939,25 @@ window.__ModuleLoader__.load({
520
939
  };
521
940
  st.doc = doc;
522
941
  setUiIfChanged({ phase: "loading", detail: "", notice: "", recovery: null });
942
+ setUi((prev) => (prev.recovered ? { ...prev, recovered: null } : prev)); // 新文档代际不继承 recovered 横幅
523
943
 
524
944
  const ticket = ticketOf(doc.sessionId, doc.absPath, doc.gen);
525
945
  const alive = () => !doc.dead && st.doc === doc && ticket.generation === doc.gen;
526
946
  const cleanups = [];
527
947
 
528
948
  function refreshPath() {
529
- doc.absPath = absolutePath(parsed.path, currentCwd(doc.sessionId));
949
+ // A session cwd change never retargets an already owned document.
950
+ if (!doc.opened && !doc.mirror && !doc.pendingApply) {
951
+ const previousPath=doc.absPath;
952
+ doc.absPath = absolutePath(parsed.path, currentCwd(doc.sessionId));
953
+ if(previousPath!==doc.absPath)openDocs.delete(doc.sessionId + "\n" + previousPath);
954
+ }
530
955
  doc.displayPath = doc.displayPath || doc.absPath;
531
956
  openDocs.set(doc.sessionId + "\n" + doc.absPath, {
532
957
  sessionId: doc.sessionId,
533
958
  path: doc.absPath,
534
959
  basename: basenameOf(doc.absPath),
960
+ storageError: error => doc.storageError?.(error),
535
961
  });
536
962
  }
537
963
  refreshPath();
@@ -554,6 +980,252 @@ window.__ModuleLoader__.load({
554
980
  }
555
981
  }
556
982
 
983
+ function draftStore() { return recoveryStore; }
984
+ doc.storageError = error => showNotice("恢复记录尚未持久化:" + String(error.message || error.code));
985
+ function inspectStoreResult(result) { if(result?.ok===false)showNotice('恢复记录尚未持久化:'+String(result.error||result.code||'storage unavailable')); return result; }
986
+ function wireSession() { return doc.mirror ? {...doc.mirror.session} : null; }
987
+ /* ── live 会话(引擎 facade mpEngine.liveDocument 承担 OT;父窗口只做中继)── */
988
+ function startLive(ticket, text) {
989
+ if (!alive() || doc.live || doc.liveUnsupported) return;
990
+ // 房间票:client/epoch(UUID 字符串)/version(整数);text 允许空串(空文件)。
991
+ if (!ticket || typeof ticket.client !== "string" || !ticket.client
992
+ || typeof ticket.epoch !== "string" || !ticket.epoch
993
+ || !Number.isSafeInteger(ticket.version) || typeof text !== "string") return;
994
+ doc.live = { client: ticket.client, epoch: ticket.epoch, version: ticket.version,
995
+ diskHash: ticket.diskHash, savedVersion: Number.isSafeInteger(ticket.savedVersion) ? ticket.savedVersion : ticket.version };
996
+ post("live-bind", { protocol: 2, docToken: doc.token, text: text, live: ticket });
997
+ // recovered:join 票带回的草稿副本(内核恢复记录)。只在本 tab 内展示一次:
998
+ // 路径可复制、原件未被覆盖;不读文件、不进 SSE、不影响其它文档。
999
+ const recovered = liveRecoveredItems(ticket);
1000
+ if (recovered.length && !doc.liveRecoveredShownFor) {
1001
+ doc.liveRecoveredShownFor = ticket.client;
1002
+ doc.liveRecoveredItems = recovered;
1003
+ setUiIfChangedWithRecovered(recovered);
1004
+ }
1005
+ }
1006
+ function stopLive(backstopLeave) {
1007
+ const session = doc.live;
1008
+ doc.live = null;
1009
+ doc.liveStaleBlocked = false; // 会话离场:STALE 预算随会话作废
1010
+ doc.liveStaleRounds = 0;
1011
+ doc.liveFlushWaiters.forEach(function (waiter) { waiter.resolve({ ok: false, code: "MP_LIVE_GONE", error: "live 会话已结束" }); });
1012
+ doc.liveFlushWaiters.clear();
1013
+ if (!session) return;
1014
+ post("live-unbind", { protocol: 2, docToken: doc.token }); // facade unbind → leave 上行
1015
+ if (backstopLeave) api("live", { client: session.client, action: "leave" }).catch(function () { /* 已关或已过期 */ });
1016
+ }
1017
+ function flushLive() {
1018
+ if (!doc.live) return Promise.resolve({ ok: false, code: "MP_LIVE_GONE", error: "live 会话已结束" });
1019
+ const id = ++doc.liveFlushSeq;
1020
+ return new Promise(function (resolve) {
1021
+ const waiter = { resolve: function (value) { clearTimeout(timer); resolve(value); } };
1022
+ const timer = setTimeout(function () {
1023
+ doc.liveFlushWaiters.delete(id);
1024
+ resolve({ ok: false, code: "MP_LIVE_TIMEOUT", error: "同步确认超时" });
1025
+ }, LIVE_FLUSH_TIMEOUT_MS);
1026
+ doc.liveFlushWaiters.set(id, waiter);
1027
+ if (!post("live-flush", { protocol: 2, docToken: doc.token, id: id })) {
1028
+ doc.liveFlushWaiters.delete(id); clearTimeout(timer);
1029
+ resolve({ ok: false, code: "MP_LIVE_GONE", error: "编辑面不可达" });
1030
+ }
1031
+ });
1032
+ }
1033
+ async function relayLiveExchange(content) {
1034
+ const client = content && content.client;
1035
+ const reply = function (response) {
1036
+ post("live-exchange-result", { protocol: 2, docToken: doc.token, id: content && content.id, response: response });
1037
+ };
1038
+ if (!alive() && !(doc.closeUntil >= Date.now())) { reply({ ok: false, error: "编辑面已关闭", code: "MP_LIVE_GONE" }); return; }
1039
+ try {
1040
+ reply(await api("live", { client: client, action: "exchange", request: content.request }));
1041
+ } catch (error) {
1042
+ reply({ ok: false, error: String(error && error.message || error), code: "MP_LIVE_TRANSPORT" });
1043
+ }
1044
+ }
1045
+ function handleLiveError(code, message) {
1046
+ const action = liveErrorAction(code);
1047
+ const friendly = String(message || code || "共享编辑会话异常");
1048
+ if (action === "unsupported") {
1049
+ doc.liveUnsupported = true;
1050
+ const session = doc.live;
1051
+ doc.live = null;
1052
+ if (session) api("live", { client: session.client, action: "leave" }).catch(function () { /* 已过期 */ });
1053
+ showNotice("当前引擎暂不支持跨窗口实时同步,已切换为单窗口安全模式");
1054
+ // 单窗口回落:重新以非 live 口径读盘(干净时),避免把共享房间的未存草稿
1055
+ // 留在编辑面里被当成磁盘基线;用户已输入(脏)时由 loadFromDisk 自身守卫保留。
1056
+ if (doc.opened && !doc.dirty && !doc.conflict) void loadFromDisk({ silent: true, reason: "live-unsupported" });
1057
+ return;
1058
+ }
1059
+ if (action === "conflict") {
1060
+ doc.live = null;
1061
+ doc.conflict = true;
1062
+ showPhase("conflict", (code === "MP_LIVE_STALE" ? "共享文档版本已变化,未覆盖;" : "共享编辑会话已结束,") + "当前内容已保留:" + friendly);
1063
+ return;
1064
+ }
1065
+ showNotice("共享编辑同步提示:" + friendly);
1066
+ }
1067
+
1068
+ function checkpointRecovery(text,session,dirty) {
1069
+ const store=draftStore();
1070
+ if(!store)return Promise.resolve({ok:false,durable:false,code:'RECOVERY_UNAVAILABLE'});
1071
+ try { return Promise.resolve(store.checkpoint({path:doc.absPath,docToken:doc.token,session,text,baseVersion:doc.lastVersion,dirty:!!dirty,composing:doc.composing})).then(inspectStoreResult).catch(error=>{showNotice('恢复记录尚未持久化:'+String(error));return{ok:false,durable:false};}); }
1072
+ catch(error){showNotice('恢复记录尚未持久化:'+String(error));return Promise.resolve({ok:false,durable:false});}
1073
+ }
1074
+ function persistCurrentRecovery() {
1075
+ const store=draftStore();
1076
+ if(store){try{return inspectStoreResult(store.flush({path:doc.absPath,docToken:doc.token}));}catch(error){showNotice('恢复记录写入失败:'+String(error));}}
1077
+ if(doc.mirror?.continuous){writeRecovery(materialize(doc.mirror),doc.composing || !!doc.pendingApply);}
1078
+ return {durable:false,retained:true};
1079
+ }
1080
+ function requestSnapshot(reason) {
1081
+ const id=doc.token+':pull:'+ (++doc.wireSeq);
1082
+ return new Promise(resolve=>{
1083
+ const pending={id,docToken:doc.token,reason,expected:wireSession(),resolve,timer:null};
1084
+ doc.pulls.set(id,pending);
1085
+ pending.timer=setTimeout(()=>{
1086
+ doc.pulls.delete(id);resolve({text:doc.text,stale:true});
1087
+ if(reason==='apply' && doc.pendingApply){doc.applyUnknown=true;doc.pendingApply.resolve?.(false);doc.pendingApply.resolve=null;if(alive())showError('编辑器应用状态尚未确认;当前稿件已保留,保存暂停');}
1088
+ },PULL_TIMEOUT_MS);
1089
+ if(!post('requestText',{protocol:2,docToken:doc.token,id,expectedSession:pending.expected})){clearTimeout(pending.timer);doc.pulls.delete(id);resolve({text:doc.text,stale:true});}
1090
+ });
1091
+ }
1092
+ function applyToFrame(candidate) {
1093
+ if(doc.pendingApply || doc.applyUnknown || doc.composing || doc.saving || !alive())return Promise.resolve(false);
1094
+ if(candidate.path!==doc.absPath || candidate.baseVersion!==doc.lastVersion || candidate.editSeq!==doc.editSeq || candidate.loadSeq!==doc.loadSeq)return Promise.resolve(false);
1095
+ if(!candidate.firstOpen && !doc.mirror?.continuous)return Promise.resolve(false);
1096
+ return new Promise(resolve=>{
1097
+ const applyId=doc.token+':apply:'+(++doc.wireSeq);
1098
+ const wire={protocol:2,docToken:doc.token,applyId,serial:doc.wireSeq,expected:wireSession(),content:candidate.value.text,kind:candidate.kind,rootPath:directoryOf(doc.absPath)};
1099
+ const pending={...candidate,docToken:doc.token,applyId,expected:wire.expected,wire,resolve,edits:[],timer:null};
1100
+ doc.pendingApply=pending;
1101
+ pending.timer=setTimeout(()=>{if(doc.pendingApply!==pending)return;doc.applyUnknown=true;void requestSnapshot('apply');},PULL_TIMEOUT_MS);
1102
+ if(candidate.firstOpen)post('theme',{dark:isDarkTheme()});
1103
+ if(!post(candidate.firstOpen?'open':'setValue',wire)){clearTimeout(pending.timer);doc.pendingApply=null;resolve(false);}
1104
+ });
1105
+ }
1106
+ function commitApplied(pending,reply,snapshot) {
1107
+ const value=pending.value,restore=pending.kind==='restore';
1108
+ doc.opened=true;doc.text=value.text;doc.textFresh=true;doc.lastVersion=value.version;
1109
+ doc.displayPath=value.displayPath||doc.absPath;doc.size=value.size;
1110
+ doc.mirror=mirrorOf(value.text,reply.session,doc.token);doc.composing=false;
1111
+ // 共享房间的未存草稿不是磁盘状态:open 带回的正文若与磁盘种子不同(value.live.dirty),
1112
+ // 必须如实标记脏 —— baseVersion/lastVersion 仍指向磁盘版本,绝不能把共享正文
1113
+ // 当成"已在盘上"的基线;后续落盘只能走 live 票(房间校验)路径。
1114
+ doc.dirty=restore||(pending.firstOpen&&value.live&&value.live.dirty===true);
1115
+ doc.conflict=false;doc.externalStale=false;doc.staleNotified=false;
1116
+ if(restore)doc.editSeq++;
1117
+ // live 会话:open(live:true) 回执带票且引擎 facade 可用时,把传输桥交给 iframe。
1118
+ // join 票对应的正文就是本次应用的 value.text,facade bind 的 initial 与之一致。
1119
+ if(value.live && pending.firstOpen && !doc.live && !doc.liveUnsupported) startLive(value.live, value.text);
1120
+ const recoveryReceipt=checkpointRecovery(value.text,reply.session,restore);
1121
+ if(snapshot && snapshot.session.epoch===reply.session.epoch && snapshot.session.sourceRevision>=reply.session.sourceRevision){
1122
+ const changed=snapshot.session.sourceRevision>reply.session.sourceRevision;
1123
+ doc.mirror=mirrorOf(snapshot.text,snapshot.session,doc.token);doc.text=snapshot.text;doc.composing=!!snapshot.composing;
1124
+ if(changed){doc.dirty=true;doc.editSeq++;checkpointRecovery(snapshot.text,snapshot.session,true);}
1125
+ }
1126
+ for(const delta of pending.edits){
1127
+ if(delta.after.epoch===doc.mirror.session.epoch && delta.after.sourceRevision<=doc.mirror.session.sourceRevision)continue;
1128
+ acceptDelta(delta);
1129
+ }
1130
+ if(pending.composition && sameSession(pending.composition.session,wireSession()))doc.composing=!!pending.composition.composing;
1131
+ if(restore){
1132
+ const consumed=pending.recoverySlot,pathAtApply=doc.absPath,tokenAtApply=doc.token;
1133
+ // Editor ACK proves application, not durability. The old recovery
1134
+ // remains the only durable copy until the new checkpoint commits.
1135
+ void recoveryReceipt.then(receipt=>{
1136
+ if(receipt?.ok!==true || receipt.durable!==true || doc.token!==tokenAtApply || doc.absPath!==pathAtApply)return;
1137
+ clearRecoverySlot(null,pathAtApply);
1138
+ if(consumed?.docToken && consumed.docToken!==tokenAtApply){try{Promise.resolve(draftStore()?.clear?.(pathAtApply,consumed.docToken,consumed.session)).then(inspectStoreResult).catch(error=>showNotice('恢复记录清理失败:'+String(error)));}catch(e){}}
1139
+ });
1140
+ doc.recovery=null;
1141
+ }else if(pending.opts.discard){clearRecoverySlot(null,doc.absPath);doc.recovery=null;}
1142
+ if(doc.dead){persistCurrentRecovery();return;}
1143
+ showPhase(doc.dirty?'dirty':'ready',restore?'已恢复未保存内容':pending.firstOpen?'':'已同步磁盘更新');
1144
+ if(doc.dirty && !doc.composing)scheduleSave(restore?0:SAVE_DEBOUNCE_MS);
1145
+ if(pending.firstOpen){
1146
+ const atEdit=doc.editSeq;
1147
+ Promise.resolve(draftStore()?.readLatest?.(doc.absPath)).then(saved=>{
1148
+ const slot=saved??readRecoverySlot(null,doc.absPath);
1149
+ if(!alive() || doc.editSeq!==atEdit || doc.dirty || !slot || typeof slot.text!=='string' || slot.text===doc.text)return;
1150
+ doc.recovery=slot;setUiIfChanged({phase:'recovery',detail:'检测到上次关闭时未保存的内容',notice:'',recovery:slot});
1151
+ }).catch(error=>showNotice('读取恢复记录失败:'+String(error)));
1152
+ }
1153
+ }
1154
+ function onApplyResult(reply,snapshot) {
1155
+ const pending=doc.pendingApply;
1156
+ if(!pending || reply?.protocol!==2 || reply.docToken!==doc.token || reply.applyId!==pending.applyId || doc.absPath!==pending.path || doc.lastVersion!==pending.baseVersion)return;
1157
+ if(reply.applied && !validSession(reply.session))return;
1158
+ clearTimeout(pending.timer);doc.pendingApply=null;doc.applyUnknown=false;
1159
+ if(reply.applied){commitApplied(pending,reply,snapshot);pending.resolve?.(true);}
1160
+ else{
1161
+ for(const delta of pending.edits)acceptDelta(delta);
1162
+ if(pending.composition && sameSession(pending.composition.session,wireSession()))doc.composing=!!pending.composition.composing;
1163
+ if(alive())showNotice(reply.reason==='composing'?'请先完成输入,再重试同步':'编辑器未应用磁盘内容,当前编辑已保留:'+String(reply.reason||'rejected'));
1164
+ pending.resolve?.(false);
1165
+ }
1166
+ }
1167
+ function acceptDelta(delta) {
1168
+ if(delta?.protocol!==2 || delta.docToken!==doc.token || !doc.mirror)return false;
1169
+ if(delta.after?.epoch===doc.mirror.session.epoch && delta.after.sourceRevision<=doc.mirror.session.sourceRevision)return true;
1170
+ const applied=applyMirrorDelta(doc.mirror,delta);
1171
+ if(!applied.ok){doc.mirror.continuous=false;doc.dirty=true;if(!doc.gapDeltas.some(x=>sameSession(x.after,delta.after)))doc.gapDeltas.push(delta);if(!doc.pulls.size)void requestSnapshot('gap');return false;}
1172
+ doc.mirror=applied.mirror;doc.textFresh=false;doc.dirty=true;doc.editSeq++;doc.composing=!!delta.composing;
1173
+ try{const result=inspectStoreResult(draftStore()?.append?.({...delta,path:doc.absPath,baseVersion:doc.lastVersion}));result?.durable?.then?.(inspectStoreResult).catch?.(error=>showNotice('恢复记录尚未持久化:'+String(error)));}catch(error){showNotice('恢复记录尚未持久化:'+String(error));}
1174
+ if(doc.dead){persistCurrentRecovery();return true;}
1175
+ showPhase('dirty',doc.composing?'输入中…':'编辑中…');
1176
+ if(doc.saveTimer){clearTimeout(doc.saveTimer);doc.saveTimer=null;}
1177
+ if(!doc.composing)scheduleSave(SAVE_DEBOUNCE_MS);
1178
+ return true;
1179
+ }
1180
+ function onDelta(delta) {
1181
+ if(delta?.protocol!==2 || delta.docToken!==doc.token)return;
1182
+ if(doc.pendingApply){
1183
+ const pending=doc.pendingApply,applied=delta.lastApply;
1184
+ if(applied?.applyId===pending.applyId && validSession(applied.session) && validSession(delta.before) && validSession(delta.after) &&
1185
+ delta.before.epoch===applied.session.epoch && delta.after.epoch===applied.session.epoch &&
1186
+ delta.before.sourceRevision>=applied.session.sourceRevision && delta.after.sourceRevision===delta.before.sourceRevision+1 &&
1187
+ (pending.firstOpen || (pending.expected?.epoch===applied.session.epoch && applied.session.sourceRevision>=pending.expected.sourceRevision))){
1188
+ onApplyResult({protocol:2,docToken:doc.token,applyId:pending.applyId,applied:true,session:applied.session});
1189
+ if(!doc.pendingApply){acceptDelta(delta);return;}
1190
+ }
1191
+ doc.pendingApply.edits.push(delta);return;
1192
+ }
1193
+ acceptDelta(delta);
1194
+ }
1195
+ function onComposition(content) {
1196
+ if(content?.protocol!==2 || content.docToken!==doc.token)return;
1197
+ if(doc.pendingApply){doc.pendingApply.composition=content;return;}
1198
+ if(!sameSession(content.session,wireSession()))return;
1199
+ doc.composing=!!content.composing;
1200
+ if(doc.dead){persistCurrentRecovery();return;}
1201
+ if(!doc.composing && doc.dirty && !doc.pendingApply){if(doc.manualPending){doc.manualPending=false;void runSave({manual:true});}else scheduleSave(SAVE_DEBOUNCE_MS);}
1202
+ }
1203
+ function onSnapshot(reply) {
1204
+ const pending=reply && doc.pulls.get(reply.id);
1205
+ if(!pending || reply.protocol!==2 || reply.docToken!==doc.token || !validSession(reply.session) || typeof reply.text!=='string')return;
1206
+ if(doc.pendingApply){
1207
+ const applying=doc.pendingApply,last=reply.lastApply;
1208
+ if(last?.applyId===applying.applyId && validSession(last.session) && reply.session.epoch===last.session.epoch && reply.session.sourceRevision>=last.session.sourceRevision){
1209
+ onApplyResult({protocol:2,docToken:doc.token,applyId:applying.applyId,applied:true,session:last.session},reply);
1210
+ }else if(sameSession(reply.session,applying.expected) && last?.applyId!==applying.applyId){
1211
+ onApplyResult({protocol:2,docToken:doc.token,applyId:applying.applyId,applied:false,reason:'not-applied',session:reply.session});
1212
+ }else{return;}
1213
+ }else{
1214
+ if(!doc.mirror || reply.session.epoch!==doc.mirror.session.epoch || reply.session.sourceRevision<doc.mirror.session.sourceRevision)return;
1215
+ const advanced=reply.session.sourceRevision>doc.mirror.session.sourceRevision;
1216
+ doc.mirror=mirrorOf(reply.text,reply.session,doc.token);doc.text=reply.text;doc.textFresh=true;doc.composing=!!reply.composing;
1217
+ if(advanced){doc.dirty=true;doc.editSeq++;}
1218
+ if(doc.dirty)checkpointRecovery(reply.text,reply.session,true);
1219
+ const queued=doc.gapDeltas;doc.gapDeltas=[];
1220
+ for(const delta of queued)acceptDelta(delta);
1221
+ }
1222
+ clearTimeout(pending.timer);doc.pulls.delete(reply.id);
1223
+ pending.resolve({text:doc.mirror?.continuous?materialize(doc.mirror):reply.text,stale:!!doc.pendingApply||doc.composing||!doc.mirror?.continuous,session:wireSession()});
1224
+ if(!doc.mirror?.continuous && !doc.pulls.size)void requestSnapshot('gap');
1225
+ if(doc.dead)persistCurrentRecovery();
1226
+ }
1227
+
1228
+
557
1229
  function showPhase(phase, detail) {
558
1230
  doc.phase = phase;
559
1231
  doc.detail = detail || "";
@@ -569,18 +1241,6 @@ window.__ModuleLoader__.load({
569
1241
  setUiIfChanged({ phase: doc.phase, detail: doc.detail || "", notice: message });
570
1242
  }
571
1243
 
572
- function sendKeepaliveSave(path, text, baseVersion) {
573
- try {
574
- const body = JSON.stringify({ path: path, text: text, baseVersion: baseVersion });
575
- fetch(API_BASE + "save", {
576
- method: "POST",
577
- keepalive: true,
578
- headers: { "content-type": "application/json" },
579
- body: body,
580
- }).catch(() => { /* keepalive 失败由恢复槽兜底 */ });
581
- } catch (e) { /* 已尽力 */ }
582
- }
583
-
584
1244
  function writeRecovery(text, stale) {
585
1245
  const slot = {
586
1246
  text: typeof text === "string" ? text : doc.text,
@@ -595,72 +1255,20 @@ window.__ModuleLoader__.load({
595
1255
  }
596
1256
  }
597
1257
 
598
- /** 关 tab flush:requestText ≤120ms → keepalive save;拿不到全文只写恢复槽,绝不拿旧缓存写盘。 */
599
1258
  function flushClose() {
600
1259
  if (doc.flushed) return;
601
- doc.flushed = true;
602
- if (!doc.opened || !doc.dirty || !doc.absPath) return;
603
- const cached = doc.text;
604
- const done = (text, stale) => {
605
- if (typeof text === "string") { doc.text = text; doc.textFresh = true; }
606
- writeRecovery(typeof text === "string" ? text : cached, stale);
607
- if (!stale && typeof doc.lastVersion === "string" && doc.lastVersion) {
608
- sendKeepaliveSave(doc.absPath, typeof text === "string" ? text : cached, doc.lastVersion);
609
- }
610
- };
611
- if (!doc.textFresh) {
612
- doc.flushResolve = (text) => {
613
- doc.flushResolve = null;
614
- if (doc.flushTimer) { clearTimeout(doc.flushTimer); doc.flushTimer = null; }
615
- done(text, typeof text !== "string");
616
- };
617
- if (!post("requestText", {})) { done(undefined, true); return; }
618
- doc.flushTimer = setTimeout(() => {
619
- if (!doc.flushResolve) return;
620
- const resolve = doc.flushResolve;
621
- doc.flushResolve = null;
622
- doc.flushTimer = null;
623
- resolve(undefined);
624
- }, FLUSH_WAIT_MS);
625
- } else {
626
- done(cached, false);
627
- }
628
- }
629
-
630
- function onText(text) {
631
- doc.text = text;
632
- doc.textFresh = true;
633
- if (doc.pullResolve) {
634
- const resolve = doc.pullResolve;
635
- doc.pullResolve = null;
636
- if (doc.pullTimer) { clearTimeout(doc.pullTimer); doc.pullTimer = null; }
637
- resolve({ text: text, stale: false });
638
- }
639
- if (doc.flushResolve) {
640
- const resolve = doc.flushResolve;
641
- doc.flushResolve = null;
642
- if (doc.flushTimer) { clearTimeout(doc.flushTimer); doc.flushTimer = null; }
643
- resolve(text);
644
- }
1260
+ doc.flushed = true; doc.closeUntil = Date.now() + FLUSH_WAIT_MS + 80;
1261
+ if (!doc.opened || !doc.absPath || !doc.dirty) return;
1262
+ persistCurrentRecovery();
1263
+ // The parent mirror survives iframe removal. Unknown/composing text
1264
+ // is recovery only; closing never promotes it to a disk write.
1265
+ if (!doc.mirror?.continuous) void requestSnapshot('close');
645
1266
  }
646
1267
 
647
1268
  function pullText() {
648
- if (doc.textFresh) return Promise.resolve({ text: doc.text, stale: false });
649
- return new Promise((resolve) => {
650
- let settled = false;
651
- const finish = (value) => {
652
- if (settled) return;
653
- settled = true;
654
- if (doc.pullResolve === finish) {
655
- doc.pullResolve = null;
656
- if (doc.pullTimer) { clearTimeout(doc.pullTimer); doc.pullTimer = null; }
657
- }
658
- resolve(value);
659
- };
660
- doc.pullResolve = finish;
661
- if (!post("requestText", {})) { finish({ text: doc.text, stale: true }); return; }
662
- doc.pullTimer = setTimeout(() => finish({ text: doc.text, stale: true }), PULL_TIMEOUT_MS);
663
- });
1269
+ if (doc.pendingApply || doc.applyUnknown || doc.composing) return Promise.resolve({text:doc.text,stale:true});
1270
+ if (doc.mirror?.continuous) return Promise.resolve({text:materialize(doc.mirror),stale:false,session:{...doc.mirror.session}});
1271
+ return requestSnapshot('save');
664
1272
  }
665
1273
 
666
1274
  function scheduleSave(delay) {
@@ -675,6 +1283,10 @@ window.__ModuleLoader__.load({
675
1283
  async function runSave(options) {
676
1284
  const manual = !!(options && options.manual);
677
1285
  if (doc.dead) return;
1286
+ // 有界预算耗尽后不再自动发保存请求(防持续竞争下无界后台唤醒);
1287
+ // 显式保存(Ctrl+S)重置预算,输入改动只保留草稿等显式动作。
1288
+ if (doc.liveStaleBlocked && !manual) return;
1289
+ if (doc.pendingApply || doc.applyUnknown || doc.composing) { if (manual) { doc.manualPending = true; post("toast", "请先完成输入或等待编辑器同步"); } return; }
678
1290
  const gate = saveGate({
679
1291
  dirty: doc.dirty,
680
1292
  saving: doc.saving,
@@ -706,53 +1318,111 @@ window.__ModuleLoader__.load({
706
1318
  if (doc.phase !== "dirty" && doc.phase !== "conflict") showPhase("saving", "保存中…");
707
1319
  if (manualThisRun) showPhase("saving", "手动保存中…");
708
1320
  try {
709
- const pulled = await pullText();
710
- if (!alive()) return;
711
- if (pulled.stale) {
712
- showError("保存暂停:编辑器未在 " + PULL_TIMEOUT_MS + "ms 内返回全文(未写入磁盘,可继续编辑后重试)");
713
- if (manualThisRun) post("toast", "保存暂停:编辑器未返回全文,未写入磁盘");
714
- return;
715
- }
716
- const request = buildSaveRequest({ path: doc.absPath, text: pulled.text, baseVersion: doc.lastVersion });
717
- if (!request.ok) {
718
- showError("保存被拒绝:" + request.reason);
719
- if (manualThisRun) post("toast", "保存被拒绝:" + request.reason);
720
- return;
721
- }
722
- const result = await api("save", request.body);
723
- if (!alive()) return;
724
- if (result.ok === true) {
725
- const editedDuringSave = doc.editSeq !== saveSeq;
726
- doc.lastVersion = result.value.version;
727
- if (!editedDuringSave) {
728
- doc.text = pulled.text;
729
- doc.textFresh = true;
730
- doc.dirty = false;
731
- } else {
732
- doc.dirty = true;
733
- doc.saveQueued = true;
1321
+ for (let attempt = 1; ; attempt += 1) {
1322
+ if (doc.live) {
1323
+ const flushed = await flushLive();
1324
+ if (!alive()) return;
1325
+ if (!flushed.ok) {
1326
+ if (["MP_LIVE_TIMEOUT", "MP_LIVE_GONE", "MP_LIVE_FLUSH", "MP_LIVE_COMPOSING", "MP_LIVE_SWITCH"].indexOf(flushed.code) !== -1) {
1327
+ showError("保存暂停:编辑器未在 " + LIVE_FLUSH_TIMEOUT_MS + "ms 内确认同步(未写入磁盘,可继续编辑后重试)");
1328
+ if (manualThisRun) post("toast", "保存暂停:编辑器未确认同步,未写入磁盘");
1329
+ } else {
1330
+ handleLiveError(flushed.code, flushed.error);
1331
+ }
1332
+ return;
1333
+ }
1334
+ if (!flushed.result) {
1335
+ // API.md §1.3:未绑定时 flush resolve null(unbind/halt 后)——丢弃残票,
1336
+ // 回落无票守卫保存(盘 CAS 仍在,绝不盲写)。
1337
+ stopLive(true);
1338
+ } else {
1339
+ doc.live.epoch = flushed.result.epoch;
1340
+ doc.live.version = flushed.result.version;
1341
+ }
734
1342
  }
735
- doc.conflict = false;
736
- doc.externalStale = false;
737
- doc.staleNotified = false;
738
- showPhase(doc.dirty ? "dirty" : "ready", doc.dirty ? "编辑中…(上一轮已保存,继续保存新改动)" : "已保存");
739
- if (manualThisRun) post("toast", doc.dirty ? "已保存;还有新改动,继续保存中…" : "已保存");
740
- // 保存飞行期间可能被外部改过:落盘后 stat 对账(干净则静默重载,脏则只标记)。
741
- try {
742
- const stat = await api("stat", { path: doc.absPath });
743
- if (alive() && stat.ok === true && stat.value && stat.value.version !== doc.lastVersion) {
744
- if (doc.dirty || doc.conflict) { doc.externalStale = true; }
745
- else await loadFromDisk({ silent: true, reason: "save-reconcile" });
1343
+ const pulled = await pullText();
1344
+ if (!alive()) return;
1345
+ if (pulled.stale) {
1346
+ showError("保存暂停:编辑器未在 " + PULL_TIMEOUT_MS + "ms 内返回全文(未写入磁盘,可继续编辑后重试)");
1347
+ if (manualThisRun) post("toast", "保存暂停:编辑器未返回全文,未写入磁盘");
1348
+ return;
1349
+ }
1350
+ const request = buildSaveRequest({ path: doc.absPath, text: pulled.text, baseVersion: doc.lastVersion,
1351
+ ...(doc.live ? { live: { client: doc.live.client, epoch: doc.live.epoch, version: doc.live.version } } : {}) });
1352
+ if (!request.ok) {
1353
+ showError("保存被拒绝:" + request.reason);
1354
+ if (manualThisRun) post("toast", "保存被拒绝:" + request.reason);
1355
+ return;
1356
+ }
1357
+ const result = await api("save", request.body);
1358
+ if (!alive()) return;
1359
+ if (result.ok === true) {
1360
+ const editedDuringSave = doc.editSeq !== saveSeq;
1361
+ doc.lastVersion = result.value.version;
1362
+ if (doc.live) doc.live = applyLiveReceipt(doc.live, result.value);
1363
+ try { Promise.resolve(draftStore()?.markSaved?.({path:doc.absPath,docToken:doc.token,session:pulled.session,baseVersion:doc.lastVersion})).then(inspectStoreResult).catch(e=>showNotice("恢复记录更新失败:" + String(e))); } catch (e) { showNotice("恢复记录更新失败:" + String(e)); }
1364
+ if (!editedDuringSave) {
1365
+ doc.text = pulled.text;
1366
+ doc.textFresh = true;
1367
+ doc.mirror = mirrorOf(pulled.text, pulled.session, doc.token);
1368
+ doc.dirty = false;
1369
+ } else {
1370
+ doc.dirty = true;
1371
+ doc.saveQueued = true;
746
1372
  }
747
- } catch (e) { /* 对账失败不阻塞保存结果 */ }
748
- } else if (result.error && result.error.code === "FS_STALE_VERSION") {
749
- doc.conflict = true;
750
- showPhase("conflict", "文件已在别处被修改(" + (result.error.message || "FS_STALE_VERSION") + ");版本守卫拒绝覆盖");
751
- if (manualThisRun) post("toast", "文件已在别处被修改,未覆盖;请先重载磁盘版本");
752
- } else {
1373
+ doc.conflict = false;
1374
+ doc.externalStale = false;
1375
+ doc.staleNotified = false;
1376
+ doc.liveStaleRounds = 0; // 保存成功 = 预算清零(下一轮竞争重新计数)
1377
+ doc.liveStaleBlocked = false;
1378
+ showPhase(doc.dirty ? "dirty" : "ready", doc.dirty ? "编辑中…(上一轮已保存,继续保存新改动)" : "已保存");
1379
+ if (manualThisRun) post("toast", doc.dirty ? "已保存;还有新改动,继续保存中…" : "已保存");
1380
+ // 保存飞行期间可能被外部改过:落盘后 stat 对账(live 会话交给房间刷新,干净非 live 才静默重载)。
1381
+ try {
1382
+ const stat = await api("stat", { path: doc.absPath });
1383
+ if (alive() && stat.ok === true && stat.value && stat.value.version !== doc.lastVersion) {
1384
+ if (doc.live) post("live-refresh", { protocol: 2, docToken: doc.token });
1385
+ else if (doc.dirty || doc.conflict) { doc.externalStale = true; }
1386
+ else await loadFromDisk({ silent: true, reason: "save-reconcile" });
1387
+ }
1388
+ } catch (e) { /* 对账失败不阻塞保存结果 */ }
1389
+ return;
1390
+ }
1391
+ // live 房间在 flush 与落盘之间又动了(对端新编辑):交给纯决策函数。
1392
+ // API.md §4 MP_LIVE_STALE:**不 halt**——快重试→有界退避→停止(保草稿等显式
1393
+ // Ctrl+S);换代/歧义替换/外盘冲突码不走这里,仍由 liveErrorAction 明确停。
1394
+ if (doc.live && result.error && result.error.code === "MP_LIVE_STALE") {
1395
+ const decision = liveStaleAction({ attempt, rounds: doc.liveStaleRounds, blocked: doc.liveStaleBlocked });
1396
+ doc.liveStaleRounds = decision.rounds;
1397
+ if (decision.action === "retry-immediate") continue;
1398
+ post("live-refresh", { protocol: 2, docToken: doc.token }); // 促 facade 立即消费对端增量
1399
+ if (decision.action === "retry-delayed") {
1400
+ if (decision.rounds >= 2) showNotice("共享房间正在同步对端编辑,稍后自动重试(正文已保留)");
1401
+ if (manualThisRun && decision.rounds >= 2) post("toast", "正在同步对端编辑,稍后自动重试保存");
1402
+ scheduleSave(decision.delayMs);
1403
+ return;
1404
+ }
1405
+ // stopped:有界预算耗尽,停止自动写请求(不无界后台唤醒),保草稿与 epoch,
1406
+ // 等用户显式动作(Ctrl+S → manualSave 重置预算)或输入触发新一轮。
1407
+ doc.liveStaleBlocked = true;
1408
+ showNotice("保存暂停:对端持续编辑中;正文已保留,按 Ctrl+S 手动保存或稍后继续编辑");
1409
+ if (manualThisRun) post("toast", "对端持续编辑中,请按 Ctrl+S 手动保存");
1410
+ return;
1411
+ }
1412
+ if (doc.live && result.error && liveErrorAction(result.error.code) === "conflict") {
1413
+ handleLiveError(result.error.code, result.error.message);
1414
+ return;
1415
+ }
1416
+ if (result.error && result.error.code === "FS_STALE_VERSION") {
1417
+ doc.conflict = true;
1418
+ showPhase("conflict", "文件已在别处被修改(" + (result.error.message || "FS_STALE_VERSION") + ");版本守卫拒绝覆盖");
1419
+ if (manualThisRun) post("toast", "文件已在别处被修改,未覆盖;请先重载磁盘版本");
1420
+ return;
1421
+ }
753
1422
  const message = (result.error && result.error.code ? result.error.code : "error") + " — " + (result.error && result.error.message ? result.error.message : "");
754
1423
  showError("保存失败:" + message);
755
1424
  if (manualThisRun) post("toast", "保存失败:" + message);
1425
+ return;
756
1426
  }
757
1427
  } catch (error) {
758
1428
  if (alive()) {
@@ -762,6 +1432,8 @@ window.__ModuleLoader__.load({
762
1432
  }
763
1433
  } finally {
764
1434
  doc.saving = false;
1435
+ const reload=doc.reloadAfterSave;doc.reloadAfterSave=null;
1436
+ if(reload && alive()){if(doc.editSeq===reload.editSeq)void loadFromDisk(reload.opts);else doc.externalStale=true;}
765
1437
  if (doc.saveQueued && alive()) {
766
1438
  doc.saveQueued = false;
767
1439
  if (doc.manualPending) {
@@ -781,6 +1453,8 @@ window.__ModuleLoader__.load({
781
1453
  */
782
1454
  function manualSave(content) {
783
1455
  if (doc.dead) return;
1456
+ doc.liveStaleBlocked = false; // 显式保存 = 重置 STALE 退避预算(只此显式入口重置)
1457
+ doc.liveStaleRounds = 0;
784
1458
  const gate = manualSaveGate({
785
1459
  dirty: doc.dirty,
786
1460
  saving: doc.saving,
@@ -812,63 +1486,26 @@ window.__ModuleLoader__.load({
812
1486
  }
813
1487
 
814
1488
 
815
- /** 首次打开 / 干净缓冲下的外部变更重载。silent=true 时失败不弹错误横幅。 */
816
1489
  async function loadFromDisk(options) {
817
1490
  const opts = options || {};
818
- if (doc.dead) return false;
1491
+ if (doc.dead || doc.pendingApply || doc.applyUnknown) return false;
1492
+ if (doc.saving) {doc.reloadAfterSave={opts:{...opts},editSeq:doc.editSeq};return false;}
819
1493
  refreshPath();
820
1494
  const editSeqAtStart = doc.editSeq;
1495
+ const loadSeq = ++doc.loadSeq, pathAtStart = doc.absPath, versionAtStart = doc.lastVersion;
1496
+ const ownsLoad = () => alive() && doc.loadSeq === loadSeq && doc.absPath === pathAtStart && doc.lastVersion === versionAtStart;
821
1497
  try {
822
- const result = await api("open", { path: doc.absPath });
823
- if (!alive()) return false;
824
- // 外部重载请求飞行期间用户又打字了:绝不 setValue 覆盖(脏缓冲只提示)。
825
- if (doc.opened && (doc.editSeq !== editSeqAtStart || doc.dirty || doc.conflict)) {
826
- doc.externalStale = true;
827
- return false;
828
- }
829
- if (result.ok !== true) {
830
- if (!opts.silent) showError("打开失败:" + (result.error && result.error.code ? result.error.code : "error") + " — " + (result.error && result.error.message ? result.error.message : ""));
831
- return false;
832
- }
833
- const value = result.value;
834
- const firstOpen = !doc.opened;
835
- doc.text = value.text;
836
- doc.textFresh = true;
837
- doc.lastVersion = value.version;
838
- doc.displayPath = value.displayPath || doc.absPath;
839
- doc.size = value.size;
840
- doc.dirty = false;
841
- doc.conflict = false;
842
- doc.externalStale = false;
843
- doc.staleNotified = false;
844
- doc.recovery = null;
845
- if (firstOpen) {
846
- doc.opened = true;
847
- // 顺序契约:先 theme,再 open(引擎可能按主题初始化编辑面)。
848
- post("theme", { dark: isDarkTheme() });
849
- post("open", {
850
- title: basenameOf(doc.absPath),
851
- content: doc.text,
852
- language: "zh-cn",
853
- rootPath: directoryOf(doc.absPath),
854
- });
855
- // 恢复槽只在"确实与磁盘不同"时提示;默认不落盘。
856
- const slot = readRecoverySlot(null, doc.absPath);
857
- if (slot && typeof slot.text === "string" && slot.text !== doc.text) {
858
- doc.recovery = slot;
859
- setUiIfChanged({ phase: "recovery", detail: "检测到上次关闭时未保存的内容", notice: "", recovery: slot });
860
- return true;
861
- }
862
- if (slot) clearRecoverySlot(null, doc.absPath);
863
- showPhase("ready", "");
864
- } else {
865
- post("setValue", doc.text);
866
- showPhase("ready", opts.reason === "sse" || opts.reason === "poll" ? "已同步磁盘更新" : "已重载磁盘版本");
867
- }
868
- return true;
869
- } catch (error) {
870
- if (!alive()) return false;
871
- if (!opts.silent) showError("打开失败:" + String(error && error.message ? error.message : error));
1498
+ // live:true 只在本机文件上会真正入房(宿主在 join 前核对原生文件与字节基线);
1499
+ // 虚拟/会话投影文件拿不到 live 票,自动走原有单窗口守卫路径。
1500
+ const result = await api("open", {path:pathAtStart, live:!doc.liveUnsupported});
1501
+ if (!ownsLoad()) return false;
1502
+ if (doc.opened && (doc.editSeq !== editSeqAtStart || doc.composing || (!opts.discard && (doc.dirty || doc.conflict)))) { doc.externalStale = true; return false; }
1503
+ if (result.ok !== true) { if (!opts.silent) showError("打开失败:" + (result.error?.message || result.error?.code || "error")); return false; }
1504
+ const value=result.value,firstOpen=!doc.opened;
1505
+ return await applyToFrame({kind:firstOpen?'open':'reload',value,firstOpen,opts,path:pathAtStart,baseVersion:versionAtStart,loadSeq,editSeq:editSeqAtStart});
1506
+ } catch(error) {
1507
+ if (!ownsLoad() || doc.editSeq !== editSeqAtStart) return false;
1508
+ if (!opts.silent) showError("打开失败:" + String(error?.message || error));
872
1509
  return false;
873
1510
  }
874
1511
  }
@@ -899,6 +1536,9 @@ window.__ModuleLoader__.load({
899
1536
  try { data = JSON.parse(event.data) || {}; } catch (e) { data = {}; }
900
1537
  if (data.path && doc.absPath && !samePath(data.path, doc.absPath)) return;
901
1538
  if (!doc.opened) return; // 握手/首次 open 之前的事件没有落点(boot 自己会读磁盘)
1539
+ // live 会话:磁盘事件的采纳权在共享房间(external 写入会被 adopt 成房间更新),
1540
+ // 这里只催一次 refresh,绝不 loadFromDisk 覆盖共享草稿。
1541
+ if (doc.live) { post("live-refresh", { protocol: 2, docToken: doc.token }); return; }
902
1542
  const version = data.version == null ? "" : String(data.version);
903
1543
  const accepted = shouldAcceptExternal({
904
1544
  dirty: doc.dirty,
@@ -1017,7 +1657,57 @@ window.__ModuleLoader__.load({
1017
1657
  if (!liveWin || event.source !== liveWin) return; // 只认当前代 iframe,旧 iframe 的晚到消息丢弃
1018
1658
  const type = data.type;
1019
1659
  // 已死代际只放行"关 tab flush 的 text 回执"(旧回执一律丢弃)。
1020
- if (!alive() && !(type === "text" && doc.flushResolve)) return;
1660
+ if (!alive() && !(doc.closeUntil >= Date.now() && ["text","document-delta","composition","apply-result","live-exchange","live-leave","live-flush-result"].includes(type))) return;
1661
+ if (type === "apply-result") { onApplyResult(data.content); return; }
1662
+ if (type === "document-delta") { onDelta(data.content); return; }
1663
+ if (type === "composition") { onComposition(data.content); return; }
1664
+ if (type === "live-exchange") { void relayLiveExchange(data.content); return; }
1665
+ if (type === "live-leave") {
1666
+ const client = data.content && data.content.client;
1667
+ if (typeof client === "string" && client) api("live", { client: client, action: "leave" }).catch(function () { /* 已过期 */ });
1668
+ return;
1669
+ }
1670
+ if (type === "live-flush-result") {
1671
+ const content = data.content || {};
1672
+ const waiter = doc.liveFlushWaiters.get(content.id);
1673
+ if (waiter) {
1674
+ doc.liveFlushWaiters.delete(content.id);
1675
+ waiter.resolve(content.ok === true
1676
+ ? { ok: true, result: content.result }
1677
+ : { ok: false, code: content.code || "MP_LIVE_FLUSH", error: content.error || "同步失败" });
1678
+ }
1679
+ return;
1680
+ }
1681
+ if (type === "live-error") {
1682
+ const content = data.content || {};
1683
+ handleLiveError(content.code, content.error || content.message);
1684
+ return;
1685
+ }
1686
+ if (type === "live-status") {
1687
+ const content = data.content || {};
1688
+ const status = content.status;
1689
+ if (alive() && status && typeof status === "object") {
1690
+ doc.liveStatus = status;
1691
+ if (doc.live && status.client) {
1692
+ if (typeof status.epoch === "string" && status.epoch) doc.live.epoch = status.epoch;
1693
+ if (Number.isSafeInteger(status.version)) doc.live.version = status.version;
1694
+ if (Number.isSafeInteger(status.savedVersion)) doc.live.savedVersion = status.savedVersion;
1695
+ if (typeof status.diskHash === "string" && status.diskHash) doc.live.diskHash = status.diskHash;
1696
+ // API.md §3.2 消脏唯一条件:version===savedVersion && pending===0 && error==null。
1697
+ // onStatus 为 null / client 未分配 / 会话错误 / 本端还有 pending / 镜像有缺口
1698
+ // (含应用未确认、保存飞行中)时绝不消脏;savedVersion 前进本身不消脏。
1699
+ if (doc.dirty && !doc.pendingApply && !doc.applyUnknown && !doc.composing && !doc.saving
1700
+ && doc.mirror && doc.mirror.continuous && !doc.gapDeltas.length
1701
+ && status.error == null && !status.conflict && status.pending === 0
1702
+ && Number.isSafeInteger(status.savedVersion) && status.version === status.savedVersion) {
1703
+ doc.dirty = false;
1704
+ if (doc.saveTimer) { clearTimeout(doc.saveTimer); doc.saveTimer = null; }
1705
+ if (doc.phase === "dirty") showPhase("ready", "已同步保存");
1706
+ }
1707
+ }
1708
+ }
1709
+ return;
1710
+ }
1021
1711
  if (type === "bridge-ready") {
1022
1712
  if (doc.bridgeUp) return;
1023
1713
  doc.bridgeUp = true;
@@ -1027,23 +1717,16 @@ window.__ModuleLoader__.load({
1027
1717
  }
1028
1718
  if (type === "init") {
1029
1719
  doc.editorReady = true;
1030
- if (data.content && data.content.again === true && doc.opened) {
1031
- // 引擎看门狗重发 init(说明它还没收到 open):幂等重发,仍先 theme。
1032
- post("theme", { dark: isDarkTheme() });
1033
- post("open", {
1034
- title: basenameOf(doc.absPath),
1035
- content: doc.text,
1036
- language: "zh-cn",
1037
- rootPath: directoryOf(doc.absPath),
1038
- });
1720
+ if (data.content && data.content.again === true && doc.pendingApply) {
1721
+ post(doc.pendingApply.firstOpen ? "open" : "setValue",doc.pendingApply.wire);
1039
1722
  return;
1040
1723
  }
1041
1724
  maybeBoot();
1042
1725
  return;
1043
1726
  }
1044
- if (type === "change") { onDirty(data.content); return; }
1727
+ if (type === "change") { if (!doc.mirror && !doc.pendingApply) onDirty(data.content); return; }
1045
1728
  if (type === "manual-save") { manualSave(data.content); return; }
1046
- if (type === "text") { onText(typeof data.content === "string" ? data.content : ""); return; }
1729
+ if (type === "text") { onSnapshot(data.content); return; }
1047
1730
  if (type === "insert-to-chat") { void onInsertToChat(data.content); return; }
1048
1731
  if (type === "host-invoke") { handleHostInvoke(data.content, post); return; }
1049
1732
  if (type === "openLink" && data.content) {
@@ -1053,25 +1736,17 @@ window.__ModuleLoader__.load({
1053
1736
  // scroll / img / media-command 等:MVP 静默(协议 v1 无对应能力)。
1054
1737
  }
1055
1738
 
1056
- function restoreRecovery() {
1057
- const slot = doc.recovery;
1058
- if (!slot || typeof slot.text !== "string") { void discardRecovery(); return; }
1059
- doc.recovery = null;
1060
- clearRecoverySlot(null, doc.absPath);
1061
- doc.text = slot.text;
1062
- doc.textFresh = true;
1063
- doc.dirty = true;
1064
- doc.editSeq += 1;
1065
- doc.conflict = false;
1066
- post("setValue", doc.text);
1067
- setUiIfChanged({ phase: "dirty", detail: "已恢复未保存内容,正在保存…", notice: "", recovery: null });
1068
- scheduleSave(0);
1739
+ async function restoreRecovery() {
1740
+ const slot=doc.recovery;
1741
+ if (!slot || typeof slot.text!=="string" || doc.pendingApply || doc.applyUnknown) return false;
1742
+ return applyToFrame({kind:'restore',value:{text:slot.text,version:doc.lastVersion,displayPath:doc.displayPath,size:slot.text.length},firstOpen:false,opts:{},path:doc.absPath,baseVersion:doc.lastVersion,loadSeq:doc.loadSeq,editSeq:doc.editSeq,recoverySlot:slot});
1069
1743
  }
1070
1744
 
1071
1745
  function discardRecovery() {
1072
- doc.recovery = null;
1073
- clearRecoverySlot(null, doc.absPath);
1074
- setUiIfChanged({ phase: "ready", detail: "已丢弃恢复内容", notice: "", recovery: null });
1746
+ const slot=doc.recovery;doc.recovery=null;
1747
+ clearRecoverySlot(null,doc.absPath);
1748
+ try{Promise.resolve(draftStore()?.clear?.(doc.absPath,slot?.docToken||doc.token,slot?.session)).catch(error=>showNotice('清除恢复记录失败:'+String(error)));}catch(error){showNotice('清除恢复记录失败:'+String(error));}
1749
+ setUiIfChanged({phase:'ready',detail:'已丢弃恢复内容',notice:'',recovery:null});
1075
1750
  return Promise.resolve();
1076
1751
  }
1077
1752
 
@@ -1091,12 +1766,13 @@ window.__ModuleLoader__.load({
1091
1766
  }
1092
1767
 
1093
1768
  function forceReload() {
1094
- doc.conflict = false;
1095
- doc.dirty = false;
1096
- doc.externalStale = false;
1097
- doc.recovery = null;
1098
- setUiIfChanged({ phase: "loading", detail: "", notice: "", recovery: null });
1099
- void loadFromDisk({ silent: false });
1769
+ stopLive(true); // 冲突后重载 = 放弃旧房间,重新 join
1770
+ setUiIfChanged({ phase: "loading", detail: "", notice: "" });
1771
+ void loadFromDisk({ silent: false, discard: true }).then(loaded => {
1772
+ if (!loaded && alive() && (doc.dirty || doc.conflict)) {
1773
+ showPhase("conflict", "未重载,当前编辑内容已保留;可再次重载磁盘版本");
1774
+ }
1775
+ });
1100
1776
  }
1101
1777
 
1102
1778
  // 横幅按钮回调(渲染侧通过 st.actions 取当前代)。
@@ -1105,7 +1781,23 @@ window.__ModuleLoader__.load({
1105
1781
  copyMine: copyMine,
1106
1782
  restoreRecovery: restoreRecovery,
1107
1783
  discardRecovery: discardRecovery,
1108
- retry: () => { showPhase("loading", ""); void loadFromDisk({ silent: false }); },
1784
+ copyRecoveredPath: (index) => {
1785
+ const items = doc.liveRecoveredItems || [];
1786
+ const item = items[index];
1787
+ if (!item) return;
1788
+ // 只把路径写入剪贴板(web shell 既有能力);不读取、不打开该文件。
1789
+ const clipboard = clipboardOf();
1790
+ if (!clipboard || typeof clipboard.writeText !== "function") { showNotice("剪贴板不可用,请手动选择复制路径"); return; }
1791
+ clipboard.writeText(item.recoveryPath).then(
1792
+ () => showNotice("已复制草稿副本路径:" + item.recoveryPath),
1793
+ (error) => showNotice("复制失败:" + String(error && error.message || error)),
1794
+ );
1795
+ },
1796
+ dismissRecovered: () => {
1797
+ doc.liveRecoveredItems = null;
1798
+ setUi((prev) => (prev.recovered ? { ...prev, recovered: null } : prev));
1799
+ },
1800
+ retry: () => { if(doc.pendingApply || doc.applyUnknown){void requestSnapshot("apply");return;} if (doc.live) stopLive(true); showPhase("loading", ""); void loadFromDisk({ silent: false }); },
1109
1801
  };
1110
1802
  doc.reload = (opts) => loadFromDisk(opts || {});
1111
1803
  doc.reloadDisk = forceReload;
@@ -1144,6 +1836,8 @@ window.__ModuleLoader__.load({
1144
1836
  const onVisibilityChange = () => {
1145
1837
  if (doc.dead) return;
1146
1838
  if (isDocumentHidden()) { clearPoll(); return; }
1839
+ // API.md §1.5:窗口重新获焦 → 立即增量交换(不是全文轮询)。
1840
+ if (doc.live) post("live-refresh", { protocol: 2, docToken: doc.token });
1147
1841
  schedulePoll(0);
1148
1842
  if (doc.opened && doc.externalStale && !doc.dirty && !doc.conflict && !doc.saving && doc.tabVisible !== false) {
1149
1843
  doc.externalStale = false;
@@ -1170,17 +1864,21 @@ window.__ModuleLoader__.load({
1170
1864
  return () => {
1171
1865
  doc.dead = true;
1172
1866
  try { flushClose(); } catch (e) { /* 已尽力 */ }
1867
+ // live 会话离场:先请 facade 解绑(iframe 还在时经上行 leave),再直接向宿主
1868
+ // 补一张 leave(幂等)兜底 iframe 已删的情形 —— 租约不落盘、不残留。
1869
+ try { stopLive(true); } catch (e) { /* 关闭路径不再抛 */ }
1173
1870
  if (doc.saveTimer) { clearTimeout(doc.saveTimer); doc.saveTimer = null; }
1174
- if (doc.pullTimer) { clearTimeout(doc.pullTimer); doc.pullTimer = null; }
1871
+ if (doc.pendingApply) { clearTimeout(doc.pendingApply.timer); doc.pendingApply.resolve?.(false); doc.pendingApply.resolve=null; }
1175
1872
  if (doc.pollTimer) { clearTimeout(doc.pollTimer); doc.pollTimer = null; }
1176
- if (doc.flushTimer) { clearTimeout(doc.flushTimer); doc.flushTimer = null; }
1873
+ // A pending close flush owns its deadline after React unmount. Its
1874
+ // timeout must still persist the cached recovery when the iframe is gone.
1177
1875
  if (doc.es) { try { doc.es.close(); } catch (e) { /* 已断 */ } doc.es = null; }
1178
1876
  if (themeObserver) { try { themeObserver.disconnect(); } catch (e) { /* 已断 */ } }
1179
1877
  for (let i = 0; i < cleanups.length; i += 1) {
1180
1878
  try { cleanups[i](); } catch (e) { /* 忽略 */ }
1181
1879
  }
1182
1880
  // 先移除 message 监听再拉全文 = 永远拿不到回执(LOG-3.3.8 已知缺口);这里留到 flush 结束。
1183
- if (doc.flushResolve) setTimeout(disposeMessage, FLUSH_WAIT_MS + 80);
1881
+ if (doc.closeUntil) setTimeout(() => { disposeMessage(); try { Promise.resolve(draftStore()?.release?.({path:doc.absPath,docToken:doc.token})).then(inspectStoreResult).catch(()=>{}); } catch(e){} }, FLUSH_WAIT_MS + 80);
1184
1882
  else disposeMessage();
1185
1883
  openDocs.delete(doc.sessionId + "\n" + doc.absPath);
1186
1884
  if (st.doc === doc) st.doc = null;
@@ -1224,9 +1922,24 @@ window.__ModuleLoader__.load({
1224
1922
  banner = h("div", { style: { ...bannerStyle(false), minHeight: 22 } }, ui.notice || ui.detail || "");
1225
1923
  }
1226
1924
 
1925
+ /* recovered:join 票带回的草稿副本提示(独立于 phase;按票一次;只展示+复制路径,
1926
+ * 不读取/不打开这些文件;不经 SSE、不影响其它文档 tab)。 */
1927
+ let recoveredBanner = null;
1928
+ if (ui.recovered && ui.recovered.length) {
1929
+ recoveredBanner = h("div", { style: bannerStyle(true), flexDirection: "column", alignItems: "stretch" },
1930
+ h("div", { style: { display: "flex", gap: 8, alignItems: "center", flexWrap: "wrap" } },
1931
+ h("span", null, "♻ 该文件存在共享会话恢复的草稿副本(原件未被覆盖):"),
1932
+ h("button", { style: btnStyle, onClick: () => { try { st.actions.dismissRecovered && st.actions.dismissRecovered(); } catch (e) { /* 忽略 */ } } }, "知道了")),
1933
+ ...ui.recovered.map((item, index) => h("div", { key: item.recoveryPath + ":" + index, style: { display: "flex", gap: 6, alignItems: "center", flexWrap: "wrap" } },
1934
+ h("code", { style: { userSelect: "all", wordBreak: "break-all", fontSize: 11 } }, item.recoveryPath),
1935
+ item.at ? h("span", { style: { opacity: 0.65, fontSize: 11 } }, new Date(item.at).toLocaleString()) : null,
1936
+ h("button", { style: btnStyle, onClick: () => { try { st.actions.copyRecoveredPath && st.actions.copyRecoveredPath(index); } catch (e) { /* 忽略 */ } } }, "复制路径"))));
1937
+ }
1938
+
1227
1939
  const frameName = "dsh-markdown-typora-" + String((tab && (tab.id || tab.occurrenceId)) || "tab");
1228
1940
  return h("div", { ref: rootRef, style: { width: "100%", height: "100%", display: "flex", flexDirection: "column", minHeight: 0 } },
1229
1941
  banner,
1942
+ recoveredBanner,
1230
1943
  h("iframe", {
1231
1944
  key: depKey,
1232
1945
  ref: frameRef,