dsh-vscode-mode 0.1.12 → 0.1.14

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.12",
3
+ "version": "0.1.14",
4
4
  "description": "DSH 上的类 VSCode 编码体验:Monaco 中央编辑器(文件页签/QuickOpen/状态栏)+ Agent 编辑差异审查(整文件差异/采纳/拒绝/归档/回滚),状态持久化到工作区旁车",
5
5
  "keywords": [
6
6
  "dsh",
@@ -39,6 +39,9 @@
39
39
  "cordis.patch.yml"
40
40
  ],
41
41
  "license": "BSD-3-Clause",
42
+ "dependencies": {
43
+ "@vscode/ripgrep": "^1.18.0"
44
+ },
42
45
  "peerDependencies": {
43
46
  "@deepseek-ai/dsh-client-ui-slots": ">=0.0.1-rc.1 <0.1.0 || >=0.1.0-rc.1 <0.2.0-0",
44
47
  "@deepseek-ai/dsh-llm": ">=0.0.1-rc.1 <0.1.0 || >=0.1.0-rc.1 <0.2.0-0",
package/src/index.ts CHANGED
@@ -8,6 +8,7 @@
8
8
  import { registerRoutes } from './routes.js'
9
9
  import { captureToolResult } from './capture.js'
10
10
  import { handleRpc } from './rpc.js'
11
+ import { newSearcher } from './search/orchestrator.js'
11
12
  import { installIsolation } from './mcpIsolation.js'
12
13
  import { dropFileIndex } from './workspace.js'
13
14
  import { cwdOf } from './registry.js'
@@ -25,6 +26,7 @@ export const inject = ['sessions', 'fs', 'webServer', 'loader', 'tools', 'worksp
25
26
  */
26
27
  export function apply(ctx: Ctx, config?: unknown): void {
27
28
  const registry: Registry = new Map()
29
+ const searcher = newSearcher(ctx)
28
30
 
29
31
  ctx.on('tools/result', (exec: unknown, result: unknown) => {
30
32
  void captureToolResult(ctx, registry, exec, result)
@@ -35,10 +37,11 @@ export function apply(ctx: Ctx, config?: unknown): void {
35
37
  if (cwd) {
36
38
  registry.delete(cwd)
37
39
  dropFileIndex(cwd)
40
+ searcher.dispose(cwd)
38
41
  }
39
42
  })
40
43
 
41
- registerRoutes(ctx, config, (method, args) => handleRpc(ctx, registry, method, args))
44
+ registerRoutes(ctx, config, (method, args) => handleRpc(ctx, registry, method, args, searcher))
42
45
  installIsolation(ctx)
43
46
 
44
47
  ctx.logger?.info?.('[' + name + '] 编辑差异审查已装配(/edrv/rpc 路由就绪,项目 MCP 隔离已启用)')
package/src/rpc.ts CHANGED
@@ -21,7 +21,8 @@ import {
21
21
  import { archiveEntryFor, markDecision, recordResolved, reconstructOriginal } from './model.js'
22
22
  import type { Registry } from './registry.js'
23
23
  import { bucketOf, cwdOf, sessionOf } from './registry.js'
24
- import { listWorkspaceFiles } from './workspace.js'
24
+ import type { SearchOrchestrator } from './search/orchestrator.js'
25
+ import { newSearcher } from './search/orchestrator.js'
25
26
  import { restoreFile, revertCall, revertHunk } from './revert.js'
26
27
  import { listMcp, refreshMcp, removeMcp, saveMcp, toggleMcp } from './mcp.js'
27
28
  import { listProjects, projectRefresh, projectRemove, projectSave, projectToggle } from './mcpProject.js'
@@ -63,7 +64,7 @@ async function requireSession(ctx: Ctx, sessionId: string | undefined): Promise<
63
64
  }
64
65
 
65
66
  /** 各方法 handler 表(类型由 shared/rpc 的 RpcHandlerMap 约束)。 */
66
- export function buildHandlers(ctx: Ctx, registry: Registry): RpcHandlerMap {
67
+ export function buildHandlers(ctx: Ctx, registry: Registry, searcher = newSearcher(ctx)): RpcHandlerMap {
67
68
  return {
68
69
  'edrv.list': async (args) => {
69
70
  const sc = await requireSession(ctx, args.sessionId)
@@ -260,27 +261,11 @@ export function buildHandlers(ctx: Ctx, registry: Registry): RpcHandlerMap {
260
261
  'edrv.searchFiles': async (args) => {
261
262
  const sc = await requireSession(ctx, args.sessionId)
262
263
  if ('err' in sc) return { ok: false, error: sc.err }
263
- const query = String(args.query ?? '').trim().toLowerCase()
264
- const files: string[] = []
265
- if (query.length >= 2) {
266
- const bucket = await bucketOf(registry, ctx, sc.cwd)
267
- const pool = new Set<string>()
268
- // 活跃差异路径恒并入(保证差异文件一定可搜到,即使工作区扫描不可用)
269
- for (const rec of bucket.values()) if (rec.path) pool.add(rec.path)
270
- const listed = await listWorkspaceFiles(ctx, sc.session, sc.cwd)
271
- if (listed) for (const f of listed) pool.add(f)
272
- const score = (p: string): number => {
273
- const pl = p.toLowerCase()
274
- const base = pl.split(/[\\/]/).pop() || ''
275
- if (base.includes(query)) return 0
276
- if (pl.includes(query)) return 1
277
- return -1
278
- }
279
- const hit = [...pool].filter((p) => score(p) >= 0)
280
- hit.sort((a, b) => (score(a) - score(b)) || (a < b ? -1 : 1))
281
- files.push(...hit.slice(0, 50))
282
- }
283
- return { ok: true, files, truncated: files.length >= 50 }
264
+ const bucket = await bucketOf(registry, ctx, sc.cwd)
265
+ const activePaths: string[] = []
266
+ for (const record of bucket.values()) if (record.path) activePaths.push(record.path)
267
+ const result = await searcher.search({ session: sc.session, cwd: sc.cwd, query: args.query, activePaths })
268
+ return { ok: true, ...result }
284
269
  },
285
270
  'mcp.list': async () => ({ ok: true, ...listMcp(ctx) }),
286
271
  'mcp.save': async (args) => {
@@ -331,8 +316,9 @@ export async function handleRpc<M extends RpcMethod>(
331
316
  registry: Registry,
332
317
  method: M,
333
318
  args: RpcRequestMap[M],
319
+ searcher = newSearcher(ctx),
334
320
  ): Promise<RpcResult<M>> {
335
- const handlers = buildHandlers(ctx, registry)
321
+ const handlers = buildHandlers(ctx, registry, searcher)
336
322
  const handler = handlers[method]
337
323
  if (!handler) return { ok: false, error: '未知方法: ' + String(method) } as RpcResult<M>
338
324
  return handler(args)
@@ -0,0 +1,76 @@
1
+ /**
2
+ * dsh-vscode-mode host — fs.listDir 查询感知 fallback。
3
+ * provider 不可用时边遍历边匹配,不再伪装有限全量扫描为完整索引。
4
+ * 作者 ddj 2026年08月24号
5
+ */
6
+ import type { Ctx, Session } from '../store.js'
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'
11
+
12
+ interface FsTarget { displayPath?: string }
13
+ interface FsEntry { name: string; type: 'file' | 'directory' | 'other'; target: FsTarget }
14
+ interface SearchFs {
15
+ resolve(path: string, opts?: { cwd?: string; signal?: AbortSignal }): Promise<FsTarget>
16
+ listDir(target: FsTarget, signal?: AbortSignal): Promise<FsEntry[]>
17
+ }
18
+
19
+ const RESULT_CAP = 500
20
+ const EXCLUDED = new Set(['node_modules', '.git', '.tmp', '.cache', 'dist', 'build', 'vendor', 'coverage', '__pycache__'])
21
+
22
+ /**
23
+ * 在一个目录树中递归查找匹配文件。
24
+ * @author ddj 2026年08月24号
25
+ * @param fs 文件系统能力
26
+ * @param target 当前目录目标
27
+ * @param input 搜索输入
28
+ * @param files 已保留候选
29
+ * @param signal 取消信号
30
+ * @returns 是否因达到上限而截断
31
+ */
32
+ async function walk(fs: SearchFs, target: FsTarget, input: WorkspaceSearchInput, files: string[], signal: AbortSignal): Promise<boolean> {
33
+ if (signal.aborted) throw new DOMException('搜索已取消', 'AbortError')
34
+ const entries = await fs.listDir(target, signal)
35
+ for (const entry of entries) {
36
+ if (signal.aborted) throw new DOMException('搜索已取消', 'AbortError')
37
+ if (entry.type === 'directory') {
38
+ if (EXCLUDED.has(entry.name)) continue
39
+ if (await walk(fs, entry.target, input, files, signal)) return true
40
+ continue
41
+ }
42
+ 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
45
+ files.push(path)
46
+ if (files.length >= Math.min(input.maxResults, RESULT_CAP)) return true
47
+ }
48
+ return false
49
+ }
50
+
51
+ /**
52
+ * 使用 ctx.fs.listDir 搜索文件。
53
+ * @author ddj 2026年08月24号
54
+ * @param input provider 输入
55
+ * @returns 有界 fallback 结果
56
+ */
57
+ export async function searchFallback(input: WorkspaceSearchInput): Promise<WorkspaceSearchResult> {
58
+ const fs = input.ctx.get('fs') as SearchFs | undefined
59
+ if (!fs) throw new Error('缺少 fs')
60
+ const policy = policyOf(input.ctx, input.session)
61
+ const cwd = input.session?.header?.cwd
62
+ const rootTarget = await fs.resolve(policy?.workspaceRoot ?? '.', cwd ? { cwd, signal: input.signal } : { signal: input.signal })
63
+ const signal = input.signal ?? new AbortController().signal
64
+ const files: string[] = []
65
+ const truncated = await walk(fs, rootTarget, input, files, signal)
66
+ return { files, truncated, complete: !truncated, source: 'fallback' }
67
+ }
68
+
69
+ /**
70
+ * 构造 fallback provider。
71
+ * @author ddj 2026年08月24号
72
+ * @returns provider 实例
73
+ */
74
+ export function newFallback(): WorkspaceSearchProvider {
75
+ return { search: searchFallback }
76
+ }
@@ -0,0 +1,276 @@
1
+ /**
2
+ * dsh-vscode-mode host — 搜索编排、缓存和旧任务取消。
3
+ * 对外只返回旧 RPC 所需的 files/truncated,内部保留 provider 边界。
4
+ * 作者 ddj 2026年08月24号
5
+ */
6
+ import type { Ctx, Session } from '../store.js'
7
+ import { policyOf } from '../store.js'
8
+ import { pathKey, pathMatch, pathText, prepareQuery } from './query.js'
9
+ import { searchRoot } from './ripgrep.js'
10
+ import { newRgProvider } from './ripgrep.js'
11
+ import { newFallback } from './fallback.js'
12
+ import { candidateOf, rankCandidates } from './ranker.js'
13
+ import type { CandidateRanker, SearchCandidate, WorkspaceSearchProvider, WorkspaceSearchResult } from './types.js'
14
+
15
+ const CACHE_TTL = 60_000
16
+ const CACHE_LIMIT = 100
17
+ const RESULT_LIMIT = 50
18
+ const PROVIDER_VERSION = 'ripgrep-v1'
19
+ const POLICY_VERSION = 'search-policy-v1'
20
+
21
+ type CacheEntry = { at: number; result: WorkspaceSearchResult }
22
+ type SearchRequest = { session: Session; cwd: string; query: string; activePaths: string[] }
23
+ type SearchResponse = { files: string[]; truncated: boolean }
24
+
25
+ /**
26
+ * 短期有界搜索缓存。
27
+ * @author ddj 2026年08月24号
28
+ * @returns 缓存对象
29
+ */
30
+ export class SearchCache {
31
+ private readonly entries = new Map<string, CacheEntry>()
32
+
33
+ /**
34
+ * 读取未过期条目。
35
+ * @author ddj 2026年08月24号
36
+ * @param key 缓存 key
37
+ * @param now 当前时间
38
+ * @returns 缓存结果或 undefined
39
+ */
40
+ get(key: string, now = Date.now()): WorkspaceSearchResult | undefined {
41
+ const entry = this.entries.get(key)
42
+ if (!entry) return undefined
43
+ if (now - entry.at >= CACHE_TTL) { this.entries.delete(key); return undefined }
44
+ this.entries.delete(key)
45
+ this.entries.set(key, entry)
46
+ return { ...entry.result, files: [...entry.result.files] }
47
+ }
48
+
49
+ /**
50
+ * 写入成功 provider 结果。
51
+ * @author ddj 2026年08月24号
52
+ * @param key 缓存 key
53
+ * @param result provider 结果
54
+ * @param now 当前时间
55
+ */
56
+ set(key: string, result: WorkspaceSearchResult, now = Date.now()): void {
57
+ this.entries.delete(key)
58
+ this.entries.set(key, { at: now, result: { ...result, files: [...result.files] } })
59
+ while (this.entries.size > CACHE_LIMIT) this.entries.delete(this.entries.keys().next().value as string)
60
+ }
61
+
62
+ /**
63
+ * 清理指定根目录的缓存。
64
+ * @author ddj 2026年08月24号
65
+ * @param root 工作区根目录
66
+ */
67
+ clearRoot(root: string): void {
68
+ for (const key of this.entries.keys()) if (key.startsWith(root + '|')) this.entries.delete(key)
69
+ }
70
+
71
+ /**
72
+ * 清空全部缓存。
73
+ * @author ddj 2026年08月24号
74
+ */
75
+ clear(): void { this.entries.clear() }
76
+ }
77
+
78
+ /**
79
+ * 获取 policy 版本,未知策略使用固定插件版本。
80
+ * @author ddj 2026年08月24号
81
+ * @param ctx DSH 上下文
82
+ * @param session 当前会话
83
+ * @returns 稳定版本文本
84
+ */
85
+ function policyVersion(ctx: Ctx, session: Session): string {
86
+ const policy = policyOf(ctx, session)
87
+ const version = policy?.version ?? policy?.policyVersion
88
+ return typeof version === 'string' || typeof version === 'number' ? String(version) : POLICY_VERSION
89
+ }
90
+
91
+ /**
92
+ * 将 root 内绝对路径统一成相对显示路径。
93
+ * @author ddj 2026年08月24号
94
+ * @param value 原始路径
95
+ * @param root 搜索根
96
+ * @returns 兼容 edrv.read 的路径
97
+ */
98
+ function displayPath(value: string, root: string): string {
99
+ const path = pathText(value)
100
+ const base = pathText(root).replace(/\/$/, '')
101
+ const lowerPath = path.toLocaleLowerCase('en-US')
102
+ const lowerBase = base.toLocaleLowerCase('en-US')
103
+ if (lowerPath === lowerBase) return '.'
104
+ if (lowerPath.startsWith(lowerBase + '/')) return path.slice(base.length + 1)
105
+ return path
106
+ }
107
+
108
+ /**
109
+ * 为显示路径生成跨绝对/相对形式的去重 key。
110
+ * @author ddj 2026年08月24号
111
+ * @param value 原始路径
112
+ * @param root 搜索根
113
+ * @returns 去重 key
114
+ */
115
+ function displayKey(value: string, root: string): string {
116
+ return pathKey(displayPath(value, root))
117
+ }
118
+
119
+ /**
120
+ * 合并 provider 与 active diff 候选并去重。
121
+ * @author ddj 2026年08月24号
122
+ * @param result provider 结果
123
+ * @param activePaths 活跃差异路径
124
+ * @param root 搜索根
125
+ * @param query 已规范化 query
126
+ * @returns 候选列表
127
+ */
128
+ function mergeCandidates(result: WorkspaceSearchResult, activePaths: string[], root: string, query: ReturnType<typeof prepareQuery>): SearchCandidate[] {
129
+ const candidates = new Map<string, SearchCandidate>()
130
+ for (const path of result.files) {
131
+ const shown = displayPath(path, root)
132
+ if (pathMatch(shown, query)) candidates.set(displayKey(path, root), candidateOf(shown, 'workspace'))
133
+ }
134
+ for (const path of activePaths) {
135
+ const shown = displayPath(path, root)
136
+ if (!pathMatch(shown, query)) continue
137
+ const key = displayKey(path, root)
138
+ if (!candidates.has(key)) candidates.set(key, candidateOf(shown, 'active-diff'))
139
+ }
140
+ return [...candidates.values()]
141
+ }
142
+
143
+ /**
144
+ * 搜索编排器。
145
+ * @author ddj 2026年08月24号
146
+ */
147
+ export class SearchOrchestrator {
148
+ private readonly cache = new SearchCache()
149
+ private readonly inflight = new Map<string, AbortController>()
150
+ private readonly rootsByCwd = new Map<string, Set<string>>()
151
+ private readonly provider: WorkspaceSearchProvider
152
+ private readonly fallback: WorkspaceSearchProvider
153
+ private readonly ranker: CandidateRanker
154
+
155
+ /**
156
+ * 创建搜索编排器。
157
+ * @author ddj 2026年08月24号
158
+ * @param ctx DSH 上下文
159
+ * @param provider 主 provider,可替换测试
160
+ * @param fallback 降级 provider,可替换测试
161
+ * @param ranker 排序器,可替换测试
162
+ */
163
+ constructor(private readonly ctx: Ctx, provider = newRgProvider(), fallback = newFallback(), ranker: CandidateRanker = { rank: rankCandidates }) {
164
+ this.provider = provider
165
+ this.fallback = fallback
166
+ this.ranker = ranker
167
+ }
168
+
169
+ /**
170
+ * 执行一次工作区搜索。
171
+ * @author ddj 2026年08月24号
172
+ * @param request 搜索请求
173
+ * @returns 旧 RPC 响应字段
174
+ */
175
+ async search(request: SearchRequest): Promise<SearchResponse> {
176
+ const query = prepareQuery(request.query)
177
+ if (query.text.length < 2) return { files: [], truncated: false }
178
+ let root: string
179
+ try { root = await searchRoot(this.ctx, request.session) } catch (error) {
180
+ return this.rankActive(request.activePaths, request.cwd, query)
181
+ }
182
+ const rootKey = pathText(root)
183
+ const roots = this.rootsByCwd.get(request.cwd) ?? new Set<string>()
184
+ roots.add(rootKey)
185
+ this.rootsByCwd.set(request.cwd, roots)
186
+ const key = [rootKey, query.text, policyVersion(this.ctx, request.session), PROVIDER_VERSION].join('|')
187
+ const cached = this.cache.get(key)
188
+ if (cached) return this.finish(cached, request.activePaths, root, query)
189
+ this.inflight.get(root)?.abort()
190
+ const controller = new AbortController()
191
+ this.inflight.set(root, controller)
192
+ try {
193
+ let result: WorkspaceSearchResult
194
+ let providerOk = true
195
+ try {
196
+ result = await this.provider.search({ ctx: this.ctx, session: request.session, cwd: request.cwd, query: query.raw, maxResults: 500, signal: controller.signal, root })
197
+ } catch (error) {
198
+ providerOk = false
199
+ if (controller.signal.aborted) return { files: [], truncated: false }
200
+ try {
201
+ result = await this.fallback.search({ ctx: this.ctx, session: request.session, cwd: request.cwd, query: query.raw, maxResults: 500, signal: controller.signal, root })
202
+ } catch (fallbackError) {
203
+ return this.rankActive(request.activePaths, root, query)
204
+ }
205
+ }
206
+ if (controller.signal.aborted) return { files: [], truncated: false }
207
+ if (providerOk) this.cache.set(key, result)
208
+ return this.finish(result, request.activePaths, root, query)
209
+ } finally {
210
+ if (this.inflight.get(root) === controller) this.inflight.delete(root)
211
+ }
212
+ }
213
+
214
+ /**
215
+ * 清理会话对应根目录的缓存和在途搜索。
216
+ * @author ddj 2026年08月24号
217
+ * @param cwd 会话工作区
218
+ */
219
+ dispose(cwd: string): void {
220
+ const roots = this.rootsByCwd.get(cwd) ?? new Set<string>([cwd])
221
+ for (const root of roots) {
222
+ this.inflight.get(root)?.abort()
223
+ this.inflight.delete(root)
224
+ this.cache.clearRoot(root)
225
+ }
226
+ this.rootsByCwd.delete(cwd)
227
+ }
228
+
229
+ /**
230
+ * 清理全部状态。
231
+ * @author ddj 2026年08月24号
232
+ */
233
+ disposeAll(): void {
234
+ for (const controller of this.inflight.values()) controller.abort()
235
+ this.inflight.clear()
236
+ this.rootsByCwd.clear()
237
+ this.cache.clear()
238
+ }
239
+
240
+ /**
241
+ * 将 provider 结果与 active diff 合并、排序和截断。
242
+ * @author ddj 2026年08月24号
243
+ * @param result provider 结果
244
+ * @param activePaths active diff 路径
245
+ * @param root 搜索根
246
+ * @param query 已规范化 query
247
+ * @returns 旧 RPC 响应字段
248
+ */
249
+ private finish(result: WorkspaceSearchResult, activePaths: string[], root: string, query: ReturnType<typeof prepareQuery>): SearchResponse {
250
+ const candidates = this.ranker.rank(mergeCandidates(result, activePaths, root, query), query)
251
+ return { files: candidates.slice(0, RESULT_LIMIT).map((candidate) => candidate.path), truncated: result.truncated || candidates.length > RESULT_LIMIT }
252
+ }
253
+
254
+ /**
255
+ * provider 失败时仅保留 active diff 命中。
256
+ * @author ddj 2026年08月24号
257
+ * @param paths active diff 路径
258
+ * @param root 搜索根
259
+ * @param query 已规范化 query
260
+ * @returns 旧 RPC 响应字段
261
+ */
262
+ private rankActive(paths: string[], root: string, query: ReturnType<typeof prepareQuery>): SearchResponse {
263
+ const result: WorkspaceSearchResult = { files: [], truncated: false, complete: true, source: 'active-diff' }
264
+ return this.finish(result, paths, root, query)
265
+ }
266
+ }
267
+
268
+ /**
269
+ * 创建默认编排器。
270
+ * @author ddj 2026年08月24号
271
+ * @param ctx DSH 上下文
272
+ * @returns 搜索编排器
273
+ */
274
+ export function newSearcher(ctx: Ctx): SearchOrchestrator {
275
+ return new SearchOrchestrator(ctx)
276
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * dsh-vscode-mode host — 搜索 query 与路径规范化。
3
+ * 查询只按字面连续片段比较,不解释为正则或 shell 语句。
4
+ * 作者 ddj 2026年08月24号
5
+ */
6
+ import { isAbsolute, resolve } from 'node:path'
7
+ import type { PreparedQuery } from './types.js'
8
+
9
+ /**
10
+ * 规范化用户搜索文本。
11
+ * @author ddj 2026年08月24号
12
+ * @param raw 原始搜索文本
13
+ * @returns 用于比较和缓存的 query
14
+ */
15
+ export function prepareQuery(raw: unknown): PreparedQuery {
16
+ const value = String(raw ?? '').trim()
17
+ return { raw: value, text: value.replaceAll('\\', '/').toLocaleLowerCase('en-US') }
18
+ }
19
+
20
+ /**
21
+ * 归一化路径分隔符并移除无意义前缀。
22
+ * @author ddj 2026年08月24号
23
+ * @param value 原始路径
24
+ * @returns 比较用路径
25
+ */
26
+ export function pathText(value: string): string {
27
+ return value.replaceAll('\\', '/').replace(/^\.\//, '')
28
+ }
29
+
30
+ /**
31
+ * 生成不区分大小写的路径 key。
32
+ * @author ddj 2026年08月24号
33
+ * @param value 原始路径
34
+ * @returns 去重 key
35
+ */
36
+ export function pathKey(value: string): string {
37
+ return pathText(value).toLocaleLowerCase('en-US')
38
+ }
39
+
40
+ /**
41
+ * 将相对文件路径映射到 provider 根目录。
42
+ * @author ddj 2026年08月24号
43
+ * @param value 文件路径
44
+ * @param root 工作区根目录
45
+ * @returns 可供 edrv.read 使用的路径
46
+ */
47
+ export function displayPath(value: string, root: string): string {
48
+ const normalized = pathText(value)
49
+ if (isAbsolute(normalized) || /^[A-Za-z]:\//.test(normalized) || normalized.startsWith('//')) return normalized
50
+ return resolve(root, normalized)
51
+ }
52
+
53
+ /**
54
+ * 判断路径是否包含连续 query。
55
+ * @author ddj 2026年08月24号
56
+ * @param value 文件路径
57
+ * @param query 已规范化 query
58
+ * @returns 是否命中
59
+ */
60
+ export function pathMatch(value: string, query: PreparedQuery): boolean {
61
+ return pathText(value).toLocaleLowerCase('en-US').includes(query.text)
62
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * dsh-vscode-mode host — 可替换的连续匹配排序器。
3
+ * 第一版保持 basename 优先和稳定路径序,后续可接 fuzzy scorer。
4
+ * 作者 ddj 2026年08月24号
5
+ */
6
+ import type { CandidateRanker, PreparedQuery, SearchCandidate } from './types.js'
7
+ import { pathText } from './query.js'
8
+
9
+ /**
10
+ * 生成搜索候选。
11
+ * @author ddj 2026年08月24号
12
+ * @param path 文件路径
13
+ * @param source 候选来源
14
+ * @returns 内部候选
15
+ */
16
+ export function candidateOf(path: string, source: SearchCandidate['source']): SearchCandidate {
17
+ const normalizedPath = pathText(path)
18
+ const basename = normalizedPath.split('/').pop() ?? normalizedPath
19
+ return { path, basename, normalizedPath: normalizedPath.toLocaleLowerCase('en-US'), source }
20
+ }
21
+
22
+ /**
23
+ * 对连续匹配候选进行排序。
24
+ * @author ddj 2026年08月24号
25
+ * @param candidates 候选列表
26
+ * @param query 已规范化 query
27
+ * @returns 排序后的候选
28
+ */
29
+ 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'))
39
+ }
40
+
41
+ /**
42
+ * 默认候选排序器。
43
+ * @author ddj 2026年08月24号
44
+ * @returns 排序器
45
+ */
46
+ export function substringRanker(): CandidateRanker {
47
+ return { rank: rankCandidates }
48
+ }