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/lib/client.js CHANGED
@@ -8942,7 +8942,13 @@ window.__ModuleLoader__.load({
8942
8942
  /** 规则面板 UI 状态(RulesPanel v1:Tab 与项目工作区选择,全局不按会话)。 */
8943
8943
  rules: "edrv.rules.v1.",
8944
8944
  /** SVN 变更面板 UI 状态(SvnPanel v1:未版本控制/忽略项显示开关,按作用域隔离)。 */
8945
- svn: "edrv.svn.v1."
8945
+ svn: "edrv.svn.v1.",
8946
+ /** Markdown 预览态(EditorView v1:处于预览态的 Markdown 路径集合,按作用域隔离)。 */
8947
+ mdPreview: "edrv.md-preview.v1.",
8948
+ /** 官方侧边栏「文件编辑」Tab 展开/激活态(officialSidebar v1:重启后自动回到编辑 Tab)。 */
8949
+ editorTab: "edrv.editor-tab.v1.",
8950
+ /** 编辑器导航历史双栈(navStateCache v1:重启后前进/后退与当前位置仍在,按作用域隔离)。 */
8951
+ navHistory: "edrv.nav-history.v1."
8946
8952
  };
8947
8953
  //#endregion
8948
8954
  //#region src/client/state/viewStateCache.ts
@@ -9048,6 +9054,654 @@ window.__ModuleLoader__.load({
9048
9054
  window.localStorage.setItem(KEY_PREFIX$4 + String(scope), serializeViewStates(states));
9049
9055
  } catch (error) {}
9050
9056
  }
9057
+ /**
9058
+ * 归一化预览态路径表:丢弃非字符串/空串、去重(保留首次出现顺序),超限截断。
9059
+ * @author ddj 2026年09月28号
9060
+ * @param value 待归一化的解析产物(任意输入,非数组 → 空表)
9061
+ * @returns 路径数组
9062
+ */
9063
+ function normalizeMdPreview(value) {
9064
+ if (!Array.isArray(value)) return [];
9065
+ const out = [];
9066
+ for (const item of value) {
9067
+ if (typeof item !== "string" || !item) continue;
9068
+ if (out.includes(item)) continue;
9069
+ out.push(item);
9070
+ if (out.length >= 50) break;
9071
+ }
9072
+ return out;
9073
+ }
9074
+ /**
9075
+ * 解析存档文本(空/损坏/非数组 → 空表)。
9076
+ * @author ddj 2026年09月28号
9077
+ * @param text localStorage 原文
9078
+ * @returns 路径数组
9079
+ */
9080
+ function parseMdPreview(text) {
9081
+ if (!text) return [];
9082
+ try {
9083
+ return normalizeMdPreview(JSON.parse(text));
9084
+ } catch (error) {
9085
+ return [];
9086
+ }
9087
+ }
9088
+ /**
9089
+ * 读某作用域处于预览态的 Markdown 路径集合。
9090
+ * @author ddj 2026年09月28号
9091
+ * @param scope 作用域键(scopeStore.workspaceScopeOf 产物)
9092
+ * @returns 预览态路径集合(存储不可用/无存档 → 空集)
9093
+ */
9094
+ function mdPreviewLoad(scope) {
9095
+ try {
9096
+ return new Set(parseMdPreview(localStorage.getItem(CACHE_KEY.mdPreview + scope)));
9097
+ } catch (error) {
9098
+ return /* @__PURE__ */ new Set();
9099
+ }
9100
+ }
9101
+ /**
9102
+ * 写某作用域的预览态集合(空集删除键,避免残留空数组)。
9103
+ * @author ddj 2026年09月28号
9104
+ * @param scope 作用域键
9105
+ * @param paths 预览态路径集合
9106
+ */
9107
+ function mdPreviewSave(scope, paths) {
9108
+ try {
9109
+ const list = normalizeMdPreview(Array.from(paths));
9110
+ if (!list.length) {
9111
+ localStorage.removeItem(CACHE_KEY.mdPreview + scope);
9112
+ return;
9113
+ }
9114
+ localStorage.setItem(CACHE_KEY.mdPreview + scope, JSON.stringify(list));
9115
+ } catch (error) {}
9116
+ }
9117
+ //#endregion
9118
+ //#region src/client/sessionScope.ts
9119
+ /** 对象窄化(非对象返回 undefined,供后续安全取字段)。 */
9120
+ function asRecord(value) {
9121
+ return value && typeof value === "object" ? value : void 0;
9122
+ }
9123
+ /** 非空字符串取值(其他类型一律视为缺失)。 */
9124
+ function strOf(value) {
9125
+ return typeof value === "string" && value ? value : void 0;
9126
+ }
9127
+ /**
9128
+ * 从 `uiSession.current` 快照读出 sessionId。
9129
+ * 兼容两种形态:绑定源本身(`{ key }`)与其包裹形态(`{ value: { key } }`)——
9130
+ * 官方 `createBindingSource` 的 `getSnapshot()` 返回绑定值本身,但跨版本包裹层存在差异,
9131
+ * 故两者都尝试。
9132
+ * @author ddj 2026年09月18号
9133
+ * @param snapshot `uiSession.current` 的 `getSnapshot()` 结果(任意形状)
9134
+ * @returns sessionId,或 undefined
9135
+ */
9136
+ function uiSessionIdOf(snapshot) {
9137
+ const binding = asRecord(snapshot);
9138
+ if (!binding) return void 0;
9139
+ return strOf(binding.key) ?? strOf(asRecord(binding.value)?.key);
9140
+ }
9141
+ /**
9142
+ * 从 `sessions.list` 快照读出 sessionId。
9143
+ * 先取旧版 `current`;缺失时回退 `byId` 中 `retainedBy.mainView > 0` 的首行
9144
+ * (与官方 ui-session 的 publishMain 同判据,故新版行为与官方视图一致)。
9145
+ * @author ddj 2026年09月18号
9146
+ * @param list `sessions.list` 的 `getSnapshot()` 结果(任意形状)
9147
+ * @returns sessionId,或 undefined
9148
+ */
9149
+ function listSessionId(list) {
9150
+ const snap = asRecord(list);
9151
+ if (!snap) return void 0;
9152
+ const current = strOf(snap.current);
9153
+ if (current) return current;
9154
+ const byId = asRecord(snap.byId);
9155
+ if (!byId) return void 0;
9156
+ for (const [id, row] of Object.entries(byId)) {
9157
+ const mainView = asRecord(asRecord(row)?.retainedBy)?.mainView;
9158
+ if (typeof mainView === "number" && mainView > 0) return id;
9159
+ }
9160
+ }
9161
+ /**
9162
+ * 三级取值链(纯函数):决定当前会话 id 与其 cwd。
9163
+ * 优先级 uiSession > list.current > list.byId.retainedBy > fallback。
9164
+ * @author ddj 2026年09月18号
9165
+ * @param sources 候选来源集合
9166
+ * @returns 会话作用域(字段缺失即省略该键,不写 undefined 占位)
9167
+ */
9168
+ function pickSessionScope(sources) {
9169
+ const sessionId = uiSessionIdOf(sources.uiSession) ?? listSessionId(sources.list) ?? strOf(sources.fallbackSessionId);
9170
+ if (!sessionId) return {};
9171
+ const cwd = strOf(asRecord(asRecord(asRecord(sources.list)?.byId)?.[sessionId])?.cwd);
9172
+ return cwd ? {
9173
+ sessionId,
9174
+ cwd
9175
+ } : { sessionId };
9176
+ }
9177
+ /** 读 `ctx.get(name)`(缺失/异常返回 undefined,不抛错)。 */
9178
+ function safeGet(ctx, name) {
9179
+ try {
9180
+ return ctx.get(name);
9181
+ } catch {
9182
+ return;
9183
+ }
9184
+ }
9185
+ /** 取 `uiSession.current` 快照(服务或订阅面缺失时返回 undefined)。 */
9186
+ function uiCurrentSnapshot(ctx) {
9187
+ const current = asRecord(asRecord(safeGet(ctx, "uiSession"))?.current);
9188
+ const getSnapshot = current?.getSnapshot;
9189
+ if (typeof getSnapshot !== "function") return void 0;
9190
+ try {
9191
+ return getSnapshot.call(current);
9192
+ } catch {
9193
+ return;
9194
+ }
9195
+ }
9196
+ /** 取 `sessions.list` 快照(服务缺失时返回 undefined)。 */
9197
+ function listSnapshot(ctx) {
9198
+ const list = asRecord(asRecord(safeGet(ctx, "sessions"))?.list);
9199
+ const getSnapshot = list?.getSnapshot;
9200
+ if (typeof getSnapshot !== "function") return void 0;
9201
+ try {
9202
+ return getSnapshot.call(list);
9203
+ } catch {
9204
+ return;
9205
+ }
9206
+ }
9207
+ /**
9208
+ * 读当前会话作用域(服务面取值 + 三级链)。
9209
+ * @author ddj 2026年09月18号
9210
+ * @param ctx 客户端根上下文(只需 `get`)
9211
+ * @param fallbackSessionId 槽位注入的 sessionId(可选)
9212
+ * @returns 会话作用域
9213
+ */
9214
+ function readSessionScope(ctx, fallbackSessionId) {
9215
+ return pickSessionScope({
9216
+ uiSession: uiCurrentSnapshot(ctx),
9217
+ list: listSnapshot(ctx),
9218
+ fallbackSessionId
9219
+ });
9220
+ }
9221
+ /**
9222
+ * 订阅会话切换(同时挂 `uiSession.current` 与新/旧 `sessions.list`)。
9223
+ * 旧版无 `uiSession`,新版 `list` 不再变(切会话只动 uiSession),故两条都要挂;
9224
+ * 回调可能因两条源先后触发而重复,调用方按上一次 sessionId 自行去重。
9225
+ * @author ddj 2026年09月18号
9226
+ * @param ctx 客户端根上下文(只需 `get`)
9227
+ * @param listener 变更回调
9228
+ * @returns 取消订阅函数(幂等)
9229
+ */
9230
+ function subscribeScope(ctx, listener) {
9231
+ const disposers = [];
9232
+ const attach = (owner) => {
9233
+ const subscribe = asRecord(owner)?.subscribe;
9234
+ if (typeof subscribe !== "function") return;
9235
+ try {
9236
+ const disposer = subscribe.call(owner, listener);
9237
+ if (typeof disposer === "function") disposers.push(disposer);
9238
+ } catch {}
9239
+ };
9240
+ attach(asRecord(asRecord(safeGet(ctx, "uiSession"))?.current));
9241
+ attach(asRecord(asRecord(safeGet(ctx, "sessions"))?.list));
9242
+ return () => {
9243
+ for (const disposer of disposers.splice(0)) try {
9244
+ disposer();
9245
+ } catch {}
9246
+ };
9247
+ }
9248
+ //#endregion
9249
+ //#region src/client/state/uiStatePersist.ts
9250
+ /**
9251
+ * dsh-vscode-mode client — 界面状态镜像(把 `edrv.*` 的 localStorage 状态写穿到 host)。
9252
+ *
9253
+ * 背景:Web GUI 的 origin(含端口)每次启动可能变化,而 localStorage 按 origin 分区,
9254
+ * 于是页签/侧栏/预览态等状态「重启就忘」。本模块包装 localStorage 写口:
9255
+ * 只拦 `edrv.` 前缀键(跳过镜像自身标记键与派生条目缓存,见 `SKIP_PREFIX`),400ms 去抖后
9256
+ * 增量上送 `edrv.uiState.set`:按体积分批,整批被拒则逐键回退,避免个别超大键拖垮同批。
9257
+ * 启动(含换 origin)后 `edrv.uiState.get` 水合,仅填本地缺失的键(本地优先,绝不覆盖);
9258
+ * 首次搬运标记只在整批落盘成功后写入(失败则下次加载重试)。
9259
+ * 全局键(不随工作区变化)走 globals 分区,其余按键原值搬进工作区分区。
9260
+ *
9261
+ * 降级:存储不可用/包装失败/host 读写失败/超限一律静默退回纯 localStorage(诊断日志留痕)。
9262
+ * 纯逻辑与依赖注入分离,`createUiStateMirror` 可在 node 环境单测。
9263
+ * 作者 ddj 2026-09-28
9264
+ */
9265
+ /** 参与镜像的键前缀(只拦插件自己的键,不碰其他插件的 localStorage)。 */
9266
+ const MIRROR_PREFIX = "edrv.";
9267
+ /** 首次回填标记键(把既有 `edrv.*` 状态一次性推给 host;自身不参与镜像)。 */
9268
+ const SEEDED_KEY = "edrv.uistate.v1.seeded";
9269
+ /** 水合完成事件名(换 origin 回填后广播,供界面重跑恢复逻辑)。 */
9270
+ const READY_EVENT = "edrv:ui-state-ready";
9271
+ /** 全局键(不随工作区变化,落用户级镜像文件)。 */
9272
+ const GLOBAL_EXACT = ["edrv.debug", CACHE_KEY.sideHint];
9273
+ /** 全局键前缀(不随工作区变化)。 */
9274
+ const GLOBAL_PREFIX = [CACHE_KEY.workspaceFold];
9275
+ /** 单次上送的字节上限(host 单文件上限 512KB;分批避免个别超大键拖垮整批)。 */
9276
+ const CHUNK_BYTES = 65536;
9277
+ /** 不参与镜像的派生缓存前缀(目录条目 SWR 缓存可达数百 KB,且 host 侧 tree.v1.json 已覆盖同类信息)。 */
9278
+ const SKIP_PREFIX = [CACHE_KEY.entries];
9279
+ /**
9280
+ * 是否参与镜像(`edrv.` 前缀且非标记键)。
9281
+ * @author ddj 2026年09月28号
9282
+ * @param key localStorage 键
9283
+ * @returns 是否镜像
9284
+ */
9285
+ function isMirroredKey(key) {
9286
+ if (!key.startsWith(MIRROR_PREFIX) || key === "edrv.uistate.v1.seeded") return false;
9287
+ return !SKIP_PREFIX.some((prefix) => key.startsWith(prefix));
9288
+ }
9289
+ /**
9290
+ * 是否全局键(不随工作区变化)。
9291
+ * @author ddj 2026年09月28号
9292
+ * @param key localStorage 键
9293
+ * @returns 是否全局键
9294
+ */
9295
+ function isGlobalKey(key) {
9296
+ if (GLOBAL_EXACT.includes(key)) return true;
9297
+ return GLOBAL_PREFIX.some((prefix) => key.startsWith(prefix));
9298
+ }
9299
+ /**
9300
+ * 收集本地全部镜像键(存储不可读 → 空数组)。
9301
+ * @author ddj 2026年09月28号
9302
+ * @param storage localStorage 替身
9303
+ * @returns 镜像键清单
9304
+ */
9305
+ function localMirrorKeys(storage) {
9306
+ const keys = [];
9307
+ try {
9308
+ for (let index = 0; index < storage.length; index++) {
9309
+ const key = storage.key(index);
9310
+ if (key && isMirroredKey(key)) keys.push(key);
9311
+ }
9312
+ } catch (error) {}
9313
+ return keys;
9314
+ }
9315
+ /**
9316
+ * 按键值体积切分补丁(单键超限则独占一批,仍由 host 容量上限裁决)。
9317
+ * @author ddj 2026年09月29号
9318
+ * @param patch 待上送补丁
9319
+ * @param limit 单批体积上限(字节;按字符数近似)
9320
+ * @returns 批次数组(空补丁 → 空数组)
9321
+ */
9322
+ function chunkPatch(patch, limit = CHUNK_BYTES) {
9323
+ const chunks = [];
9324
+ let current = {};
9325
+ let size = 0;
9326
+ for (const [key, value] of Object.entries(patch)) {
9327
+ const cost = key.length + (value === null ? 0 : value.length);
9328
+ if (size > 0 && size + cost > limit) {
9329
+ chunks.push(current);
9330
+ current = {};
9331
+ size = 0;
9332
+ }
9333
+ current[key] = value;
9334
+ size += cost;
9335
+ }
9336
+ if (size > 0) chunks.push(current);
9337
+ return chunks;
9338
+ }
9339
+ /**
9340
+ * 创建界面状态镜像实例。
9341
+ * @author ddj 2026年09月28号
9342
+ * @param deps 依赖(存储/上送/取回/调度)
9343
+ * @returns 镜像实例
9344
+ */
9345
+ function createUiStateMirror(deps) {
9346
+ const { storage } = deps;
9347
+ const schedule = deps.schedule ?? ((fn, ms) => setTimeout(fn, ms));
9348
+ const cancel = deps.cancel ?? ((handle) => clearTimeout(handle));
9349
+ const log = deps.log ?? (() => {});
9350
+ const report = deps.report ?? ((text, level) => log(text));
9351
+ const pending = {
9352
+ keys: {},
9353
+ globals: {}
9354
+ };
9355
+ let timer = null;
9356
+ let inflight = null;
9357
+ let suppressed = 0;
9358
+ let done = false;
9359
+ let installed = false;
9360
+ let lastError = "";
9361
+ /** 水合尝试次数(失败重试;超过 `HYDRATE_RETRY_MAX` 放弃并解禁)。 */
9362
+ let attempts = 0;
9363
+ let settleDone = false;
9364
+ /** 记入待推送批次并安排去抖上送(水合期间或非镜像键 → 跳过)。 */
9365
+ function queue(key, value) {
9366
+ if (suppressed > 0 || !isMirroredKey(key)) return;
9367
+ const bucket = isGlobalKey(key) ? pending.globals : pending.keys;
9368
+ bucket[key] = value;
9369
+ if (timer !== null) cancel(timer);
9370
+ timer = schedule(() => {
9371
+ timer = null;
9372
+ flush();
9373
+ }, 400);
9374
+ }
9375
+ /** 上送一批补丁(失败重试一次)。 */
9376
+ async function pushBatch(payload) {
9377
+ for (let attempt = 0; attempt < 2; attempt++) try {
9378
+ await deps.push(payload, deps.sessionIdOf?.());
9379
+ return true;
9380
+ } catch (error) {
9381
+ lastError = String(error);
9382
+ }
9383
+ return false;
9384
+ }
9385
+ /** 逐键回退上送(整批被拒后用,避免个别大键拖垮同批其它键),返回丢弃键数。 */
9386
+ async function pushEach(bucket, asGlobal) {
9387
+ let lost = 0;
9388
+ for (const [key, value] of Object.entries(bucket)) {
9389
+ if (await pushBatch(asGlobal ? { globals: { [key]: value } } : { keys: { [key]: value } })) continue;
9390
+ lost++;
9391
+ log("界面状态镜像上送失败(已丢弃 " + key + "): " + lastError);
9392
+ }
9393
+ return lost;
9394
+ }
9395
+ /** 分区上送:按体积分批,整批被拒则逐键回退,返回最终丢弃的键数。 */
9396
+ async function pushBucket(bucket, asGlobal) {
9397
+ let lost = 0;
9398
+ for (const chunk of chunkPatch(bucket, deps.chunkBytes)) if (!await pushBatch(asGlobal ? { globals: chunk } : { keys: chunk })) lost += await pushEach(chunk, asGlobal);
9399
+ return lost;
9400
+ }
9401
+ /** 立即上送待推送批次(空批次不请求;与在途上送串行),返回最终丢弃的键数。 */
9402
+ async function flush() {
9403
+ if (inflight) await inflight.catch(() => {});
9404
+ if (timer !== null) {
9405
+ cancel(timer);
9406
+ timer = null;
9407
+ }
9408
+ const keys = pending.keys;
9409
+ const globals = pending.globals;
9410
+ const hasKeys = Object.keys(keys).length > 0;
9411
+ const hasGlobals = Object.keys(globals).length > 0;
9412
+ if (!hasKeys && !hasGlobals) return 0;
9413
+ pending.keys = {};
9414
+ pending.globals = {};
9415
+ const run = (async () => {
9416
+ let lost = 0;
9417
+ if (hasKeys) lost += await pushBucket(keys, false);
9418
+ if (hasGlobals) lost += await pushBucket(globals, true);
9419
+ return lost;
9420
+ })();
9421
+ inflight = run.then(() => {}, () => {});
9422
+ return run;
9423
+ }
9424
+ /** 回填本地缺失键(本地已有值一律保留)。 */
9425
+ function applyHydration(snapshot) {
9426
+ let filled = 0;
9427
+ suppressed++;
9428
+ try {
9429
+ const all = {
9430
+ ...snapshot.globals,
9431
+ ...snapshot.keys
9432
+ };
9433
+ for (const [key, value] of Object.entries(all)) {
9434
+ if (!isMirroredKey(key) || typeof value !== "string") continue;
9435
+ if (storage.getItem(key) !== null) continue;
9436
+ storage.setItem(key, value);
9437
+ filled++;
9438
+ report("回填键 " + key + "(" + value.length + " 字符)");
9439
+ }
9440
+ } catch (error) {
9441
+ log("界面状态回填写入失败: " + String(error));
9442
+ } finally {
9443
+ suppressed--;
9444
+ }
9445
+ return filled;
9446
+ }
9447
+ /** 首次运行时把既有本地状态一次性推给 host(只入队;成功后才落标记,避免失败后永不重试)。 */
9448
+ function seedLocal() {
9449
+ try {
9450
+ if (storage.getItem("edrv.uistate.v1.seeded") === "1") return 0;
9451
+ const keys = localMirrorKeys(storage);
9452
+ for (const key of keys) queue(key, storage.getItem(key));
9453
+ return keys.length;
9454
+ } catch (error) {
9455
+ log("界面状态首次搬运失败: " + String(error));
9456
+ return 0;
9457
+ }
9458
+ }
9459
+ /** 落首次搬运标记(仅在本批全部落盘成功后调用;写失败下次加载重搬,幂等)。 */
9460
+ function markSeeded() {
9461
+ try {
9462
+ storage.setItem(SEEDED_KEY, "1");
9463
+ } catch (error) {}
9464
+ }
9465
+ /** 广播水合完成事件(无 window 环境静默)。 */
9466
+ function emitReady() {
9467
+ try {
9468
+ if (typeof window !== "undefined") window.dispatchEvent(new Event(READY_EVENT));
9469
+ } catch (error) {}
9470
+ }
9471
+ /**
9472
+ * 结束水合阶段(成功或重试耗尽):解禁界面落盘,并广播就绪事件让恢复逻辑补跑一次。
9473
+ * 冷启动早期会话未就绪时 `edrv.uiState.get` 会返回「会话不存在」,故本阶段必须可重试;
9474
+ * 未结束前界面不得写存档,否则空初值会覆盖 host 上的真实状态。
9475
+ */
9476
+ function settle() {
9477
+ if (settleDone) return;
9478
+ settleDone = true;
9479
+ emitReady();
9480
+ report("水合阶段结束(就绪;" + (done ? "已回填" : "未回填,退回纯 localStorage") + ")");
9481
+ }
9482
+ /**
9483
+ * 取回 host 镜像并回填本地缺失键;失败按 `HYDRATE_RETRY_MS` 重试,超限则结束水合阶段。
9484
+ * @returns 本次回填键数(失败/重试中为 0)
9485
+ */
9486
+ async function hydrate() {
9487
+ try {
9488
+ report("水合开始(origin=" + originOf() + ",第 " + (attempts + 1) + " 次)");
9489
+ const snapshot = await deps.fetch(deps.sessionIdOf?.());
9490
+ const seeded = seedLocal();
9491
+ const filled = applyHydration(snapshot);
9492
+ const stored = Object.keys({
9493
+ ...snapshot.globals,
9494
+ ...snapshot.keys
9495
+ }).length;
9496
+ done = true;
9497
+ report("水合完成:host 镜像 " + stored + " 键、本地搬运 " + seeded + " 键、回填 " + filled + " 键");
9498
+ if (seeded > 0) {
9499
+ const lost = await flush();
9500
+ report("首次搬运输送完成:丢弃 " + lost + " 键");
9501
+ if (lost === 0) markSeeded();
9502
+ }
9503
+ settle();
9504
+ return filled;
9505
+ } catch (error) {
9506
+ attempts++;
9507
+ if (attempts < 8) {
9508
+ report("水合失败(第 " + attempts + " 次,800ms 后重试): " + String(error), "warn");
9509
+ schedule(() => {
9510
+ hydrate();
9511
+ }, 800);
9512
+ return 0;
9513
+ }
9514
+ report("水合放弃(重试 " + attempts + " 次仍失败): " + String(error), "warn");
9515
+ settle();
9516
+ return 0;
9517
+ }
9518
+ }
9519
+ return {
9520
+ install() {
9521
+ if (installed) return true;
9522
+ try {
9523
+ const proto = Object.getPrototypeOf(storage);
9524
+ const rawSet = proto?.setItem;
9525
+ const rawRemove = proto?.removeItem;
9526
+ if (typeof rawSet !== "function" || typeof rawRemove !== "function") return false;
9527
+ storage.setItem = function(key, value) {
9528
+ rawSet.call(storage, key, value);
9529
+ queue(String(key), String(value));
9530
+ };
9531
+ storage.removeItem = function(key) {
9532
+ rawRemove.call(storage, key);
9533
+ queue(String(key), null);
9534
+ };
9535
+ installed = true;
9536
+ report("镜像已包装 localStorage 写口");
9537
+ return true;
9538
+ } catch (error) {
9539
+ report("界面状态镜像包装失败(退回纯 localStorage): " + String(error), "warn");
9540
+ return false;
9541
+ }
9542
+ },
9543
+ hydrate,
9544
+ flush,
9545
+ hydrated() {
9546
+ return done;
9547
+ },
9548
+ settled() {
9549
+ return settleDone;
9550
+ }
9551
+ };
9552
+ }
9553
+ /** 单例镜像(installUiStatePersist 幂等:HMR 重挂不重复包装)。 */
9554
+ let singleton = null;
9555
+ /** 是否已回填过(晚到的订阅者据此立即执行)。 */
9556
+ let uiReady = false;
9557
+ /** 无门控上报出口(由 installUiStatePersist 注入;未装配时静默)。 */
9558
+ let reporter = null;
9559
+ /** 在途水合(供 apply 等待,尽量让回填先于界面挂载)。 */
9560
+ let hydrating = null;
9561
+ /** 当前页面 origin(无 location 环境返回 '?')。 */
9562
+ function originOf() {
9563
+ try {
9564
+ if (typeof location !== "undefined" && location.origin) return location.origin;
9565
+ } catch (error) {}
9566
+ return "?";
9567
+ }
9568
+ /**
9569
+ * 上报界面状态镜像事件(无门控:绕过客户端 debug 开关,直接落 host 诊断日志)。
9570
+ * @author ddj 2026年09月29号
9571
+ * @param text 事件文本
9572
+ * @param level 日志级别(缺省 'debug')
9573
+ */
9574
+ function reportUiStateEvent(text, level) {
9575
+ try {
9576
+ reporter?.(text, level);
9577
+ } catch (error) {}
9578
+ }
9579
+ /**
9580
+ * 触发(或加入)一次水合并等待结果:让界面挂载前尽量拿到回填值;水合阶段已结束则返回 0。
9581
+ * @author ddj 2026年09月29号
9582
+ * @returns 本次回填键数
9583
+ */
9584
+ async function hydrateUiState() {
9585
+ if (!singleton || singleton.settled()) return 0;
9586
+ if (hydrating) return hydrating;
9587
+ return singleton.hydrate();
9588
+ }
9589
+ /**
9590
+ * 水合阶段是否已结束(未结束时界面不得写存档:空初值会覆盖 host 上的真实状态)。
9591
+ * @author ddj 2026年09月29号
9592
+ * @returns 是否已结束(未装配视为已结束,退回纯 localStorage 行为)
9593
+ */
9594
+ function uiStateSettled() {
9595
+ return singleton ? singleton.settled() : true;
9596
+ }
9597
+ /** 读浏览器 localStorage(不可用 → null)。 */
9598
+ function readLocalStorage() {
9599
+ try {
9600
+ if (typeof localStorage === "undefined") return null;
9601
+ return localStorage;
9602
+ } catch (error) {
9603
+ return null;
9604
+ }
9605
+ }
9606
+ /** 上送镜像(host 返回 ok:false 视为失败,触发内部重试)。 */
9607
+ async function pushViaRpc(payload, sessionId) {
9608
+ const res = await rpc("edrv.uiState.set", {
9609
+ sessionId,
9610
+ keys: payload.keys,
9611
+ globals: payload.globals
9612
+ });
9613
+ if (!res.ok) throw new Error(res.error);
9614
+ }
9615
+ /** 取回镜像。 */
9616
+ async function fetchViaRpc(sessionId) {
9617
+ const res = await rpc("edrv.uiState.get", { sessionId });
9618
+ if (!res.ok) throw new Error(res.error);
9619
+ return {
9620
+ keys: res.keys,
9621
+ globals: res.globals
9622
+ };
9623
+ }
9624
+ /**
9625
+ * 安装界面状态镜像(幂等):包装 localStorage 写口并触发一次回填。
9626
+ * @author ddj 2026年09月28号
9627
+ * @param ctx 客户端根上下文(只需 `get`,用于解析当前会话)
9628
+ * @param schedule 去抖调度(客户端 apply 提供的 ctx.timeout 包装)
9629
+ * @param report 无门控上报(缺省静默;传入后所有镜像事件绕过 debug 开关落 host 日志)
9630
+ * @returns 是否安装成功(存储不可用 → false,功能退回纯 localStorage)
9631
+ */
9632
+ function installUiStatePersist(ctx, schedule, report) {
9633
+ if (singleton) return true;
9634
+ if (report) reporter = report;
9635
+ const storage = readLocalStorage();
9636
+ if (!storage) {
9637
+ reportUiStateEvent("镜像安装失败:localStorage 不可用", "warn");
9638
+ return false;
9639
+ }
9640
+ const sessionIdOf = () => readSessionScope(ctx).sessionId;
9641
+ const mirror = createUiStateMirror({
9642
+ storage,
9643
+ push: pushViaRpc,
9644
+ fetch: fetchViaRpc,
9645
+ sessionIdOf,
9646
+ schedule,
9647
+ report: (text, level) => reportUiStateEvent(text, level),
9648
+ log: (text) => {
9649
+ try {
9650
+ dbg(sessionIdOf(), "ui-state: " + text, "warn");
9651
+ } catch (error) {}
9652
+ }
9653
+ });
9654
+ if (!mirror.install()) {
9655
+ reportUiStateEvent("镜像安装失败:localStorage 写口无法包装", "warn");
9656
+ return false;
9657
+ }
9658
+ singleton = mirror;
9659
+ try {
9660
+ if (typeof window !== "undefined") window.addEventListener("pagehide", () => {
9661
+ mirror.flush();
9662
+ });
9663
+ } catch (error) {}
9664
+ hydrating = mirror.hydrate().then((filled) => {
9665
+ if (filled > 0 || mirror.settled()) uiReady = true;
9666
+ return filled;
9667
+ });
9668
+ hydrating.finally(() => {
9669
+ if (mirror.settled()) {
9670
+ uiReady = true;
9671
+ hydrating = null;
9672
+ }
9673
+ });
9674
+ return true;
9675
+ }
9676
+ /**
9677
+ * 订阅「界面状态回填完成」(已回填过则立即执行一次)。
9678
+ * @author ddj 2026年09月28号
9679
+ * @param listener 回调
9680
+ * @returns 取消订阅函数(幂等)
9681
+ */
9682
+ function onUiStateReady(listener) {
9683
+ if (typeof window === "undefined") {
9684
+ if (uiReady) listener();
9685
+ return () => {};
9686
+ }
9687
+ if (uiReady) {
9688
+ listener();
9689
+ return () => {};
9690
+ }
9691
+ const handler = () => {
9692
+ listener();
9693
+ };
9694
+ window.addEventListener(READY_EVENT, handler);
9695
+ return () => window.removeEventListener(READY_EVENT, handler);
9696
+ }
9697
+ /**
9698
+ * 立即上送待推送批次(卸载/隐藏前调用,尽量少丢最后一次改动)。
9699
+ * @author ddj 2026年09月28号
9700
+ * @returns 上送完成
9701
+ */
9702
+ async function flushUiState() {
9703
+ if (singleton) await singleton.flush();
9704
+ }
9051
9705
  //#endregion
9052
9706
  //#region src/client/state/scopeStore.ts
9053
9707
  /**
@@ -9273,6 +9927,27 @@ window.__ModuleLoader__.load({
9273
9927
  function getSidebarMinWidth() {
9274
9928
  return currentMin;
9275
9929
  }
9930
+ /**
9931
+ * 归一化条目数组:丢弃无路径项;line/column 仅保留正整数,剥掉 viewState(体积大且另有存档);
9932
+ * 超限时保留末尾(最近的)cap 条。
9933
+ * @author ddj 2026年09月28号
9934
+ * @param entries 待归一化的原始数组(非数组 → 空表)
9935
+ * @param cap 单栈上限(默认 NAV_PERSIST_CAP)
9936
+ * @returns 归一化条目数组
9937
+ */
9938
+ function cleanNavEntries(entries, cap = 30) {
9939
+ if (!Array.isArray(entries)) return [];
9940
+ const out = [];
9941
+ for (const raw of entries) {
9942
+ const entry = raw;
9943
+ if (!entry || typeof entry.path !== "string" || !entry.path) continue;
9944
+ const item = { path: entry.path };
9945
+ if (typeof entry.line === "number" && Number.isInteger(entry.line) && entry.line > 0) item.line = entry.line;
9946
+ if (typeof entry.column === "number" && Number.isInteger(entry.column) && entry.column > 0) item.column = entry.column;
9947
+ out.push(item);
9948
+ }
9949
+ return out.length > cap ? out.slice(out.length - cap) : out;
9950
+ }
9276
9951
  /** scope → 导航历史实例(同工作区跨会话复用同一份历史)。 */
9277
9952
  const scopeHistories = /* @__PURE__ */ new Map();
9278
9953
  /** 路径一致(容忍相对路径大小写差异,对齐既有 sameFile 语义常用写法)。 */
@@ -9335,6 +10010,19 @@ window.__ModuleLoader__.load({
9335
10010
  },
9336
10011
  peekForward() {
9337
10012
  return future.length ? future[future.length - 1] : null;
10013
+ },
10014
+ snapshot() {
10015
+ return {
10016
+ past: past.map((entry) => ({ ...entry })),
10017
+ future: future.map((entry) => ({ ...entry }))
10018
+ };
10019
+ },
10020
+ restore(state) {
10021
+ if (!state) return;
10022
+ past.length = 0;
10023
+ future.length = 0;
10024
+ past.push(...cleanNavEntries(state.past, cap));
10025
+ future.push(...cleanNavEntries(state.future, cap));
9338
10026
  }
9339
10027
  };
9340
10028
  }
