dsh-vscode-mode 0.14.11 → 0.14.13

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.14.11",
3
+ "version": "0.14.13",
4
4
  "description": "DSH 上的类 VSCode 编码体验:Monaco 编辑器(文件页签/QuickOpen/状态栏,可驻 DSH 0.1.5+ 官方右侧 Sidebar 与对话同屏)+ 命令栏(Ctrl+Shift+P)与可扩展指令系统 + VS Code 兼容代码片段(.code-snippets 全局/项目,IntelliSense 展开)+ Agent 编辑差异审查(整文件差异/采纳/拒绝/归档/回滚)+ SVN 集成(侧栏变更面板/日志弹窗/并排差异)+ 语言服务器(LSP)智能(跳转定义/引用/hover/大纲 + VSIX 扩展市场安装),状态持久化到工作区旁车,界面状态(页签/光标/侧栏/编辑 Tab/导航历史)镜像到 DSH home 跨重启恢复",
5
5
  "icon": "assets/icon.svg",
6
6
  "keywords": [
@@ -24,6 +24,7 @@ import { Button } from '@deepseek-ai/dsh-client-ui-primitives'
24
24
  import { emitRefresh } from '../../events.js'
25
25
  import { svnOverlayEl, viewOverlayOf } from '../../ui/SvnOverlay.js'
26
26
  import { useExplorerSvn } from './useExplorerSvn.js'
27
+ import { useExplorerReveal, treeRevealPath } from './useExplorerReveal.js'
27
28
  import type { SidebarCtx } from '../types.js'
28
29
 
29
30
  const DIR_CAP = 4000
@@ -31,10 +32,6 @@ const SAVE_DEBOUNCE_MS = 300
31
32
  const FOLLOW_INTERVAL_MS = 10_000
32
33
  const PREFETCH_MAX = 4
33
34
  const PREFETCH_EXCLUDED = new Set(['node_modules', '.git', '.hg', '.svn', '.pnpm', '.pnpm-store'])
34
- /** 「在资源管理器视图中显示」高亮时长与定位重试上限(目录懒加载需等待行渲染)。 */
35
- const REVEAL_HIGHLIGHT_MS = 2000
36
- const REVEAL_RETRY_MAX = 6
37
- const REVEAL_RETRY_MS = 120
38
35
  /** 外部文件变化重列去抖:一轮外部批量写入(如 agent 连写多文件)合并为一次重列。 */
39
36
  const FILE_CHANGE_DEBOUNCE_MS = 400
40
37
 
@@ -97,16 +94,6 @@ function rowView(props) {
97
94
  }
98
95
  const ExplorerRow = React.memo(rowView)
99
96
 
100
- /**
101
- * 转义 CSS 属性选择器的值(路径含引号/反斜杠时避免选择器语法错误)。
102
- * @author ddj 2026年09月11号
103
- * @param value 原始值
104
- * @returns 可安全嵌入 `[attr="…"]` 的字符串
105
- */
106
- function cssEscape(value) {
107
- return String(value ?? '').replace(/\\/g, '\\\\').replace(/"/g, '\\"')
108
- }
109
-
110
97
  /**
111
98
  * 目录树面板主体(SWR:有缓存先渲染,无缓存才显示加载态;加载总在后台)。
112
99
  * @param props.ctx 面板共享上下文(sessionId/openFile/activePath/pendingByPath/fileMenuItems/notify)
@@ -127,7 +114,6 @@ export function FileExplorer(props) {
127
114
  const [loading, setLoading] = React.useState({})
128
115
  const [errors, setErrors] = React.useState({}) // rel → 错误文案(仅无任何数据时展示)
129
116
  const [menu, setMenu] = React.useState(null) // 右键菜单 { x, y, target }
130
- const [revealPath, setRevealPath] = React.useState(null) // 高亮的相对路径(「在资源管理器视图中显示」)
131
117
  // 各状态 ref 镜像:定时器/监听用最新闭包
132
118
  const tokensRef = React.useRef({})
133
119
  const expandedRef = React.useRef({})
@@ -139,9 +125,6 @@ export function FileExplorer(props) {
139
125
  const saveTimerRef = React.useRef(null)
140
126
  const loadDirRef = React.useRef(null)
141
127
  const refreshRef = React.useRef(null)
142
- const revealTimerRef = React.useRef(null) // 高亮清除计时器
143
- const revealTryRef = React.useRef(0) // 当前定位的重试计数(行渲染需等目录加载)
144
- const revealInTreeRef = React.useRef(null) // 定位动作最新闭包(窗口监听读取)
145
128
  const treeRef = React.useRef(null) // 目录树容器(定位时按 data-edrv-path 查行)
146
129
  const reloadDirRef = React.useRef(null) // loadDir 最新闭包(文件变化监听读取,防陈旧闭包)
147
130
  const changedTimerRef = React.useRef(null) // 文件变化合并去抖计时器
@@ -172,8 +155,10 @@ export function FileExplorer(props) {
172
155
  const force = opts?.force === true
173
156
  const doPrefetch = opts?.prefetch === true
174
157
  if (!force && loadingRef.current[rel]) return
175
- const token = (tokensRef.current[rel] || 0) + 1
176
- tokensRef.current[rel] = token
158
+ const tokens = tokensRef.current
159
+ const token = (tokens[rel] || 0) + 1
160
+ tokens[rel] = token
161
+ loadingRef.current = Object.assign({}, loadingRef.current, { [rel]: true })
177
162
  setLoading((prev) => Object.assign({}, prev, { [rel]: true }))
178
163
  setErrors((prev) => {
179
164
  const next = Object.assign({}, prev)
@@ -183,7 +168,7 @@ export function FileExplorer(props) {
183
168
  const args = { sessionId, path: rel }
184
169
  if (force) args.force = true
185
170
  rpc('edrv.listDir', args).then((res) => {
186
- if (tokensRef.current[rel] !== token) return
171
+ if (scopeRef.current !== scope || tokensRef.current !== tokens || tokens[rel] !== token) return
187
172
  if (res && res.ok && Array.isArray(res.entries)) {
188
173
  setDirs((prev) => Object.assign({}, prev, { [rel]: res.entries }))
189
174
  if (res.root) setRoot((prev) => prev || res.root)
@@ -196,12 +181,12 @@ export function FileExplorer(props) {
196
181
  }
197
182
  }
198
183
  }).catch((e) => {
199
- if (tokensRef.current[rel] !== token) return
184
+ if (scopeRef.current !== scope || tokensRef.current !== tokens || tokens[rel] !== token) return
200
185
  if (!entriesOf(rel)) {
201
186
  setErrors((prev) => Object.assign({}, prev, { [rel]: '读取目录异常:' + String(e) }))
202
187
  }
203
188
  }).finally(() => {
204
- if (tokensRef.current[rel] !== token) return
189
+ if (scopeRef.current !== scope || tokensRef.current !== tokens || tokens[rel] !== token) return
205
190
  setLoading((prev) => {
206
191
  const next = Object.assign({}, prev)
207
192
  delete next[rel]
@@ -220,55 +205,28 @@ export function FileExplorer(props) {
220
205
  void loadDir(rel, { prefetch: true })
221
206
  }
222
207
 
223
- /**
224
- * 尝试把目标行滚入可见并高亮;行尚未渲染(祖先目录仍在加载)时按上限重试。
225
- * @author ddj 2026年09月11号
226
- * @param path 目标相对路径
227
- */
228
- const tryReveal = (path) => {
229
- const host = treeRef.current
230
- const row = host?.querySelector?.('[data-edrv-path="' + cssEscape(path) + '"]')
231
- if (!row) {
232
- revealTryRef.current += 1
233
- if (revealTryRef.current < REVEAL_RETRY_MAX) {
234
- window.setTimeout(() => tryReveal(path), REVEAL_RETRY_MS)
235
- }
236
- return
237
- }
238
- row.scrollIntoView?.({ block: 'center' })
239
- setRevealPath(path)
240
- if (revealTimerRef.current) clearTimeout(revealTimerRef.current)
241
- revealTimerRef.current = window.setTimeout(() => {
242
- revealTimerRef.current = null
243
- setRevealPath(null)
244
- }, REVEAL_HIGHLIGHT_MS)
245
- }
246
-
247
- /**
248
- * 在资源管理器视图中显示:展开全部祖先目录并定位高亮目标文件。
249
- * 目录条目懒加载,故先展开祖先并触发加载,再由 tryReveal 轮询等行渲染。
250
- * @author ddj 2026年09月11号
251
- * @param path 目标相对路径
252
- */
253
- const revealInTree = (path) => {
254
- if (!path) return
208
+ /** @private @author ddj 2026年10月14号 @param path Safe relative file path. Expands only the ancestor chain and reuses available directory entries. */
209
+ function prepareReveal(path) {
255
210
  const ancestors = ancestorDirsOf(path)
256
- revealTryRef.current = 0
257
- setExpanded((prev) => {
211
+ setExpanded(expandParents)
212
+ for (const dir of ['', ...ancestors]) {
213
+ if (entriesOf(dir)) continue
214
+ void loadDir(dir, { prefetch: false })
215
+ }
216
+ /** @private @author ddj 2026年10月14号 @param prev Current expansion map. @returns Expanded ancestor map. */
217
+ function expandParents(prev) {
258
218
  const next = Object.assign({}, prev)
259
219
  for (const dir of ancestors) next[dir] = true
260
220
  return next
261
- })
262
- for (const dir of ancestors) void loadDir(dir, { prefetch: false })
263
- tryReveal(path)
221
+ }
264
222
  }
265
- revealInTreeRef.current = revealInTree
266
223
 
267
224
  const refresh = (event) => {
268
225
  if (event?.detail?.scope && event.detail.scope !== scope) return
269
226
  if (event?.detail?.paths?.length) { for (const path of event.detail.paths) onFileChanged(path); return }
270
227
  const keep = Object.keys(expandedRef.current).filter((k) => expandedRef.current[k] === true)
271
228
  tokensRef.current = {}
229
+ loadingRef.current = {}
272
230
  setMenu(null)
273
231
  setErrors({})
274
232
  // SWR:保留旧条目不清空,后台强制重列(force 跳过索引)
@@ -334,13 +292,13 @@ export function FileExplorer(props) {
334
292
 
335
293
  React.useEffect(() => {
336
294
  tokensRef.current = {}
295
+ loadingRef.current = {}
337
296
  setDirs({})
338
297
  setExpanded({})
339
298
  setLoading({})
340
299
  setErrors({})
341
300
  setMenu(null)
342
301
  setRoot(null)
343
- setRevealPath(null)
344
302
  // 恢复上次展开状态(对齐 VSCode:持久化展开路径,条目缓存即时渲染、后台刷新)
345
303
  const cached = scope ? explorerLoad(scope) : null
346
304
  const restored = cached?.expanded ?? []
@@ -379,19 +337,9 @@ export function FileExplorer(props) {
379
337
  // eslint-disable-next-line react-hooks/exhaustive-deps
380
338
  }, [])
381
339
 
382
- // 在资源管理器视图中显示(页签右键菜单):展开祖先目录并定位高亮
383
- React.useEffect(() => {
384
- const onReveal = (event) => {
385
- const path = event?.detail?.path
386
- if (typeof path === 'string' && path) revealInTreeRef.current?.(path)
387
- }
388
- window.addEventListener('edrv:reveal-path', onReveal)
389
- return () => {
390
- window.removeEventListener('edrv:reveal-path', onReveal)
391
- if (revealTimerRef.current) { clearTimeout(revealTimerRef.current); revealTimerRef.current = null }
392
- }
393
- // eslint-disable-next-line react-hooks/exhaustive-deps
394
- }, [])
340
+ // 在恢复工作区展开状态之后跟随活动文件;隐藏期间目标由编辑器保留,重新挂载时兑现。
341
+ const { revealPath } = useExplorerReveal({ scope, activePath, treeRef, prepare: prepareReveal })
342
+ const activeTreePath = treeRevealPath(activePath)
395
343
 
396
344
  // 10s 轻量跟随:已展开目录后台刷新(命中 host 索引,近零成本),树跟随 agent 写入
397
345
  React.useEffect(() => {
@@ -446,7 +394,7 @@ export function FileExplorer(props) {
446
394
  if (!row.e) return React.createElement('div', { key: row.key, className: 'edrv-tree-loading', style: { paddingLeft: 6 + row.depth * 14 } }, row.text)
447
395
  const { e, depth, isDir, isOpen } = row
448
396
  return React.createElement(ExplorerRow, { key: e.path, path: e.path, name: e.name, type: e.type, depth, isDir, isOpen,
449
- pending: isDir ? 0 : pendingByPath[e.path] ?? 0, active: !isDir && e.path === activePath,
397
+ pending: isDir ? 0 : pendingByPath[e.path] ?? 0, active: !isDir && e.path === activeTreePath,
450
398
  context: Boolean(menu && menuEntries.length && menu.target.path === e.path), reveal: revealPath === e.path,
451
399
  mark: svn.local?.get(e.path), remote: svn.remote?.get(e.path) ?? 0, onSelect, onContext })
452
400
  }
@@ -98,6 +98,7 @@ export function SearchPanel(props) {
98
98
  const seqRef = React.useRef(0)
99
99
  const timerRef = React.useRef(null)
100
100
  const inputRef = React.useRef(null)
101
+ const [focusTick, setFocusTick] = React.useState(0)
101
102
  const queryRef = React.useRef('')
102
103
  queryRef.current = query
103
104
  const activePathRef = React.useRef(activePath)
@@ -261,7 +262,6 @@ export function SearchPanel(props) {
261
262
  queryRef.current = seed
262
263
  setQuery(seed)
263
264
  runSearch(seed)
264
- inputRef.current?.select?.()
265
265
  return true
266
266
  }
267
267
 
@@ -289,19 +289,27 @@ export function SearchPanel(props) {
289
289
  // 事件无人接收,故必须由挂载路径兜底。
290
290
  // ⚠️ 声明在「按作用域恢复」effect 之后:React 按声明序执行 effect,恢复值先落地,
291
291
  // 种子再覆盖,避免被记忆的旧查询词盖掉用户刚选中的内容。
292
- React.useEffect(() => { applyScopeSeed(); applySeed() }, [])
292
+ React.useEffect(() => { applyScopeSeed(); applySeed(); setFocusTick((tick) => tick + 1) }, [])
293
293
 
294
- // Ctrl+Shift+F / 「在文件夹中查找…」重复触发:消费种子(有则填入)并聚焦输入框(EditorView 派发 edrv:search-focus)
294
+ // Ctrl+Shift+F / 「在文件夹中查找…」:先提交恢复值/种子,再全选新内容。
295
295
  React.useEffect(() => {
296
+ /** @private @author ddj 2026年10月14号 @description 消费种子并请求提交后聚焦全选;不额外发起搜索。 */
296
297
  const onFocus = () => {
297
298
  applyScopeSeed()
298
299
  applySeed()
299
- inputRef.current?.focus?.()
300
+ setFocusTick((tick) => tick + 1)
300
301
  }
301
302
  window.addEventListener('edrv:search-focus', onFocus)
302
303
  return () => window.removeEventListener('edrv:search-focus', onFocus)
303
304
  }, [])
304
305
 
306
+ // 一次意图对应一次提交后选区;无需定时回调,卸载后不会抢焦点。
307
+ React.useLayoutEffect(() => {
308
+ if (!focusTick) return
309
+ inputRef.current?.focus?.()
310
+ inputRef.current?.select?.()
311
+ }, [focusTick])
312
+
305
313
  // 结果按文件分组(保持 rg 输出顺序)
306
314
  const groups = React.useMemo(() => {
307
315
  if (!Array.isArray(matches)) return []
@@ -0,0 +1,109 @@
1
+ import React from 'react'
2
+ import { isTreeRevealable } from '../../tabActions.js'
3
+
4
+ /** @private @author ddj 2026年10月14号 @param path Editor path. @returns Safe normalized tree path, or null. */
5
+ export function treeRevealPath(path: string | null): string | null {
6
+ if (!path || !isTreeRevealable(path)) return null
7
+ return path.replace(/\\/g, '/').replace(/^\.\//, '')
8
+ }
9
+
10
+ /** @private @author ddj 2026年10月14号 @param host Tree scroll container. @param path Exact row path. @returns Whether a visible row was located. Only scrolls the tree. */
11
+ export function revealTreeRow(host: HTMLElement | null, path: string): boolean {
12
+ if (!host || host.clientHeight <= 0) return false
13
+ const escaped = path.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
14
+ const row = host.querySelector<HTMLElement>('[data-edrv-path="' + escaped + '"]')
15
+ if (!row) return false
16
+ const viewport = host.getBoundingClientRect()
17
+ const bounds = row.getBoundingClientRect()
18
+ const top = viewport.top + host.clientTop
19
+ if (bounds.top < top || bounds.bottom > top + host.clientHeight) {
20
+ host.scrollTop += bounds.top - top - (host.clientHeight - bounds.height) / 2
21
+ }
22
+ return true
23
+ }
24
+
25
+ interface RevealOptions {
26
+ scope: string
27
+ activePath: string | null
28
+ treeRef: React.RefObject<HTMLElement>
29
+ prepare: (path: string) => void
30
+ }
31
+ interface RevealTarget {
32
+ scope: string
33
+ path: string
34
+ active: string | null
35
+ }
36
+
37
+ /** @private @author ddj 2026年10月14号 @param options Current workspace, active editor and tree preparation. @returns Highlight and manual request callback. Waits for committed rows without polling; unmount cancels highlight cleanup. */
38
+ export function useExplorerReveal(options: RevealOptions) {
39
+ const { scope, activePath, treeRef } = options
40
+ const [target, setTarget] = React.useState<RevealTarget | null>(null)
41
+ const [revealPath, setRevealPath] = React.useState<string | null>(null)
42
+ const pending = React.useRef<RevealTarget | null>(null)
43
+ const timer = React.useRef<ReturnType<typeof setTimeout> | null>(null)
44
+ const latest = React.useRef(options)
45
+ latest.current = options
46
+
47
+ /** @private @author ddj 2026年10月14号 Clears only this hook's highlight timer. */
48
+ function clearHighlight(): void {
49
+ if (timer.current !== null) clearTimeout(timer.current)
50
+ timer.current = null
51
+ }
52
+ /** @private @author ddj 2026年10月14号 @param path Requested tree path. Expands ancestors and replaces any previous pending target. */
53
+ function requestReveal(path: string | null): void {
54
+ clearHighlight()
55
+ setRevealPath(null)
56
+ const normalized = treeRevealPath(path)
57
+ const current = latest.current
58
+ const next = normalized ? { scope: current.scope, path: normalized, active: current.activePath } : null
59
+ pending.current = next
60
+ setTarget(next)
61
+ if (next) current.prepare(next.path)
62
+ }
63
+ /** @private @author ddj 2026年10月14号 Attempts the latest target after row commit or container resize. Consumes only a successful, current request. */
64
+ function tryReveal(): void {
65
+ const next = pending.current
66
+ const current = latest.current
67
+ if (!next || next.scope !== current.scope || next.active !== current.activePath) return
68
+ if (!revealTreeRow(current.treeRef.current, next.path)) return
69
+ pending.current = null
70
+ setRevealPath(next.path)
71
+ clearHighlight()
72
+ timer.current = setTimeout(endHighlight, 2000)
73
+ }
74
+ /** @private @author ddj 2026年10月14号 Ends transient reveal styling without changing the active editor row. */
75
+ function endHighlight(): void {
76
+ timer.current = null
77
+ setRevealPath(null)
78
+ }
79
+
80
+ /** @private @author ddj 2026年10月14号 Establishes the latest editor target after workspace tree restoration. */
81
+ function followActive(): void { requestReveal(activePath) }
82
+ React.useEffect(followActive, [scope, activePath])
83
+ React.useLayoutEffect(tryReveal)
84
+
85
+ /** @private @author ddj 2026年10月14号 @returns Cleanup for manual events, resize observation and highlight timer. */
86
+ function listenReveal(): () => void {
87
+ /** @private @author ddj 2026年10月14号 @param event Existing explicit reveal request. */
88
+ function onReveal(event: Event): void {
89
+ const detail = (event as CustomEvent<{ path?: string; scope?: string }>).detail
90
+ if (detail?.scope && detail.scope !== latest.current.scope) return
91
+ if (typeof detail?.path === 'string') requestReveal(detail.path)
92
+ }
93
+ const observer = typeof ResizeObserver === 'function' ? new ResizeObserver(tryReveal) : null
94
+ if (treeRef.current) observer?.observe(treeRef.current)
95
+ window.addEventListener('edrv:reveal-path', onReveal)
96
+ /** @private @author ddj 2026年10月14号 Cancels callbacks owned by this mounted tree. */
97
+ function cleanup(): void {
98
+ observer?.disconnect()
99
+ window.removeEventListener('edrv:reveal-path', onReveal)
100
+ pending.current = null
101
+ clearHighlight()
102
+ }
103
+ return cleanup
104
+ }
105
+ React.useEffect(listenReveal, [treeRef])
106
+ // Keep the request state as a render trigger when an already-open tree is revealed manually.
107
+ void target
108
+ return { revealPath, requestReveal }
109
+ }
@@ -5,9 +5,8 @@
5
5
  */
6
6
  import type { Ctx, Session } from '../store.js'
7
7
  import { policyOf } from '../store.js'
8
- import { pathText, pathMatch } from './query.js'
9
- import { searchRoot } from './ripgrep.js'
10
- import type { WorkspaceSearchInput, WorkspaceSearchProvider, WorkspaceSearchResult } from './types.js'
8
+ import { pathText, pathMatch, prepareQuery } from './query.js'
9
+ import type { PreparedQuery, WorkspaceSearchInput, WorkspaceSearchProvider, WorkspaceSearchResult } from './types.js'
11
10
 
12
11
  interface FsTarget { displayPath?: string }
13
12
  interface FsEntry { name: string; type: 'file' | 'directory' | 'other'; target: FsTarget }
@@ -21,27 +20,31 @@ const EXCLUDED = new Set(['node_modules', '.git', '.tmp', '.cache', 'dist', 'bui
21
20
 
22
21
  /**
23
22
  * 在一个目录树中递归查找匹配文件。
24
- * @author ddj 2026年08月24号
23
+ * @private
24
+ * @author ddj 2026年10月14号
25
25
  * @param fs 文件系统能力
26
26
  * @param target 当前目录目标
27
27
  * @param input 搜索输入
28
28
  * @param files 已保留候选
29
29
  * @param signal 取消信号
30
+ * @param query 已规范化查询
31
+ * @param prefix 当前目录相对于搜索根的路径
30
32
  * @returns 是否因达到上限而截断
31
33
  */
32
- async function walk(fs: SearchFs, target: FsTarget, input: WorkspaceSearchInput, files: string[], signal: AbortSignal): Promise<boolean> {
34
+ async function walk(fs: SearchFs, target: FsTarget, input: WorkspaceSearchInput, files: string[], signal: AbortSignal, query: PreparedQuery, prefix = ''): Promise<boolean> {
33
35
  if (signal.aborted) throw new DOMException('搜索已取消', 'AbortError')
34
36
  const entries = await fs.listDir(target, signal)
35
37
  for (const entry of entries) {
36
38
  if (signal.aborted) throw new DOMException('搜索已取消', 'AbortError')
39
+ const relative = prefix + entry.name
37
40
  if (entry.type === 'directory') {
38
41
  if (EXCLUDED.has(entry.name)) continue
39
- if (await walk(fs, entry.target, input, files, signal)) return true
42
+ if (await walk(fs, entry.target, input, files, signal, query, relative + '/')) return true
40
43
  continue
41
44
  }
42
45
  if (entry.type !== 'file') continue
43
- const path = pathText(entry.target.displayPath ?? entry.name)
44
- if (!pathMatch(path, { raw: input.query, text: pathText(input.query).toLocaleLowerCase('en-US') })) continue
46
+ const path = pathText(entry.target.displayPath ?? relative)
47
+ if (!pathMatch(relative, query)) continue
45
48
  files.push(path)
46
49
  if (files.length >= Math.min(input.maxResults, RESULT_CAP)) return true
47
50
  }
@@ -62,7 +65,7 @@ export async function searchFallback(input: WorkspaceSearchInput): Promise<Works
62
65
  const rootTarget = await fs.resolve(policy?.workspaceRoot ?? '.', cwd ? { cwd, signal: input.signal } : { signal: input.signal })
63
66
  const signal = input.signal ?? new AbortController().signal
64
67
  const files: string[] = []
65
- const truncated = await walk(fs, rootTarget, input, files, signal)
68
+ const truncated = await walk(fs, rootTarget, input, files, signal, prepareQuery(input.query))
66
69
  return { files, truncated, complete: !truncated, source: 'fallback' }
67
70
  }
68
71
 
@@ -15,7 +15,7 @@ import type { CandidateRanker, SearchCandidate, WorkspaceSearchProvider, Workspa
15
15
  const CACHE_TTL = 60_000
16
16
  const CACHE_LIMIT = 100
17
17
  const RESULT_LIMIT = 50
18
- const PROVIDER_VERSION = 'ripgrep-v1'
18
+ const PROVIDER_VERSION = 'ripgrep-fuzzy-v2'
19
19
  const POLICY_VERSION = 'search-policy-v1'
20
20
 
21
21
  type CacheEntry<T> = { at: number; result: T }
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * dsh-vscode-mode host — 搜索 query 与路径规范化。
3
- * 查询只按字面连续片段比较,不解释为正则或 shell 语句。
3
+ * 文件名支持有序子序列;带目录的查询保持连续片段,不解释为正则或 shell 语句。
4
4
  * 作者 ddj 2026年08月24号
5
5
  */
6
6
  import { isAbsolute, resolve } from 'node:path'
@@ -51,12 +51,36 @@ export function displayPath(value: string, root: string): string {
51
51
  }
52
52
 
53
53
  /**
54
- * 判断路径是否包含连续 query。
55
- * @author ddj 2026年08月24号
56
- * @param value 文件路径
54
+ * @public
55
+ * @author ddj 2026年10月14号
56
+ * @description 按顺序匹配不连续字符;每个位置最多消费一次。
57
+ * @param value 已归一化候选文本
58
+ * @param text 已归一化查询文本
59
+ * @returns 是否完整匹配所有查询字符
60
+ */
61
+ export function subsequence(value: string, text: string): boolean {
62
+ let offset = 0
63
+ for (const char of text) {
64
+ const index = value.indexOf(char, offset)
65
+ if (index < 0) return false
66
+ offset = index + char.length
67
+ }
68
+ return true
69
+ }
70
+
71
+ /**
72
+ * @public
73
+ * @author ddj 2026年10月14号
74
+ * @description 连续匹配相对路径,或对无目录查询模糊匹配文件名。
75
+ * @param value 文件路径(调用方移除工作区根前缀)
57
76
  * @param query 已规范化 query
58
- * @returns 是否命中
77
+ * @returns 是否命中;空查询不命中
59
78
  */
60
79
  export function pathMatch(value: string, query: PreparedQuery): boolean {
61
- return pathText(value).toLocaleLowerCase('en-US').includes(query.text)
80
+ if (!query.text) return false
81
+ const path = pathText(value).toLocaleLowerCase('en-US')
82
+ if (path.includes(query.text)) return true
83
+ if (query.text.includes('/')) return false
84
+ const basename = path.slice(path.lastIndexOf('/') + 1)
85
+ return subsequence(basename, query.text)
62
86
  }
@@ -1,6 +1,6 @@
1
1
  /**
2
- * dsh-vscode-mode host — 可替换的连续匹配排序器。
3
- * 第一版保持 basename 优先和稳定路径序,后续可接 fuzzy scorer。
2
+ * dsh-vscode-mode host — 文件名优先的连续/模糊匹配排序器。
3
+ * 每个候选仅计算一次匹配等级与模糊代价,平分按稳定路径序。
4
4
  * 作者 ddj 2026年08月24号
5
5
  */
6
6
  import type { CandidateRanker, PreparedQuery, SearchCandidate } from './types.js'
@@ -20,22 +20,61 @@ export function candidateOf(path: string, source: SearchCandidate['source']): Se
20
20
  }
21
21
 
22
22
  /**
23
- * 对连续匹配候选进行排序。
24
- * @author ddj 2026年08月24号
23
+ * @private
24
+ * @author ddj 2026年10月14号
25
+ * @description 贪心匹配文件名,优先词/驼峰边界,其次紧凑程度;不枚举组合。
26
+ * @param name 原始大小写文件名
27
+ * @param text 已规范化查询
28
+ * @returns 边界漏命中数与跨度代价;无匹配返回 null
29
+ */
30
+ function fuzzyCost(name: string, text: string): number[] | null {
31
+ const base = name.toLocaleLowerCase('en-US')
32
+ let offset = 0
33
+ let first = -1
34
+ let misses = 0
35
+ for (const char of text) {
36
+ const index = base.indexOf(char, offset)
37
+ if (index < 0) return null
38
+ if (first < 0) first = index
39
+ const boundary = index === 0 || /[\s_.\-]/.test(name[index - 1]) || /[a-z0-9]/.test(name[index - 1]) && /[A-Z]/.test(name[index])
40
+ if (!boundary) misses++
41
+ offset = index + char.length
42
+ }
43
+ return [misses, offset - first - text.length]
44
+ }
45
+
46
+ /**
47
+ * @private
48
+ * @author ddj 2026年10月14号
49
+ * @description 计算完全/前缀/连续/模糊/路径命中的排序等级。
50
+ * @param candidate 文件候选
51
+ * @param text 已规范化查询
52
+ * @returns 等级、边界与跨度代价
53
+ */
54
+ function rankKey(candidate: SearchCandidate, text: string): number[] {
55
+ const base = candidate.basename.toLocaleLowerCase('en-US')
56
+ if (base === text) return [0, 0, 0]
57
+ if (base.startsWith(text)) return [1, 0, 0]
58
+ if (base.includes(text)) return [2, 0, 0]
59
+ const cost = text.includes('/') ? null : fuzzyCost(candidate.basename, text)
60
+ if (cost) return [3, ...cost]
61
+ return [candidate.normalizedPath.includes(text) ? 4 : 5, 0, 0]
62
+ }
63
+
64
+ /**
65
+ * @public
66
+ * @author ddj 2026年10月14号
67
+ * @description 每个候选只评分一次,排序不修改调用方数组。
25
68
  * @param candidates 候选列表
26
69
  * @param query 已规范化 query
27
70
  * @returns 排序后的候选
28
71
  */
29
72
  export function rankCandidates(candidates: SearchCandidate[], query: PreparedQuery): SearchCandidate[] {
30
- const text = query.text
31
- const score = (candidate: SearchCandidate): number => {
32
- const base = candidate.basename.toLocaleLowerCase('en-US')
33
- if (base.startsWith(text)) return 0
34
- if (base.includes(text)) return 1
35
- if (candidate.normalizedPath.includes(text)) return 2
36
- return 3
37
- }
38
- return [...candidates].sort((left, right) => score(left) - score(right) || left.normalizedPath.localeCompare(right.normalizedPath, 'en-US'))
73
+ const ranked = candidates.map((candidate) => ({ candidate, key: rankKey(candidate, query.text) }))
74
+ ranked.sort((left, right) => left.key[0] - right.key[0] || left.key[1] - right.key[1] || left.key[2] - right.key[2]
75
+ || left.candidate.normalizedPath.localeCompare(right.candidate.normalizedPath, 'en-US')
76
+ || left.candidate.path.localeCompare(right.candidate.path, 'en-US'))
77
+ return ranked.map((item) => item.candidate)
39
78
  }
40
79
 
41
80
  /**
@@ -70,19 +70,34 @@ interface SearchSubprocess {
70
70
  * @returns 安全 glob
71
71
  */
72
72
  export function queryGlob(query: string): string {
73
+ return '**/*' + Array.from(pathText(query), globChar).join('') + '*'
74
+ }
75
+
76
+ /**
77
+ * @private
78
+ * @author ddj 2026年10月14号
79
+ * @description 将字面字符转为安全且大小写不敏感的 glob 片段。
80
+ * @param char 单个 Unicode 字符
81
+ * @returns 转义字符或大小写字符类
82
+ */
83
+ function globChar(char: string): string {
84
+ const lower = char.toLowerCase()
85
+ const upper = char.toUpperCase()
86
+ if (lower !== upper && lower.length === 1 && upper.length === 1) return '[' + lower + upper + ']'
87
+ return /[\*?\[\]{}!]/.test(char) ? '\\' + char : char
88
+ }
89
+
90
+ /**
91
+ * @public
92
+ * @author ddj 2026年10月14号
93
+ * @description 构造仅匹配文件名的有序子序列 glob,不让星号跨目录。
94
+ * @param query 无目录的字面查询文本
95
+ * @returns 模糊 glob;空查询或带目录时返回 null
96
+ */
97
+ export function fuzzyGlob(query: string): string | null {
73
98
  const value = pathText(query)
74
- let body = ''
75
- for (const char of value) {
76
- const lower = char.toLowerCase()
77
- const upper = char.toUpperCase()
78
- // 有大小写之分的字符(单字符映射,避开 ß→SS 这类长度变化)→ 字符类
79
- if (lower !== upper && lower.length === 1 && upper.length === 1) {
80
- body += '[' + lower + upper + ']'
81
- continue
82
- }
83
- body += /[\*?\[\]{}!]/.test(char) ? '\\' + char : char
84
- }
85
- return '**/*' + body + '*'
99
+ if (!value || value.includes('/')) return null
100
+ return '**/*' + Array.from(value, globChar).join('*') + '*'
86
101
  }
87
102
 
88
103
  /**
@@ -193,6 +208,8 @@ export function rgExitFailure(code: number | null | undefined, stderrText: strin
193
208
  */
194
209
  export function filesArgv(binary: string, root: string, query: string): string[] {
195
210
  const argv = [binary, '--no-config', '--files', '--hidden', '--no-ignore', '--glob', queryGlob(query)]
211
+ const fuzzy = fuzzyGlob(query)
212
+ if (fuzzy) argv.push('--glob', fuzzy)
196
213
  for (const excluded of EXCLUDES) argv.push('--glob', '!**/' + excluded + '/**')
197
214
  for (const excluded of FILE_EXCLUDES) argv.push('--glob', '!' + excluded)
198
215
  argv.push('--', root)