dsh-vscode-mode 0.13.1 → 0.14.0

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.
@@ -50,12 +50,15 @@ import { editorHeight } from '../editorLayout.js'
50
50
  import { revealInExplorer as revealPathInExplorer } from '../fileReveal.js'
51
51
  import { setSidePending, SIDEBAR_INSTALL_CMD } from '../sidebarBridge.js'
52
52
  import { upsertViewState, viewStatesLoad, viewStatesSave } from '../state/viewStateCache.js'
53
+ import { mdPreviewLoad, mdPreviewSave } from '../state/mdPreviewCache.js'
54
+ import { onUiStateReady, reportUiStateEvent, uiStateSettled } from '../state/uiStatePersist.js'
53
55
  import { migrateScopedKeys, workspaceScopeOf } from '../state/scopeStore.js'
54
56
  import { modelsForScope, rememberModel } from '../state/modelCache.js'
55
57
  import { navFlashRangeOf } from '../navHighlight.js'
56
58
  import { chordOf, useKeybindingsVersion } from '../keybindings.js'
57
59
  import { getSidebarMinWidth } from '../sidebarMin.js'
58
60
  import { navHistoryFor } from '../navHistory.js'
61
+ import { navStateLoad, navStateSave } from '../state/navStateCache.js'
59
62
  import { statusOfAdd } from '../addToConversation.js'
60
63
  import { setSearchScope, setSearchSeed } from '../searchSeed.js'
61
64
  import { CACHE_KEY } from '../paths.js'
@@ -324,6 +327,8 @@ export function EditorView(props) {
324
327
  const loadSeqRef = React.useRef(0)
325
328
  const programmaticRef = React.useRef(false)
326
329
  const restoredScopeRef = React.useRef(null) // 已恢复状态的作用域(cwd 晚到 sid→ws 时允许重恢复)
330
+ // 界面状态镜像迟到回填的触发器(重启换 origin:首帧 localStorage 为空,回填后需重跑恢复)
331
+ const [hydrateTick, setHydrateTick] = React.useState(0)
327
332
  const pendingFocusRef = React.useRef(null) // { path, region, line, column } 内容加载后跳转
328
333
  const navFlashRef = React.useRef([]) // 跳转目标高亮装饰 id(独立于 diff/下划线,随跳转替换)
329
334
  const navFlashTimerRef = React.useRef(null) // 跳转目标高亮自动清除计时器
@@ -1299,6 +1304,8 @@ export function EditorView(props) {
1299
1304
  // model 缓存跟随作用域(切工作区释放旧作用域模型;同工作区跨挂载复用)
1300
1305
  modelsRef.current = modelsForScope(scope)
1301
1306
  viewStatesRef.current = viewStatesLoad(scope)
1307
+ // Markdown 预览态同样按作用域恢复(重启/换端口后由界面状态镜像回填,见 lateHydrateRef 分支)
1308
+ mdPreviewRef.current = mdPreviewLoad(String(scope))
1302
1309
  try {
1303
1310
  // v3 优先;缺失时回读 v2(旧版纯路径数组)——normalizeTabs 兼容两种形状
1304
1311
  const raw = localStorage.getItem(CACHE_KEY.editor + String(scope))
@@ -1320,28 +1327,64 @@ export function EditorView(props) {
1320
1327
  const kept = plan.length ? restored.filter((tab) => !dropped.has(tab.path)) : restored
1321
1328
  setTabs(kept)
1322
1329
  setActive(kept.some((tab) => tab.path === activePath) ? activePath : (kept[0]?.path ?? null))
1330
+ reportUiStateEvent(
1331
+ '页签恢复(scope=' + String(scope) + '):存档 ' + restored.length + ' 个、保留 ' + kept.length
1332
+ + ' 个、活动 ' + String(activePath),
1333
+ )
1334
+ } else {
1335
+ reportUiStateEvent('页签恢复(scope=' + String(scope) + '):存档非空但归一化后为 0 个')
1323
1336
  }
1337
+ } else {
1338
+ reportUiStateEvent('页签恢复(scope=' + String(scope) + '):localStorage 无存档')
1324
1339
  }
1325
- } catch (e) { /* 损坏忽略 */ }
1326
- }, [sessionId, scope])
1340
+ } catch (e) {
1341
+ reportUiStateEvent('页签恢复失败(scope=' + String(scope) + '): ' + String(e), 'warn')
1342
+ }
1343
+ }, [sessionId, scope, hydrateTick])
1344
+
1345
+ // 镜像迟到回填:重启换 origin 时首帧读不到存档,host 回填完成后重跑一次恢复。
1346
+ // 仅在本页签尚未恢复出任何内容时重跑(用户已打开/关闭过页签则不打扰)。
1347
+ React.useEffect(() => onUiStateReady(() => {
1348
+ if (tabsRef.current.length) return
1349
+ restoredScopeRef.current = null
1350
+ setHydrateTick((v) => v + 1)
1351
+ }), [])
1327
1352
 