@@ -9358,6 +10046,79 @@ window.__ModuleLoader__.load({
9358
10046
  }
9359
10047
  return created;
9360
10048
  }
10049
+ /**
10050
+ * 序列化双栈(空栈 → 空串,调用方据此删键)。
10051
+ * @author ddj 2026年09月28号
10052
+ * @param state 双栈快照(null/undefined → 空串)
10053
+ * @returns 存档文本
10054
+ */
10055
+ function serializeNavState(state) {
10056
+ if (!state) return "";
10057
+ const past = cleanNavEntries(state.past, 30);
10058
+ const future = cleanNavEntries(state.future, 30);
10059
+ if (!past.length && !future.length) return "";
10060
+ return JSON.stringify({
10061
+ v: 1,
10062
+ past,
10063
+ future
10064
+ });
10065
+ }
10066
+ /**
10067
+ * 解析存档文本(空/损坏/版本不符/无可恢复条目 → null)。
10068
+ * @author ddj 2026年09月28号
10069
+ * @param text localStorage 原文
10070
+ * @returns 双栈快照
10071
+ */
10072
+ function parseNavState(text) {
10073
+ if (!text) return null;
10074
+ try {
10075
+ const value = JSON.parse(text);
10076
+ if (!value || typeof value !== "object" || value.v !== 1) return null;
10077
+ const past = cleanNavEntries(value.past, 30);
10078
+ const future = cleanNavEntries(value.future, 30);
10079
+ if (!past.length && !future.length) return null;
10080
+ return {
10081
+ past,
10082
+ future
10083
+ };
10084
+ } catch (error) {
10085
+ return null;
10086
+ }
10087
+ }
10088
+ /** scope → 最近写入的原文(去重写:光标移动触发的快照大多与上次相同)。 */
10089
+ const lastWritten = /* @__PURE__ */ new Map();
10090
+ /**
10091
+ * 读某作用域的导航历史存档。
10092
+ * @author ddj 2026年09月28号
10093
+ * @param scope 作用域键(scopeStore.workspaceScopeOf 产物)
10094
+ * @returns 双栈快照(无存档/存储不可用 → null)
10095
+ */
10096
+ function navStateLoad(scope) {
10097
+ try {
10098
+ return parseNavState(localStorage.getItem(CACHE_KEY.navHistory + scope));
10099
+ } catch (error) {
10100
+ return null;
10101
+ }
10102
+ }
10103
+ /**
10104
+ * 写某作用域的导航历史存档(空存档删键;原文未变跳过写入)。
10105
+ * @author ddj 2026年09月28号
10106
+ * @param scope 作用域键
10107
+ * @param state 双栈快照(null → 删除存档)
10108
+ */
10109
+ function navStateSave(scope, state) {
10110
+ try {
10111
+ const text = serializeNavState(state);
10112
+ if (lastWritten.get(scope) === text) return;
10113
+ lastWritten.set(scope, text);
10114
+ const key = CACHE_KEY.navHistory + scope;
10115
+ if (!text) {
10116
+ localStorage.removeItem(key);
10117
+ return;
10118
+ }
10119
+ localStorage.setItem(key, text);
10120
+ } catch (error) {}
10121
+ }
9361
10122
  //#endregion
