dsh-vscode-mode 0.1.60 → 0.1.62

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,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-vscode-mode",
3
- "version": "0.1.60",
3
+ "version": "0.1.62",
4
4
  "description": "DSH 上的类 VSCode 编码体验:Monaco 编辑器(文件页签/QuickOpen/状态栏,可驻 DSH 0.1.5+ 官方右侧 Sidebar 与对话同屏)+ Agent 编辑差异审查(整文件差异/采纳/拒绝/归档/回滚)+ 语言服务器(LSP)智能(跳转定义/引用/hover/大纲 + VSIX 扩展市场安装),状态持久化到工作区旁车",
5
5
  "keywords": [
6
6
  "dsh",
@@ -30,7 +30,7 @@ import { setupExtOpen } from './externalOpen.js'
30
30
  import { SettingsContext } from './settingsContext.js'
31
31
  import { SIDEBAR_PLUGIN, pickSettingsBinder, registerSlotSafely } from './compat.js'
32
32
  import { detectSidebarService, installSideEditor, setEnsureSideEditor, SIDEBAR_INSTALL_CMD } from './sidebarBridge.js'
33
- import { detectOfficial, installOfficial, registerOfficialFileClaim, OFFICIAL_TAB_TITLE } from './officialSidebar.js'
33
+ import { detectOfficial, installOfficial, registerOfficialFileClaim, OFFICIAL_TAB_KIND, OFFICIAL_TAB_TITLE, isEditorTabActive, restoreEditorTab } from './officialSidebar.js'
34
34
  import { SideEditorTab } from './ui/SideEditorTab.js'
35
35
  import { OfficialSideTab } from './ui/OfficialSideTab.js'
36
36
  import { createAddToConversation } from './addToConversation.js'
@@ -98,6 +98,24 @@ export function apply(ctx: any): void {
98
98
  setSession(list.getSnapshot()?.current)
99
99
  }), 'vscode-mode: lsp session sync')
100
100
  }
101
+ // 会话切换 → 编辑 Tab 跨会话自动恢复:上一会话编辑区激活时,新会话自动展开并激活
102
+ //(官方停靠面按会话隔离,新会话默认收起;先试 openTabIn 直写新会话面,未 adopt 走有界重试)。
103
+ // 注意:首次打开某会话时通知早于 current 字段落定,故通知只调度延迟任务,执行时再读最新快照
104
+ if (monacoList && typeof monacoList.subscribe === 'function') {
105
+ const list = monacoList as { getSnapshot: () => { current?: string }; subscribe: (listener: () => void) => () => void }
106
+ ctx.effect(() => {
107
+ let lastCurrent = list.getSnapshot?.()?.current
108
+ const check = () => {
109
+ const current = list.getSnapshot?.()?.current
110
+ if (!current || current === lastCurrent) return
111
+ lastCurrent = current
112
+ if (!isEditorTabActive() || officialService === undefined) return
113
+ try { officialService.service.openTabIn?.(current, OFFICIAL_TAB_KIND, {}) } catch { /* 未 adopt 等 seat 就绪走重试 */ }
114
+ restoreEditorTab(officialService.service, schedule)
115
+ }
116
+ return list.subscribe(() => schedule(check, 0))
117
+ }, 'vscode-mode: editor tab restore')
118
+ }
101
119
  const originalOpenPath = workspaces?.openPath
102
120
  const binder = pickSettingsBinder(ctx)
103
121
  const settings = binder.scope
@@ -4,7 +4,7 @@
4
4
  * 后退回到上一处焦点,前进回到下一处,跨文件生效。
5
5
  * 数据模型:past(末位 = 当前)+ future 两个栈;record 压栈并清空 future;
6
6
  * 与栈顶同 path+line+column 视为同一位置:仅刷新 viewState 快照,不压栈、不清 future。
7
- * 纯逻辑无 DOM 依赖,可单测。
7
+ * 纯逻辑无 DOM 依赖,可单测;navHistoryFor 提供按工作区作用域的实例仓(跨会话复用)。
8
8
  * 作者 ddj 2026年09月04号
9
9
  */
10
10
 