1328
1353
  // 导航历史按工作区作用域隔离:同工作区切换对话保留历史,跨工作区换实例
1329
1354
  React.useEffect(() => {
1330
- navRef.current = navHistoryFor(scope)
1355
+ const history = navHistoryFor(scope)
1356
+ // 空实例(首次创建/换作用域)才用存档恢复,避免覆盖本次会话已积累的历史;
1357
+ // 换 origin 时存档由镜像迟到回填,hydrateTick 变化后再跑一次本效应
1358
+ const current = history.snapshot()
1359
+ if (!current.past.length && !current.future.length) {
1360
+ const saved = navStateLoad(String(scope))
1361
+ if (saved) history.restore(saved)
1362
+ reportUiStateEvent(
1363
+ '导航历史恢复(scope=' + String(scope) + '):'
1364
+ + (saved ? 'past=' + saved.past.length + ' future=' + saved.future.length : 'localStorage 无存档'),
1365
+ )
1366
+ }
1367
+ navRef.current = history
1331
1368
  navPendingRef.current = null
1332
1369
  if (navCursorTimerRef.current) { clearTimeout(navCursorTimerRef.current); navCursorTimerRef.current = null }
1333
1370
  setNavTick((v) => v + 1)
1334
- }, [scope])
1371
+ }, [scope, hydrateTick])
1335
1372
 
1373
+ // 导航双栈落 localStorage(同原文去重写;镜像层负责跨重启):刷新/重启后前进后退与当前位置仍在
1336
1374
  React.useEffect(() => {
1337
- if (!sessionId) return
1375
+ if (!sessionId || !uiStateSettled()) return
1376
+ navStateSave(String(scope), navRef.current?.snapshot?.() ?? null)
1377
+ }, [navTick, cursor, active, sessionId, scope, hydrateTick])
1378
+
1379
+ React.useEffect(() => {
1380
+ if (!sessionId || !uiStateSettled()) return
1338
1381
  try {
1339
1382
  // v3 写入:页签带 pinned(只写有值字段,减小体积);固定态随工作区作用域持久化
1340
1383
  const payload = { tabs: tabs.map((t) => (t.pinned ? { path: t.path, pinned: true } : { path: t.path })), active }
1341
1384
  localStorage.setItem(CACHE_KEY.editor + String(scope), JSON.stringify(payload))
1342
1385
  }
1343
1386
  catch (e) { /* 忽略 */ }
1344
- }, [tabs, active, sessionId, scope])
1387
+ }, [tabs, active, sessionId, scope, hydrateTick])
1345
1388
 
1346
1389
  /**
1347
1390
  * 标记页签为「最近使用」(LRU 序号单调递增;编辑动作也算使用,见 onEdit)。
@@ -1394,9 +1437,17 @@ export function EditorView(props) {
1394
1437
  if (typeof saved.on === 'boolean') setSidebarOn(saved.on)
1395
1438
  if (typeof saved.width === 'number') setSidebarW(Math.max(getSidebarMinWidth(), Math.min(560, saved.width)))
1396
1439
  if (typeof saved.panel === 'string') setActivePanel(saved.panel)
1440
+ reportUiStateEvent(
1441
+ '侧栏恢复(键 ' + sidebarKey + '):on=' + String(saved.on) + ' width=' + String(saved.width)
1442
+ + ' panel=' + String(saved.panel),
1443
+ )
1444
+ } else {
1445
+ reportUiStateEvent('侧栏恢复(键 ' + sidebarKey + '):localStorage 无存档')
1397
1446
  }
1398
- } catch (e) { /* 损坏忽略 */ }
1399
- }, [sessionId, sidebarKey])
1447
+ } catch (e) {
1448
+ reportUiStateEvent('侧栏恢复失败(键 ' + sidebarKey + '): ' + String(e), 'warn')
1449
+ }
1450
+ }, [sessionId, sidebarKey, hydrateTick])
1400
1451
 