9362
10123
  //#region src/client/addToConversation.ts
9363
10124
  /**
@@ -14806,6 +15567,57 @@ window.__ModuleLoader__.load({
14806
15567
  let editorTabActive = false;
14807
15568
  /** 正文挂载纪元(每次挂载自增;延迟判定期间有新挂载则放弃清除)。 */
14808
15569
  let mountEpoch = 0;
15570
+ /** 作用域解析器(index.ts 装配时注入;未注入则不落盘,退回纯内存标记)。 */
15571
+ let tabScopeOf = null;
15572
+ /**
15573
+ * 注入编辑 Tab 状态的作用域解析器(index.ts 装配一次;键跟随会话/工作区切换)。
15574
+ * @author ddj 2026年09月28号
15575
+ * @param resolve 返回工作区作用域键(scopeStore.workspaceScopeOf 产物)
15576
+ */
15577
+ function setEditorTabScope(resolve) {
15578
+ tabScopeOf = resolve;
15579
+ }
15580
+ /**
15581
+ * 编辑 Tab 状态的作用域键(未注入/解析失败 → 空串,调用方跳过持久化)。
15582
+ * @author ddj 2026年09月28号
15583
+ * @returns localStorage 键
15584
+ */
15585
+ function editorTabKey() {
15586
+ if (!tabScopeOf) return "";
15587
+ try {
15588
+ const scope = tabScopeOf();
15589
+ return scope ? CACHE_KEY.editorTab + scope : "";
15590
+ } catch (error) {
15591
+ return "";
15592
+ }
15593
+ }
15594
+ /**
15595
+ * 读回「上次退出时编辑 Tab 是否处于激活态」(供冷启动自动展开编辑 Tab)。
15596
+ * @author ddj 2026年09月28号
15597
+ * @returns 是否曾被激活(存储不可用/未注入 → false)
15598
+ */
15599
+ function readSavedTab() {
15600
+ const key = editorTabKey();
15601
+ if (!key) return false;
15602
+ try {
15603
+ return localStorage.getItem(key) === "1";
15604
+ } catch (error) {
15605
+ return false;
15606
+ }
15607
+ }
15608
+ /**
15609
+ * 持久化编辑 Tab 激活态(存储不可用静默;镜像层会把它写穿到 host 跨重启保留)。
15610
+ * @author ddj 2026年09月28号
15611
+ * @param active 是否激活
15612
+ */
15613
+ function saveTabActive(active) {
15614
+ const key = editorTabKey();
15615
+ if (!key) return;
15616
+ try {
15617
+ if (active) localStorage.setItem(key, "1");
15618
+ else localStorage.removeItem(key);
15619
+ } catch (error) {}
15620
+ }
14809
15621
  /**
14810
15622
  * 标记编辑 Tab 激活并推进挂载纪元(正文挂载时调用)。
14811
15623
  * @author ddj 2026年09月09号
@@ -14816,11 +15628,13 @@ window.__ModuleLoader__.load({
14816
15628
  }
14817
15629
  /**
14818
15630
  * 直接设置激活标记(正文卸载延迟判定用;测试 also 用作状态编排入口)。
14819
- * @author ddj 2026年09月09号
15631
+ * 同时写穿到 localStorage(镜像层负责跨重启保留),供下次冷启动恢复。
15632
+ * @author ddj 2026年09月09号 / 2026年09月28号
14820
15633
  * @param active 是否激活
14821
15634
  */