@@ -41,6 +41,12 @@ export interface NavHistory {
41
41
  /** 后退栈默认容量上限(超出逐出最旧)。 */
42
42
  export const NAV_HISTORY_CAP = 200
43
43
 
44
+ /** 工作区历史实例缓存上限(FIFO 逐出最久未用作用域,防长期驻留泄漏)。 */
45
+ export const NAV_SCOPE_CAP = 6
46
+
47
+ /** scope → 导航历史实例(同工作区跨会话复用同一份历史)。 */
48
+ const scopeHistories = new Map<string, NavHistory>()
49
+
44
50
  /** 路径一致(容忍相对路径大小写差异,对齐既有 sameFile 语义常用写法)。 */
45
51
  function samePath(a: string, b: string): boolean {
46
52
  if (a === b) return true
@@ -102,3 +108,24 @@ export function createNavHistory(cap = NAV_HISTORY_CAP): NavHistory {
102
108
  },
103
109
  }
104
110
  }
111
+
112
+ /**
113
+ * 取(或建)某工作区作用域的导航历史实例:同 scope 跨会话复用同一实例,
114
+ * 后退/前进历史在切换对话后保留(对齐「同一工作区同一编辑区」体感)。
115
+ * 超出 NAV_SCOPE_CAP 时逐出最旧的实例(Map 插入序近似 LRU)。
116
+ * @author ddj 2026年09月09号
117
+ * @param scope 作用域键(scopeStore.workspaceScopeOf 产物)
118
+ * @param cap 后退栈容量上限(透传 createNavHistory)
119
+ * @returns 导航历史实例
120
+ */
121
+ export function navHistoryFor(scope: string, cap = NAV_HISTORY_CAP): NavHistory {
122
+ const existing = scopeHistories.get(scope)
123
+ if (existing) return existing
124
+ const created = createNavHistory(cap)
125
+ scopeHistories.set(scope, created)
126
+ if (scopeHistories.size > NAV_SCOPE_CAP) {
127
+ const oldest = scopeHistories.keys().next().value
128
+ if (typeof oldest === 'string') scopeHistories.delete(oldest)
129
+ }
130
+ return created
131
+ }
@@ -44,10 +44,14 @@ export interface SidebarRightTabsLike {
44
44
  register: (definition: unknown) => () => void
45
45
  }
46
46
 
47
- /** sidebarRight 服务的最小结构面(openTab 页路由 + openResource 资源路由)。 */
47
+ /** sidebarRight 服务的最小结构面(openTab 页路由 + openResource 资源路由 + 可选查询/定向打开)。 */
48
48
  export interface SidebarRightServiceLike {
49
49
  openTab: (kind: string, options?: { params?: unknown }) => void
50
50
  openResource?: (address: string, options?: { params?: unknown }) => void
51
+ /** 挂载会话的激活 Tab(判读编辑 Tab 是否已激活;无 seat 时 undefined)。 */
52
+ active?: () => { kind?: string } | undefined
53
+ /** 向指定会话的停靠面打开页类型(store 未 adopt 时 no-op)。 */
54
+ openTabIn?: (sessionId: string, kind: string, options?: { params?: unknown }) => void
51
55
  }
52
56
 
53
57
  /** slots 服务的最小结构面(等待声明 + 注册)。 */
@@ -279,3 +283,70 @@ export function registerOfficialFileClaim(options: {
279
283
  if (typeof bodyDisposer === 'function') bodyDisposer()
280
284
  }
281
285
  }
