dsh-vscode-mode 0.1.30 → 0.1.35

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.
Files changed (58) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +30 -0
  3. package/lib/client.js +1439 -46
  4. package/lib/client.js.map +1 -1
  5. package/lib/index.js +3519 -222
  6. package/lib/index.js.map +1 -1
  7. package/package.json +2 -2
  8. package/src/capture.ts +3 -0
  9. package/src/client/events.ts +11 -0
  10. package/src/client/index.ts +20 -2
  11. package/src/client/monaco/lsp/index.ts +30 -0
  12. package/src/client/monaco/lsp/lspClient.ts +192 -0
  13. package/src/client/monaco/lsp/providers.ts +229 -0
  14. package/src/client/outline/OutlinePanel.tsx +1 -1
  15. package/src/client/outline/lspSource.ts +49 -0
  16. package/src/client/outline/parse.ts +31 -1
  17. package/src/client/outline/types.ts +2 -0
  18. package/src/client/paths.ts +36 -0
  19. package/src/client/sidebar/panels/FileExplorer.ts +95 -30
  20. package/src/client/sidebar/panels/SearchPanel.ts +11 -2
  21. package/src/client/state/explorerCache.ts +3 -1
  22. package/src/client/state/explorerEntriesCache.ts +172 -0
  23. package/src/client/state/viewStateCache.ts +3 -1
  24. package/src/client/styles/editor.css +16 -0
  25. package/src/client/styles/mcp.css +63 -0
  26. package/src/client/ui/EditorView.ts +61 -6
  27. package/src/client/ui/LspSettings.ts +268 -0
  28. package/src/client/ui/McpSettings.ts +5 -1
  29. package/src/client/ui/PerfSettings.ts +242 -0
  30. package/src/client/ui/PerfSizeBadge.ts +54 -0
  31. package/src/compat.ts +3 -2
  32. package/src/fileOpenSettings.ts +1 -0
  33. package/src/index.ts +25 -2
  34. package/src/lsp/client.ts +205 -0
  35. package/src/lsp/config.ts +107 -0
  36. package/src/lsp/extmgr.ts +208 -0
  37. package/src/lsp/jsonrpc.ts +86 -0
  38. package/src/lsp/manager.ts +133 -0
  39. package/src/lsp/providers.ts +235 -0
  40. package/src/lsp/rpc.ts +334 -0
  41. package/src/lsp/server.ts +346 -0
  42. package/src/lsp/settings.ts +44 -0
  43. package/src/lsp/transport.ts +143 -0
  44. package/src/lsp/uri.ts +62 -0
  45. package/src/lsp/zip.ts +82 -0
  46. package/src/paths.ts +284 -0
  47. package/src/perf.ts +340 -0
  48. package/src/perfPatch.ts +76 -0
  49. package/src/routes.ts +7 -12
  50. package/src/rpc.ts +184 -36
  51. package/src/search/content.ts +14 -7
  52. package/src/search/ripgrep.ts +54 -1
  53. package/src/search/types.ts +4 -1
  54. package/src/shared/lsp.ts +138 -0
  55. package/src/shared/rpc.ts +103 -2
  56. package/src/store.ts +2 -2
  57. package/src/treeIndex.ts +397 -0
  58. package/src/treeRead.ts +87 -0
@@ -0,0 +1,36 @@
1
+ /**
2
+ * dsh-vscode-mode client — 浏览器端缓存键/路径统一出口(PathConst)。
3
+ * 统一 localStorage 键前缀(各缓存模块/编辑区/搜索面板共用),避免内联散落;
4
+ * 键带 schema 版本号,未来版本递增时经 sweepLegacyKeys 自动清旧。
5
+ * 浏览器不涉 ~/.dsh 文件系统,这里只管键名 + shared 路由常量。
6
+ * 作者 ddj 2026-09-01
7
+ */
8
+
9
+ /** localStorage 键前缀表(按功能隔离;均按会话拼接)。 */
10
+ export const CACHE_KEY = {
11
+ /** 展开状态(explorerCache v1,当前使用)。 */
12
+ expanded: 'edrv.cache.explorer.v1.',
13
+ /** 目录条目 SWR 缓存(explorerEntriesCache v2,当前使用)。 */
14
+ entries: 'edrv.cache.entries.v2.',
15
+ /** 编辑区视图状态(viewStateCache v1)。 */
16
+ viewstate: 'edrv.cache.viewstate.v1.',
17
+ /** 编辑器页签/活动文件(EditorView v2)。 */
18
+ editor: 'edrv.editor.v2.',
19
+ /** 侧边栏状态(EditorView;按布局 side/central 追加段)。 */
20
+ sidebar: 'edrv.sidebar.',
21
+ /** 搜索面板条件(SearchPanel v1)。 */
22
+ search: 'edrv.search.v1.',
23
+ /** 侧边栏提示已关闭标记(全局,无会话)。 */
24
+ sideHint: 'edrv.side-hint-dismissed',
25
+ } as const
26
+
27
+ /** 按前缀拼会话键(侧边栏/编辑器等需要布局段时自行拼接后传入)。 */
28
+ export function cacheKey(prefix: string, sessionId: string): string {
29
+ return prefix + sessionId
30
+ }
31
+
32
+ /** 旧版本键清理入口:未来前缀版本递增时在此登记旧前缀并清空(当前无实际旧键)。 */
33
+ export function sweepLegacyKeys(): void {
34
+ // 例:entries v2 取代 v1 时 → localStorage.removeItem('edrv.cache.entries.v1.' + sid)
35
+ // 当前各功能均为现役版本,无待清旧键。
36
+ }
@@ -1,11 +1,12 @@
1
1
  // @ts-nocheck