14822
15635
  function markEditorActive(active) {
14823
15636
  editorTabActive = active;
15637
+ saveTabActive(active);
14824
15638
  }
14825
15639
  /**
14826
15640
  * 读编辑 Tab 激活标记(会话切换时决定是否自动恢复编辑栏)。
@@ -19019,6 +19833,7 @@ window.__ModuleLoader__.load({
19019
19833
  const loadSeqRef = react.default.useRef(0);
19020
19834
  const programmaticRef = react.default.useRef(false);
19021
19835
  const restoredScopeRef = react.default.useRef(null);
19836
+ const [hydrateTick, setHydrateTick] = react.default.useState(0);
19022
19837
  const pendingFocusRef = react.default.useRef(null);
19023
19838
  const navFlashRef = react.default.useRef([]);
19024
19839
  const navFlashTimerRef = react.default.useRef(null);
@@ -20043,6 +20858,7 @@ window.__ModuleLoader__.load({
20043
20858
  restoredScopeRef.current = scope;
20044
20859
  modelsRef.current = modelsForScope(scope);
20045
20860
  viewStatesRef.current = viewStatesLoad(scope);
20861
+ mdPreviewRef.current = mdPreviewLoad(String(scope));
20046
20862
  try {
20047
20863
  const raw = localStorage.getItem(CACHE_KEY.editor + String(scope)) ?? localStorage.getItem(CACHE_KEY.editorLegacy + String(scope));
20048
20864
  if (raw) {
@@ -20058,21 +20874,51 @@ window.__ModuleLoader__.load({
20058
20874
  const kept = plan.length ? restored.filter((tab) => !dropped.has(tab.path)) : restored;
20059
20875
  setTabs(kept);
20060
20876
  setActive(kept.some((tab) => tab.path === activePath) ? activePath : kept[0]?.path ?? null);
20061
- }
20062
- }
20063
- } catch (e) {}
20064
- }, [sessionId, scope]);
20877
+ reportUiStateEvent("页签恢复(scope=" + String(scope) + "):存档 " + restored.length + " 个、保留 " + kept.length + " 个、活动 " + String(activePath));
20878
+ } else reportUiStateEvent("页签恢复(scope=" + String(scope) + "):存档非空但归一化后为 0 个");
20879
+ } else reportUiStateEvent("页签恢复(scope=" + String(scope) + "):localStorage 无存档");
20880
+ } catch (e) {
20881
+ reportUiStateEvent("页签恢复失败(scope=" + String(scope) + "): " + String(e), "warn");
20882
+ }
20883
+ }, [
20884
+ sessionId,
20885
+ scope,
20886
+ hydrateTick
20887
+ ]);
20888
+ react.default.useEffect(() => onUiStateReady(() => {
20889
+ if (tabsRef.current.length) return;
20890
+ restoredScopeRef.current = null;
20891
+ setHydrateTick((v) => v + 1);
20892
+ }), []);
20065
20893
  react.default.useEffect(() => {
20066
- navRef.current = navHistoryFor(scope);
20894
+ const history = navHistoryFor(scope);
20895
+ const current = history.snapshot();
20896
+ if (!current.past.length && !current.future.length) {
20897
+ const saved = navStateLoad(String(scope));
20898
+ if (saved) history.restore(saved);
20899
+ reportUiStateEvent("导航历史恢复(scope=" + String(scope) + "):" + (saved ? "past=" + saved.past.length + " future=" + saved.future.length : "localStorage 无存档"));
20900
+ }
20901
+ navRef.current = history;
20067
20902
  navPendingRef.current = null;
20068
20903
  if (navCursorTimerRef.current) {
20069
20904
  clearTimeout(navCursorTimerRef.current);
20070
20905
  navCursorTimerRef.current = null;
20071
20906
  }
20072
20907
  setNavTick((v) => v + 1);
20073
- }, [scope]);
20908
+ }, [scope, hydrateTick]);
20074
20909
  react.default.useEffect(() => {
20075
- if (!sessionId) return;
20910
+ if (!sessionId || !uiStateSettled()) return;
20911
+ navStateSave(String(scope), navRef.current?.snapshot?.() ?? null);
20912
+ }, [
20913
+ navTick,
20914
+ cursor,
20915
+ active,
20916
+ sessionId,
20917
+ scope,
20918
+ hydrateTick
20919
+ ]);
20920
+ react.default.useEffect(() => {
20921
+ if (!sessionId || !uiStateSettled()) return;
20076
20922
  try {
20077
20923
  const payload = {
20078
20924
  tabs: tabs.map((t) => t.pinned ? {
@@ -20087,7 +20933,8 @@ window.__ModuleLoader__.load({
20087
20933
  tabs,
20088
20934
  active,
20089
20935
  sessionId,
20090
- scope
20936
+ scope,
20937
+ hydrateTick
20091
20938
  ]);
20092
20939
  /**
20093
20940
  * 标记页签为「最近使用」(LRU 序号单调递增;编辑动作也算使用,见 onEdit)。
@@ -20133,9 +20980,16 @@ window.__ModuleLoader__.load({
20133
20980
  if (typeof saved.on === "boolean") setSidebarOn(saved.on);
20134
20981
  if (typeof saved.width === "number") setSidebarW(Math.max(getSidebarMinWidth(), Math.min(560, saved.width)));
20135
20982
  if (typeof saved.panel === "string") setActivePanel(saved.panel);
20136
- }
20137
- } catch (e) {}
20138
- }, [sessionId, sidebarKey]);
20983
+ reportUiStateEvent("侧栏恢复(键 " + sidebarKey + "):on=" + String(saved.on) + " width=" + String(saved.width) + " panel=" + String(saved.panel));
20984
+ } else reportUiStateEvent("侧栏恢复(键 " + sidebarKey + "):localStorage 无存档");
20985
+ } catch (e) {
20986
+ reportUiStateEvent("侧栏恢复失败(键 " + sidebarKey + "): " + String(e), "warn");
20987
+ }
20988
+ }, [
20989
+ sessionId,
20990
+ sidebarKey,
20991
+ hydrateTick
20992
+ ]);
20139
20993
  const [sidebarMinW, setSidebarMinW] = react.default.useState(() => getSidebarMinWidth());
20140
20994
  react.default.useEffect(() => {
20141
20995
  const onMinChange = () => {
@@ -20146,7 +21000,7 @@ window.__ModuleLoader__.load({
20146
21000
  return () => window.removeEventListener("edrv:sidebar-min-width", onMinChange);
20147
21001
  }, []);
20148
21002
  react.default.useEffect(() => {
20149
- if (!sessionId) return;
21003
+ if (!sessionId || !uiStateSettled()) return;
20150
21004
  try {
20151
21005
  localStorage.setItem(sidebarKey, JSON.stringify({
20152
21006
  on: sidebarOn,
@@ -20159,7 +21013,8 @@ window.__ModuleLoader__.load({
20159
21013
  sidebarW,
20160
21014
  activePanel,
20161
21015
  sessionId,
20162
- sidebarKey
21016
+ sidebarKey,
21017
+ hydrateTick
20163
21018
  ]);
20164
21019
  /**
20165
21020
  * 打开搜索面板(Ctrl+Shift+F 与命令栏「在工作区中搜索」的唯一动作)。
@@ -20798,6 +21653,7 @@ window.__ModuleLoader__.load({
20798
21653
  react.default.useEffect(() => () => {
20799
21654
  flushSave();
20800
21655
  flushNavCursor();
21656
+ navStateSave(String(scope), navRef.current?.snapshot?.() ?? null);
20801
21657
  saveViewStateRef.current?.();
20802
21658
  diffRendererRef.current?.dispose?.();
20803
21659
  if (navFlashTimerRef.current) {
@@ -21353,6 +22209,7 @@ window.__ModuleLoader__.load({
21353
22209
  next.add(path);
21354
22210
  } else next.delete(path);
21355
22211
  mdPreviewRef.current = next;
22212
+ mdPreviewSave(String(scope), next);
21356
22213
  setStatus(entering ? "已切换为 Markdown 预览" : "已切换为源码编辑");
21357
22214
  };
21358
22215
  toggleMdPreviewRef.current = toggleMdPreview;
@@ -26990,137 +27847,6 @@ window.__ModuleLoader__.load({
26990
27847
  });
26991
27848
  }
26992
27849
  //#endregion
26993
- //#region src/client/sessionScope.ts
26994
- /** 对象窄化(非对象返回 undefined,供后续安全取字段)。 */
26995
- function asRecord(value) {
26996
- return value && typeof value === "object" ? value : void 0;
26997
- }
26998
- /** 非空字符串取值(其他类型一律视为缺失)。 */
26999
- function strOf(value) {
27000
- return typeof value === "string" && value ? value : void 0;
27001
- }
27002
- /**
27003
- * 从 `uiSession.current` 快照读出 sessionId。
27004
- * 兼容两种形态:绑定源本身(`{ key }`)与其包裹形态(`{ value: { key } }`)——
27005
- * 官方 `createBindingSource` 的 `getSnapshot()` 返回绑定值本身,但跨版本包裹层存在差异,
27006
- * 故两者都尝试。
27007
- * @author ddj 2026年09月18号
27008
- * @param snapshot `uiSession.current` 的 `getSnapshot()` 结果(任意形状)
27009
- * @returns sessionId,或 undefined
27010
- */
27011
- function uiSessionIdOf(snapshot) {
27012
- const binding = asRecord(snapshot);
27013
- if (!binding) return void 0;
27014
- return strOf(binding.key) ?? strOf(asRecord(binding.value)?.key);
27015
- }
27016
- /**
27017
- * 从 `sessions.list` 快照读出 sessionId。
27018
- * 先取旧版 `current`;缺失时回退 `byId` 中 `retainedBy.mainView > 0` 的首行
27019
- * (与官方 ui-session 的 publishMain 同判据,故新版行为与官方视图一致)。
27020
- * @author ddj 2026年09月18号
27021
- * @param list `sessions.list` 的 `getSnapshot()` 结果(任意形状)
27022
- * @returns sessionId,或 undefined
27023
- */
27024
- function listSessionId(list) {
27025
- const snap = asRecord(list);
27026
- if (!snap) return void 0;
27027
- const current = strOf(snap.current);
27028
- if (current) return current;
27029
- const byId = asRecord(snap.byId);
27030
- if (!byId) return void 0;
27031
- for (const [id, row] of Object.entries(byId)) {
27032
- const mainView = asRecord(asRecord(row)?.retainedBy)?.mainView;
27033
- if (typeof mainView === "number" && mainView > 0) return id;
27034
- }
27035
- }
27036
- /**
27037
- * 三级取值链(纯函数):决定当前会话 id 与其 cwd。
27038
- * 优先级 uiSession > list.current > list.byId.retainedBy > fallback。
27039
- * @author ddj 2026年09月18号
27040
- * @param sources 候选来源集合
27041
- * @returns 会话作用域(字段缺失即省略该键,不写 undefined 占位)
27042
- */
27043
- function pickSessionScope(sources) {
27044
- const sessionId = uiSessionIdOf(sources.uiSession) ?? listSessionId(sources.list) ?? strOf(sources.fallbackSessionId);
27045
- if (!sessionId) return {};
27046
- const cwd = strOf(asRecord(asRecord(asRecord(sources.list)?.byId)?.[sessionId])?.cwd);
27047
- return cwd ? {
27048
- sessionId,
27049
- cwd
27050
- } : { sessionId };
27051
- }
27052
- /** 读 `ctx.get(name)`(缺失/异常返回 undefined,不抛错)。 */
27053
- function safeGet(ctx, name) {
27054
- try {
27055
- return ctx.get(name);
27056
- } catch {
27057
- return;
27058
- }
27059
- }
27060
- /** 取 `uiSession.current` 快照(服务或订阅面缺失时返回 undefined)。 */
27061
- function uiCurrentSnapshot(ctx) {
27062
- const current = asRecord(asRecord(safeGet(ctx, "uiSession"))?.current);
27063
- const getSnapshot = current?.getSnapshot;
27064
- if (typeof getSnapshot !== "function") return void 0;
27065
- try {
27066
- return getSnapshot.call(current);
27067
- } catch {
27068
- return;
27069
- }
27070
- }
27071
- /** 取 `sessions.list` 快照(服务缺失时返回 undefined)。 */
27072
- function listSnapshot(ctx) {
27073
- const list = asRecord(asRecord(safeGet(ctx, "sessions"))?.list);
27074
- const getSnapshot = list?.getSnapshot;
27075
- if (typeof getSnapshot !== "function") return void 0;
27076
- try {
27077
- return getSnapshot.call(list);
27078
- } catch {
27079
- return;
27080
- }
27081
- }
27082
- /**
27083
- * 读当前会话作用域(服务面取值 + 三级链)。
27084
- * @author ddj 2026年09月18号
27085
- * @param ctx 客户端根上下文(只需 `get`)
27086
- * @param fallbackSessionId 槽位注入的 sessionId(可选)
27087
- * @returns 会话作用域
27088
- */
27089
- function readSessionScope(ctx, fallbackSessionId) {
27090
- return pickSessionScope({
27091
- uiSession: uiCurrentSnapshot(ctx),
27092
- list: listSnapshot(ctx),
27093
- fallbackSessionId
27094
- });
27095
- }
27096
- /**
27097
- * 订阅会话切换(同时挂 `uiSession.current` 与新/旧 `sessions.list`)。
27098
- * 旧版无 `uiSession`,新版 `list` 不再变(切会话只动 uiSession),故两条都要挂;
27099
- * 回调可能因两条源先后触发而重复,调用方按上一次 sessionId 自行去重。
27100
- * @author ddj 2026年09月18号
27101
- * @param ctx 客户端根上下文(只需 `get`)
27102
- * @param listener 变更回调
27103
- * @returns 取消订阅函数(幂等)
27104
- */
27105
- function subscribeScope(ctx, listener) {
27106
- const disposers = [];
27107
- const attach = (owner) => {
27108
- const subscribe = asRecord(owner)?.subscribe;
27109
- if (typeof subscribe !== "function") return;
27110
- try {
27111
- const disposer = subscribe.call(owner, listener);
27112
- if (typeof disposer === "function") disposers.push(disposer);
27113
- } catch {}
27114
- };
27115
- attach(asRecord(asRecord(safeGet(ctx, "uiSession"))?.current));
27116
- attach(asRecord(asRecord(safeGet(ctx, "sessions"))?.list));
27117
- return () => {
27118
- for (const disposer of disposers.splice(0)) try {
27119
- disposer();
27120
- } catch {}
27121
- };
27122
- }
27123
- //#endregion
27124
27850
  //#region src/client/ui/OfficialSideTab.ts