286
+
287
+ // --region 编辑 Tab 跨会话自动恢复
288
+
289
+ /** 编辑 Tab 是否处于激活态(正文挂载 = 激活;模块级,跨会话存活)。 */
290
+ let editorTabActive = false
291
+ /** 正文挂载纪元(每次挂载自增;延迟判定期间有新挂载则放弃清除)。 */
292
+ let mountEpoch = 0
293
+
294
+ /**
295
+ * 标记编辑 Tab 激活并推进挂载纪元(正文挂载时调用)。
296
+ * @author ddj 2026年09月09号
297
+ */
298
+ export function markEditorMounted(): void {
299
+ mountEpoch += 1
300
+ markEditorActive(true)
301
+ }
302
+
303
+ /**
304
+ * 直接设置激活标记(正文卸载延迟判定用;测试 also 用作状态编排入口)。
305
+ * @author ddj 2026年09月09号
306
+ * @param active 是否激活
307
+ */
308
+ export function markEditorActive(active: boolean): void {
309
+ editorTabActive = active
310
+ }
311
+
312
+ /**
313
+ * 读编辑 Tab 激活标记(会话切换时决定是否自动恢复编辑栏)。
314
+ * @author ddj 2026年09月09号
315
+ * @returns 上次标记的激活状态
316
+ */
317
+ export function isEditorTabActive(): boolean {
318
+ return editorTabActive
319
+ }
320
+
321
+ /**
322
+ * 读当前挂载纪元(卸载延迟判定用:纪元已推进 = 有新正文接管,不清标记)。
323
+ * @author ddj 2026年09月09号
324
+ * @returns 当前纪元
325
+ */
326
+ export function editorMountEpoch(): number {
327
+ return mountEpoch
328
+ }
329
+
330
+ /**
331
+ * 在挂载会话恢复编辑 Tab:已激活跳过(官方幂等);seat 未就绪抛错时有界重试。
332
+ * openTab 官方语义「打开即展开侧栏」,一次调用同时完成展开 + 激活。
333
+ * @author ddj 2026年09月09号
334
+ * @param service 官方 sidebarRight 服务(缺失/未装配直接返回)
335
+ * @param schedule 延时调度(ctx.timeout;重试节拍)
336
+ * @param attempts 剩余尝试次数(默认 10 次 × 150ms ≈ 1.5s 窗口)
337
+ */
338
+ export function restoreEditorTab(
339
+ service: SidebarRightServiceLike | undefined | null,
340
+ schedule: (fn: () => void, ms: number) => void,
341
+ attempts = 10,
342
+ ): void {
343
+ if (!service || typeof service.openTab !== 'function' || !editorTabActive) return
344
+ try {
345
+ if (service.active?.()?.kind === OFFICIAL_TAB_KIND) return
346
+ service.openTab(OFFICIAL_TAB_KIND, {})
347
+ } catch {
348
+ // seat 未挂载/未绑定(会话面尚未渲染):等一拍再试,放弃后保持现状不阻塞
349
+ if (attempts > 1) schedule(() => restoreEditorTab(service, schedule, attempts - 1), 150)
350
+ }
351
+ }
352
+ // --endregion
@@ -6,7 +6,7 @@
6
6
  * 作者 ddj 2026-09-01
7
7
  */
8
8
 