2
2
  /**
3
- * dsh-vscode-mode client — 侧边栏「文件管理」面板(懒加载目录树)。
4
- * 点目录展开/收起(首次经 edrv.listDir 拉取并缓存),点文件经 ctx.openFile 打开;
5
- * 差异角标取 pendingByPath,活动文件高亮;edrv:refresh 事件与手动刷新重载树。
6
- * 右键行/空白弹出菜单:项来自 ctx.fileMenuItems 注册表(可拓展),
7
- * 内置「在文件浏览器中打开」经 edrv.revealInExplorer 定位/打开。
8
- * 作者 ddj 2026-08-26 / 2026-08-27
3
+ * dsh-vscode-mode client — 侧边栏「文件管理」面板(懒加载目录树 + SWR 缓存)。
4
+ * 展开目录立即渲染(dirs 内存态 → 本地条目缓存兜底),后台刷新(edrv.listDir 走
5
+ * host 树索引,命中近乎零成本);挂载/切会话从缓存即时恢复展开树;⟳/edrv:refresh
6
+ * 保留旧条目强制后台重列(force);每次用户发起加载后预取 ≤4 个子目录(排除重型
7
+ * 目录、缓存新鲜跳过、不级联);10s 轻量跟随已展开目录(命中索引,RPC 近零成本)。
8
+ * 差异角标/右键菜单/展开状态持久化行为不变。
9
+ * 作者 ddj 2026-08-26 / 2026-08-27 / 2026-08-31
9
10
  */
10
11
  import React from 'react'
11
12
  import { rpc } from '../../rpc.js'
@@ -13,13 +14,17 @@ import { ContextMenu } from '../../ui/ContextMenu.js'
13
14
  import { rowMenuPosition } from '../../ui/menuPosition.js'
14
15
  import { buildTreeMenu } from '../contextMenu.js'
15
16
  import { explorerLoad, explorerSave } from '../../state/explorerCache.js'
17
+ import { entriesCacheGet, entriesCacheIsFresh, entriesCachePut } from '../../state/explorerEntriesCache.js'
16
18
  import type { SidebarCtx } from '../types.js'
17
19
 
18
20
  const DIR_CAP = 4000
19
21
  const SAVE_DEBOUNCE_MS = 300
22
+ const FOLLOW_INTERVAL_MS = 10_000
23
+ const PREFETCH_MAX = 4
24
+ const PREFETCH_EXCLUDED = new Set(['node_modules', '.git', '.hg', '.svn', '.pnpm', '.pnpm-store'])
20
25
 
21
26
  /**
22
- * 目录树面板主体。
27
+ * 目录树面板主体(SWR:有缓存先渲染,无缓存才显示加载态;加载总在后台)。
23
28
  * @param props.ctx 面板共享上下文(sessionId/openFile/activePath/pendingByPath/fileMenuItems/notify)
24
29
  */