27125
27851
  /**
27126
27852
  * dsh-vscode-mode client — OfficialSideTab:DSH 官方右侧 Sidebar「文件编辑」页签正文包装。
@@ -32928,8 +33654,40 @@ window.__ModuleLoader__.load({
32928
33654
  * @author ddj 2026年08月20号
32929
33655
  * @param ctx 客户端根上下文(slots + timer 服务)
32930
33656
  */
32931
- function apply(ctx) {
33657
+ async function apply(ctx) {
32932
33658
  const schedule = (fn, ms) => ctx.timeout(fn, ms);
33659
+ /**
33660
+ * 无门控上报界面状态镜像事件(绕过客户端 edrv.debug 开关,直接落 host 诊断日志)。
33661
+ * 开关关闭时 dbg 直接 return,「重启后没恢复」这类问题会在客户端侧彻底无痕,故另开一条通路。
33662
+ * @author ddj 2026年09月29号
33663
+ * @param text 事件文本
33664
+ * @param level 日志级别(缺省 'debug')
33665
+ */
33666
+ const reportMirror = (text, level) => {
33667
+ const lv = level === "info" || level === "warn" || level === "error" ? level : "debug";
33668
+ try {
33669
+ rpc("edrv.debug", {
33670
+ sessionId: readSessionScope(ctx).sessionId,
33671
+ text: "ui-state: " + text,
33672
+ level: lv
33673
+ }).catch(() => {});
33674
+ } catch (error) {}
33675
+ };
33676
+ if (installUiStatePersist(ctx, schedule, reportMirror)) {
33677
+ reportMirror("镜像装配成功(origin=" + (typeof location === "undefined" ? "?" : location.origin) + ")");
33678
+ log.info("界面状态镜像已装配:界面状态随 host 持久化(重启/换端口可恢复)");
33679
+ } else {
33680
+ reportMirror("镜像装配失败:localStorage 不可用", "warn");
33681
+ log.warn("界面状态镜像不可用(无 localStorage):界面状态仅存当前 origin");
33682
+ }
33683
+ if (readSessionScope(ctx).sessionId) try {
33684
+ await Promise.race([hydrateUiState(), new Promise((resolve) => {
33685
+ schedule(() => resolve(), 600);
33686
+ })]);
33687
+ } catch (error) {}
33688
+ ctx.effect(() => () => {
33689
+ flushUiState();
33690
+ }, "vscode-mode: ui state flush");
32933
33691
  ctx.effect(() => {
32934
33692
  let disposed = false;
32935
33693
  let attempts = 0;
@@ -33306,6 +34064,19 @@ window.__ModuleLoader__.load({
33306
34064
  return;
33307
34065
  }
33308
34066
  officialDisposer = ctx.effect(() => outcome, "vscode-mode: official sidebar editor tab");
34067
+ setEditorTabScope(() => {
34068
+ const scope = readSessionScope(ctx);
34069
+ return workspaceScopeOf(scope.cwd, scope.sessionId);
34070
+ });
34071
+ const restoreActiveTab = () => {
34072
+ const saved = readSavedTab();
34073
+ const active = isEditorTabActive();
34074
+ reportUiStateEvent("编辑 Tab 恢复:本地存档=" + String(saved) + "、当前激活=" + String(active));
34075
+ if (!active && saved) markEditorActive(true);
34076
+ restoreEditorTab(official.service, schedule);
34077
+ };
34078
+ restoreActiveTab();
34079
+ ctx.effect(() => onUiStateReady(restoreActiveTab), "vscode-mode: editor tab state hydration");
33309
34080
  if (typeof official.service.openResource === "function") {
33310
34081
  ctx.effect(() => registry.register(officialSidebarOpener(official.service)), "vscode-mode: official sidebar opener");
33311
34082
  ctx.effect(() => {
@@ -33440,6 +34211,15 @@ window.__ModuleLoader__.load({
33440
34211
  resetNativeOpen();
33441
34212
  } catch {}
33442
34213
  }, "vscode-mode: monaco providers teardown");
34214
+ ctx.effect(() => subscribeScope(ctx, () => {
34215
+ if (!uiStateSettled()) hydrateUiState();
34216
+ }), "vscode-mode: ui state rehydrate");
34217
+ try {
34218
+ await Promise.race([hydrateUiState(), new Promise((resolve) => {
34219
+ schedule(() => resolve(), 1200);
34220
+ })]);
34221
+ } catch (error) {}
34222
+ reportUiStateEvent("客户端装配完成:水合" + (uiStateSettled() ? "已结束" : "仍在进行(界面稍后重跑恢复)"));
33443
34223
  }
33444
34224
  //#endregion
33445
34225
  exports.apply = apply;