1401
1452
  // 侧边栏最小宽度设置变更(edrv:sidebar-min-width)→ 更新 minW 并重夹当前宽度
1402
1453
  const [sidebarMinW, setSidebarMinW] = React.useState(() => getSidebarMinWidth())
@@ -1411,10 +1462,10 @@ export function EditorView(props) {
1411
1462
 
1412
1463
  // 侧边栏状态持久化
1413
1464
  React.useEffect(() => {
1414
- if (!sessionId) return
1465
+ if (!sessionId || !uiStateSettled()) return
1415
1466
  try { localStorage.setItem(sidebarKey, JSON.stringify({ on: sidebarOn, width: sidebarW, panel: activePanel })) }
1416
1467
  catch (e) { /* 忽略 */ }
1417
- }, [sidebarOn, sidebarW, activePanel, sessionId, sidebarKey])
1468
+ }, [sidebarOn, sidebarW, activePanel, sessionId, sidebarKey, hydrateTick])
1418
1469
  // 编辑器键位(侧栏/保存/搜索/Markdown 预览/导航/页签循环)已归官方 shortcuts 机制派发:
1419
1470
  // 官方 resolve → 指令注册表 run → `edrv.command.*` 事件 → 下方「指令系统接线」表执行。
1420
1471
  // 历史 capture 监听移除(与官方派发并存会双执行);Markdown 预览的「非 .md 不吞键」
@@ -2014,6 +2065,8 @@ export function EditorView(props) {
2014
2065
  // 卸载即切作用域/重挂载:先把待记录光标落进历史,再存当前文件视图状态
2015
2066
  //(经 ref 取最新闭包,修正旧实现捕获挂载时 active 的问题)
2016
2067
  flushNavCursor()
2068
+ // 关页面/切作用域前把双栈落到 localStorage(镜像层 flush 负责写穿 host)
2069
+ navStateSave(String(scope), navRef.current?.snapshot?.() ?? null)
2017
2070
  saveViewStateRef.current?.()
2018
2071
  diffRendererRef.current?.dispose?.()
2019
2072
  hideReferencesOverlay()
@@ -2536,6 +2589,8 @@ export function EditorView(props) {
2536
2589
  next.delete(path)
2537
2590
  }
2538
2591
  mdPreviewRef.current = next
2592
+ // 预览态按作用域落 localStorage(镜像层写穿 host):刷新/重启后仍以预览打开
2593
+ mdPreviewSave(String(scope), next)
2539
2594
  setStatus(entering ? '已切换为 Markdown 预览' : '已切换为源码编辑')
2540
2595
  }
2541
2596
  toggleMdPreviewRef.current = toggleMdPreview
package/src/paths.ts CHANGED
@@ -25,6 +25,8 @@ export const DSH_DIR_NAME = '.dsh'
25
25
  export const DSH_HOME_ENV = 'DSH_HOME'
26
26
  /** 树缓存 schema 版本(文件名携带;版本递增即触发旧版清理)。 */
27
27
  export const TREE_CACHE_SCHEMA = 1
28
+ /** UI 状态镜像 schema 版本(文件名携带;版本递增即触发旧版清理)。 */
29
+ export const UI_STATE_SCHEMA = 1
28
30
  /** LSP provider 发现缓存 schema 版本(格式变更即递增,旧文件自动失效)。 */
29
31
  export const LSP_SPEC_CACHE_SCHEMA = 4
30
32
  /** 树缓存保留期:超期文件视为废弃(工作区搬迁/废弃残留)。 */
@@ -161,6 +163,31 @@ export function treeCacheFile(cwd: string, home = dshHome(), schema = TREE_CACHE
161
163
  return join(workspaceCacheDir(hashOf(cwd), home), 'tree.v' + schema + '.json')
162
164
  }
163
165
 