25
30
  export function FileExplorer(props) {
@@ -29,34 +34,76 @@ export function FileExplorer(props) {
29
34
  const activePath = ctx?.activePath ?? null
30
35
  const pendingByPath = ctx?.pendingByPath ?? {}
31
36
  const [root, setRoot] = React.useState(null)
32
- const [dirs, setDirs] = React.useState({})
37
+ const [dirs, setDirs] = React.useState({}) // rel → 最新条目(本会话内存态)
33
38
  const [expanded, setExpanded] = React.useState({})
34
39
  const [loading, setLoading] = React.useState({})
35
- const [error, setError] = React.useState(null)
40
+ const [errors, setErrors] = React.useState({}) // rel → 错误文案(仅无任何数据时展示)
36
41
  const [menu, setMenu] = React.useState(null) // 右键菜单 { x, y, target }
42
+ // 各状态 ref 镜像:定时器/监听用最新闭包
37
43
  const tokensRef = React.useRef({})
38
- // expanded 的 ref 镜像:edrv:refresh 监听用首次渲染闭包,但需读到最新展开态
39
44
  const expandedRef = React.useRef({})
40
45
  expandedRef.current = expanded
41
- // 展开状态防抖写回计时器
46
+ const loadingRef = React.useRef({})
47
+ loadingRef.current = loading
48
+ const dirsRef = React.useRef({})
49
+ dirsRef.current = dirs
42
50
  const saveTimerRef = React.useRef(null)
51
+ const loadDirRef = React.useRef(null)
52
+ const refreshRef = React.useRef(null)
43
53
 
44
- const loadDir = (rel) => {
54
+ /** 渲染取数:内存态 → 本地条目缓存 → null(显示加载态)。 */
55
+ const entriesOf = (rel) => dirsRef.current[rel] ?? entriesCacheGet(sessionId, rel) ?? null
56
+
57
+ /** 预取子目录:≤4 个、排除重型目录、缓存新鲜跳过、已加载跳过、不级联。 */
58
+ const prefetchDirs = (rel, entries) => {
59
+ const want = []
60
+ for (const e of entries) {
61
+ if (e.type !== 'directory') continue
62
+ if (PREFETCH_EXCLUDED.has(e.name)) continue
63
+ if (entriesCacheIsFresh(sessionId, e.path)) continue
64
+ if (dirsRef.current[e.path] !== undefined) continue
65
+ want.push(e.path)
66
+ if (want.length >= PREFETCH_MAX) break
67
+ }
68
+ for (const sub of want) void loadDir(sub, {})
69
+ }
70
+
71
+ /**
72
+ * 后台加载目录:token 守卫防乱序;force 跳过索引命中强制实列;
73
+ * 非 force 且已在途时跳过(避免轮询打断用户展开的响应)。
74
+ */
75
+ const loadDir = (rel, opts) => {
76
+ const force = opts?.force === true
77
+ const doPrefetch = opts?.prefetch === true
78
+ if (!force && loadingRef.current[rel]) return
45
79
  const token = (tokensRef.current[rel] || 0) + 1
46
80
  tokensRef.current[rel] = token
47
81
  setLoading((prev) => Object.assign({}, prev, { [rel]: true }))
48
- setError(null)
49
- rpc('edrv.listDir', { sessionId, path: rel }).then((res) => {
82
+ setErrors((prev) => {
83
+ const next = Object.assign({}, prev)
84
+ delete next[rel]
85
+ return next
86
+ })
87
+ const args = { sessionId, path: rel }
88
+ if (force) args.force = true
89
+ rpc('edrv.listDir', args).then((res) => {
50
90
  if (tokensRef.current[rel] !== token) return
51
91
  if (res && res.ok && Array.isArray(res.entries)) {
52
92
  setDirs((prev) => Object.assign({}, prev, { [rel]: res.entries }))
53
- if (res.root && !root) setRoot(res.root)
93
+ if (res.root) setRoot((prev) => prev || res.root)
94
+ entriesCachePut(sessionId, rel, res.entries)
95
+ if (doPrefetch) prefetchDirs(rel, res.entries)
54
96
  } else {
55
- setError(res?.error ? String(res.error) : '读取目录失败')
97
+ // 有可渲染数据时静默(旧数据可能过期但可用);全无数据才报错
98
+ if (!entriesOf(rel)) {
99
+ setErrors((prev) => Object.assign({}, prev, { [rel]: res?.error ? String(res.error) : '读取目录失败' }))
100
+ }
56
101
  }
57
102
  }).catch((e) => {
58
103
  if (tokensRef.current[rel] !== token) return
59
- setError('读取目录异常:' + String(e))
104
+ if (!entriesOf(rel)) {
105
+ setErrors((prev) => Object.assign({}, prev, { [rel]: '读取目录异常:' + String(e) }))
106
+ }
60
107
  }).finally(() => {
61
108
  if (tokensRef.current[rel] !== token) return
62
109
  setLoading((prev) => {
@@ -66,43 +113,45 @@ export function FileExplorer(props) {
66
113
  })
67
114
  })
68
115
  }
116
+ loadDirRef.current = loadDir
69
117
 
70
118
  const toggle = (rel) => {
71
119
  if (expanded[rel] === true) {
72
120
  setExpanded((prev) => Object.assign({}, prev, { [rel]: false }))
73
121
  return
74
122
  }
75
- if (!dirs[rel]) void loadDir(rel)
76
123
  setExpanded((prev) => Object.assign({}, prev, { [rel]: true }))
124
+ void loadDir(rel, { prefetch: true })
77
125
  }
78
126
 
79
127
  const refresh = () => {
80
128
  const keep = Object.keys(expandedRef.current).filter((k) => expandedRef.current[k] === true)
81
129
  tokensRef.current = {}
82
130
  setMenu(null)
83
- setDirs({})
131
+ setErrors({})
132
+ // SWR:保留旧条目不清空,后台强制重列(force 跳过索引)
84
133
  setLoading({})
85
- setError(null)
86
134
  if (keep.length) {
87
135
  const next = {}
88
136
  for (const rel of keep) next[rel] = true
89
137
  setExpanded(next)
90
- for (const rel of keep) void loadDir(rel)
138
+ for (const rel of keep) void loadDir(rel, { force: true, prefetch: true })
91
139
  } else {
92
140
  setExpanded({})
93
141
  }
94
- void loadDir('')
142
+ void loadDir('', { force: true, prefetch: true })
95
143
  }
144
+ refreshRef.current = refresh
96
145
 
97
146
  React.useEffect(() => {
98
147
  tokensRef.current = {}
99
148
  setDirs({})
100
149
  setExpanded({})
101
150
  setLoading({})
102
- setError(null)
151
+ setErrors({})
103
152
  setMenu(null)
104
153
  setRoot(null)
105
- // 恢复上次展开状态(对齐 VSCode:持久化展开路径,目录条目实时拉取)
154
+ // 恢复上次展开状态(对齐 VSCode:持久化展开路径,条目缓存即时渲染、后台刷新)
106
155
  const cached = sessionId ? explorerLoad(sessionId) : null
107
156
  const restored = cached?.expanded ?? []
108
157
  if (cached?.root) setRoot(cached.root)
@@ -111,9 +160,9 @@ export function FileExplorer(props) {
111
160
  for (const rel of restored) next[rel] = true
112
161
  setExpanded(next)
113
162
  }
114
- void loadDir('')
163
+ void loadDir('', { prefetch: true })
115
164
  for (const rel of restored) {
116
- if (rel) void loadDir(rel)
165
+ if (rel) void loadDir(rel, { prefetch: true })
117
166
  }
118
167
  // eslint-disable-next-line react-hooks/exhaustive-deps
119
168
  }, [sessionId])
@@ -132,13 +181,26 @@ export function FileExplorer(props) {
132
181
  }
133
182
  }, [expanded, root, sessionId])
134
183
 
184
+ // edrv:refresh(差异决策/回滚后):保留旧条目,强制后台重列
135
185
  React.useEffect(() => {
136
- const onRefresh = () => refresh()
186
+ const onRefresh = () => refreshRef.current?.()
137
187
  window.addEventListener('edrv:refresh', onRefresh)
138
188
  return () => window.removeEventListener('edrv:refresh', onRefresh)
139
189
  // eslint-disable-next-line react-hooks/exhaustive-deps
140
190
  }, [])
141
191
 
192
+ // 10s 轻量跟随:已展开目录后台刷新(命中 host 索引,近零成本),树跟随 agent 写入
193
+ React.useEffect(() => {
194
+ if (!sessionId) return
195
+ const timer = setInterval(() => {
196
+ const open = Object.keys(expandedRef.current).filter((k) => expandedRef.current[k] === true)
197
+ if (!open.length) return
198
+ for (const rel of open) loadDirRef.current?.(rel, {})
199
+ }, FOLLOW_INTERVAL_MS)
200
+ return () => clearInterval(timer)
201
+ // eslint-disable-next-line react-hooks/exhaustive-deps
202
+ }, [sessionId])
203
+
142
204
  const rootName = root ? String(root).split(/[\\/]/).pop() || root : ''
143
205
 
144
206
  const rowEl = (e, depth, isDir, isOpen) => {
@@ -172,7 +234,7 @@ export function FileExplorer(props) {
172
234
  }
173
235
 
174
236
  const rowsOf = (rel, depth) => {
175
- const entries = dirs[rel]
237
+ const entries = entriesOf(rel)
176
238
  if (!entries) {
177
239
  return [React.createElement('div', { key: rel + ':loading', className: 'edrv-tree-loading', style: { paddingLeft: 6 + depth * 14 } },
178
240
  loading[rel] ? '加载中…' : '无法加载')]
@@ -212,6 +274,9 @@ export function FileExplorer(props) {
212
274
  }))
213
275
  : []
214
276
 
277
+ const errorList = Object.entries(errors)
278
+ const errorText = errorList.length ? errorList[0][1] : null
279
+
215
280
  return React.createElement('div', { className: 'edrv-side-panel', onContextMenu: onPanelContext },
216
281
  React.createElement('div', { className: 'edrv-side-head' },
217
282
  React.createElement('span', { className: 'edrv-side-title' }, '资源管理器'),
@@ -219,9 +284,9 @@ export function FileExplorer(props) {
219
284
  React.createElement('span', { style: { flex: 1 } }),
220
285
  React.createElement('button', { className: 'edrv-side-btn', title: '刷新目录树', onClick: refresh }, '⟳')),
221
286
  React.createElement('div', { className: 'edrv-tree' },
222
- (error
287
+ (errorText
223
288
  ? React.createElement('div', { className: 'edrv-tree-error' },
224
- React.createElement('span', null, String(error)),
289
+ React.createElement('span', null, String(errorText)),
225
290
  React.createElement('button', { className: 'edrv-side-btn', onClick: () => refresh() }, '重试'))
226
291
  : null),
227
292
  rowsOf('', 0)),
@@ -10,6 +10,7 @@
10
10
  import React from 'react'
11
11
  import { rpc } from '../../rpc.js'
12
12
  import type { SidebarCtx } from '../types.js'
13
+ import { CACHE_KEY } from '../../paths.js'
13
14
 
14
15
  const DEBOUNCE_MS = 250
15
16
  const INCLUDE_PLACEHOLDER = '例如 *.ts, src/**/include'
@@ -56,6 +57,7 @@ export function SearchPanel(props) {
56
57
  const [sectionOpen, setSectionOpen] = React.useState(false)
57
58
  const [status, setStatus] = React.useState('idle') // idle | searching | done | error
58
59
  const [error, setError] = React.useState('')
60
+ const [warning, setWarning] = React.useState('') // rg 遍历错误时的部分结果提示(结果照常展示)
59
61
  const [matches, setMatches] = React.useState(null)
60
62
  const [truncated, setTruncated] = React.useState(false)
61
63
  const [collapsed, setCollapsed] = React.useState({})
@@ -78,7 +80,7 @@ export function SearchPanel(props) {
78
80
  React.useEffect(() => {
79
81
  if (!sessionId) return
80
82
  try {
81
- const saved = JSON.parse(localStorage.getItem('edrv.search.v1.' + String(sessionId)) || 'null')
83
+ const saved = JSON.parse(localStorage.getItem(CACHE_KEY.search + String(sessionId)) || 'null')
82
84
  if (saved && typeof saved === 'object') {
83
85
  if (typeof saved.query === 'string') setQuery(saved.query)
84
86
  if (typeof saved.matchCase === 'boolean') setMatchCase(saved.matchCase)
@@ -97,7 +99,7 @@ export function SearchPanel(props) {
97
99
  React.useEffect(() => {
98
100
  if (!sessionId) return
99
101
  try {
100
- localStorage.setItem('edrv.search.v1.' + String(sessionId), JSON.stringify({
102
+ localStorage.setItem(CACHE_KEY.search + String(sessionId), JSON.stringify({
101
103
  query, matchCase, wholeWord, regex, includeText, excludeText, onlyActive, excludeOn, sectionOpen,
102
104
  }))
103
105
  } catch (e) { /* 忽略 */ }
@@ -114,6 +116,7 @@ export function SearchPanel(props) {
114
116
  const req = requestRef.current
115
117
  setStatus('searching')
116
118
  setError('')
119
+ setWarning('')
117
120
  rpc('edrv.searchContent', {
118
121
  sessionId, query: clean,
119
122
  matchCase: req.matchCase, wholeWord: req.wholeWord, regex: req.regex,
@@ -123,6 +126,7 @@ export function SearchPanel(props) {
123
126
  if (res && res.ok) {
124
127
  setMatches(Array.isArray(res.matches) ? res.matches : [])
125
128
  setTruncated(res.truncated === true)
129
+ setWarning(res?.warning ? String(res.warning) : '')
126
130
  setStatus('done')
127
131
  } else {
128
132
  setError(res?.error ? String(res.error) : '搜索失败')
@@ -249,6 +253,10 @@ export function SearchPanel(props) {
249
253
  : '无匹配结果')
250
254
  : null
251
255
 
256
+ const warnBanner = warning
257
+ ? React.createElement('div', { className: 'edrv-search-warn', title: warning }, warning)
258
+ : null
259
+
252
260
  const body = (() => {
253
261
  if (status === 'searching') return React.createElement('div', { className: 'edrv-search-empty' }, '搜索中…')
254
262
  if (status === 'error') return React.createElement('div', { className: 'edrv-search-error' }, error)
@@ -290,5 +298,6 @@ export function SearchPanel(props) {
290
298
  optsBar,
291
299
  filterRows,
292
300
  summary,
301
+ warnBanner,
293
302
  body)
294
303
  }
@@ -1,11 +1,13 @@
1
1
  /**
2
2
  * dsh-vscode-mode client — 资源管理栏展开状态持久化(纯函数,可单测)。
3
3
  * 对齐 VSCode:只持久化展开路径集合,不缓存目录条目数据(条目实时 listDir)。
4
+ * 键前缀统一走 paths.ts PathConst(CACHE_KEY.expanded)。
4
5
  * 作者 ddj 2026-08-28
5
6
  */
7
+ import { CACHE_KEY } from '../paths.js'
6
8
 
7
9
  /** localStorage 键前缀(按会话隔离)。 */
8
- const KEY_PREFIX = 'edrv.cache.explorer.v1.'
10
+ const KEY_PREFIX = CACHE_KEY.expanded
9
11
 
10
12
  /** 展开路径容量上限:超出丢弃最旧(按写入顺序近似 LRU)。 */
11
13
  export const EXPANDED_CAP = 500
@@ -0,0 +1,172 @@
1
+ /**
2
+ * dsh-vscode-mode client — 资源管理栏目录条目缓存(SWR:旧数据即时渲染 + 后台刷新)。
3
+ * 与 explorerCache(展开状态持久化)互补:本文件只缓存目录条目数据,键按会话隔离。
4
+ * 纯函数(序列化/解析/裁剪)导出可单测;内存镜像 Map 同步读避免每次渲染 parse
5
+ * localStorage,落盘 300ms 防抖;配额满/隐私模式 try/catch 降级纯内存。
6
+ * 作者 ddj 2026-08-31
7
+ */
8
+ import type { TreeEntry } from '../../shared/rpc.js'
9
+ import { CACHE_KEY } from '../paths.js'
10
+
11
+ // --region 常量
12
+ const KEY_PREFIX = CACHE_KEY.entries
13
+ /** 缓存目录上限(超出按 ts 逐出最旧,保活热门目录)。 */
14
+ export const CACHE_MAX_DIRS = 300
15
+ /** 单目录条目缓存上限(即时渲染用,刷新补齐全量)。 */
16
+ export const CACHE_DIR_ENTRIES = 500
17
+ /** 新鲜度窗口:预取去重用(展示不受限,永远可用缓存渲染)。 */
18
+ export const CACHE_FRESH_MS = 30_000
19
+ /** 落盘防抖。 */
20
+ export const CACHE_SAVE_DEBOUNCE_MS = 300
21
+ // --endregion
22
+
23
+ /** 单目录缓存条目。 */
24
+ export interface CachedDir {
25
+ ts: number
26
+ entries: TreeEntry[]
27
+ }
28
+
29
+ /** 缓存数据(v2:与 v1 展开状态缓存分离)。 */
30
+ export interface EntriesCacheData {
31
+ v: 2
32
+ root: string | null
33
+ dirs: Record<string, CachedDir>
34
+ }
35
+
36
+ /** 裁剪到上限(目录按 ts 逐出最旧;单目录条目截断)。 */
37
+ export function entriesTrim(data: EntriesCacheData, maxDirs = CACHE_MAX_DIRS, maxEntries = CACHE_DIR_ENTRIES): EntriesCacheData {
38
+ const dirs: Record<string, CachedDir> = {}
39
+ const sorted = Object.entries(data.dirs).sort((a, b) => b[1].ts - a[1].ts)
40
+ for (const [rel, dir] of sorted) {
41
+ if (Object.keys(dirs).length >= maxDirs) break
42
+ dirs[rel] = { ts: dir.ts, entries: dir.entries.length > maxEntries ? dir.entries.slice(0, maxEntries) : dir.entries }
43
+ }
44
+ return { v: 2, root: data.root, dirs }
45
+ }
46
+
47
+ /** 序列化缓存数据。 */
48
+ export function entriesSerialize(data: EntriesCacheData): string {
49
+ return JSON.stringify(data)
50
+ }
51
+
52
+ /** 解析缓存文本;损坏/格式不符 → null。 */
53
+ export function entriesParse(text: string | null): EntriesCacheData | null {
54
+ if (!text) return null
55
+ try {
56
+ const raw = JSON.parse(text) as { v?: unknown; root?: unknown; dirs?: unknown }
57
+ if (!raw || raw.v !== 2 || !raw.dirs || typeof raw.dirs !== 'object') return null
58
+ const dirs: Record<string, CachedDir> = {}
59
+ for (const [rel, d] of Object.entries(raw.dirs)) {
60
+ if (!d || typeof d !== 'object') continue
61
+ const dir = d as { ts?: unknown; entries?: unknown }
62
+ if (!Array.isArray(dir.entries)) continue
63
+ const entries: TreeEntry[] = []
64
+ for (const e of dir.entries) {
65
+ if (!e || typeof e !== 'object') continue
66
+ const item = e as { name?: unknown; path?: unknown; type?: unknown }
67
+ if (typeof item.name !== 'string' || typeof item.path !== 'string') continue
68
+ const type = item.type === 'directory' ? 'directory' : item.type === 'file' ? 'file' : 'other'
69
+ entries.push({ name: item.name, path: item.path, type })
70
+ }
71
+ if (!entries.length) continue
72
+ dirs[rel] = { ts: typeof dir.ts === 'number' ? dir.ts : 0, entries }
73
+ }
74
+ return { v: 2, root: typeof raw.root === 'string' ? raw.root : null, dirs }
75
+ } catch (error) {
76
+ return null
77
+ }
78
+ }
79
+
80
+ // --region 内存镜像 + 防抖落盘
81
+ /** sessionId → 缓存镜像(同步读,避免每次渲染 parse)。 */
82
+ const mirrors = new Map<string, EntriesCacheData>()
83
+ /** sessionId → 落盘防抖计时器。 */
84
+ const saveTimers = new Map<string, ReturnType<typeof setTimeout>>()
85
+ /** 单调时钟:连续 put 的 ts 严格递增(同毫秒不并列,逐出顺序可预期)。 */
86
+ let clock = 0
87
+
88
+ /** 取下一时间戳(不小于真实时间且严格递增)。 */
89
+ function nextTs(): number {
90
+ clock = Math.max(Date.now(), clock + 1)
91
+ return clock
92
+ }
93
+
94
+ /** localStorage 读取(不可用 → null)。 */
95
+ function readRaw(sessionId: string): string | null {
96
+ try {
97
+ return window.localStorage.getItem(KEY_PREFIX + sessionId)
98
+ } catch (error) {
99
+ return null
100
+ }
101
+ }
102
+
103
+ /** localStorage 写入(配额/隐私模式失败静默)。 */
104
+ function writeRaw(sessionId: string, text: string): void {
105
+ try {
106
+ window.localStorage.setItem(KEY_PREFIX + sessionId, text)
107
+ } catch (error) { /* 配额满/隐私模式忽略 */ }
108
+ }
109
+
110
+ /** 取(或建)会话缓存镜像。 */
111
+ function mirrorOf(sessionId: string): EntriesCacheData {
112
+ const existing = mirrors.get(sessionId)
113
+ if (existing) return existing
114
+ const data = entriesParse(readRaw(sessionId)) ?? { v: 2, root: null, dirs: {} }
115
+ mirrors.set(sessionId, data)
116
+ return data
117
+ }
118
+
119
+ /** 调度防抖落盘(重置已有计时器)。 */
120
+ function scheduleSave(sessionId: string): void {
121
+ const prev = saveTimers.get(sessionId)
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)))
127
+ }, CACHE_SAVE_DEBOUNCE_MS))
128
+ }
129
+ // --endregion
130
+
131
+ /**
132
+ * 读某目录缓存条目(无 → null;展示不受新鲜度限制)。
133
+ * @author ddj 2026年08月31号
134
+ * @param sessionId 会话 id
135
+ * @param rel 目录相对路径('' = 根)
136
+ * @returns 缓存条目或 null
137
+ */
138
+ export function entriesCacheGet(sessionId: string | undefined, rel: string): TreeEntry[] | null {
139
+ if (!sessionId) return null
140
+ const dir = mirrorOf(sessionId).dirs[rel]
141
+ if (!dir || !dir.entries.length) return null
142
+ return dir.entries
143
+ }
144
+
145
+ /**
146
+ * 某目录缓存是否新鲜(预取去重用)。
147
+ * @author ddj 2026年08月31号
148
+ * @param sessionId 会话 id
149
+ * @param rel 目录相对路径
150
+ * @returns 新鲜返回 true
151
+ */
152
+ export function entriesCacheIsFresh(sessionId: string | undefined, rel: string): boolean {
153
+ if (!sessionId) return false
154
+ const dir = mirrorOf(sessionId).dirs[rel]
155
+ return Boolean(dir && Date.now() - dir.ts < CACHE_FRESH_MS)
156
+ }
157
+
158
+ /**
159
+ * 写入某目录缓存条目(内存镜像 + 防抖落盘;单目录条目截断)。
160
+ * @author ddj 2026年08月31号
161
+ * @param sessionId 会话 id
162
+ * @param rel 目录相对路径
163
+ * @param entries 目录条目
164
+ */
165
+ export function entriesCachePut(sessionId: string | undefined, rel: string, entries: TreeEntry[]): void {
166
+ if (!sessionId) return
167
+ const data = mirrorOf(sessionId)
168
+ data.dirs[rel] = { ts: nextTs(), entries: entries.slice(0, CACHE_DIR_ENTRIES) }
169
+ const trimmed = entriesTrim(data)
170
+ data.dirs = trimmed.dirs
171
+ scheduleSave(sessionId)
172
+ }
@@ -2,11 +2,13 @@
2
2
  * dsh-vscode-mode client — 编辑器视图状态持久化(纯函数,可单测)。
3
3
  * 对齐 VSCode workbench.editor.restoreViewState:恢复每个文件的光标/滚动/折叠位置。
4
4
  * 视图状态 = Monaco editor.saveViewState() 返回值(JSON 可序列化),按 path 存储。
5
+ * 键前缀统一走 paths.ts PathConst(CACHE_KEY.viewstate)。
5
6
  * 作者 ddj 2026-08-28
6
7
  */
8
+ import { CACHE_KEY } from '../paths.js'
7
9
 
8
10
  /** localStorage 键前缀(按会话隔离)。 */
9
- const KEY_PREFIX = 'edrv.cache.viewstate.v1.'
11
+ const KEY_PREFIX = CACHE_KEY.viewstate
10
12
 
11
13
  /** 视图状态条目容量上限。 */
12
14
  export const VIEWSTATE_CAP = 50
@@ -33,6 +33,13 @@
33
33
  [data-edrv-view] .edrv-btn-mini:disabled { opacity: .45; cursor: default; }
34
34
  [data-edrv-view] .edrv-statusbar { display: flex; align-items: center; gap: 10px; padding: 3px 10px; border-top: 1px solid var(--dsw-alias-border-l1, #333); background: var(--dsw-alias-bg-layer-1, transparent); font-size: 11px; color: var(--dsw-alias-label-tertiary, #888); flex-shrink: 0; white-space: nowrap; overflow: hidden; }
35
35
  [data-edrv-view] .edrv-statusbar .edrv-sp-path { flex: 1; overflow: hidden; text-overflow: ellipsis; }
36
+ [data-edrv-view] .edrv-statusbar .edrv-sp-lsp { flex: 0 1 auto; overflow: hidden; text-overflow: ellipsis; }
37
+ [data-edrv-view] .edrv-statusbar .edrv-sp-progress-message { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; color: var(--dsw-alias-label-secondary, #52606d); }
38
+ [data-edrv-view] .edrv-statusbar .edrv-sp-progress { flex: 0 0 auto; min-width: 36px; text-align: right; font-variant-numeric: tabular-nums; color: var(--dsw-alias-label-secondary, #52606d); }
39
+ [data-edrv-view] .edrv-lsp-status-dot { width: 7px; height: 7px; border-radius: 50%; flex: 0 0 auto; background: var(--dsw-alias-label-tertiary, #9aa5b1); }
40
+ [data-edrv-view] .edrv-lsp-status-dot.ready { background: var(--dsw-alias-state-success-primary, #0f9d58); }
41
+ [data-edrv-view] .edrv-lsp-status-dot.busy { background: var(--dsw-alias-state-warn-primary, #d97706); animation: edrv-lsp-pulse 1.2s ease-in-out infinite; }
42
+ @keyframes edrv-lsp-pulse { 50% { opacity: .45; } }
36
43
  [data-edrv-view] .edrv-diffchip { font-size: 11px; padding: 2px 10px; border: 1px solid var(--dsw-alias-border-l1, #333); border-radius: 999px; background: var(--dsw-alias-bg-layer-2, transparent); color: var(--dsw-alias-state-warn-primary, #b7791f); cursor: pointer; white-space: nowrap; flex-shrink: 0; }
37
44
  [data-edrv-view] .edrv-chip-btn { font-size: 12px; padding: 2px 6px; border: none; background: transparent; color: var(--dsw-alias-label-secondary, #aaa); cursor: pointer; border-radius: 4px; flex-shrink: 0; }
38
45
  [data-edrv-view] .edrv-chip-btn:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(255,255,255,.06)); }
@@ -270,6 +277,7 @@
270
277
  [data-edrv-view] .edrv-search-hit { background: var(--dsw-alias-state-warn-primary, #e5b93f); color: #1f2933; border-radius: 2px; padding: 0 1px; }
271
278
  [data-edrv-view] .edrv-search-empty { padding: 8px 4px; font-size: 12px; color: var(--dsw-alias-label-tertiary, #9aa5b1); }
272
279
  [data-edrv-view] .edrv-search-error { padding: 6px 8px; border: 1px solid rgba(217,83,79,.4); border-radius: 6px; background: #fdeaea; color: var(--dsw-alias-state-error-primary, #d9534f); font-size: 11px; }
280
+ [data-edrv-view] .edrv-search-warn { padding: 6px 8px; border: 1px solid rgba(240,173,78,.45); border-radius: 6px; background: #fdf6e3; color: #b8860b; font-size: 11px; word-break: break-all; }
273
281
 
274
282
  /* include/exclude filter rows (collapsible) */
275
283
  [data-edrv-view] .edrv-search-rows { display: flex; flex-direction: column; gap: 4px; padding: 2px 0 4px; }
@@ -283,3 +291,11 @@
283
291
  [data-edrv-view] .edrv-search-toggle.on { background: var(--dsw-alias-interactive-bg-hover, rgba(15,157,88,.14)); color: var(--dsw-alias-label-primary, #1f2933); border-color: var(--dsw-alias-brand-primary, #0f9d58); }
284
292
  [data-edrv-view] .edrv-search-toggle:disabled { opacity: .4; cursor: default; }
285
293
 
294
+ /* LSP 引用结果浮动面板(Shift+F12 / 右键「查找所有引用」) */
295
+ [data-edrv-view] .edrv-refs { position: absolute; right: 12px; top: 40px; z-index: 60; width: 360px; max-height: 60%; display: flex; flex-direction: column; border: 1px solid var(--dsw-alias-border-l2, #555); border-radius: 8px; background: var(--dsw-alias-bg-panel, #161b22); box-shadow: 0 6px 24px rgba(0,0,0,.4); overflow: hidden; font-size: 12px; }
296
+ [data-edrv-view] .edrv-refs-head { padding: 6px 10px; font-weight: 600; color: var(--dsw-alias-label-primary, #eee); border-bottom: 1px solid var(--dsw-alias-border-l2, #333); background: var(--dsw-alias-bg-layer-1, transparent); }
297
+ [data-edrv-view] .edrv-refs-list { overflow-y: auto; }
298
+ [data-edrv-view] .edrv-refs-row { padding: 4px 10px; cursor: pointer; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--dsw-alias-label-secondary, #c0c8d0); font-family: var(--ds-font-family-code, ui-monospace, monospace); }
299
+ [data-edrv-view] .edrv-refs-row:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(15,157,88,.10)); color: var(--dsw-alias-label-primary, #fff); }
300
+
301
+
@@ -128,3 +128,66 @@
128
128
  .vsm-kb-actionsbar{display:flex;align-items:center;gap:10px;margin-top:12px}
129
129
  .vsm-kb-dirty{flex:1;font-size:12px;color:var(--dsw-alias-state-warn-primary,#e5b93f)}
130
130
  .vsm-kb-message{font-size:12px;color:var(--dsw-alias-brand-primary,#4f8cff);margin-top:8px}
131
+
132
+ /* 语言服务器(LSP)设置子 Tab */
133
+ .vsm-lsp-form{display:flex;flex-direction:column;gap:6px;margin-top:8px}
134
+ .vsm-lsp-form label{display:flex;flex-direction:column;gap:4px;font-size:12px;color:var(--dsw-alias-label-secondary,#9aa5b1)}
135
+ .vsm-lsp-form input{height:26px;padding:0 8px;border:1px solid var(--dsw-alias-border-l2,#555);border-radius:6px;background:var(--dsw-alias-bg-base,#111);color:var(--dsw-alias-label-primary,#eee);font-size:12px;outline:none}
136
+ .vsm-lsp-form input:focus{border-color:var(--dsw-alias-brand-primary,#4f8cff)}
137
+ .vsm-lsp-form button{align-self:flex-start;margin-top:2px}
138
+ .vsm-lsp-hint{font-size:12px;color:var(--dsw-alias-label-tertiary,#9aa5b1);margin-top:10px;line-height:1.6}
139
+
140
+ /* 会话性能(PerfSettings 子 Tab) */
141
+ .vsm-perf-page{display:flex;flex-direction:column;gap:14px;max-width:920px}
142
+ .vsm-perf-page h2{margin:0;font-size:20px;color:var(--dsw-alias-label-primary,#e6e6e6)}
143
+ .vsm-perf-page p{margin:0;color:var(--dsw-alias-label-tertiary,#9aa5b1);font-size:13px;line-height:1.5}
144
+ .vsm-perf-cards{display:flex;gap:12px;flex-wrap:wrap}
145
+ .vsm-perf-card{display:flex;flex-direction:column;gap:4px;min-width:120px;padding:12px 16px;border:1px solid var(--dsw-alias-border-l2,#3a3a3a);border-radius:10px;background:var(--dsw-alias-bg-layer-2,transparent)}
146
+ .vsm-perf-card span{font-size:12px;color:var(--dsw-alias-label-tertiary,#9aa5b1)}
147
+ .vsm-perf-card b{font-size:20px;color:var(--dsw-alias-label-primary,#e6e6e6)}
148
+ .vsm-perf-card b.big{color:var(--dsw-alias-state-error-primary,#e05252)}
149
+ .vsm-perf-toolbar{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
150
+ .vsm-perf-selcount{font-size:12px;color:var(--dsw-alias-label-tertiary,#9aa5b1);margin:0 4px}
151
+ .vsm-perf-ws{border:1px solid var(--dsw-alias-border-l2,#3a3a3a);border-radius:10px;overflow:hidden}
152
+ .vsm-perf-ws-head{display:flex;justify-content:space-between;align-items:center;gap:12px;padding:10px 14px;background:var(--dsw-alias-bg-layer-1,#262626);border-bottom:1px solid var(--dsw-alias-border-l2,#3a3a3a)}
153
+ .vsm-perf-ws-name{font-size:13px;font-weight:600;color:var(--dsw-alias-label-primary,#e6e6e6);font-family:var(--ds-font-family-code,ui-monospace,monospace);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
154
+ .vsm-perf-ws-meta{font-size:12px;color:var(--dsw-alias-label-tertiary,#9aa5b1);flex-shrink:0}
155
+ .vsm-perf-rows{display:flex;flex-direction:column}
156
+ .vsm-perf-row{display:flex;align-items:center;gap:10px;padding:7px 14px;border-bottom:1px solid var(--dsw-alias-border-l1,#333);font-size:12px;color:var(--dsw-alias-label-secondary,#c8c8c8)}
157
+ .vsm-perf-row:last-child{border-bottom:0}
158
+ .vsm-perf-row:hover{background:var(--dsw-alias-interactive-bg-hover,#ffffff0d)}
159
+ .vsm-perf-row input[type=checkbox]{accent-color:var(--dsw-alias-brand-primary,#6d55ff);cursor:pointer}
160
+ .vsm-perf-cell{min-width:0}
161
+ .vsm-perf-id{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:var(--ds-font-family-code,ui-monospace,monospace);color:var(--dsw-alias-label-primary,#eee)}
162
+ .vsm-perf-size{width:70px;text-align:right;flex-shrink:0;color:var(--dsw-alias-label-secondary,#c8c8c8)}
163
+ .vsm-perf-size.big{color:var(--dsw-alias-state-error-primary,#e05252);font-weight:600}
164
+ .vsm-perf-mtime{width:44px;flex-shrink:0;color:var(--dsw-alias-label-tertiary,#9aa5b1)}
165
+ .vsm-perf-active{width:44px;flex-shrink:0;font-size:11px;color:var(--dsw-alias-state-warn-primary,#e5b93f);text-align:right}
166
+ .vsm-perf-moved{border:1px solid var(--dsw-alias-border-l2,#3a3a3a);border-radius:10px;padding:12px 14px;background:var(--dsw-alias-bg-layer-2,transparent)}
167
+ .vsm-perf-moved h3{margin:0 0 8px;font-size:14px;color:var(--dsw-alias-label-primary,#e6e6e6)}
168
+ .vsm-perf-config{border:1px solid var(--dsw-alias-border-l2,#3a3a3a);border-radius:10px;padding:12px 14px;background:var(--dsw-alias-bg-layer-2,transparent)}
169
+ .vsm-perf-config h3{margin:0 0 8px;font-size:14px;color:var(--dsw-alias-label-primary,#e6e6e6)}
170
+ .vsm-perf-config-state{display:flex;align-items:center;gap:12px;margin:10px 0}
171
+ .vsm-perf-note{font-size:12px;color:var(--dsw-alias-state-warn-primary,#e5b93f)}
172
+ .vsm-perf-block{margin:8px 0 0;padding:10px 12px;border:1px solid var(--dsw-alias-border-l2,#3a3a3a);border-radius:8px;background:var(--dsw-alias-bg-base,#111);color:var(--dsw-alias-label-secondary,#c8c8c8);font-size:11px;line-height:1.5;font-family:var(--ds-font-family-code,ui-monospace,monospace);white-space:pre-wrap;overflow-x:auto;max-width:100%}
173
+ .vsm-perf-guide{border:1px solid var(--dsw-alias-border-l2,#3a3a3a);border-radius:10px;padding:12px 14px;background:var(--dsw-alias-bg-layer-2,transparent)}
174
+ .vsm-perf-guide h3{margin:0 0 8px;font-size:14px;color:var(--dsw-alias-label-primary,#e6e6e6)}
175
+ .vsm-perf-modal{position:fixed;inset:0;display:flex;align-items:center;justify-content:center;background:var(--dsw-alias-bg-mask-1,#000000aa);z-index:1000;padding:20px}
176
+ .vsm-perf-modal-card{width:min(560px,92vw);max-height:72vh;display:flex;flex-direction:column;gap:12px;padding:18px 20px;border:1px solid var(--dsw-alias-border-l2,#3a3a3a);border-radius:12px;background:var(--dsw-alias-bg-layer-2,#1f1f1f);color:var(--dsw-alias-label-primary,#e6e6e6)}
177
+ .vsm-perf-modal-card h3{margin:0;font-size:16px}
178
+ .vsm-perf-modal-note{font-size:12px}
179
+ .vsm-perf-modal-list{list-style:none;margin:0;padding:0;overflow-y:auto;font-size:12px;color:var(--dsw-alias-label-secondary,#c8c8c8);display:flex;flex-direction:column;gap:4px}
180
+ .vsm-perf-modal-list li{padding:4px 6px;border-radius:6px;background:var(--dsw-alias-bg-layer-1,#262626);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
181
+ .vsm-perf-modal-more{font-size:12px;color:var(--dsw-alias-label-tertiary,#9aa5b1)}
182
+ .vsm-perf-modal-actions{display:flex;justify-content:flex-end;gap:10px;margin-top:4px}
183
+ .vsm-secondary{border:1px solid var(--dsw-alias-border-l2,#555);border-radius:6px;padding:8px 16px;background:none;color:var(--dsw-alias-label-primary,#eee);font-size:13px;cursor:pointer;white-space:nowrap}
184
+ .vsm-secondary:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover,#ffffff14)}
185
+ .vsm-secondary:disabled{opacity:.55;cursor:default}
186
+ .vsm-small{padding:4px 10px;font-size:12px}
187
+
188
+ /* 扩展管理(LSP 语言服务器) */
189
+ .vsm-mcp-actions button.vsm-primary,
190
+ .vsm-mcp-actions button.vsm-danger{width:auto;height:auto;padding:5px 12px;font-size:12px;border-radius:6px;line-height:1.4}
191
+ .vsm-mcp-actions button.vsm-danger{color:var(--dsw-alias-state-error-primary,#f07878);background:none}
192
+ .vsm-mcp-actions button.vsm-danger:hover{background:var(--dsw-alias-interactive-bg-hover-danger,#ff000014)}
193
+ .vsm-ext-ver{font-weight:400;font-size:12px;color:var(--dsw-alias-label-tertiary,#9aa5b1);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}