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/lib/client.js +364 -91
- package/lib/client.js.map +1 -1
- package/package.json +1 -1
- package/src/client/index.ts +19 -1
- package/src/client/navHistory.ts +28 -1
- package/src/client/officialSidebar.ts +72 -1
- package/src/client/paths.ts +3 -3
- package/src/client/sidebar/panels/FileExplorer.ts +14 -11
- package/src/client/sidebar/panels/SearchPanel.ts +10 -7
- package/src/client/sidebar/types.ts +2 -0
- package/src/client/state/explorerCache.ts +9 -9
- package/src/client/state/explorerEntriesCache.ts +31 -31
- package/src/client/state/modelCache.ts +77 -0
- package/src/client/state/scopeStore.ts +96 -0
- package/src/client/state/viewStateCache.ts +9 -9
- package/src/client/styles/editor.css +1 -1
- package/src/client/ui/EditorView.ts +64 -28
- package/src/client/ui/OfficialSideTab.ts +21 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-vscode-mode",
|
|
3
|
-
"version": "0.1.
|
|
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",
|
package/src/client/index.ts
CHANGED
|
@@ -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
|
package/src/client/navHistory.ts
CHANGED
|
@@ -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
|
package/src/client/paths.ts
CHANGED
|
@@ -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(
|
|
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(
|
|
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(
|
|
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 =
|
|
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
|
-
}, [
|
|
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 (!
|
|
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(
|
|
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,
|
|
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 (!
|
|
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
|
-
}, [
|
|
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
|
-
//
|
|
82
|
+
// 按作用域恢复上次查询、选项与过滤模式(VSCode 记忆搜索词)
|
|
80
83
|
React.useEffect(() => {
|
|
81
|
-
if (!
|
|
84
|
+
if (!scope) return
|
|
82
85
|
try {
|
|
83
|
-
const saved = JSON.parse(localStorage.getItem(CACHE_KEY.search + String(
|
|
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
|
-
}, [
|
|
99
|
+
}, [scope])
|
|
97
100
|
|
|
98
101
|
// 查询/选项/过滤变化 → 持久化
|
|
99
102
|
React.useEffect(() => {
|
|
100
|
-
if (!
|
|
103
|
+
if (!scope) return
|
|
101
104
|
try {
|
|
102
|
-
localStorage.setItem(CACHE_KEY.search + String(
|
|
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
|
-
}, [
|
|
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
|
-
*
|
|
91
|
+
* 读取某作用域的展开状态缓存(localStorage 不可用 → null)。
|
|
92
92
|
* @author ddj 2026年08月28号
|
|
93
|
-
* @param
|
|
93
|
+
* @param scope 作用域键(scopeStore.workspaceScopeOf 产物)
|
|
94
94
|
* @returns 缓存数据或 null
|
|
95
95
|
*/
|
|
96
|
-
export function explorerLoad(
|
|
96
|
+
export function explorerLoad(scope: string): ExplorerCacheData | null {
|
|
97
97
|
try {
|
|
98
|
-
return parseExplorer(window.localStorage.getItem(KEY_PREFIX + String(
|
|
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
|
|
107
|
+
* @param scope 作用域键(scopeStore.workspaceScopeOf 产物)
|
|
108
108
|
* @param data 缓存数据
|
|
109
109
|
*/
|
|
110
|
-
export function explorerSave(
|
|
110
|
+
export function explorerSave(scope: string, data: ExplorerCacheData): void {
|
|
111
111
|
try {
|
|
112
|
-
window.localStorage.setItem(KEY_PREFIX + String(
|
|
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
|
-
/**
|
|
81
|
+
/** scope → 缓存镜像(同步读,避免每次渲染 parse)。 */
|
|
82
82
|
const mirrors = new Map<string, EntriesCacheData>()
|
|
83
|
-
/**
|
|
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(
|
|
95
|
+
function readRaw(scope: string): string | null {
|
|
96
96
|
try {
|
|
97
|
-
return window.localStorage.getItem(KEY_PREFIX +
|
|
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(
|
|
104
|
+
function writeRaw(scope: string, text: string): void {
|
|
105
105
|
try {
|
|
106
|
-
window.localStorage.setItem(KEY_PREFIX +
|
|
106
|
+
window.localStorage.setItem(KEY_PREFIX + scope, text)
|
|
107
107
|
} catch (error) { /* 配额满/隐私模式忽略 */ }
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
-
/**
|
|
111
|
-
function mirrorOf(
|
|
112
|
-
const existing = mirrors.get(
|
|
110
|
+
/** 取(或建)作用域缓存镜像。 */
|
|
111
|
+
function mirrorOf(scope: string): EntriesCacheData {
|
|
112
|
+
const existing = mirrors.get(scope)
|
|
113
113
|
if (existing) return existing
|
|
114
|
-
const data = entriesParse(readRaw(
|
|
115
|
-
mirrors.set(
|
|
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(
|
|
121
|
-
const prev = saveTimers.get(
|
|
120
|
+
function scheduleSave(scope: string): void {
|
|
121
|
+
const prev = saveTimers.get(scope)
|
|
122
122
|
if (prev) clearTimeout(prev)
|
|
123
|
-
saveTimers.set(
|
|
124
|
-
saveTimers.delete(
|
|
125
|
-
const data = mirrors.get(
|
|
126
|
-
if (data) writeRaw(
|
|
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
|
|
134
|
+
* @param scope 作用域键(scopeStore.workspaceScopeOf 产物)
|
|
135
135
|
* @param rel 目录相对路径('' = 根)
|
|
136
136
|
* @returns 缓存条目或 null
|
|
137
137
|
*/
|
|
138
|
-
export function entriesCacheGet(
|
|
139
|
-
if (!
|
|
140
|
-
const dir = mirrorOf(
|
|
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
|
|
148
|
+
* @param scope 作用域键(scopeStore.workspaceScopeOf 产物)
|
|
149
149
|
* @param rel 目录相对路径
|
|
150
150
|
* @returns 新鲜返回 true
|
|
151
151
|
*/
|
|
152
|
-
export function entriesCacheIsFresh(
|
|
153
|
-
if (!
|
|
154
|
-
const dir = mirrorOf(
|
|
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
|
|
161
|
+
* @param scope 作用域键(scopeStore.workspaceScopeOf 产物)
|
|
162
162
|
* @param rel 目录相对路径
|
|
163
163
|
* @param entries 目录条目
|
|
164
164
|
*/
|
|
165
|
-
export function entriesCachePut(
|
|
166
|
-
if (!
|
|
167
|
-
const data = mirrorOf(
|
|
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(
|
|
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
|
+
}
|