dsh-vscode-mode 0.13.0 → 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.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-vscode-mode",
3
- "version": "0.13.0",
4
- "description": "DSH 上的类 VSCode 编码体验:Monaco 编辑器(文件页签/QuickOpen/状态栏,可驻 DSH 0.1.5+ 官方右侧 Sidebar 与对话同屏)+ 命令栏(Ctrl+Shift+P)与可扩展指令系统 + VS Code 兼容代码片段(.code-snippets 全局/项目,IntelliSense 展开)+ Agent 编辑差异审查(整文件差异/采纳/拒绝/归档/回滚)+ SVN 集成(侧栏变更面板/日志弹窗/并排差异)+ 语言服务器(LSP)智能(跳转定义/引用/hover/大纲 + VSIX 扩展市场安装),状态持久化到工作区旁车",
3
+ "version": "0.14.0",
4
+ "description": "DSH 上的类 VSCode 编码体验:Monaco 编辑器(文件页签/QuickOpen/状态栏,可驻 DSH 0.1.5+ 官方右侧 Sidebar 与对话同屏)+ 命令栏(Ctrl+Shift+P)与可扩展指令系统 + VS Code 兼容代码片段(.code-snippets 全局/项目,IntelliSense 展开)+ Agent 编辑差异审查(整文件差异/采纳/拒绝/归档/回滚)+ SVN 集成(侧栏变更面板/日志弹窗/并排差异)+ 语言服务器(LSP)智能(跳转定义/引用/hover/大纲 + VSIX 扩展市场安装),状态持久化到工作区旁车,界面状态(页签/光标/侧栏/编辑 Tab/导航历史)镜像到 DSH home 跨重启恢复",
5
5
  "icon": "assets/icon.svg",
