dsh-vscode-mode 0.1.29 → 0.1.32

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.29",
3
+ "version": "0.1.32",
4
4
  "description": "DSH 上的类 VSCode 编码体验:Monaco 编辑器(文件页签/QuickOpen/状态栏,可驻 dsh-better-sidebar 侧边栏与对话同屏)+ Agent 编辑差异审查(整文件差异/采纳/拒绝/归档/回滚),状态持久化到工作区旁车",
5
5
  "keywords": [
6
6
  "dsh",
package/src/capture.ts CHANGED
@@ -10,6 +10,7 @@ import { loadBucket, saveBucket } from './store.js'
10
10
  import { fileMaxBatch, prune } from './model.js'
11
11
  import type { Registry } from './registry.js'
12
12
  import { cwdOf } from './registry.js'
13
+ import { invalidateIndex } from './treeIndex.js'
13
14
 
14
15
  /** 校验并提取工具 result meta 中的文件 hunk。 */
15
16
  function validHunks(raw: unknown): Hunk[] {
@@ -97,6 +98,8 @@ export async function captureToolResult(ctx: Ctx, registry: Registry, exec: any,
97
98
  // 旧实现按文件 batch 自动归档 prior,会导致界面看起来每个文件只剩一条差异。
98
99
  prune(bucket)
99
100
  await saveBucket(ctx, cwd, bucket, session)
101
+ // agent 写盘后目录树可能变化(新建/删除文件/目录):父目录+祖先进失效,后台自愈。
102
+ invalidateIndex(ctx, cwd, path)
100
103
  } catch (error) {
101
104
  console.error('edrv capture failed', error)
102
105
  }
@@ -17,6 +17,7 @@ import React from 'react'
17
17
  import './styles/editor.css'
18
18
  import { EditorView } from './ui/EditorView.js'
19
19
  import { DiffBadge } from './ui/DiffBadge.js'
20
+ import { PerfSizeBadge } from './ui/PerfSizeBadge.js'
20
21
  import { ConversationDiffDock } from './ui/ConversationDiffDock.js'
21
22
  import { McpSettings } from './ui/McpSettings.js'
22
23
  import { rpc } from './rpc.js'
@@ -230,6 +231,10 @@ export function apply(ctx: any): void {
230
231
  registerSlotSafely(ctx, { name: 'conversation.session.header.utilities', id: 'edrv-diff-badge', order: 90, label: '差异' },
231
232
  (props: unknown) => React.createElement(DiffBadge, Object.assign({}, props)))
232
233
 
234
+ // header 会话体积指示器:当前会话持久化体积,超阈值提示新开会话(性能优化)
235
+ registerSlotSafely(ctx, { name: 'conversation.session.header.utilities', id: 'edrv-perf-size', order: 89, label: '会话体积' },
236
+ (props: unknown) => React.createElement(PerfSizeBadge, Object.assign({}, props, { sessions })))
237
+
233
238
  // 设置页中的 VSCodeMode 专属页签,MCP 管理作为该页签的内部子 Tab。
234
239
  registerSlotSafely(ctx, {
235
240
  name: 'settings.section',
@@ -12,6 +12,8 @@
12
12
  export function createDiffRenderer(log) {
13
13
  let viewZoneIds = []
14
14
  let decorations = []
15
+ /** 上次已渲染的差异指纹(callId:idx@start-end:oldLen/newLen 串):内容未变时跳过重建。 */
16
+ let lastKey = null
15
17
 
16
18
  /**
17
19
  * 渲染当前 pending 差异:绿底 decoration + 删除块 view zones + '-' 号 overlay(滚动同步)。
@@ -26,6 +28,11 @@ export function createDiffRenderer(log) {
26
28
  if (!pendingRegions.length && viewZoneIds.length === 0 && decorations.length === 0) return
27
29
  const renderT0 = Date.now()
28
30
  const callIdAttr = (callId, idx) => String(callId) + ':' + String(idx)
31
+ // 指纹相同 = 内容未变(轮询/引用空转/重复点击触发的重渲染):残留 DOM 已在位,
32
+ // 直接跳过整轮重建(view zones 拆建 / overlay DOM / 50ms 校正),消除同区域重复渲染成本。
33
+ const key = pendingRegions.map((r) => callIdAttr(r.callId, r.idx) + '@' + (r.start ?? '?') + '-' + (r.end ?? '?') + ':' + (r.oldLines ? r.oldLines.length : 0) + '/' + (r.newLines ? r.newLines.length : 0)).join('|')
34
+ if (key === lastKey) return
35
+ lastKey = key
29
36
  log(sessionId, '[diff-render] begin regs=' + pendingRegions.length + ' ' + pendingRegions.map((r) => callIdAttr(r.callId, r.idx) + '@' + (r.start ?? '?') + '-' + (r.end ?? '?') + '(-' + (r.oldLines ? r.oldLines.length : 0) + '/+' + (r.newLines ? r.newLines.length : 0) + ')').join('|'))
30
37
 
31
38
  const decos = []
@@ -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
+ }
package/src/client/rpc.ts CHANGED
@@ -6,6 +6,16 @@
6
6
  import { RPC_PATH } from '../shared/rpc.js'
7
7
  import type { RpcMethod, RpcRequestMap, RpcResult } from '../shared/rpc.js'
8
8
 
9
+ /**
10
+ * 诊断日志开关(默认关):diff 渲染热路径每条日志 = 1 次 RPC 往返 +
11
+ * host 端 debug 文件全量读改写(上限 512KB),高频渲染下开销可观。
12
+ * 排查问题时在浏览器控制台执行 localStorage.setItem('edrv.debug','1') 后刷新开启。
13
+ * @author ddj 2026年08月26号
14
+ */
15
+ const DEBUG = (() => {
16
+ try { return localStorage.getItem('edrv.debug') === '1' } catch (e) { return false }
17
+ })()
18
+
9
19
  /** 同源 RPC 调用(host /edrv/rpc 精确路由)。 */
10
20
  export function rpc<M extends RpcMethod>(method: M, args: RpcRequestMap[M]): Promise<RpcResult<M>> {
11
21
  return fetch(RPC_PATH, {
@@ -16,12 +26,13 @@ export function rpc<M extends RpcMethod>(method: M, args: RpcRequestMap[M]): Pro
16
26
  }
17
27
 
18
28
  /**
19
- * 诊断日志:统一走 host edrv.debug 落盘,避免 console 刷屏。
20
- * @author ddj 2026年08月20号
29
+ * 诊断日志:开关关闭时直接返回(零 RPC 零落盘);开启时走 host edrv.debug 落盘。
30
+ * @author ddj 2026年08月26号
21
31
  * @param sessionId 会话 id
22
32
  * @param text 日志文本
23
33
  */
24
34
  export function dbg(sessionId: string | undefined, text: string): void {
35
+ if (!DEBUG) return
25
36
  try {
26
37
  rpc('edrv.debug', { sessionId, text }).catch(() => {})
27
38
  } catch (e) { /* 日志失败忽略 */ }
@@ -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
@@ -270,6 +270,7 @@
270
270
  [data-edrv-view] .edrv-search-hit { background: var(--dsw-alias-state-warn-primary, #e5b93f); color: #1f2933; border-radius: 2px; padding: 0 1px; }
271
271
  [data-edrv-view] .edrv-search-empty { padding: 8px 4px; font-size: 12px; color: var(--dsw-alias-label-tertiary, #9aa5b1); }
272
272
  [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; }
273
+ [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
274
 
274
275
  /* include/exclude filter rows (collapsible) */
275
276
  [data-edrv-view] .edrv-search-rows { display: flex; flex-direction: column; gap: 4px; padding: 2px 0 4px; }