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/README.md +15 -14
- package/core.json +5 -5
- package/host/document-store.js +181 -0
- package/host/live-documents.js +524 -0
- package/host/native/file-worker.exe +0 -0
- package/host/native/file-worker.exe.build.json +10 -0
- package/host/native-worker-lease.js +127 -0
- package/host/native-worker-path.ps1 +63 -0
- package/host/package.json +1 -0
- package/host/safe-file.js +137 -0
- package/host/text-codec.js +67 -0
- package/host.json +50 -0
- package/lib/client.js +929 -216
- package/lib/index.js +1506 -1120
- package/package.json +5 -3
- package/renderer-cm6/cm6/app.js +193 -151
- package/renderer-cm6/dsh-bridge.js +216 -5
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
|
-
// ·
|
|
10
|
-
//
|
|
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; //
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
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.
|
|
649
|
-
return
|
|
650
|
-
|
|
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
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
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
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
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
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
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
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
if (
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
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() && !(
|
|
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.
|
|
1031
|
-
|
|
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") {
|
|
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
|
|
1058
|
-
if (!slot || typeof slot.text
|
|
1059
|
-
doc.
|
|
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
|
|
1073
|
-
clearRecoverySlot(null,
|
|
1074
|
-
|
|
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
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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,
|