166
+ /**
167
+ * 每工作区 UI 状态镜像文件(工作区级:workspace/<id>/ui.v<schema>.json)。
168
+ * 存编辑器页签/侧栏/预览态等界面状态的 localStorage 原值副本,供换 origin 后回填。
169
+ * @author ddj 2026年09月28号
170
+ * @param cwd 工作区绝对路径
171
+ * @param home DSH home(测试可注入)
172
+ * @param schema schema 版本(缺省 UI_STATE_SCHEMA)
173
+ * @returns UI 状态镜像文件绝对路径
174
+ */
175
+ export function uiStateFile(cwd: string, home = dshHome(), schema = UI_STATE_SCHEMA): string {
176
+ return join(workspaceCacheDir(hashOf(cwd), home), 'ui.v' + schema + '.json')
177
+ }
178
+
179
+ /**
180
+ * 用户级 UI 状态镜像文件(跨工作区共享:user/ui-globals.v<schema>.json)。
181
+ * 存与工作区无关的全局键(侧栏提示已忽略、工作区折叠态等)。
182
+ * @author ddj 2026年09月28号
183
+ * @param home DSH home(测试可注入)
184
+ * @param schema schema 版本(缺省 UI_STATE_SCHEMA)
185
+ * @returns 全局 UI 状态镜像文件绝对路径
186
+ */
187
+ export function uiGlobalsFile(home = dshHome(), schema = UI_STATE_SCHEMA): string {
188
+ return join(userCacheDir(home), 'ui-globals.v' + schema + '.json')
189
+ }
190
+
164
191
  /** 每工作区 debug 日志文件(用户级日志根下,按 cwd hash 隔离)。 */
