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/lib/index.js +607 -88
- package/lib/index.js.map +1 -1
- package/package.json +4 -1
- package/src/index.ts +4 -1
- package/src/rpc.ts +10 -24
- package/src/search/fallback.ts +76 -0
- package/src/search/orchestrator.ts +276 -0
- package/src/search/query.ts +62 -0
- package/src/search/ranker.ts +48 -0
- package/src/search/ripgrep.ts +144 -0
- package/src/search/types.ts +47 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-vscode-mode",
|
|
3
|
-
"version": "0.1.
|
|
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 {
|
|
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
|
|
264
|
-
const
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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
|
+
}
|