6
6
  "keywords": [
7
7
  "dsh",
@@ -33,7 +33,7 @@ import { setupExtOpen } from './externalOpen.js'
33
33
  import { SettingsContext } from './settingsContext.js'
34
34
  import { SIDEBAR_PLUGIN, registerSlotSafely, settingsBridge } from './compat.js'
35
35
  import { detectSidebarService, hasSideRoute, installSideEditor, routeSideEditor, setEnsureSideEditor, SIDEBAR_INSTALL_CMD } from './sidebarBridge.js'
36
- import { detectOfficial, installOfficial, registerOfficialFileClaim, OFFICIAL_TAB_KIND, OFFICIAL_TAB_TITLE, isEditorTabActive, restoreEditorTab, buildFileAddress } from './officialSidebar.js'
36
+ import { detectOfficial, installOfficial, registerOfficialFileClaim, OFFICIAL_TAB_KIND, OFFICIAL_TAB_TITLE, isEditorTabActive, markEditorActive, restoreEditorTab, buildFileAddress, readSavedTab, setEditorTabScope } from './officialSidebar.js'
37
37
  import { setNativeCsv, setNativeOpenHandler, resetNativeOpen } from './nativeOpenStore.js'
38
38
  import { SideEditorTab } from './ui/SideEditorTab.js'
39
39
  import { OfficialSideTab } from './ui/OfficialSideTab.js'
@@ -56,7 +56,6 @@ import type { CommandBridge } from './commandBridge.js'
56
56
  import { REGISTRY_GLOBAL } from './commandGlobals.js'
57
57
  import { awaitShortcutsService, bindCatalogChords, detectShortcuts, installLegacyKeys, migrateLegacyKeybindings, registerOfficialShortcuts } from './shortcutsOfficial.js'
58
58
  import { SHORTCUT_PROFILES, defaultKeybindings, normalizeKeybindings } from '../shared/keybindings.js'
59
- import { ShortcutSettings } from './ui/ShortcutSettings.js'
60
59
  import { sidebarMinApply } from './sidebarMin.js'
61
60
  import { editorLimitApply } from './editorLimit.js'
62
61
  import { log } from './log.js'
@@ -65,6 +64,8 @@ import { disposeSnippets } from './snippets/provider.js'
65
64
  import { disposeLaunchJson } from './dap/launchSnippetProvider.js'
66
65
  import { disposeAiInline } from './ai/inlineProvider.js'
67
66
  import { readSessionScope, subscribeScope } from './sessionScope.js'
67
+ import { flushUiState, hydrateUiState, installUiStatePersist, onUiStateReady, reportUiStateEvent, uiStateSettled } from './state/uiStatePersist.js'
68
+ import { workspaceScopeOf } from './state/scopeStore.js'
68
69
  import { dapStore } from './dap/store.js'
69
70
  import type { CompatAdapter } from '../shared/compat.js'
70
71
 
@@ -115,8 +116,48 @@ function setupCommands(ctx: any): CommandBridge {
115
116
  * @param ctx 客户端根上下文(slots + timer 服务)
116
117
  */
117
118
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
118
- export function apply(ctx: any): void {
119
+ export async function apply(ctx: any): Promise<void> {
119
120
  const schedule = (fn: () => void, ms: number) => ctx.timeout(fn, ms)
121
+
122
+ /**
123
+ * 无门控上报界面状态镜像事件(绕过客户端 edrv.debug 开关,直接落 host 诊断日志)。
124
+ * 开关关闭时 dbg 直接 return,「重启后没恢复」这类问题会在客户端侧彻底无痕,故另开一条通路。
125
+ * @author ddj 2026年09月29号
126
+ * @param text 事件文本
127
+ * @param level 日志级别(缺省 'debug')
128
+ */
129
+ const reportMirror = (text: string, level?: string): void => {
130
+ // 只认 host 侧已知级别,其余落 'debug'(镜像内部只发 'debug'/'warn')
131
+ const lv = level === 'info' || level === 'warn' || level === 'error' ? level : 'debug'
132
+ try {
133
+ void rpc('edrv.debug', { sessionId: readSessionScope(ctx).sessionId, text: 'ui-state: ' + text, level: lv })
134
+ .catch(() => { /* 诊断上报失败忽略 */ })
135
+ } catch (error) { /* 会话未就绪等异常忽略 */ }
136
+ }
137
+
138
+ // 界面状态镜像:把 `edrv.*` 的 localStorage 状态写穿到 host,换 origin(重启换端口)后回填。
139
+ // 必须早于任何界面首次读写 localStorage(编辑器页签/侧栏/预览态的恢复逻辑都读它)。
140
+ if (installUiStatePersist(ctx, schedule, reportMirror)) {
141
+ reportMirror('镜像装配成功(origin=' + (typeof location === 'undefined' ? '?' : location.origin) + ')')
142
+ log.info('界面状态镜像已装配:界面状态随 host 持久化(重启/换端口可恢复)')
143
+ } else {
144
+ reportMirror('镜像装配失败:localStorage 不可用', 'warn')
145
+ log.warn('界面状态镜像不可用(无 localStorage):界面状态仅存当前 origin')
146
+ }
147
+
148
+ // 争取「回填先于任何界面组件挂载」:面板/编辑器的恢复逻辑都在挂载时读 localStorage,
149
+ // 挂载后再回填只能靠 `edrv:ui-state-ready` 事件补跑(目前编辑器页签/侧栏已支持)。
150
+ // 已知会话时这次 RPC 是本地往返(通常几十毫秒),故短超时即可;会话未就绪则不在此等待,
151
+ // 交给镜像内部重试与迟到回填事件,避免拖慢启动。
152
+ if (readSessionScope(ctx).sessionId) {
153
+ try {
154
+ await Promise.race([
155
+ hydrateUiState(),
156
+ new Promise<void>((resolve) => { schedule(() => resolve(), 600) }),
157
+ ])
158
+ } catch (error) { /* 水合失败已在镜像内降级 */ }
159
+ }
160
+ ctx.effect(() => () => { void flushUiState() }, 'vscode-mode: ui state flush')
120
161
  ctx.effect(() => {
121
162
  let disposed = false
122
163
  let attempts = 0
@@ -483,6 +524,22 @@ export function apply(ctx: any): void {
483
524
  return
484
525
  }
485
526
  officialDisposer = ctx.effect(() => outcome, 'vscode-mode: official sidebar editor tab')
527
+ // 编辑 Tab 展开/激活态按工作区落 localStorage(镜像层写穿 host,重启/换端口后仍在):
528
+ // 冷启动据此自动回到上次的编辑 Tab;官方 openTab 幂等,已激活时不重复调用。
529
+ setEditorTabScope(() => {
530
+ const scope = readSessionScope(ctx)
531
+ return workspaceScopeOf(scope.cwd, scope.sessionId)
532
+ })
533
+ const restoreActiveTab = (): void => {
534
+ const saved = readSavedTab()
535
+ const active = isEditorTabActive()
536
+ reportUiStateEvent('编辑 Tab 恢复:本地存档=' + String(saved) + '、当前激活=' + String(active))
537
+ if (!active && saved) markEditorActive(true)
538
+ restoreEditorTab(official.service, schedule)
539
+ }
540
+ restoreActiveTab()
541
+ // 换 origin 时本地 localStorage 为空:镜像回填完成后重判一次,迟到的回填同样能恢复编辑 Tab
542
+ ctx.effect(() => onUiStateReady(restoreActiveTab), 'vscode-mode: editor tab state hydration')
486
543
  // 官方打开器(下拉「官方侧边栏」选项,动态出现;openResource 能力缺失时跳过)+ 按当前设置同步 file 地址认领
487
544
  if (typeof official.service.openResource === 'function') {
488
545
  ctx.effect(() => registry.register(officialSidebarOpener(official.service)), 'vscode-mode: official sidebar opener')
@@ -575,16 +632,13 @@ export function apply(ctx: any): void {
575
632
  id: 'vscode-mode',
576
633
  order: 30,
577
634
  label: 'VSCodeMode',
578
- }, () => React.createElement(SettingsContext.Provider, { value: bridge.scope() }, React.createElement(McpSettings, { openerRegistry: registry, compatSummary })))
579
-
580
- // 独立「快捷键」设置区(与 VSCodeMode 平级):官方快捷键子页 + 插件快捷键子页。
581
- // 服务经 getShortcuts 运行时探测(自愈),组件内部自带就绪重试。
582
- registerSlotSafely(ctx, {
583
- name: 'settings.section',
584
- id: 'vscode-mode-shortcuts',
585
- order: 31,
586
- label: '快捷键',
587
- }, () => React.createElement(ShortcutSettings, { getShortcuts: () => detectShortcuts(ctx) }))
635
+ }, () => React.createElement(SettingsContext.Provider, { value: bridge.scope() }, React.createElement(McpSettings, {
636
+ openerRegistry: registry,
637
+ compatSummary,
638
+ // 快捷键统一走 DSH 官方配置:通用页只提供「打开官方快捷键配置」入口。
639
+ // 服务运行时探测(自愈):旧版 DSH 无该服务时按钮给出手动打开方式。
640
+ getShortcuts: () => detectShortcuts(ctx),
641
+ })))
588
642
 
589
643
  // 卸载收尾(G4,DSH 0.1.6-alpha.2 起支持运行时卸载/重载):
590
644
  // 注销挂在存活的 window.monaco 上的全部 Monaco provider 并复位模块状态。
@@ -597,4 +651,20 @@ export function apply(ctx: any): void {
597
651
  // 原生打开范围复位回默认集(handler 解绑已由其自身 ctx.effect disposer 承担)
598
652
  try { resetNativeOpen() } catch { /* 同上 */ }
599
653
  }, 'vscode-mode: monaco providers teardown')
654
+
655
+ // 会话晚到补试:冷启动早期 `sessions` 未就绪时 host 答「会话不存在」,首次水合会失败重试;
656
+ // 作用域变化(sid→ws)后同样补一次,保证界面状态尽早回填。
657
+ ctx.effect(() => subscribeScope(ctx, () => {
658
+ if (!uiStateSettled()) void hydrateUiState()
659
+ }), 'vscode-mode: ui state rehydrate')
660
+
661
+ // 争取「回填先于界面首次读 localStorage」:宿主若 await 本 apply,此处等到水合结束(或 1.2s 超时兜底)。
662
+ // 失败/超时不抛:镜像内部自带重试与结束事件,界面侧还有迟到回填重跑。
663
+ try {
664
+ await Promise.race([
665
+ hydrateUiState(),
666
+ new Promise<void>((resolve) => { schedule(() => resolve(), 1200) }),
667
+ ])
668
+ } catch (error) { /* 水合失败已在镜像内降级 */ }
669
+ reportUiStateEvent('客户端装配完成:水合' + (uiStateSettled() ? '已结束' : '仍在进行(界面稍后重跑恢复)'))
600
670
  }
@@ -36,6 +36,10 @@ export interface NavHistory {
36
36
  peekBack(): NavEntry | null
37
37
  /** 前进目标条目(不改变状态;不可进 → null)。 */
38
38
  peekForward(): NavEntry | null
39
+ /** 导出双栈快照(浅拷贝;持久化用)。 */
40
+ snapshot(): NavState
41
+ /** 用存档恢复双栈(调用方仅在实例为空时调用;非法项丢弃、超限截断)。 */
42
+ restore(state: NavState | null | undefined): void
39
43
  }
40
44
 
41
45
  /** 后退栈默认容量上限(超出逐出最旧)。 */
@@ -44,6 +48,37 @@ export const NAV_HISTORY_CAP = 200
44
48
  /** 工作区历史实例缓存上限(FIFO 逐出最久未用作用域,防长期驻留泄漏)。 */
45
49
  export const NAV_SCOPE_CAP = 6
46
50
 
51
+ /** 持久化单栈容量上限(存档只留最近若干条,防 localStorage 膨胀)。 */
52
+ export const NAV_PERSIST_CAP = 30
53
+
54
+ /** 导航历史双栈快照(持久化/恢复用;viewState 由 viewStateCache 按路径另行恢复)。 */
55
+ export interface NavState {
56
+ past: NavEntry[]
57
+ future: NavEntry[]
58
+ }
59
+
60
+ /**
61
+ * 归一化条目数组:丢弃无路径项;line/column 仅保留正整数,剥掉 viewState(体积大且另有存档);
62
+ * 超限时保留末尾(最近的)cap 条。
63
+ * @author ddj 2026年09月28号
64
+ * @param entries 待归一化的原始数组(非数组 → 空表)
65
+ * @param cap 单栈上限(默认 NAV_PERSIST_CAP)
66
+ * @returns 归一化条目数组
67
+ */
68
+ export function cleanNavEntries(entries: unknown, cap = NAV_PERSIST_CAP): NavEntry[] {
69
+ if (!Array.isArray(entries)) return []
70
+ const out: NavEntry[] = []
71
+ for (const raw of entries) {
72
+ const entry = raw as NavEntry
73
+ if (!entry || typeof entry.path !== 'string' || !entry.path) continue
74
+ const item: NavEntry = { path: entry.path }
75
+ if (typeof entry.line === 'number' && Number.isInteger(entry.line) && entry.line > 0) item.line = entry.line
76
+ if (typeof entry.column === 'number' && Number.isInteger(entry.column) && entry.column > 0) item.column = entry.column
77
+ out.push(item)
78
+ }
79
+ return out.length > cap ? out.slice(out.length - cap) : out
80
+ }
81
+
47
82
  /** scope → 导航历史实例(同工作区跨会话复用同一份历史)。 */
48
83
  const scopeHistories = new Map<string, NavHistory>()
49
84
 
@@ -106,6 +141,16 @@ export function createNavHistory(cap = NAV_HISTORY_CAP): NavHistory {
106
141
  peekForward(): NavEntry | null {
107
142
  return future.length ? future[future.length - 1] : null
108
143
  },
144
+ snapshot(): NavState {
145
+ return { past: past.map((entry) => ({ ...entry })), future: future.map((entry) => ({ ...entry })) }
146
+ },
147
+ restore(state: NavState | null | undefined): void {
148
+ if (!state) return
149
+ past.length = 0
150
+ future.length = 0
151
+ past.push(...cleanNavEntries(state.past, cap))
152
+ future.push(...cleanNavEntries(state.future, cap))
153
+ },
109
154
  }
110
155
  }
111
156
 
@@ -11,6 +11,7 @@
11
11
 
12
12
  import { log } from './log.js'
13
13
  import { setEnsureSideEditor } from './sidebarBridge.js'
14
+ import { CACHE_KEY } from './paths.js'
14
15
  import { OFFICE_EXT, BLIND_EXT, suffixOf } from '../shared/nativeOpen.js'
15
16
  import { inNativePath } from './nativeOpenStore.js'
16
17
 
@@ -365,6 +366,61 @@ export function registerOfficialFileClaim(options: {
365
366
  let editorTabActive = false
366
367
  /** 正文挂载纪元(每次挂载自增;延迟判定期间有新挂载则放弃清除)。 */
367
368
  let mountEpoch = 0
369
+ /** 作用域解析器(index.ts 装配时注入;未注入则不落盘,退回纯内存标记)。 */
370
+ let tabScopeOf: (() => string) | null = null
371
+
372
+ /**
373
+ * 注入编辑 Tab 状态的作用域解析器(index.ts 装配一次;键跟随会话/工作区切换)。
374
+ * @author ddj 2026年09月28号
375
+ * @param resolve 返回工作区作用域键(scopeStore.workspaceScopeOf 产物)
376
+ */
377
+ export function setEditorTabScope(resolve: () => string): void {
378
+ tabScopeOf = resolve
379
+ }
380
+
381
+ /**
382
+ * 编辑 Tab 状态的作用域键(未注入/解析失败 → 空串,调用方跳过持久化)。
383
+ * @author ddj 2026年09月28号
384
+ * @returns localStorage 键
385
+ */
386
+ function editorTabKey(): string {
387
+ if (!tabScopeOf) return ''
388
+ try {
389
+ const scope = tabScopeOf()
390
+ return scope ? CACHE_KEY.editorTab + scope : ''
391
+ } catch (error) {
392
+ return ''
393
+ }
394
+ }
395
+
396
+ /**
397
+ * 读回「上次退出时编辑 Tab 是否处于激活态」(供冷启动自动展开编辑 Tab)。
398
+ * @author ddj 2026年09月28号
399
+ * @returns 是否曾被激活(存储不可用/未注入 → false)
400
+ */
401
+ export function readSavedTab(): boolean {
402
+ const key = editorTabKey()
403
+ if (!key) return false
404
+ try {
405
+ return localStorage.getItem(key) === '1'
406
+ } catch (error) {
407
+ return false
408
+ }
409
+ }
410
+
411
+ /**
412
+ * 持久化编辑 Tab 激活态(存储不可用静默;镜像层会把它写穿到 host 跨重启保留)。
413
+ * @author ddj 2026年09月28号
414
+ * @param active 是否激活
415
+ */
416
+ export function saveTabActive(active: boolean): void {
417
+ const key = editorTabKey()
418
+ if (!key) return
419
+ try {
420
+ if (active) localStorage.setItem(key, '1')
421
+ else localStorage.removeItem(key)
422
+ } catch (error) { /* 存储不可用:仅内存标记生效 */ }
423
+ }
368
424
 
369
425
  /**
370
426
  * 标记编辑 Tab 激活并推进挂载纪元(正文挂载时调用)。
@@ -377,11 +433,13 @@ export function markEditorMounted(): void {
377
433
 
378
434
  /**
379
435
  * 直接设置激活标记(正文卸载延迟判定用;测试 also 用作状态编排入口)。
380
- * @author ddj 2026年09月09号
436
+ * 同时写穿到 localStorage(镜像层负责跨重启保留),供下次冷启动恢复。
437
+ * @author ddj 2026年09月09号 / 2026年09月28号
381
438
  * @param active 是否激活
382
439
  */
383
440
  export function markEditorActive(active: boolean): void {
384
441
  editorTabActive = active
442
+ saveTabActive(active)
385
443
  }
386
444
 
387
445
  /**
@@ -30,6 +30,12 @@ export const CACHE_KEY = {
30
30
  rules: 'edrv.rules.v1.',
31
31
  /** SVN 变更面板 UI 状态(SvnPanel v1:未版本控制/忽略项显示开关,按作用域隔离)。 */
32
32
  svn: 'edrv.svn.v1.',
33
+ /** Markdown 预览态(EditorView v1:处于预览态的 Markdown 路径集合,按作用域隔离)。 */
34
+ mdPreview: 'edrv.md-preview.v1.',
35
+ /** 官方侧边栏「文件编辑」Tab 展开/激活态(officialSidebar v1:重启后自动回到编辑 Tab)。 */
36
+ editorTab: 'edrv.editor-tab.v1.',
37
+ /** 编辑器导航历史双栈(navStateCache v1:重启后前进/后退与当前位置仍在,按作用域隔离)。 */
38
+ navHistory: 'edrv.nav-history.v1.',
33
39
  } as const
34
40
 
35
41
  /** 按前缀拼作用域键(scopeStore 产出作用域;侧边栏/编辑器等需要布局段时自行拼接后传入)。 */
@@ -308,6 +308,32 @@ export function bindCatalogChords(service: ShortcutsServiceLike, onChange: (chor
308
308
  return service.catalog.subscribe(sync)
309
309
  }
310
310
 
311
+ /**
312
+ * 唤起官方快捷键弹窗(VSCodeMode 通用页的跳转入口用;插件不再自建录键界面)。
313
+ * 官方入口不可用(旧版 DSH / registry 缺失)或唤起失败时返回可读说明,
314
+ * 供设置页原样展示:不抛错、不阻断设置渲染。
315
+ * @author ddj 2026年09月28号
316
+ * @param service 官方 shortcuts 服务(可为 null:运行时探测未就绪)
317
+ * @returns 空串 = 已成功唤起;否则为给用户看的失败说明
318
+ */
319
+ export function openOfficialShortcuts(service: ShortcutsServiceLike | null): string {
320
+ const manual = '可按 Ctrl+/ 或到 DSH 设置 → 通用 → 快捷键 打开'
321
+ if (!service || !service.registry || typeof service.registry.invoke !== 'function') {
322
+ return '官方快捷键弹窗入口不可用(当前 DSH 版本或外壳未提供):' + manual
323
+ }
324
+ try {
325
+ service.registry.invoke('shortcuts.open', {
326
+ region: 'page',
327
+ modal: null,
328
+ target: typeof document === 'undefined' ? null : document.activeElement,
329
+ })
330
+ return ''
331
+ } catch (error) {
332
+ bridgeLog.warn('打开官方快捷键弹窗失败:' + String(error))
333
+ return '打开官方快捷键弹窗失败:' + String(error) + ';' + manual
334
+ }
335
+ }
336
+
311
337
  //#region 旧设置迁移
312
338
 
313
339
  /** 旧设置 scope 最小形状(compat settingsBridge 绑定产物的最小面,结构与 SettingsScopeLike 兼容)。 */
@@ -0,0 +1,74 @@
1
+ /**
2
+ * dsh-vscode-mode client — Markdown 预览态缓存(localStorage,按工作区作用域)。
3
+ * 语义对齐编辑器布局记忆:切换页签/刷新/重启后,处于预览态的 Markdown 仍以预览打开。
4
+ * 键 = CACHE_KEY.mdPreview + scope;值为路径数组。解析/归一化纯函数可单测,IO 收在 load/save 两端。
5
+ * 作者 ddj 2026-09-28
6
+ */
7
+ import { CACHE_KEY } from '../paths.js'
8
+
9
+ /** 单作用域记忆的预览态路径上限(超出按顺序丢弃最早,防 localStorage 膨胀)。 */
10
+ export const MD_PREVIEW_CAP = 50
11
+
12
+ /**
13
+ * 归一化预览态路径表:丢弃非字符串/空串、去重(保留首次出现顺序),超限截断。
14
+ * @author ddj 2026年09月28号
15
+ * @param value 待归一化的解析产物(任意输入,非数组 → 空表)
16
+ * @returns 路径数组
17
+ */
18
+ export function normalizeMdPreview(value: unknown): string[] {
19
+ if (!Array.isArray(value)) return []
20
+ const out: string[] = []
21
+ for (const item of value) {
22
+ if (typeof item !== 'string' || !item) continue
23
+ if (out.includes(item)) continue
24
+ out.push(item)
25
+ if (out.length >= MD_PREVIEW_CAP) break
26
+ }
27
+ return out
28
+ }
29
+
30
+ /**
31
+ * 解析存档文本(空/损坏/非数组 → 空表)。
32
+ * @author ddj 2026年09月28号
33
+ * @param text localStorage 原文
34
+ * @returns 路径数组
35
+ */
36
+ export function parseMdPreview(text: string | null): string[] {
37
+ if (!text) return []
38
+ try {
39
+ return normalizeMdPreview(JSON.parse(text))
40
+ } catch (error) {
41
+ return []
42
+ }
43
+ }
44
+
45
+ /**
46
+ * 读某作用域处于预览态的 Markdown 路径集合。
47
+ * @author ddj 2026年09月28号
48
+ * @param scope 作用域键(scopeStore.workspaceScopeOf 产物)
49
+ * @returns 预览态路径集合(存储不可用/无存档 → 空集)
50
+ */
51
+ export function mdPreviewLoad(scope: string): Set<string> {
52
+ try {
53
+ return new Set(parseMdPreview(localStorage.getItem(CACHE_KEY.mdPreview + scope)))
54
+ } catch (error) {
55
+ return new Set()
56
+ }
57
+ }
58
+
59
+ /**
60
+ * 写某作用域的预览态集合(空集删除键,避免残留空数组)。
61
+ * @author ddj 2026年09月28号
62
+ * @param scope 作用域键
63
+ * @param paths 预览态路径集合
64
+ */
65
+ export function mdPreviewSave(scope: string, paths: Set<string>): void {
66
+ try {
67
+ const list = normalizeMdPreview(Array.from(paths))
68
+ if (!list.length) {
69
+ localStorage.removeItem(CACHE_KEY.mdPreview + scope)
70
+ return
71
+ }
72
+ localStorage.setItem(CACHE_KEY.mdPreview + scope, JSON.stringify(list))
73
+ } catch (error) { /* 存储不可用:预览态仅存活本次会话 */ }
74
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * dsh-vscode-mode client — 编辑器导航历史存档(localStorage,按工作区作用域)。
3
+ * 语义:刷新/重启后「后退/前进」双栈与当前位置仍在(对齐 VSCode navigation history 的体感,
4
+ * 只是延长到跨会话/跨 origin)。条目不含 viewState——它体积大且已由 viewStateCache 按路径存档。
5
+ * 键 = CACHE_KEY.navHistory + scope;序列化/解析为纯函数,IO 收在 load/save 两端。
6
+ * 作者 ddj 2026-09-28
7
+ */
8
+ import { CACHE_KEY } from '../paths.js'
9
+ import { cleanNavEntries, NAV_PERSIST_CAP, type NavState } from '../navHistory.js'
10
+
11
+ /** 存档格式版本(结构不兼容时递增,旧档自动作废)。 */
12
+ export const NAV_STATE_VERSION = 1
13
+
14
+ /**
15
+ * 序列化双栈(空栈 → 空串,调用方据此删键)。
16
+ * @author ddj 2026年09月28号
17
+ * @param state 双栈快照(null/undefined → 空串)
18
+ * @returns 存档文本
19
+ */
20
+ export function serializeNavState(state: NavState | null | undefined): string {
21
+ if (!state) return ''
22
+ const past = cleanNavEntries(state.past, NAV_PERSIST_CAP)
23
+ const future = cleanNavEntries(state.future, NAV_PERSIST_CAP)
24
+ if (!past.length && !future.length) return ''
25
+ return JSON.stringify({ v: NAV_STATE_VERSION, past, future })
26
+ }
27
+
28
+ /**
29
+ * 解析存档文本(空/损坏/版本不符/无可恢复条目 → null)。
30
+ * @author ddj 2026年09月28号
31
+ * @param text localStorage 原文
32
+ * @returns 双栈快照
33
+ */
34
+ export function parseNavState(text: string | null): NavState | null {
35
+ if (!text) return null
36
+ try {
37
+ const value = JSON.parse(text) as { v?: unknown; past?: unknown; future?: unknown }
38
+ if (!value || typeof value !== 'object' || value.v !== NAV_STATE_VERSION) return null
39
+ const past = cleanNavEntries(value.past, NAV_PERSIST_CAP)
40
+ const future = cleanNavEntries(value.future, NAV_PERSIST_CAP)
41
+ if (!past.length && !future.length) return null
42
+ return { past, future }
43
+ } catch (error) {
44
+ return null
45
+ }
46
+ }
47
+
48
+ /** scope → 最近写入的原文(去重写:光标移动触发的快照大多与上次相同)。 */
49
+ const lastWritten = new Map<string, string>()
50
+
51
+ /**
52
+ * 读某作用域的导航历史存档。
53
+ * @author ddj 2026年09月28号
54
+ * @param scope 作用域键(scopeStore.workspaceScopeOf 产物)
55
+ * @returns 双栈快照(无存档/存储不可用 → null)
56
+ */
57
+ export function navStateLoad(scope: string): NavState | null {
58
+ try {
59
+ return parseNavState(localStorage.getItem(CACHE_KEY.navHistory + scope))
60
+ } catch (error) {
61
+ return null
62
+ }
63
+ }
64
+
65
+ /**
66
+ * 写某作用域的导航历史存档(空存档删键;原文未变跳过写入)。
67
+ * @author ddj 2026年09月28号
68
+ * @param scope 作用域键
69
+ * @param state 双栈快照(null → 删除存档)
70
+ */
71
+ export function navStateSave(scope: string, state: NavState | null | undefined): void {
72
+ try {
73
+ const text = serializeNavState(state)
74
+ if (lastWritten.get(scope) === text) return
75
+ lastWritten.set(scope, text)
76
+ const key = CACHE_KEY.navHistory + scope
77
+ if (!text) {
78
+ localStorage.removeItem(key)
79
+ return
80
+ }
81
+ localStorage.setItem(key, text)
82
+ } catch (error) { /* 存储不可用:导航历史仅存活本次会话 */ }
83
+ }