165
192
  export function debugLogFile(cwd: string, home = dshHome()): string {
166
193
  return join(pluginLogRoot(home), 'debug.' + hashOf(cwd) + '.log')
@@ -201,6 +228,37 @@ export function treeSchemaOf(filename: string): number | null {
201
228
  return m ? Number(m[1]) : null
202
229
  }
203
230
 
231
+ /**
232
+ * 已知缓存文件种类 → 当前 schema 版本(sweep 判旧依据)。
233
+ * 新增缓存文件必须登记在此,否则被当作未知残留清理。
234
+ */
235
+ const CACHE_SCHEMAS: Record<string, number> = {
236
+ tree: TREE_CACHE_SCHEMA,
237
+ 'lsp-specs': LSP_SPEC_CACHE_SCHEMA,
238
+ ui: UI_STATE_SCHEMA,
239
+ 'ui-globals': UI_STATE_SCHEMA,
240
+ }
241
+
242
+ /** 缓存文件名 → 种类与 schema 版本(`<kind>.v<schema>.json`;未登记种类/命名不合规 → null)。 */
243
+ function parseCacheName(filename: string): { kind: string; schema: number } | null {
244
+ const m = /^([a-z-]+)\.v(\d+)\.json$/.exec(filename)
245
+ if (!m) return null
246
+ const kind = m[1]
247
+ if (CACHE_SCHEMAS[kind] === undefined) return null
248
+ return { kind, schema: Number(m[2]) }
249
+ }
250
+
251
+ /**
252
+ * 该缓存文件名是否为已登记种类的当前 schema 版本(sweep 判旧/判残留依据)。
253
+ * @author ddj 2026年09月28号
254
+ * @param filename 缓存文件名(不含目录)
255
+ * @returns 已登记种类且版本为当前版本
256
+ */
257
+ export function isCurrentCacheFile(filename: string): boolean {
258
+ const parsed = parseCacheName(filename)
259
+ return parsed !== null && parsed.schema === CACHE_SCHEMAS[parsed.kind]
260
+ }
261
+
204
262
  /** 按「schema 版本 + 保留期」清扫某目录内文件(目录级,返回相对路径清单)。 */
205
263
  async function sweepDirFiles(dir: string, removed: string[], now: number, dropAll: boolean): Promise<void> {
206
264
  const names = await readdir(dir).catch(() => [])
@@ -208,9 +266,7 @@ async function sweepDirFiles(dir: string, removed: string[], now: number, dropAl
208
266
  const full = join(dir, name)
209
267
  let drop = dropAll
210
268
  if (!drop) {
211
- const schema = treeSchemaOf(name)
212
- if (schema === null) drop = true // 未知残留
213
- else if (schema !== TREE_CACHE_SCHEMA) drop = true // 旧版本
269
+ if (!isCurrentCacheFile(name)) drop = true // 未知残留 / 旧版本
214
270
  else {
215
271
  try {
216
272
  const info = await stat(full)
package/src/rpc.ts CHANGED
@@ -46,7 +46,8 @@ import { baseNameOf, checkNewName, checkRenameName, isSubPath, joinRelPath, pare
46
46
  import { invalidateIndex, listDirCached } from './treeIndex.js'
47
47
  import { findRemoteWs, remoteDisplayOf, type RemoteWs } from './remoteWorkspace.js'
48
48
  import { revealInExplorer } from './reveal.js'
49
- import { dshHome, debugLogFile, pluginLogRoot } from './paths.js'
49
+ import { dshHome, debugLogFile, pluginLogRoot, uiGlobalsFile, uiStateFile } from './paths.js'
50
+ import { readUiState, writeUiState } from './uiState.js'
50
51
  import { clearDebugLog, debugRecord, isDebugLogName, listDebugLogs, readDebugLog } from './debugLog.js'
51
52
  import { markActiveSessions, markOfficialFlags, moveOutSessions, planMoveOut, purgeArchive, restoreSession, scanSessionInventory, sessionsArchiveRoot, sessionSizeOf, sidecarSummaryOf, unarchiveOfficial } from './perf.js'
52
53
  import { patchHasPerfConfig, patchInsertPerfConfig, patchRemovePerfConfig, perfConfigBlock } from './perfPatch.js'
@@ -57,6 +58,12 @@ import { settingsDocPath } from './settingsDocPath.js'
57
58
  const staleCheckedAt = new Map<string, number>()
58
59
  /** stale 自动清理最小间隔。 */
59
60
  const STALE_CHECK_MIN_MS = 10_000
61
+ /**
62
+ * 无会话期诊断的伪 cwd:冷启动早期本插件客户端就会上报界面状态镜像事件
63
+ * (此时 `sessions` 可能尚未就绪,`requireSession` 会失败),退回本固定值落盘
64
+ * `logs/debug.<hashOf(该值)>.log`,保证「换 origin 后没恢复」这类问题的诊断不丢。
65
+ */
66
+ const UI_AUDIT_CWD = 'ui-state'
60
67
 
61
68
  /** 记录 → 客户端视图(不含 before 全文,仅长度);远程工作区附远端展示路径(issue #7)。 */
62
69
  function recView(record: DiffRecord, ws: RemoteWs | null = null): RecordView {
@@ -465,6 +472,40 @@ export function buildHandlers(
465
472
  out.sort((a, b) => (a.at < b.at ? -1 : 1))
466
473
  return { ok: true, records: out }
467
474
  },
475
+ /**
476
+ * 界面状态镜像读取:回填换 origin(Web GUI 重启换端口)后丢失的 localStorage 界面状态。
477
+ * 文件缺失/损坏 → 空表(客户端自然不动作)。
478
+ * @author ddj 2026年09月28号
479
+ */
480
+ 'edrv.uiState.get': async (args) => {
481
+ const sc = await requireSession(ctx, args.sessionId)
482
+ if ('err' in sc) return { ok: false, error: sc.err }
483
+ const keys = await readUiState(uiStateFile(sc.cwd))
484
+ const globals = await readUiState(uiGlobalsFile())
485
+ return { ok: true, keys, globals }
486
+ },
487
+ /**
488
+ * 界面状态镜像写入:把客户端改动过的 `edrv.*` 键增量同步到 host 文件(空补丁不落盘)。
489
+ * 失败时写一条诊断日志(默认静默的客户端 dbg 之外的可查证据),并如实回错误由客户端逐键回退。
490
+ * @author ddj 2026年09月28号 / 2026年09月29号
491
+ */
492
+ 'edrv.uiState.set': async (args) => {
493
+ const sc = await requireSession(ctx, args.sessionId)
494
+ if ('err' in sc) return { ok: false, error: sc.err }
495
+ const tasks: Array<Promise<{ ok: true } | { ok: false; error: string }>> = []
496
+ if (args.keys && Object.keys(args.keys).length) tasks.push(writeUiState(uiStateFile(sc.cwd), args.keys))
497
+ if (args.globals && Object.keys(args.globals).length) tasks.push(writeUiState(uiGlobalsFile(), args.globals))
498
+ const results = await Promise.all(tasks)
499
+ const failed = results.find((item) => !item.ok)
500
+ if (failed && !failed.ok) {
501
+ const keyCount = Object.keys(args.keys ?? {}).length
502
+ const globalCount = Object.keys(args.globals ?? {}).length
503
+ debugRecord(ctx, sc.cwd, '界面状态镜像写入失败: ' + failed.error
504
+ + '(keys=' + keyCount + ' 键, globals=' + globalCount + ' 键)', 'warn')
505
+ return { ok: false, error: failed.error }
506
+ }
507
+ return { ok: true }
508
+ },
468
509
  'edrv.accept': async (args) => {
469
510
  const sc = await requireSession(ctx, args.sessionId)
470
511
  if ('err' in sc) return { ok: false, error: sc.err }
@@ -749,9 +790,15 @@ export function buildHandlers(
749
790
  'edrv.debug': async (args) => {
750
791
  // 诊断日志:client 上报 → debugLog 模块缓冲批量落盘 ~/.dsh/dsh-vscode-mode/logs/
751
792
  // (console 不一定落盘,文件可靠)。只出现在调试开关开启时(client dbg 默认关)。
793
+ const text = String(args.text ?? '')
752
794
  const sc = await requireSession(ctx, args.sessionId)
753
- if ('err' in sc) return { ok: false, error: sc.err }
754
- debugRecord(ctx, sc.cwd, String(args.text ?? ''), args.level)
795
+ if ('err' in sc) {
796
+ // 会话未就绪(冷启动早期、界面状态镜像水合期)也必须能落盘:否则换 origin 后的
797
+ // 「水合失败/放弃」诊断全部丢失,只剩「界面没恢复」这个不可观测的现象。
798
+ debugRecord(ctx, UI_AUDIT_CWD, text, args.level)
799
+ return { ok: true }
800
+ }
801
+ debugRecord(ctx, sc.cwd, text, args.level)
755
802
  return { ok: true }
756
803
  },
757
804
  'edrv.dlog.list': async (args) => {
package/src/shared/rpc.ts CHANGED
@@ -331,6 +331,24 @@ export interface DebugLogRead {
331
331
  truncated: boolean
332
332
  }
333
333
 
334
+ /** 界面状态镜像快照(edrv.uiState.get):界面状态键值表 + 跨工作区全局键值表。 */
335
+ export interface UiStateSnapshot {
336
+ /** 工作区级键值表(localStorage 键 → 字符串原值)。 */
337
+ keys: Record<string, string>
338
+ /** 全局键值表(与工作区无关,跨工作区共享)。 */
339
+ globals: Record<string, string>
340
+ }
341
+
342
+ /** 界面状态镜像写入补丁(edrv.uiState.set):值 null 表示删除该键。 */
343
+ export interface UiStatePatchInput {
344
+ /** 会话 id(缺省取唯一活跃会话)。 */
345
+ sessionId?: string
346
+ /** 工作区级增量补丁。 */
347
+ keys?: Record<string, string | null>
348
+ /** 全局增量补丁。 */
349
+ globals?: Record<string, string | null>
350
+ }
351
+
334
352
  /** 每个方法的请求参数(sessionId 为公共可选字段)。 */
335
353
  export interface RpcRequestMap {
336
354
  'edrv.list': { sessionId?: string; callIds?: string[]; skipStale?: boolean }
@@ -481,6 +499,10 @@ export interface RpcRequestMap {
481
499
  'edrv.dap.variables': { ref: number }
482
500
  'edrv.dap.evaluate': { expression: string; frameId?: number }
483
501
  'edrv.dap.command': { action: DapAction }
502
+ /** 界面状态镜像读取:回填换 origin(重启换端口)后丢失的 localStorage 界面状态。 */
503
+ 'edrv.uiState.get': { sessionId?: string }
504
+ /** 界面状态镜像写入:把客户端改动过的 `edrv.*` 键增量同步到 host 文件。 */
505
+ 'edrv.uiState.set': UiStatePatchInput
484
506
  }
485
507
 
486
508
  export type RpcMethod = keyof RpcRequestMap
@@ -632,6 +654,8 @@ export interface RpcOkMap {
632
654
  'edrv.dap.variables': { variables: DapVariableView[] }
633
655
  'edrv.dap.evaluate': { result: string; type?: string; ref: number }
634
656
  'edrv.dap.command': object
657
+ 'edrv.uiState.get': UiStateSnapshot
658
+ 'edrv.uiState.set': object
635
659
  }
636
660
 
637
661
  /** 统一响应:{ok:true, ...payload} 或 {ok:false, error}。 */
package/src/uiState.ts ADDED
@@ -0,0 +1,165 @@
1
+ /**
2
+ * dsh-vscode-mode host — UI 状态镜像存储(界面状态的跨重启记忆)。
3
+ * 背景:Web GUI 的 origin(含端口)可能每次启动变化,而 localStorage 按 origin 分区,
4
+ * 于是页签/侧栏/预览态等状态随重启一并丢失。这里把客户端 `edrv.*` 键的字符串原值
5
+ * 按工作区镜像到 host 文件(workspace/<hash>/ui.v<schema>.json;全局键走 user/),
6
+ * 启动时原样回填本地缺失的键。
7
+ * 值不解析、不校验业务格式(客户端自会解析并丢弃损坏值),只保证键值对可靠落盘:
8
+ * - 原子写:先写临时文件再 rename,避免半截文件
9
+ * - 串行化:同一文件的写操作链式排队,避免并发丢更新
10
+ * - 容量上限:序列化超过 UI_STATE_BYTES_CAP 即拒写(客户端保留 localStorage,不丢数据)
11
+ * 纯函数(parse/serialize/merge)与文件读写均参数注入,可单测。
12
+ * 作者 ddj 2026-09-28
13
+ */
14
+ import { Buffer } from 'node:buffer'
15
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
16
+ import { dirname } from 'node:path'
17
+
18
+ // --region 常量与类型
19
+ /** UI 状态镜像文档版本(与文件名 schema 解耦:文件名管失效,字段管结构)。 */
20
+ export const UI_STATE_VERSION = 1
21
+ /** UI 状态镜像容量上限:序列化后超出即拒写(客户端回退纯 localStorage)。 */
22
+ export const UI_STATE_BYTES_CAP = 512 * 1024
23
+
24
+ /** UI 状态镜像文档。 */
25
+ export interface UiStateDoc {
26
+ /** 文档版本(不匹配即视为损坏)。 */
27
+ version: number
28
+ /** 最后写入时间(ISO 字符串,仅供排查)。 */
29
+ updatedAt: string
30
+ /** 界面状态键值表(localStorage 键 → 字符串原值)。 */
31
+ keys: Record<string, string>
32
+ }
33
+
34
+ /** 增量补丁:值 null/undefined 表示删除该键(localStorage 值恒为字符串,语义无歧义)。 */
35
+ export type UiStatePatch = Record<string, string | null | undefined>
36
+ // --endregion
37
+
38
+ // --region 纯函数
39
+ /**
40
+ * 合并增量补丁到键值表(不改入参)。
41
+ * @author ddj 2026年09月28号
42
+ * @param base 已有键值表
43
+ * @param patch 增量补丁(null/undefined 值删除该键;空键跳过)
44
+ * @returns 合并后的新键值表
45
+ */
46
+ export function mergeUiStateKeys(base: Record<string, string>, patch: UiStatePatch): Record<string, string> {
47
+ const merged: Record<string, string> = { ...base }
48
+ for (const [key, value] of Object.entries(patch)) {
49
+ if (!key) continue
50
+ if (value === null || value === undefined) delete merged[key]
51
+ else merged[key] = String(value)
52
+ }
53
+ return merged
54
+ }
55
+
56
+ /**
57
+ * 解析镜像文件文本;损坏/版本不符 → null(调用方视为空)。
58
+ * @author ddj 2026年09月28号
59
+ * @param text 文件文本(可为空)
60
+ * @returns 解析后的文档;不可用时 null
61
+ */
62
+ export function parseUiState(text: string | null | undefined): UiStateDoc | null {
63
+ if (!text) return null
64
+ let raw: unknown
65
+ try {
66
+ raw = JSON.parse(text)
67
+ } catch (error) {
68
+ return null
69
+ }
70
+ if (!raw || typeof raw !== 'object') return null
71
+ const doc = raw as { version?: unknown; updatedAt?: unknown; keys?: unknown }
72
+ if (doc.version !== UI_STATE_VERSION) return null
73
+ if (!doc.keys || typeof doc.keys !== 'object') return null
74
+ const keys: Record<string, string> = {}
75
+ for (const [key, value] of Object.entries(doc.keys as Record<string, unknown>)) {
76
+ if (!key || typeof value !== 'string') continue
77
+ keys[key] = value
78
+ }
79
+ return {
80
+ version: UI_STATE_VERSION,
81
+ updatedAt: typeof doc.updatedAt === 'string' ? doc.updatedAt : '',
82
+ keys,
83
+ }
84
+ }
85
+
86
+ /**
87
+ * 序列化为镜像文件文本。
88
+ * @author ddj 2026年09月28号
89
+ * @param keys 键值表
90
+ * @param updatedAt 更新时间(缺省当前时间)
91
+ * @returns 文件文本
92
+ */
93
+ export function serializeUiState(keys: Record<string, string>, updatedAt = new Date().toISOString()): string {
94
+ const doc: UiStateDoc = { version: UI_STATE_VERSION, updatedAt, keys }
95
+ return JSON.stringify(doc)
96
+ }
97
+ // --endregion
98
+
99
+ // --region 文件读写
100
+ /** 同文件写队列尾(串行化并发写入;队列清空即移除条目)。 */
101
+ const writeChains = new Map<string, Promise<unknown>>()
102
+
103
+ /**
104
+ * 把写任务挂到该文件的队尾串行执行(前序失败不影响后续)。
105
+ * @author ddj 2026年09月28号
106
+ * @param file 目标文件绝对路径
107
+ * @param run 实际写操作
108
+ * @returns 本次写操作结果
109
+ */
110
+ function chainWrite<T>(file: string, run: () => Promise<T>): Promise<T> {
111
+ const prev = writeChains.get(file) ?? Promise.resolve()
112
+ const next = prev.then(run, run)
113
+ const tail = next.then(() => undefined, () => undefined)
114
+ writeChains.set(file, tail)
115
+ void tail.then(() => {
116
+ if (writeChains.get(file) === tail) writeChains.delete(file)
117
+ })
118
+ return next
119
+ }
120
+
121
+ /**
122
+ * 读取镜像文件的键值表(文件缺失/损坏/不可读 → 空表,静默)。
123
+ * @author ddj 2026年09月28号
124
+ * @param file 镜像文件绝对路径
125
+ * @returns 键值表(localStorage 键 → 字符串原值)
126
+ */
127
+ export async function readUiState(file: string): Promise<Record<string, string>> {
128
+ try {
129
+ const doc = parseUiState(await readFile(file, 'utf8'))
130
+ return doc ? doc.keys : {}
131
+ } catch (error) {
132
+ return {}
133
+ }
134
+ }
135
+
136
+ /**
137
+ * 合并写入镜像文件(读旧 → 合并补丁 → 容量校验 → 原子替换),同文件写操作串行。
138
+ * @author ddj 2026年09月28号
139
+ * @param file 镜像文件绝对路径
140
+ * @param patch 增量补丁(null/undefined 值删除该键)
141
+ * @returns ok;超限或写入失败时 ok:false + 中文错误文案
142
+ */
143
+ export async function writeUiState(
144
+ file: string,
145
+ patch: UiStatePatch,
146
+ ): Promise<{ ok: true } | { ok: false; error: string }> {
147
+ return chainWrite(file, async () => {
148
+ try {
149
+ const merged = mergeUiStateKeys(await readUiState(file), patch)
150
+ const json = serializeUiState(merged)
151
+ if (Buffer.byteLength(json, 'utf8') > UI_STATE_BYTES_CAP) {
152
+ return { ok: false as const, error: '界面状态镜像超出容量上限(' + UI_STATE_BYTES_CAP + ' 字节)' }
153
+ }
154
+ await mkdir(dirname(file), { recursive: true })
155
+ const tmp = file + '.tmp'
156
+ await writeFile(tmp, json, 'utf8')
157
+ await rename(tmp, file)
158
+ return { ok: true as const }
159
+ } catch (error) {
160
+ const reason = error instanceof Error ? error.message : String(error)
161
+ return { ok: false as const, error: '界面状态写入失败: ' + reason }
162
+ }
163
+ })
164
+ }
165
+ // --endregion