9
- /** localStorage 键前缀表(按功能隔离;均按会话拼接)。 */
9
+ /** localStorage 键前缀表(按功能隔离;与 scopeStore 作用域键拼接:工作区优先,无 cwd 回退会话)。 */
10
10
  export const CACHE_KEY = {
11
11
  /** 展开状态(explorerCache v1,当前使用)。 */
12
12
  expanded: 'edrv.cache.explorer.v1.',
@@ -20,7 +20,7 @@ export const CACHE_KEY = {
20
20
  sidebar: 'edrv.sidebar.',
21
21
  /** 搜索面板条件(SearchPanel v1)。 */
22
22
  search: 'edrv.search.v1.',
23
- /** 侧边栏提示已关闭标记(全局,无会话)。 */
23
+ /** 侧边栏提示已关闭标记(全局,无作用域)。 */
24
24
  sideHint: 'edrv.side-hint-dismissed',
25
25
  /** 「性能优化」页工作区栏目展开状态(workspaceFoldCache v1,全局不按会话)。 */
26
26
  workspaceFold: 'edrv.ws-fold.v1.',
@@ -28,7 +28,7 @@ export const CACHE_KEY = {
28
28
  rules: 'edrv.rules.v1.',
29
29
  } as const
30
30
 
31
- /** 按前缀拼会话键(侧边栏/编辑器等需要布局段时自行拼接后传入)。 */
31
+ /** 按前缀拼作用域键(scopeStore 产出作用域;侧边栏/编辑器等需要布局段时自行拼接后传入)。 */
32
32
  export function cacheKey(prefix: string, sessionId: string): string {
33
33
  return prefix + sessionId
34
34
  }
@@ -14,6 +14,7 @@ import { ContextMenu } from '../../ui/ContextMenu.js'
14
14
  import { buildTreeMenu } from '../contextMenu.js'
15
15
  import { explorerLoad, explorerSave } from '../../state/explorerCache.js'
16
16
  import { entriesCacheGet, entriesCacheIsFresh, entriesCachePut } from '../../state/explorerEntriesCache.js'
17
+ import { workspaceScopeOf } from '../../state/scopeStore.js'
17
18
  import type { SidebarCtx } from '../types.js'
18
19
 
19
20
  const DIR_CAP = 4000
@@ -29,6 +30,8 @@ const PREFETCH_EXCLUDED = new Set(['node_modules', '.git', '.hg', '.svn', '.pnpm
29
30
  export function FileExplorer(props) {
30
31
  const ctx = props?.ctx
31
32
  const sessionId = ctx?.sessionId
33
+ // 状态作用域:展开树/条目缓存按工作区隔离(同工作区切对话恢复同一份),无 cwd 回退会话
34
+ const scope = ctx?.scope ?? workspaceScopeOf(null, sessionId)
32
35
  const openFile = ctx?.openFile
33
36
  const activePath = ctx?.activePath ?? null
34
37
  const pendingByPath = ctx?.pendingByPath ?? {}
@@ -51,7 +54,7 @@ export function FileExplorer(props) {
51
54
  const refreshRef = React.useRef(null)
52
55
 
53
56
  /** 渲染取数:内存态 → 本地条目缓存 → null(显示加载态)。 */
54
- const entriesOf = (rel) => dirsRef.current[rel] ?? entriesCacheGet(sessionId, rel) ?? null
57
+ const entriesOf = (rel) => dirsRef.current[rel] ?? entriesCacheGet(scope, rel) ?? null
55
58
 
56
59
  /** 预取子目录:≤4 个、排除重型目录、缓存新鲜跳过、已加载跳过、不级联。 */
57
60
  const prefetchDirs = (rel, entries) => {
@@ -59,7 +62,7 @@ export function FileExplorer(props) {
59
62
  for (const e of entries) {
60
63
  if (e.type !== 'directory') continue
61
64
  if (PREFETCH_EXCLUDED.has(e.name)) continue
62
- if (entriesCacheIsFresh(sessionId, e.path)) continue
65
+ if (entriesCacheIsFresh(scope, e.path)) continue
63
66
  if (dirsRef.current[e.path] !== undefined) continue
64
67
  want.push(e.path)
65
68
  if (want.length >= PREFETCH_MAX) break
@@ -90,7 +93,7 @@ export function FileExplorer(props) {
90
93
  if (res && res.ok && Array.isArray(res.entries)) {
91
94
  setDirs((prev) => Object.assign({}, prev, { [rel]: res.entries }))
92
95
  if (res.root) setRoot((prev) => prev || res.root)
93
- entriesCachePut(sessionId, rel, res.entries)
96
+ entriesCachePut(scope, rel, res.entries)
94
97
  if (doPrefetch) prefetchDirs(rel, res.entries)
95
98
  } else {
96
99
  // 有可渲染数据时静默(旧数据可能过期但可用);全无数据才报错
@@ -151,7 +154,7 @@ export function FileExplorer(props) {
151
154
  setMenu(null)
152
155
  setRoot(null)
153
156
  // 恢复上次展开状态(对齐 VSCode:持久化展开路径,条目缓存即时渲染、后台刷新)
154
- const cached = sessionId ? explorerLoad(sessionId) : null
157
+ const cached = scope ? explorerLoad(scope) : null
155
158
  const restored = cached?.expanded ?? []
156
159
  if (cached?.root) setRoot(cached.root)
157
160
  if (restored.length) {
@@ -164,21 +167,21 @@ export function FileExplorer(props) {
164
167
  if (rel) void loadDir(rel, { prefetch: true })
165
168
  }
166
169
  // eslint-disable-next-line react-hooks/exhaustive-deps
167
- }, [sessionId])
170
+ }, [scope])
168
171
 
169
- // 展开状态防抖写回 localStorage(重启后恢复展开结构)
172
+ // 展开状态防抖写回 localStorage(重启后恢复展开结构;按工作区作用域隔离)
170
173
  React.useEffect(() => {
171
174
  if (saveTimerRef.current) { clearTimeout(saveTimerRef.current); saveTimerRef.current = null }
172
- if (!sessionId) return
175
+ if (!scope) return
173
176
  saveTimerRef.current = setTimeout(() => {
174
177
  saveTimerRef.current = null
175
178
  const expandedList = Object.keys(expanded).filter((k) => expanded[k] === true)
176
- explorerSave(sessionId, { root, expanded: expandedList })
179
+ explorerSave(scope, { root, expanded: expandedList })
177
180
  }, SAVE_DEBOUNCE_MS)
178
181
  return () => {
179
182
  if (saveTimerRef.current) { clearTimeout(saveTimerRef.current); saveTimerRef.current = null }
180
183
  }
181
- }, [expanded, root, sessionId])
184
+ }, [expanded, root, scope])
182
185
 
183
186
  // edrv:refresh(差异决策/回滚后):保留旧条目,强制后台重列
184
187
  React.useEffect(() => {
@@ -190,7 +193,7 @@ export function FileExplorer(props) {
190
193
 
191
194
  // 10s 轻量跟随:已展开目录后台刷新(命中 host 索引,近零成本),树跟随 agent 写入
192
195
  React.useEffect(() => {
193
- if (!sessionId) return
196
+ if (!scope) return
194
197
  const timer = setInterval(() => {
195
198
  const open = Object.keys(expandedRef.current).filter((k) => expandedRef.current[k] === true)
196
199
  if (!open.length) return
@@ -198,7 +201,7 @@ export function FileExplorer(props) {
198
201
  }, FOLLOW_INTERVAL_MS)
199
202
  return () => clearInterval(timer)
200
203
  // eslint-disable-next-line react-hooks/exhaustive-deps
201
- }, [sessionId])
204
+ }, [scope])
202
205
 
203
206
  const rootName = root ? String(root).split(/[\\/]/).pop() || root : ''
204
207
 
@@ -11,6 +11,7 @@ import React from 'react'
11
11
  import { rpc } from '../../rpc.js'
12
12
  import type { SidebarCtx } from '../types.js'
13
13
  import { CACHE_KEY } from '../../paths.js'
14
+ import { workspaceScopeOf } from '../../state/scopeStore.js'
14
15
 
15
16
  const DEBOUNCE_MS = 250
16
17
  const INCLUDE_PLACEHOLDER = '例如 *.ts, src/**/include'
@@ -44,6 +45,8 @@ function splitHit(text, start, end) {
44
45
  export function SearchPanel(props) {
45
46
  const ctx = props?.ctx
46
47
  const sessionId = ctx?.sessionId
48
+ // 搜索条件按工作区作用域记忆(同工作区切对话恢复同一份),无 cwd 回退会话
49
+ const scope = ctx?.scope ?? workspaceScopeOf(null, sessionId)
47
50
  const openFileAt = ctx?.openFileAt ?? (() => {})
48
51
  const activePath = ctx?.activePath ?? null
49
52
  const [query, setQuery] = React.useState('')
@@ -76,11 +79,11 @@ export function SearchPanel(props) {
76
79
  exclude: excludeOn ? splitGlobs(excludeText) : [],
77
80
  }
78
81
 
79
- // 按会话恢复上次查询、选项与过滤模式(VSCode 记忆搜索词)
82
+ // 按作用域恢复上次查询、选项与过滤模式(VSCode 记忆搜索词)
80
83
  React.useEffect(() => {
81
- if (!sessionId) return
84
+ if (!scope) return
82
85
  try {
83
- const saved = JSON.parse(localStorage.getItem(CACHE_KEY.search + String(sessionId)) || 'null')
86
+ const saved = JSON.parse(localStorage.getItem(CACHE_KEY.search + String(scope)) || 'null')
84
87
  if (saved && typeof saved === 'object') {
85
88
  if (typeof saved.query === 'string') setQuery(saved.query)
86
89
  if (typeof saved.matchCase === 'boolean') setMatchCase(saved.matchCase)
@@ -93,17 +96,17 @@ export function SearchPanel(props) {
93
96
  if (typeof saved.sectionOpen === 'boolean') setSectionOpen(saved.sectionOpen)
94
97
  }
95
98
  } catch (e) { /* 损坏忽略 */ }
96
- }, [sessionId])
99
+ }, [scope])
97
100
 
98
101
  // 查询/选项/过滤变化 → 持久化
99
102
  React.useEffect(() => {
100
- if (!sessionId) return
103
+ if (!scope) return
101
104
  try {
102
- localStorage.setItem(CACHE_KEY.search + String(sessionId), JSON.stringify({
105
+ localStorage.setItem(CACHE_KEY.search + String(scope), JSON.stringify({
103
106
  query, matchCase, wholeWord, regex, includeText, excludeText, onlyActive, excludeOn, sectionOpen,
104
107
  }))
105
108
  } catch (e) { /* 忽略 */ }
106
- }, [sessionId, query, matchCase, wholeWord, regex, includeText, excludeText, onlyActive, excludeOn, sectionOpen])
109
+ }, [scope, query, matchCase, wholeWord, regex, includeText, excludeText, onlyActive, excludeOn, sectionOpen])
107
110
 
108
111
  /**
109
112
  * 执行内容搜索(请求参数从 ref 读最新值)。
@@ -12,6 +12,8 @@ export interface SidebarCtx {
12
12
  sessionId?: string
13
13
  /** 当前会话工作区 cwd(来自 sessions 快照;规则面板项目 Tab 自动匹配用)。 */
14
14
  cwd?: string | null
15
+ /** 状态作用域键(工作区优先,无 cwd 回退会话;面板持久化统一走它,scopeStore 产出)。 */
16
+ scope?: string
15
17
  openFile: (path: string) => void
16
18
  /** 打开文件并跳转到指定行/列(搜索面板命中跳转;缺省仅打开)。 */
17
19
  openFileAt?: (path: string, line?: number, column?: number) => void
@@ -6,7 +6,7 @@
6
6
  */
7
7
  import { CACHE_KEY } from '../paths.js'
8
8
 
9
- /** localStorage 键前缀(按会话隔离)。 */
9
+ /** localStorage 键前缀(按工作区作用域隔离,无 cwd 会话回退会话键)。 */
10
10
  const KEY_PREFIX = CACHE_KEY.expanded
11
11
 
12
12
  /** 展开路径容量上限:超出丢弃最旧(按写入顺序近似 LRU)。 */
@@ -88,27 +88,27 @@ export function parseExplorer(text: string | null): ExplorerCacheData | null {
88
88
  }
89
89
 
90
90
  /**
91
- * 读取某会话的展开状态缓存(localStorage 不可用 → null)。
91
+ * 读取某作用域的展开状态缓存(localStorage 不可用 → null)。
92
92
  * @author ddj 2026年08月28号
93
- * @param sessionId 会话 id
93
+ * @param scope 作用域键(scopeStore.workspaceScopeOf 产物)
94
94
  * @returns 缓存数据或 null
95
95
  */
96
- export function explorerLoad(sessionId: string): ExplorerCacheData | null {
96
+ export function explorerLoad(scope: string): ExplorerCacheData | null {
97
97
  try {
98
- return parseExplorer(window.localStorage.getItem(KEY_PREFIX + String(sessionId)))
98
+ return parseExplorer(window.localStorage.getItem(KEY_PREFIX + String(scope)))
99
99
  } catch (error) {
100
100
  return null
101
101
  }
102
102
  }
103
103
 
104
104
  /**
105
- * 写入某会话的展开状态缓存(损坏/配额满 → 忽略)。
105
+ * 写入某作用域的展开状态缓存(损坏/配额满 → 忽略)。
106
106
  * @author ddj 2026年08月28号
107
- * @param sessionId 会话 id
107
+ * @param scope 作用域键(scopeStore.workspaceScopeOf 产物)
108
108
  * @param data 缓存数据
109
109
  */
110
- export function explorerSave(sessionId: string, data: ExplorerCacheData): void {
110
+ export function explorerSave(scope: string, data: ExplorerCacheData): void {
111
111
  try {
112
- window.localStorage.setItem(KEY_PREFIX + String(sessionId), serializeExplorer(data))
112
+ window.localStorage.setItem(KEY_PREFIX + String(scope), serializeExplorer(data))
113
113
  } catch (error) { /* 配额满/隐私模式忽略 */ }
114
114
  }
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * dsh-vscode-mode client — 资源管理栏目录条目缓存(SWR:旧数据即时渲染 + 后台刷新)。
3
- * 与 explorerCache(展开状态持久化)互补:本文件只缓存目录条目数据,键按会话隔离。
3
+ * 与 explorerCache(展开状态持久化)互补:本文件只缓存目录条目数据,键按工作区作用域隔离。
4
4
  * 纯函数(序列化/解析/裁剪)导出可单测;内存镜像 Map 同步读避免每次渲染 parse
5
5
  * localStorage,落盘 300ms 防抖;配额满/隐私模式 try/catch 降级纯内存。
6
6
  * 作者 ddj 2026-08-31
@@ -78,9 +78,9 @@ export function entriesParse(text: string | null): EntriesCacheData | null {
78
78
  }
79
79
 
80
80
  // --region 内存镜像 + 防抖落盘
81
- /** sessionId → 缓存镜像(同步读,避免每次渲染 parse)。 */
81
+ /** scope → 缓存镜像(同步读,避免每次渲染 parse)。 */
82
82
  const mirrors = new Map<string, EntriesCacheData>()
83
- /** sessionId → 落盘防抖计时器。 */
83
+ /** scope → 落盘防抖计时器。 */
84
84
  const saveTimers = new Map<string, ReturnType<typeof setTimeout>>()
85
85
  /** 单调时钟:连续 put 的 ts 严格递增(同毫秒不并列,逐出顺序可预期)。 */
86
86
  let clock = 0
@@ -92,38 +92,38 @@ function nextTs(): number {
92
92
  }
93
93
 
94
94
  /** localStorage 读取(不可用 → null)。 */
95
- function readRaw(sessionId: string): string | null {
95
+ function readRaw(scope: string): string | null {
96
96
  try {
97
- return window.localStorage.getItem(KEY_PREFIX + sessionId)
97
+ return window.localStorage.getItem(KEY_PREFIX + scope)
98
98
  } catch (error) {
99
99
  return null
100
100
  }
101
101
  }
102
102
 
103
103
  /** localStorage 写入(配额/隐私模式失败静默)。 */
104
- function writeRaw(sessionId: string, text: string): void {
104
+ function writeRaw(scope: string, text: string): void {
105
105
  try {
106
- window.localStorage.setItem(KEY_PREFIX + sessionId, text)
106
+ window.localStorage.setItem(KEY_PREFIX + scope, text)
107
107
  } catch (error) { /* 配额满/隐私模式忽略 */ }
108
108
  }
109
109
 
110
- /** 取(或建)会话缓存镜像。 */
111
- function mirrorOf(sessionId: string): EntriesCacheData {
112
- const existing = mirrors.get(sessionId)
110
+ /** 取(或建)作用域缓存镜像。 */
111
+ function mirrorOf(scope: string): EntriesCacheData {
112
+ const existing = mirrors.get(scope)
113
113
  if (existing) return existing
114
- const data = entriesParse(readRaw(sessionId)) ?? { v: 2, root: null, dirs: {} }
115
- mirrors.set(sessionId, data)
114
+ const data = entriesParse(readRaw(scope)) ?? { v: 2, root: null, dirs: {} }
115
+ mirrors.set(scope, data)
116
116
  return data
117
117
  }
118
118
 
119
119
  /** 调度防抖落盘(重置已有计时器)。 */
120
- function scheduleSave(sessionId: string): void {
121
- const prev = saveTimers.get(sessionId)
120
+ function scheduleSave(scope: string): void {
121
+ const prev = saveTimers.get(scope)
122
122
  if (prev) clearTimeout(prev)
123
- saveTimers.set(sessionId, setTimeout(() => {
124
- saveTimers.delete(sessionId)
125
- const data = mirrors.get(sessionId)
126
- if (data) writeRaw(sessionId, entriesSerialize(entriesTrim(data)))
123
+ saveTimers.set(scope, setTimeout(() => {
124
+ saveTimers.delete(scope)
125
+ const data = mirrors.get(scope)
126
+ if (data) writeRaw(scope, entriesSerialize(entriesTrim(data)))
127
127
  }, CACHE_SAVE_DEBOUNCE_MS))
128
128
  }
129
129
  // --endregion
@@ -131,13 +131,13 @@ function scheduleSave(sessionId: string): void {
131
131
  /**
132
132
  * 读某目录缓存条目(无 → null;展示不受新鲜度限制)。
133
133
  * @author ddj 2026年08月31号
134
- * @param sessionId 会话 id
134
+ * @param scope 作用域键(scopeStore.workspaceScopeOf 产物)
135
135
  * @param rel 目录相对路径('' = 根)
136
136
  * @returns 缓存条目或 null
137
137
  */
138
- export function entriesCacheGet(sessionId: string | undefined, rel: string): TreeEntry[] | null {
139
- if (!sessionId) return null
140
- const dir = mirrorOf(sessionId).dirs[rel]
138
+ export function entriesCacheGet(scope: string | undefined, rel: string): TreeEntry[] | null {
139
+ if (!scope) return null
140
+ const dir = mirrorOf(scope).dirs[rel]
141
141
  if (!dir || !dir.entries.length) return null
142
142
  return dir.entries
143
143
  }
@@ -145,28 +145,28 @@ export function entriesCacheGet(sessionId: string | undefined, rel: string): Tre
145
145
  /**
146
146
  * 某目录缓存是否新鲜(预取去重用)。
147
147
  * @author ddj 2026年08月31号
148
- * @param sessionId 会话 id
148
+ * @param scope 作用域键(scopeStore.workspaceScopeOf 产物)
149
149
  * @param rel 目录相对路径
150
150
  * @returns 新鲜返回 true
151
151
  */
152
- export function entriesCacheIsFresh(sessionId: string | undefined, rel: string): boolean {
153
- if (!sessionId) return false
154
- const dir = mirrorOf(sessionId).dirs[rel]
152
+ export function entriesCacheIsFresh(scope: string | undefined, rel: string): boolean {
153
+ if (!scope) return false
154
+ const dir = mirrorOf(scope).dirs[rel]
155
155
  return Boolean(dir && Date.now() - dir.ts < CACHE_FRESH_MS)
156
156
  }
157
157
 
158
158
  /**
159
159
  * 写入某目录缓存条目(内存镜像 + 防抖落盘;单目录条目截断)。
160
160
  * @author ddj 2026年08月31号
161
- * @param sessionId 会话 id
161
+ * @param scope 作用域键(scopeStore.workspaceScopeOf 产物)
162
162
  * @param rel 目录相对路径
163
163
  * @param entries 目录条目
164
164
  */
165
- export function entriesCachePut(sessionId: string | undefined, rel: string, entries: TreeEntry[]): void {
166
- if (!sessionId) return
167
- const data = mirrorOf(sessionId)
165
+ export function entriesCachePut(scope: string | undefined, rel: string, entries: TreeEntry[]): void {
166
+ if (!scope) return
167
+ const data = mirrorOf(scope)
168
168
  data.dirs[rel] = { ts: nextTs(), entries: entries.slice(0, CACHE_DIR_ENTRIES) }
169
169
  const trimmed = entriesTrim(data)
170
170
  data.dirs = trimmed.dirs
171
- scheduleSave(sessionId)
171
+ scheduleSave(scope)
172
172
  }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * dsh-vscode-mode client — Monaco model 跨挂载缓存(按工作区作用域)。
3
+ * 编辑区每会话重挂载会销毁实例,但文件 model(内容)与工作区绑定:
4
+ * 按作用域把 model 缓存在模块级,重挂载后 loadContent 命中缓存同步种出内容,
5
+ * 消除切换对话时的加载闪烁。切换作用域时释放上一作用域未附着编辑器的模型
6
+ * (附着中的仅移出缓存,交由编辑器自身生命周期,防正在显示的模型被销毁)。
7
+ * 纯对象容器逻辑,不依赖 monaco,可单测。
8
+ * 作者 ddj 2026-09-09
9
+ */
10
+
11
+ // --region 常量与状态
12
+
13
+ /** 单作用域缓存上限(超出按写入序逐出最旧,近似 LRU)。 */
14
+ export const MODEL_CACHE_CAP = 40
15
+
16
+ /** scope →(path → model)缓存表。 */
17
+ const caches = new Map<string, Map<string, unknown>>()
18
+ /** 当前激活作用域(切换时释放上一个)。 */
19
+ let activeScope: string | null = null
20
+ // --endregion
21
+
22
+ /** 模型是否仍附着在某个编辑器实例上(缺方法视为未附着)。 */
23
+ function isAttached(model: unknown): boolean {
24
+ const fn = (model as { isAttachedToEditor?: () => boolean } | null)?.isAttachedToEditor
25
+ return typeof fn === 'function' ? fn.call(model) === true : false
26
+ }
27
+
28
+ /** 释放模型(仅未附着编辑器时真正 dispose;附着中的保留给编辑器自毁)。 */
29
+ function disposeIfFree(model: unknown): void {
30
+ if (!model || isAttached(model)) return
31
+ try { (model as { dispose: () => void }).dispose() } catch { /* 已释放/异常忽略 */ }
32
+ }
33
+
34
+ /**
35
+ * 取(或切)某作用域的 model 缓存:同作用域重复调用复用同一 Map(跨挂载存活);
36
+ * 作用域变化时释放上一作用域的模型(未附着的 dispose,附着中的仅移出缓存)。
37
+ * @author ddj 2026年09月09号
38
+ * @param scope 作用域键(scopeStore.workspaceScopeOf 产物)
39
+ * @returns 该作用域的 path → model Map
40
+ */
41
+ export function modelsForScope(scope: string): Map<string, unknown> {
42
+ const existing = caches.get(scope)
43
+ if (existing && activeScope === scope) return existing
44
+ if (activeScope !== null && activeScope !== scope) {
45
+ const prev = caches.get(activeScope)
46
+ if (prev) {
47
+ for (const model of prev.values()) disposeIfFree(model)
48
+ prev.clear()
49
+ caches.delete(activeScope)
50
+ }
51
+ }
52
+ activeScope = scope
53
+ if (existing) return existing
54
+ const map = new Map<string, unknown>()
55
+ caches.set(scope, map)
56
+ return map
57
+ }
58
+
59
+ /**
60
+ * 记入模型并刷新 LRU 序(先删后插保证最新),超上限逐出最旧(未附着才 dispose)。
61
+ * @author ddj 2026年09月09号
62
+ * @param map 作用域缓存 Map
63
+ * @param path 工作区相对路径
64
+ * @param model Monaco model(或测试桩)
65
+ */
66
+ export function rememberModel(map: Map<string, unknown>, path: string, model: unknown): void {
67
+ if (!path || !model) return
68
+ map.delete(path)
69
+ map.set(path, model)
70
+ while (map.size > MODEL_CACHE_CAP) {
71
+ const oldest = map.keys().next().value
72
+ if (oldest === undefined) break
73
+ const evicted = map.get(oldest)
74
+ map.delete(oldest)
75
+ disposeIfFree(evicted)
76
+ }
77
+ }