dsh-vscode-mode 0.13.1 → 0.14.1
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 +47 -5
- package/lib/client.js +927 -147
- package/lib/client.js.map +1 -1
- package/lib/index.js +235 -14
- package/lib/index.js.map +1 -1
- package/package.json +8 -8
- package/src/client/index.ts +76 -2
- package/src/client/navHistory.ts +45 -0
- package/src/client/officialSidebar.ts +59 -1
- package/src/client/paths.ts +6 -0
- package/src/client/state/mdPreviewCache.ts +74 -0
- package/src/client/state/navStateCache.ts +83 -0
- package/src/client/state/uiStatePersist.ts +562 -0
- package/src/client/ui/EditorView.ts +65 -10
- package/src/dshVersion.ts +4 -2
- package/src/paths.ts +59 -3
- package/src/rpc.ts +50 -3
- package/src/shared/rpc.ts +24 -0
- package/src/uiState.ts +165 -0
|
@@ -0,0 +1,562 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-vscode-mode client — 界面状态镜像(把 `edrv.*` 的 localStorage 状态写穿到 host)。
|
|
3
|
+
*
|
|
4
|
+
* 背景:Web GUI 的 origin(含端口)每次启动可能变化,而 localStorage 按 origin 分区,
|
|
5
|
+
* 于是页签/侧栏/预览态等状态「重启就忘」。本模块包装 localStorage 写口:
|
|
6
|
+
* 只拦 `edrv.` 前缀键(跳过镜像自身标记键与派生条目缓存,见 `SKIP_PREFIX`),400ms 去抖后
|
|
7
|
+
* 增量上送 `edrv.uiState.set`:按体积分批,整批被拒则逐键回退,避免个别超大键拖垮同批。
|
|
8
|
+
* 启动(含换 origin)后 `edrv.uiState.get` 水合,仅填本地缺失的键(本地优先,绝不覆盖);
|
|
9
|
+
* 首次搬运标记只在整批落盘成功后写入(失败则下次加载重试)。
|
|
10
|
+
* 全局键(不随工作区变化)走 globals 分区,其余按键原值搬进工作区分区。
|
|
11
|
+
*
|
|
12
|
+
* 降级:存储不可用/包装失败/host 读写失败/超限一律静默退回纯 localStorage(诊断日志留痕)。
|
|
13
|
+
* 纯逻辑与依赖注入分离,`createUiStateMirror` 可在 node 环境单测。
|
|
14
|
+
* 作者 ddj 2026-09-28
|
|
15
|
+
*/
|
|
16
|
+
import { CACHE_KEY } from '../paths.js'
|
|
17
|
+
import { dbg, rpc } from '../rpc.js'
|
|
18
|
+
import { readSessionScope } from '../sessionScope.js'
|
|
19
|
+
|
|
20
|
+
// --region 常量
|
|
21
|
+
/** 参与镜像的键前缀(只拦插件自己的键,不碰其他插件的 localStorage)。 */
|
|
22
|
+
const MIRROR_PREFIX = 'edrv.'
|
|
23
|
+
/** 去抖间隔:改动密集时合并为一次上送(毫秒)。 */
|
|
24
|
+
export const FLUSH_DEBOUNCE_MS = 400
|
|
25
|
+
/** 首次回填标记键(把既有 `edrv.*` 状态一次性推给 host;自身不参与镜像)。 */
|
|
26
|
+
export const SEEDED_KEY = 'edrv.uistate.v1.seeded'
|
|
27
|
+
/** 水合完成事件名(换 origin 回填后广播,供界面重跑恢复逻辑)。 */
|
|
28
|
+
export const READY_EVENT = 'edrv:ui-state-ready'
|
|
29
|
+
/** 全局键(不随工作区变化,落用户级镜像文件)。 */
|
|
30
|
+
const GLOBAL_EXACT = ['edrv.debug', CACHE_KEY.sideHint]
|
|
31
|
+
/** 全局键前缀(不随工作区变化)。 */
|
|
32
|
+
const GLOBAL_PREFIX = [CACHE_KEY.workspaceFold]
|
|
33
|
+
/** 单次上送的字节上限(host 单文件上限 512KB;分批避免个别超大键拖垮整批)。 */
|
|
34
|
+
export const CHUNK_BYTES = 64 * 1024
|
|
35
|
+
/** 不参与镜像的派生缓存前缀(目录条目 SWR 缓存可达数百 KB,且 host 侧 tree.v1.json 已覆盖同类信息)。 */
|
|
36
|
+
const SKIP_PREFIX = [CACHE_KEY.entries]
|
|
37
|
+
/** 水合失败重试次数(启动早期会话尚未就绪时 host 会答「会话不存在」,须稍后补试)。 */
|
|
38
|
+
export const HYDRATE_RETRY_MAX = 8
|
|
39
|
+
/** 水合失败重试间隔(毫秒)。 */
|
|
40
|
+
export const HYDRATE_RETRY_MS = 800
|
|
41
|
+
// --endregion
|
|
42
|
+
|
|
43
|
+
// --region 类型
|
|
44
|
+
/** localStorage 最小接口(便于注入假实现)。 */
|
|
45
|
+
export interface UiStorage {
|
|
46
|
+
/** 键数量。 */
|
|
47
|
+
length: number
|
|
48
|
+
/** 按下标取键名。 */
|
|
49
|
+
key(index: number): string | null
|
|
50
|
+
/** 读键值。 */
|
|
51
|
+
getItem(key: string): string | null
|
|
52
|
+
/** 写键值。 */
|
|
53
|
+
setItem(key: string, value: string): void
|
|
54
|
+
/** 删键。 */
|
|
55
|
+
removeItem(key: string): void
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** 上送补丁:null 表示删除该键。 */
|
|
59
|
+
export interface UiStatePush {
|
|
60
|
+
/** 工作区级补丁。 */
|
|
61
|
+
keys?: Record<string, string | null>
|
|
62
|
+
/** 全局补丁。 */
|
|
63
|
+
globals?: Record<string, string | null>
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** host 取回快照。 */
|
|
67
|
+
export interface UiStateSnapshot {
|
|
68
|
+
/** 工作区级键值表。 */
|
|
69
|
+
keys?: Record<string, string>
|
|
70
|
+
/** 全局键值表。 */
|
|
71
|
+
globals?: Record<string, string>
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** 镜像依赖(全部可注入;缺省走浏览器存储 + RPC)。 */
|
|
75
|
+
export interface UiStateMirrorDeps {
|
|
76
|
+
/** localStorage 替身。 */
|
|
77
|
+
storage: UiStorage
|
|
78
|
+
/** 上送补丁(拒绝即抛错,内部重试一次)。 */
|
|
79
|
+
push: (payload: UiStatePush, sessionId?: string) => Promise<void>
|
|
80
|
+
/** 取回镜像。 */
|
|
81
|
+
fetch: (sessionId?: string) => Promise<UiStateSnapshot>
|
|
82
|
+
/** 当前会话 id。 */
|
|
83
|
+
sessionIdOf?: () => string | undefined
|
|
84
|
+
/** 去抖调度(缺省 setTimeout)。 */
|
|
85
|
+
schedule?: (fn: () => void, ms: number) => unknown
|
|
86
|
+
/** 取消去抖(缺省 clearTimeout)。 */
|
|
87
|
+
cancel?: (handle: unknown) => void
|
|
88
|
+
/** 诊断日志(缺省静默)。 */
|
|
89
|
+
log?: (text: string) => void
|
|
90
|
+
/** 无门控上报(缺省退化为 `log`):绕过客户端 debug 开关,供 host 侧留痕排查。 */
|
|
91
|
+
report?: (text: string, level?: string) => void
|
|
92
|
+
/** 单批体积上限(缺省 `CHUNK_BYTES`;测试注入小值以覆盖分批)。 */
|
|
93
|
+
chunkBytes?: number
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** 镜像实例。 */
|
|
97
|
+
export interface UiStateMirror {
|
|
98
|
+
/** 包装 localStorage 写口(写口不可用时返回 false)。 */
|
|
99
|
+
install(): boolean
|
|
100
|
+
/** 取回 host 镜像并回填本地缺失键,返回回填条数。 */
|
|
101
|
+
hydrate(): Promise<number>
|
|
102
|
+
/** 立即上送待推送批次,返回最终丢弃的键数(0 = 全部落盘;供卸载与测试驱动)。 */
|
|
103
|
+
flush(): Promise<number>
|
|
104
|
+
/** 是否已完成一次水合。 */
|
|
105
|
+
hydrated(): boolean
|
|
106
|
+
/** 水合阶段是否已结束(成功或重试耗尽)。未结束时界面不应写入存档,否则会把空初值推给 host。 */
|
|
107
|
+
settled(): boolean
|
|
108
|
+
}
|
|
109
|
+
// --endregion
|
|
110
|
+
|
|
111
|
+
// --region 纯逻辑
|
|
112
|
+
/**
|
|
113
|
+
* 是否参与镜像(`edrv.` 前缀且非标记键)。
|
|
114
|
+
* @author ddj 2026年09月28号
|
|
115
|
+
* @param key localStorage 键
|
|
116
|
+
* @returns 是否镜像
|
|
117
|
+
*/
|
|
118
|
+
export function isMirroredKey(key: string): boolean {
|
|
119
|
+
if (!key.startsWith(MIRROR_PREFIX) || key === SEEDED_KEY) return false
|
|
120
|
+
return !SKIP_PREFIX.some((prefix) => key.startsWith(prefix))
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* 是否全局键(不随工作区变化)。
|
|
125
|
+
* @author ddj 2026年09月28号
|
|
126
|
+
* @param key localStorage 键
|
|
127
|
+
* @returns 是否全局键
|
|
128
|
+
*/
|
|
129
|
+
export function isGlobalKey(key: string): boolean {
|
|
130
|
+
if (GLOBAL_EXACT.includes(key)) return true
|
|
131
|
+
return GLOBAL_PREFIX.some((prefix) => key.startsWith(prefix))
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* 收集本地全部镜像键(存储不可读 → 空数组)。
|
|
136
|
+
* @author ddj 2026年09月28号
|
|
137
|
+
* @param storage localStorage 替身
|
|
138
|
+
* @returns 镜像键清单
|
|
139
|
+
*/
|
|
140
|
+
export function localMirrorKeys(storage: UiStorage): string[] {
|
|
141
|
+
const keys: string[] = []
|
|
142
|
+
try {
|
|
143
|
+
for (let index = 0; index < storage.length; index++) {
|
|
144
|
+
const key = storage.key(index)
|
|
145
|
+
if (key && isMirroredKey(key)) keys.push(key)
|
|
146
|
+
}
|
|
147
|
+
} catch (error) { /* 存储不可读:视为空 */ }
|
|
148
|
+
return keys
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* 按键值体积切分补丁(单键超限则独占一批,仍由 host 容量上限裁决)。
|
|
153
|
+
* @author ddj 2026年09月29号
|
|
154
|
+
* @param patch 待上送补丁
|
|
155
|
+
* @param limit 单批体积上限(字节;按字符数近似)
|
|
156
|
+
* @returns 批次数组(空补丁 → 空数组)
|
|
157
|
+
*/
|
|
158
|
+
export function chunkPatch(patch: Record<string, string | null>, limit = CHUNK_BYTES): Array<Record<string, string | null>> {
|
|
159
|
+
const chunks: Array<Record<string, string | null>> = []
|
|
160
|
+
let current: Record<string, string | null> = {}
|
|
161
|
+
let size = 0
|
|
162
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
163
|
+
const cost = key.length + (value === null ? 0 : value.length)
|
|
164
|
+
if (size > 0 && size + cost > limit) {
|
|
165
|
+
chunks.push(current)
|
|
166
|
+
current = {}
|
|
167
|
+
size = 0
|
|
168
|
+
}
|
|
169
|
+
current[key] = value
|
|
170
|
+
size += cost
|
|
171
|
+
}
|
|
172
|
+
if (size > 0) chunks.push(current)
|
|
173
|
+
return chunks
|
|
174
|
+
}
|
|
175
|
+
// --endregion
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* 创建界面状态镜像实例。
|
|
179
|
+
* @author ddj 2026年09月28号
|
|
180
|
+
* @param deps 依赖(存储/上送/取回/调度)
|
|
181
|
+
* @returns 镜像实例
|
|
182
|
+
*/
|
|
183
|
+
export function createUiStateMirror(deps: UiStateMirrorDeps): UiStateMirror {
|
|
184
|
+
const { storage } = deps
|
|
185
|
+
const schedule = deps.schedule ?? ((fn: () => void, ms: number) => setTimeout(fn, ms))
|
|
186
|
+
const cancel = deps.cancel ?? ((handle: unknown) => clearTimeout(handle as ReturnType<typeof setTimeout>))
|
|
187
|
+
const log = deps.log ?? (() => { /* 默认静默 */ })
|
|
188
|
+
const report = deps.report ?? ((text: string, level?: string) => log(text))
|
|
189
|
+
const pending: Required<UiStatePush> = { keys: {}, globals: {} }
|
|
190
|
+
let timer: unknown = null
|
|
191
|
+
let inflight: Promise<void> | null = null
|
|
192
|
+
let suppressed = 0
|
|
193
|
+
let done = false
|
|
194
|
+
let installed = false
|
|
195
|
+
let lastError = ''
|
|
196
|
+
/** 水合尝试次数(失败重试;超过 `HYDRATE_RETRY_MAX` 放弃并解禁)。 */
|
|
197
|
+
let attempts = 0
|
|
198
|
+
/** 重试定时器(与水合无关的 flush 去抖计时器分开,避免互相取消)。 */
|
|
199
|
+
let retryTimer: unknown = null
|
|
200
|
+
let settleDone = false
|
|
201
|
+
|
|
202
|
+
/** 记入待推送批次并安排去抖上送(水合期间或非镜像键 → 跳过)。 */
|
|
203
|
+
function queue(key: string, value: string | null): void {
|
|
204
|
+
if (suppressed > 0 || !isMirroredKey(key)) return
|
|
205
|
+
const bucket = isGlobalKey(key) ? pending.globals : pending.keys
|
|
206
|
+
bucket[key] = value
|
|
207
|
+
if (timer !== null) cancel(timer)
|
|
208
|
+
timer = schedule(() => {
|
|
209
|
+
timer = null
|
|
210
|
+
void flush()
|
|
211
|
+
}, FLUSH_DEBOUNCE_MS)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** 上送一批补丁(失败重试一次)。 */
|
|
215
|
+
async function pushBatch(payload: UiStatePush): Promise<boolean> {
|
|
216
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
217
|
+
try {
|
|
218
|
+
await deps.push(payload, deps.sessionIdOf?.())
|
|
219
|
+
return true
|
|
220
|
+
} catch (error) {
|
|
221
|
+
lastError = String(error)
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return false
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** 逐键回退上送(整批被拒后用,避免个别大键拖垮同批其它键),返回丢弃键数。 */
|
|
228
|
+
async function pushEach(bucket: Record<string, string | null>, asGlobal: boolean): Promise<number> {
|
|
229
|
+
let lost = 0
|
|
230
|
+
for (const [key, value] of Object.entries(bucket)) {
|
|
231
|
+
if (await pushBatch(asGlobal ? { globals: { [key]: value } } : { keys: { [key]: value } })) continue
|
|
232
|
+
lost++
|
|
233
|
+
log('界面状态镜像上送失败(已丢弃 ' + key + '): ' + lastError)
|
|
234
|
+
}
|
|
235
|
+
return lost
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** 分区上送:按体积分批,整批被拒则逐键回退,返回最终丢弃的键数。 */
|
|
239
|
+
async function pushBucket(bucket: Record<string, string | null>, asGlobal: boolean): Promise<number> {
|
|
240
|
+
let lost = 0
|
|
241
|
+
for (const chunk of chunkPatch(bucket, deps.chunkBytes)) {
|
|
242
|
+
if (!(await pushBatch(asGlobal ? { globals: chunk } : { keys: chunk }))) lost += await pushEach(chunk, asGlobal)
|
|
243
|
+
}
|
|
244
|
+
return lost
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** 立即上送待推送批次(空批次不请求;与在途上送串行),返回最终丢弃的键数。 */
|
|
248
|
+
async function flush(): Promise<number> {
|
|
249
|
+
if (inflight) await inflight.catch(() => { /* 在途失败不影响本批 */ })
|
|
250
|
+
if (timer !== null) {
|
|
251
|
+
cancel(timer)
|
|
252
|
+
timer = null
|
|
253
|
+
}
|
|
254
|
+
const keys = pending.keys
|
|
255
|
+
const globals = pending.globals
|
|
256
|
+
const hasKeys = Object.keys(keys).length > 0
|
|
257
|
+
const hasGlobals = Object.keys(globals).length > 0
|
|
258
|
+
if (!hasKeys && !hasGlobals) return 0
|
|
259
|
+
pending.keys = {}
|
|
260
|
+
pending.globals = {}
|
|
261
|
+
const run = (async (): Promise<number> => {
|
|
262
|
+
let lost = 0
|
|
263
|
+
// 两个分区各自成请求:任一分区被 host 拒绝都不牵连另一个分区
|
|
264
|
+
if (hasKeys) lost += await pushBucket(keys, false)
|
|
265
|
+
if (hasGlobals) lost += await pushBucket(globals, true)
|
|
266
|
+
return lost
|
|
267
|
+
})()
|
|
268
|
+
inflight = run.then(() => { /* 结果由 run 返回 */ }, () => { /* 异常已在内部消化 */ })
|
|
269
|
+
return run
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** 回填本地缺失键(本地已有值一律保留)。 */
|
|
273
|
+
function applyHydration(snapshot: UiStateSnapshot): number {
|
|
274
|
+
let filled = 0
|
|
275
|
+
suppressed++
|
|
276
|
+
try {
|
|
277
|
+
const all = { ...snapshot.globals, ...snapshot.keys }
|
|
278
|
+
for (const [key, value] of Object.entries(all)) {
|
|
279
|
+
if (!isMirroredKey(key) || typeof value !== 'string') continue
|
|
280
|
+
if (storage.getItem(key) !== null) continue
|
|
281
|
+
storage.setItem(key, value)
|
|
282
|
+
filled++
|
|
283
|
+
report('回填键 ' + key + '(' + value.length + ' 字符)')
|
|
284
|
+
}
|
|
285
|
+
} catch (error) {
|
|
286
|
+
log('界面状态回填写入失败: ' + String(error))
|
|
287
|
+
} finally {
|
|
288
|
+
suppressed--
|
|
289
|
+
}
|
|
290
|
+
return filled
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** 首次运行时把既有本地状态一次性推给 host(只入队;成功后才落标记,避免失败后永不重试)。 */
|
|
294
|
+
function seedLocal(): number {
|
|
295
|
+
try {
|
|
296
|
+
if (storage.getItem(SEEDED_KEY) === '1') return 0
|
|
297
|
+
const keys = localMirrorKeys(storage)
|
|
298
|
+
for (const key of keys) queue(key, storage.getItem(key))
|
|
299
|
+
return keys.length
|
|
300
|
+
} catch (error) {
|
|
301
|
+
log('界面状态首次搬运失败: ' + String(error))
|
|
302
|
+
return 0
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** 落首次搬运标记(仅在本批全部落盘成功后调用;写失败下次加载重搬,幂等)。 */
|
|
307
|
+
function markSeeded(): void {
|
|
308
|
+
try {
|
|
309
|
+
storage.setItem(SEEDED_KEY, '1')
|
|
310
|
+
} catch (error) { /* 标记写失败:下次加载重搬 */ }
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** 广播水合完成事件(无 window 环境静默)。 */
|
|
314
|
+
function emitReady(): void {
|
|
315
|
+
try {
|
|
316
|
+
if (typeof window !== 'undefined') window.dispatchEvent(new Event(READY_EVENT))
|
|
317
|
+
} catch (error) { /* 事件广播失败忽略 */ }
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* 结束水合阶段(成功或重试耗尽):解禁界面落盘,并广播就绪事件让恢复逻辑补跑一次。
|
|
322
|
+
* 冷启动早期会话未就绪时 `edrv.uiState.get` 会返回「会话不存在」,故本阶段必须可重试;
|
|
323
|
+
* 未结束前界面不得写存档,否则空初值会覆盖 host 上的真实状态。
|
|
324
|
+
*/
|
|
325
|
+
function settle(): void {
|
|
326
|
+
if (settleDone) return
|
|
327
|
+
settleDone = true
|
|
328
|
+
emitReady()
|
|
329
|
+
report('水合阶段结束(就绪;' + (done ? '已回填' : '未回填,退回纯 localStorage') + ')')
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* 取回 host 镜像并回填本地缺失键;失败按 `HYDRATE_RETRY_MS` 重试,超限则结束水合阶段。
|
|
334
|
+
* @returns 本次回填键数(失败/重试中为 0)
|
|
335
|
+
*/
|
|
336
|
+
async function hydrate(): Promise<number> {
|
|
337
|
+
try {
|
|
338
|
+
report('水合开始(origin=' + originOf() + ',第 ' + (attempts + 1) + ' 次)')
|
|
339
|
+
const snapshot = await deps.fetch(deps.sessionIdOf?.())
|
|
340
|
+
// 先搬运本 origin 既有状态(只含换 origin 前就存在的键),再回填缺失键:
|
|
341
|
+
// 顺序反过来会把刚回填的键再回送一遍(值与 host 完全相同,纯属多余流量)。
|
|
342
|
+
const seeded = seedLocal()
|
|
343
|
+
const filled = applyHydration(snapshot)
|
|
344
|
+
const stored = Object.keys({ ...snapshot.globals, ...snapshot.keys }).length
|
|
345
|
+
done = true
|
|
346
|
+
report('水合完成:host 镜像 ' + stored + ' 键、本地搬运 ' + seeded + ' 键、回填 ' + filled + ' 键')
|
|
347
|
+
if (seeded > 0) {
|
|
348
|
+
const lost = await flush()
|
|
349
|
+
report('首次搬运输送完成:丢弃 ' + lost + ' 键')
|
|
350
|
+
if (lost === 0) markSeeded()
|
|
351
|
+
}
|
|
352
|
+
settle()
|
|
353
|
+
return filled
|
|
354
|
+
} catch (error) {
|
|
355
|
+
attempts++
|
|
356
|
+
if (attempts < HYDRATE_RETRY_MAX) {
|
|
357
|
+
report('水合失败(第 ' + attempts + ' 次,' + HYDRATE_RETRY_MS + 'ms 后重试): ' + String(error), 'warn')
|
|
358
|
+
retryTimer = schedule(() => {
|
|
359
|
+
retryTimer = null
|
|
360
|
+
void hydrate()
|
|
361
|
+
}, HYDRATE_RETRY_MS)
|
|
362
|
+
return 0
|
|
363
|
+
}
|
|
364
|
+
report('水合放弃(重试 ' + attempts + ' 次仍失败): ' + String(error), 'warn')
|
|
365
|
+
settle()
|
|
366
|
+
return 0
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
return {
|
|
371
|
+
install(): boolean {
|
|
372
|
+
if (installed) return true
|
|
373
|
+
try {
|
|
374
|
+
const proto = Object.getPrototypeOf(storage) as Partial<UiStorage> | null
|
|
375
|
+
const rawSet = proto?.setItem
|
|
376
|
+
const rawRemove = proto?.removeItem
|
|
377
|
+
if (typeof rawSet !== 'function' || typeof rawRemove !== 'function') return false
|
|
378
|
+
storage.setItem = function (key: string, value: string): void {
|
|
379
|
+
rawSet.call(storage, key, value)
|
|
380
|
+
queue(String(key), String(value))
|
|
381
|
+
}
|
|
382
|
+
storage.removeItem = function (key: string): void {
|
|
383
|
+
rawRemove.call(storage, key)
|
|
384
|
+
queue(String(key), null)
|
|
385
|
+
}
|
|
386
|
+
installed = true
|
|
387
|
+
report('镜像已包装 localStorage 写口')
|
|
388
|
+
return true
|
|
389
|
+
} catch (error) {
|
|
390
|
+
report('界面状态镜像包装失败(退回纯 localStorage): ' + String(error), 'warn')
|
|
391
|
+
return false
|
|
392
|
+
}
|
|
393
|
+
},
|
|
394
|
+
hydrate,
|
|
395
|
+
flush,
|
|
396
|
+
hydrated(): boolean {
|
|
397
|
+
return done
|
|
398
|
+
},
|
|
399
|
+
settled(): boolean {
|
|
400
|
+
return settleDone
|
|
401
|
+
},
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// --region 插件接线
|
|
406
|
+
/** 单例镜像(installUiStatePersist 幂等:HMR 重挂不重复包装)。 */
|
|
407
|
+
let singleton: UiStateMirror | null = null
|
|
408
|
+
/** 是否已回填过(晚到的订阅者据此立即执行)。 */
|
|
409
|
+
let uiReady = false
|
|
410
|
+
/** 无门控上报出口(由 installUiStatePersist 注入;未装配时静默)。 */
|
|
411
|
+
let reporter: ((text: string, level?: string) => void) | null = null
|
|
412
|
+
/** 在途水合(供 apply 等待,尽量让回填先于界面挂载)。 */
|
|
413
|
+
let hydrating: Promise<number> | null = null
|
|
414
|
+
|
|
415
|
+
/** 当前页面 origin(无 location 环境返回 '?')。 */
|
|
416
|
+
function originOf(): string {
|
|
417
|
+
try {
|
|
418
|
+
if (typeof location !== 'undefined' && location.origin) return location.origin
|
|
419
|
+
} catch (error) { /* 无 location 环境 */ }
|
|
420
|
+
return '?'
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* 上报界面状态镜像事件(无门控:绕过客户端 debug 开关,直接落 host 诊断日志)。
|
|
425
|
+
* @author ddj 2026年09月29号
|
|
426
|
+
* @param text 事件文本
|
|
427
|
+
* @param level 日志级别(缺省 'debug')
|
|
428
|
+
*/
|
|
429
|
+
export function reportUiStateEvent(text: string, level?: string): void {
|
|
430
|
+
try {
|
|
431
|
+
reporter?.(text, level)
|
|
432
|
+
} catch (error) { /* 上报失败忽略 */ }
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* 触发(或加入)一次水合并等待结果:让界面挂载前尽量拿到回填值;水合阶段已结束则返回 0。
|
|
437
|
+
* @author ddj 2026年09月29号
|
|
438
|
+
* @returns 本次回填键数
|
|
439
|
+
*/
|
|
440
|
+
export async function hydrateUiState(): Promise<number> {
|
|
441
|
+
if (!singleton || singleton.settled()) return 0
|
|
442
|
+
if (hydrating) return hydrating
|
|
443
|
+
return singleton.hydrate()
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* 水合阶段是否已结束(未结束时界面不得写存档:空初值会覆盖 host 上的真实状态)。
|
|
448
|
+
* @author ddj 2026年09月29号
|
|
449
|
+
* @returns 是否已结束(未装配视为已结束,退回纯 localStorage 行为)
|
|
450
|
+
*/
|
|
451
|
+
export function uiStateSettled(): boolean {
|
|
452
|
+
return singleton ? singleton.settled() : true
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/** 读浏览器 localStorage(不可用 → null)。 */
|
|
456
|
+
function readLocalStorage(): UiStorage | null {
|
|
457
|
+
try {
|
|
458
|
+
if (typeof localStorage === 'undefined') return null
|
|
459
|
+
return localStorage as unknown as UiStorage
|
|
460
|
+
} catch (error) {
|
|
461
|
+
return null
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** 上送镜像(host 返回 ok:false 视为失败,触发内部重试)。 */
|
|
466
|
+
async function pushViaRpc(payload: UiStatePush, sessionId?: string): Promise<void> {
|
|
467
|
+
const res = await rpc('edrv.uiState.set', { sessionId, keys: payload.keys, globals: payload.globals })
|
|
468
|
+
if (!res.ok) throw new Error(res.error)
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/** 取回镜像。 */
|
|
472
|
+
async function fetchViaRpc(sessionId?: string): Promise<UiStateSnapshot> {
|
|
473
|
+
const res = await rpc('edrv.uiState.get', { sessionId })
|
|
474
|
+
if (!res.ok) throw new Error(res.error)
|
|
475
|
+
return { keys: res.keys, globals: res.globals }
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* 安装界面状态镜像(幂等):包装 localStorage 写口并触发一次回填。
|
|
480
|
+
* @author ddj 2026年09月28号
|
|
481
|
+
* @param ctx 客户端根上下文(只需 `get`,用于解析当前会话)
|
|
482
|
+
* @param schedule 去抖调度(客户端 apply 提供的 ctx.timeout 包装)
|
|
483
|
+
* @param report 无门控上报(缺省静默;传入后所有镜像事件绕过 debug 开关落 host 日志)
|
|
484
|
+
* @returns 是否安装成功(存储不可用 → false,功能退回纯 localStorage)
|
|
485
|
+
*/
|
|
486
|
+
export function installUiStatePersist(
|
|
487
|
+
ctx: { get: (name: string) => unknown },
|
|
488
|
+
schedule: (fn: () => void, ms: number) => unknown,
|
|
489
|
+
report?: (text: string, level?: string) => void,
|
|
490
|
+
): boolean {
|
|
491
|
+
if (singleton) return true
|
|
492
|
+
if (report) reporter = report
|
|
493
|
+
const storage = readLocalStorage()
|
|
494
|
+
if (!storage) {
|
|
495
|
+
reportUiStateEvent('镜像安装失败:localStorage 不可用', 'warn')
|
|
496
|
+
return false
|
|
497
|
+
}
|
|
498
|
+
const sessionIdOf = (): string | undefined => readSessionScope(ctx).sessionId
|
|
499
|
+
const mirror = createUiStateMirror({
|
|
500
|
+
storage,
|
|
501
|
+
push: pushViaRpc,
|
|
502
|
+
fetch: fetchViaRpc,
|
|
503
|
+
sessionIdOf,
|
|
504
|
+
schedule,
|
|
505
|
+
report: (text: string, level?: string) => reportUiStateEvent(text, level),
|
|
506
|
+
log: (text: string) => {
|
|
507
|
+
try { dbg(sessionIdOf(), 'ui-state: ' + text, 'warn') } catch (error) { /* 日志失败忽略 */ }
|
|
508
|
+
},
|
|
509
|
+
})
|
|
510
|
+
if (!mirror.install()) {
|
|
511
|
+
reportUiStateEvent('镜像安装失败:localStorage 写口无法包装', 'warn')
|
|
512
|
+
return false
|
|
513
|
+
}
|
|
514
|
+
singleton = mirror
|
|
515
|
+
try {
|
|
516
|
+
if (typeof window !== 'undefined') {
|
|
517
|
+
window.addEventListener('pagehide', () => { void mirror.flush() })
|
|
518
|
+
}
|
|
519
|
+
} catch (error) { /* 无 window 环境忽略 */ }
|
|
520
|
+
hydrating = mirror.hydrate().then((filled) => {
|
|
521
|
+
if (filled > 0 || mirror.settled()) uiReady = true
|
|
522
|
+
return filled
|
|
523
|
+
})
|
|
524
|
+
void hydrating.finally(() => {
|
|
525
|
+
// 首轮结束后:已结束水合阶段才允许后续 hydrateUiState() 重新发起(重试由镜像内部承担)
|
|
526
|
+
if (mirror.settled()) {
|
|
527
|
+
uiReady = true
|
|
528
|
+
hydrating = null
|
|
529
|
+
}
|
|
530
|
+
})
|
|
531
|
+
return true
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* 订阅「界面状态回填完成」(已回填过则立即执行一次)。
|
|
536
|
+
* @author ddj 2026年09月28号
|
|
537
|
+
* @param listener 回调
|
|
538
|
+
* @returns 取消订阅函数(幂等)
|
|
539
|
+
*/
|
|
540
|
+
export function onUiStateReady(listener: () => void): () => void {
|
|
541
|
+
if (typeof window === 'undefined') {
|
|
542
|
+
if (uiReady) listener()
|
|
543
|
+
return () => { /* 无事件源 */ }
|
|
544
|
+
}
|
|
545
|
+
if (uiReady) {
|
|
546
|
+
listener()
|
|
547
|
+
return () => { /* 已完成后无需取消 */ }
|
|
548
|
+
}
|
|
549
|
+
const handler = (): void => { listener() }
|
|
550
|
+
window.addEventListener(READY_EVENT, handler)
|
|
551
|
+
return () => window.removeEventListener(READY_EVENT, handler)
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* 立即上送待推送批次(卸载/隐藏前调用,尽量少丢最后一次改动)。
|
|
556
|
+
* @author ddj 2026年09月28号
|
|
557
|
+
* @returns 上送完成
|
|
558
|
+
*/
|
|
559
|
+
export async function flushUiState(): Promise<void> {
|
|
560
|
+
if (singleton) await singleton.flush()
|
|
561
|
+
}
|
|
562
|
+
// --endregion
|