dsh-vscode-mode 0.1.28 → 0.1.30
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/client.js +859 -27
- package/lib/client.js.map +1 -1
- package/lib/index.js +513 -41
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/client/compat.ts +1 -1
- package/src/client/index.ts +15 -0
- package/src/client/keybindings.ts +169 -0
- package/src/client/monaco/diffRender.ts +7 -0
- package/src/client/rpc.ts +13 -2
- package/src/client/sidebar/panels/SearchPanel.ts +294 -0
- package/src/client/sidebar/panels/index.ts +18 -2
- package/src/client/sidebar/types.ts +2 -0
- package/src/client/styles/editor.css +40 -0
- package/src/client/styles/mcp.css +18 -0
- package/src/client/ui/EditorView.ts +76 -15
- package/src/client/ui/KeybindingsPanel.ts +187 -0
- package/src/client/ui/McpSettings.ts +7 -4
- package/src/client/ui/QuickOpen.ts +6 -3
- package/src/fileOpenSettings.ts +20 -4
- package/src/index.ts +4 -1
- package/src/rpc.ts +106 -22
- package/src/search/content.ts +319 -0
- package/src/search/fallback.ts +1 -1
- package/src/search/orchestrator.ts +17 -10
- package/src/search/ripgrep.ts +17 -2
- package/src/search/types.ts +40 -0
- package/src/shared/keybindings.ts +39 -0
- package/src/shared/rpc.ts +11 -0
package/src/rpc.ts
CHANGED
|
@@ -23,6 +23,8 @@ import type { Registry } from './registry.js'
|
|
|
23
23
|
import { bucketOf, cwdOf, sessionOf } from './registry.js'
|
|
24
24
|
import type { SearchOrchestrator } from './search/orchestrator.js'
|
|
25
25
|
import { newSearcher } from './search/orchestrator.js'
|
|
26
|
+
import type { ContentSearcher } from './search/content.js'
|
|
27
|
+
import { newContentSearcher } from './search/content.js'
|
|
26
28
|
import { restoreFile, revertCall, revertHunk } from './revert.js'
|
|
27
29
|
import { listMcp, refreshMcp, removeMcp, saveMcp, toggleMcp } from './mcp.js'
|
|
28
30
|
import { listProjects, projectRefresh, projectRemove, projectSave, projectToggle } from './mcpProject.js'
|
|
@@ -32,10 +34,75 @@ import { readDevForm, setDevForm } from './devForm.js'
|
|
|
32
34
|
import { normalizeRel, toTreeEntries } from './tree.js'
|
|
33
35
|
import { revealInExplorer } from './reveal.js'
|
|
34
36
|
|
|
35
|
-
/** cwd →
|
|
37
|
+
/** cwd → 内存缓冲:行数组 + 累计字节数 + 待触发 flush 定时器(攒批落盘,避免每条日志全文件读改写)。 */
|
|
38
|
+
const debugBuffers = new Map<string, { lines: string[]; len: number; timer: ReturnType<typeof setTimeout> | null }>()
|
|
39
|
+
/** cwd → Promise 链:串行化 debug 日志落盘(fs read+write 非原子,避免并发丢行)。 */
|
|
36
40
|
const debugWriteQueues = new Map<string, Promise<void>>()
|
|
37
41
|
/** debug 日志单文件上限:超限截断保留尾部,防文件无限增长拖慢每次追加。 */
|
|
38
42
|
const DEBUG_LOG_CAP = 512 * 1024
|
|
43
|
+
/** debug 日志批量缓冲上限:攒满即落盘(一次读改写),上限内不逐条写文件。 */
|
|
44
|
+
const DEBUG_BUF_CAP = 32 * 1024
|
|
45
|
+
/** debug 日志空闲 flush 延迟:缓冲未满时,静默一段时间后落盘一次。 */
|
|
46
|
+
const DEBUG_FLUSH_IDLE_MS = 1000
|
|
47
|
+
/** cwd → 上次 stale 自动清理时间:全量轮询(EditorView/DiffBadge/Dock 各自 5s)节流,避免每轮都读文件算指纹。 */
|
|
48
|
+
const staleCheckedAt = new Map<string, number>()
|
|
49
|
+
/** stale 自动清理最小间隔。 */
|
|
50
|
+
const STALE_CHECK_MIN_MS = 10_000
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 调试日志入队:先攒内存缓冲(行 + 字节数),满 DEBUG_BUF_CAP 立即落盘,
|
|
54
|
+
* 否则空闲 DEBUG_FLUSH_IDLE_MS 后落盘;落盘 = 读旧文件 + 追加整批 + 超限截断 + 一次写入。
|
|
55
|
+
* @author ddj 2026年08月26号
|
|
56
|
+
* @param ctx DSH 上下文
|
|
57
|
+
* @param cwd 工作区(日志文件按工作区旁车存放)
|
|
58
|
+
* @param policy 会话沙箱策略(入队时刻捕获)
|
|
59
|
+
* @param line 单条日志文本(不含换行)
|
|
60
|
+
*/
|
|
61
|
+
function enqueueDebug(ctx: Ctx, cwd: string, policy: unknown, line: string): void {
|
|
62
|
+
const st = debugBuffers.get(cwd) ?? { lines: [], len: 0, timer: null }
|
|
63
|
+
st.lines.push(line)
|
|
64
|
+
st.len += line.length
|
|
65
|
+
const flush = () => {
|
|
66
|
+
st.timer = null
|
|
67
|
+
const batch = st.lines
|
|
68
|
+
st.lines = []
|
|
69
|
+
st.len = 0
|
|
70
|
+
void flushDebug(ctx, cwd, policy, batch)
|
|
71
|
+
}
|
|
72
|
+
if (st.len >= DEBUG_BUF_CAP) {
|
|
73
|
+
if (st.timer) clearTimeout(st.timer)
|
|
74
|
+
flush()
|
|
75
|
+
} else if (!st.timer) {
|
|
76
|
+
st.timer = setTimeout(flush, DEBUG_FLUSH_IDLE_MS)
|
|
77
|
+
}
|
|
78
|
+
debugBuffers.set(cwd, st)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* 批量落盘一条 debug 日志缓冲:读旧文件 → 追加 → 超上限截断保留尾部 → 一次写回。
|
|
83
|
+
* 串行链保证同一 cwd 的读改写不交错丢行;写失败静默忽略(调试日志不阻塞业务)。
|
|
84
|
+
* @author ddj 2026年08月26号
|
|
85
|
+
* @param ctx DSH 上下文
|
|
86
|
+
* @param cwd 工作区
|
|
87
|
+
* @param policy 会话沙箱策略(enqueue 时刻捕获)
|
|
88
|
+
* @param batch 本批日志行
|
|
89
|
+
*/
|
|
90
|
+
function flushDebug(ctx: Ctx, cwd: string, policy: unknown, batch: string[]): Promise<void> {
|
|
91
|
+
const prev = debugWriteQueues.get(cwd) ?? Promise.resolve()
|
|
92
|
+
const task = prev.then(async () => {
|
|
93
|
+
try {
|
|
94
|
+
const fs = ctx.get('fs')
|
|
95
|
+
if (!fs) return
|
|
96
|
+
const target = await fs.resolve('.dsh-edit-review-debug.log', { cwd })
|
|
97
|
+
const old = await fs.readText(target).catch(() => '')
|
|
98
|
+
const appended = old + batch.map((l) => new Date().toISOString() + ' ' + l + '\n').join('')
|
|
99
|
+
const next = appended.length > DEBUG_LOG_CAP ? appended.slice(-Math.floor(DEBUG_LOG_CAP / 2)) : appended
|
|
100
|
+
await fs.writeText(target, next, void 0, void 0, policy)
|
|
101
|
+
} catch (e) { /* 写日志失败忽略 */ }
|
|
102
|
+
})
|
|
103
|
+
debugWriteQueues.set(cwd, task)
|
|
104
|
+
return task
|
|
105
|
+
}
|
|
39
106
|
|
|
40
107
|
/** 记录 → 客户端视图(不含 before 全文,仅长度)。 */
|
|
41
108
|
function recView(record: DiffRecord): RecordView {
|
|
@@ -117,14 +184,23 @@ async function applyDecisions(
|
|
|
117
184
|
}
|
|
118
185
|
|
|
119
186
|
/** 各方法 handler 表(类型由 shared/rpc 的 RpcHandlerMap 约束)。 */
|
|
120
|
-
export function buildHandlers(ctx: Ctx, registry: Registry, searcher = newSearcher(ctx)): RpcHandlerMap {
|
|
187
|
+
export function buildHandlers(ctx: Ctx, registry: Registry, searcher = newSearcher(ctx), contentSearcher = newContentSearcher(ctx)): RpcHandlerMap {
|
|
121
188
|
return {
|
|
122
189
|
'edrv.list': async (args) => {
|
|
123
190
|
const sc = await requireSession(ctx, args.sessionId)
|
|
124
191
|
if ('err' in sc) return { ok: false, error: sc.err }
|
|
125
192
|
const bucket = await bucketOf(registry, ctx, sc.cwd)
|
|
126
193
|
const want = Array.isArray(args.callIds) ? new Set(args.callIds) : null
|
|
127
|
-
|
|
194
|
+
// 全量轮询时自动清理 stale 幽灵差异(批量决策后的即时刷新可 skipStale 跳过);
|
|
195
|
+
// 三个组件各自 5s 全量 list,stale 检查节流到 STALE_CHECK_MIN_MS 一次,
|
|
196
|
+
// 避免每轮都对全部记录读文件 + 算指纹(keepall 期间多组件同时刷新的主要 host 开销)。
|
|
197
|
+
if (!want && args.skipStale !== true) {
|
|
198
|
+
const now = Date.now()
|
|
199
|
+
if (now - (staleCheckedAt.get(sc.cwd) ?? 0) > STALE_CHECK_MIN_MS) {
|
|
200
|
+
staleCheckedAt.set(sc.cwd, now)
|
|
201
|
+
await autoArchiveStale(ctx, sc.session, sc.cwd, bucket)
|
|
202
|
+
}
|
|
203
|
+
}
|
|
128
204
|
const out: RecordView[] = []
|
|
129
205
|
for (const rec of bucket.values()) {
|
|
130
206
|
// 面板全量查询过滤已归档;聊天条按 callId 查询保留(状态徽章仍需正确显示)
|
|
@@ -291,26 +367,12 @@ export function buildHandlers(ctx: Ctx, registry: Registry, searcher = newSearch
|
|
|
291
367
|
return { ok: true, path: args.path, batch: batch ?? null }
|
|
292
368
|
},
|
|
293
369
|
'edrv.debug': async (args) => {
|
|
294
|
-
// 诊断日志:client 上报 →
|
|
370
|
+
// 诊断日志:client 上报 → 内存缓冲批量落盘 .dsh-edit-review-debug.log(console 不一定落盘,文件可靠)。
|
|
371
|
+
// 只出现在调试开关开启时(client dbg 默认关),终端仍逐条打印便于实时观察。
|
|
295
372
|
const sc = await requireSession(ctx, args.sessionId)
|
|
296
373
|
if ('err' in sc) return { ok: false, error: sc.err }
|
|
297
|
-
const fs = ctx.get('fs')
|
|
298
374
|
const text = String(args.text ?? '')
|
|
299
|
-
|
|
300
|
-
const task = prev.then(async () => {
|
|
301
|
-
try {
|
|
302
|
-
const target = await fs.resolve('.dsh-edit-review-debug.log', { cwd: sc.cwd })
|
|
303
|
-
const old = await fs.readText(target).catch(() => '')
|
|
304
|
-
const line = new Date().toISOString() + ' ' + text + '\n'
|
|
305
|
-
// 超上限时截断保留尾部:单文件体积有界,每次追加的读改写成本不随会话时长增长
|
|
306
|
-
const next = old.length + line.length > DEBUG_LOG_CAP
|
|
307
|
-
? old.slice(-Math.floor(DEBUG_LOG_CAP / 2)) + line
|
|
308
|
-
: old + line
|
|
309
|
-
await fs.writeText(target, next, void 0, void 0, policyOf(ctx, sc.session))
|
|
310
|
-
} catch (e) { /* 写日志失败忽略 */ }
|
|
311
|
-
})
|
|
312
|
-
debugWriteQueues.set(sc.cwd, task)
|
|
313
|
-
await task
|
|
375
|
+
enqueueDebug(ctx, sc.cwd, policyOf(ctx, sc.session), text)
|
|
314
376
|
console.error('[edrv-debug] ' + text)
|
|
315
377
|
return { ok: true }
|
|
316
378
|
},
|
|
@@ -323,6 +385,27 @@ export function buildHandlers(ctx: Ctx, registry: Registry, searcher = newSearch
|
|
|
323
385
|
const result = await searcher.search({ session: sc.session, cwd: sc.cwd, query: args.query, activePaths })
|
|
324
386
|
return { ok: true, ...result }
|
|
325
387
|
},
|
|
388
|
+
'edrv.searchContent': async (args) => {
|
|
389
|
+
// 工作区内容搜索:rg --json 主路径;provider 失败转错误响应(无 fallback)。
|
|
390
|
+
const sc = await requireSession(ctx, args.sessionId)
|
|
391
|
+
if ('err' in sc) return { ok: false, error: sc.err }
|
|
392
|
+
try {
|
|
393
|
+
const result = await contentSearcher.search({
|
|
394
|
+
session: sc.session,
|
|
395
|
+
cwd: sc.cwd,
|
|
396
|
+
query: args.query,
|
|
397
|
+
matchCase: args.matchCase,
|
|
398
|
+
wholeWord: args.wholeWord,
|
|
399
|
+
regex: args.regex,
|
|
400
|
+
maxResults: args.maxResults,
|
|
401
|
+
include: args.include,
|
|
402
|
+
exclude: args.exclude,
|
|
403
|
+
})
|
|
404
|
+
return { ok: true, ...result }
|
|
405
|
+
} catch (error) {
|
|
406
|
+
return { ok: false, error: '搜索失败:' + String(error) }
|
|
407
|
+
}
|
|
408
|
+
},
|
|
326
409
|
'edrv.listDir': async (args) => {
|
|
327
410
|
// 目录树(侧边栏文件管理用):按会话 cwd 解析,与 edrv.read 同基准,树内相对路径点开即打开。
|
|
328
411
|
const sc = await requireSession(ctx, args.sessionId)
|
|
@@ -433,7 +516,7 @@ export function buildHandlers(ctx: Ctx, registry: Registry, searcher = newSearch
|
|
|
433
516
|
|
|
434
517
|
/**
|
|
435
518
|
* 统一入口:按方法分发到 handler 表。
|
|
436
|
-
* @author ddj 2026年08月20号
|
|
519
|
+
* @author ddj 2026年08月20号 / 2026年08月26号
|
|
437
520
|
*/
|
|
438
521
|
export async function handleRpc<M extends RpcMethod>(
|
|
439
522
|
ctx: Ctx,
|
|
@@ -441,8 +524,9 @@ export async function handleRpc<M extends RpcMethod>(
|
|
|
441
524
|
method: M,
|
|
442
525
|
args: RpcRequestMap[M],
|
|
443
526
|
searcher = newSearcher(ctx),
|
|
527
|
+
contentSearcher = newContentSearcher(ctx),
|
|
444
528
|
): Promise<RpcResult<M>> {
|
|
445
|
-
const handlers = buildHandlers(ctx, registry, searcher)
|
|
529
|
+
const handlers = buildHandlers(ctx, registry, searcher, contentSearcher)
|
|
446
530
|
const handler = handlers[method]
|
|
447
531
|
if (!handler) return { ok: false, error: '未知方法: ' + String(method) } as RpcResult<M>
|
|
448
532
|
return handler(args)
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-vscode-mode host — 工作区内容搜索(rg --json 主路径 + 有界编排)。
|
|
3
|
+
* 命中列由 rg 行内字节偏移转 UTF-16(1-based),可直接供 Monaco 跳转/高亮。
|
|
4
|
+
* 无 fallback:provider 失败抛错,由 RPC 层转错误响应(避免整树读文件)。
|
|
5
|
+
* 作者 ddj 2026年08月26号
|
|
6
|
+
*/
|
|
7
|
+
import type { Ctx, Session } from '../store.js'
|
|
8
|
+
import { pathText } from './query.js'
|
|
9
|
+
import { EXCLUDES, FILE_EXCLUDES, ripgrepPath, searchRoot } from './ripgrep.js'
|
|
10
|
+
import { SearchCache } from './orchestrator.js'
|
|
11
|
+
import type { ContentMatch, ContentSearchInput, ContentSearchProvider, ContentSearchResult } from './types.js'
|
|
12
|
+
|
|
13
|
+
const STDOUT_CAP = 16 << 20
|
|
14
|
+
const STDERR_CAP = 64 << 10
|
|
15
|
+
const GRACE_MS = 20_000
|
|
16
|
+
const DEFAULT_MAX_MATCHES = 500
|
|
17
|
+
const DEFAULT_MAX_FILES = 100
|
|
18
|
+
const CACHE_TTL = 60_000
|
|
19
|
+
const CACHE_LIMIT = 100
|
|
20
|
+
const PROVIDER_VERSION = 'content-ripgrep-v1'
|
|
21
|
+
/** 单行 JSON 记录上限:巨型单行文件(源映射/打包产物)的命中行对搜索 UI 无意义,直接跳过。 */
|
|
22
|
+
const MAX_RECORD_LINE = 1 << 20
|
|
23
|
+
|
|
24
|
+
/** rg --json 单条 match 记录的宽松形状(未知字段忽略)。 */
|
|
25
|
+
interface RgJsonItem {
|
|
26
|
+
type?: string
|
|
27
|
+
data?: {
|
|
28
|
+
path?: { text?: string }
|
|
29
|
+
lines?: { text?: string }
|
|
30
|
+
line_number?: number
|
|
31
|
+
submatches?: Array<{ start?: number; end?: number }>
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** 内容搜索请求(RPC 参数映射)。 */
|
|
36
|
+
export interface ContentSearchRequest {
|
|
37
|
+
session: Session
|
|
38
|
+
cwd: string
|
|
39
|
+
query: string
|
|
40
|
+
matchCase?: boolean
|
|
41
|
+
wholeWord?: boolean
|
|
42
|
+
regex?: boolean
|
|
43
|
+
maxResults?: number
|
|
44
|
+
/** 正选 glob(仅在这些文件内搜索)。 */
|
|
45
|
+
include?: string[]
|
|
46
|
+
/** 排除 glob(这些文件不参与搜索)。 */
|
|
47
|
+
exclude?: string[]
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
type ContentSearchResponse = { matches: ContentMatch[]; truncated: boolean }
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 把行内字节偏移转成 UTF-16 列(1-based;越界钳到行尾)。
|
|
54
|
+
* @author ddj 2026年08月26号
|
|
55
|
+
* @param text 行文本
|
|
56
|
+
* @param byteOffset 行内字节偏移(rg submatches.start/end,实测为行相对)
|
|
57
|
+
* @returns 1-based UTF-16 列
|
|
58
|
+
*/
|
|
59
|
+
export function byteToUtf16Col(text: string, byteOffset: number): number {
|
|
60
|
+
let bytes = 0
|
|
61
|
+
let col = 1
|
|
62
|
+
for (const char of text) {
|
|
63
|
+
if (bytes >= byteOffset) break
|
|
64
|
+
bytes += Buffer.byteLength(char)
|
|
65
|
+
col++
|
|
66
|
+
}
|
|
67
|
+
return col
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* 根内绝对路径 → 工作区相对显示路径(供 edrv.read 与面板展示)。
|
|
72
|
+
* @author ddj 2026年08月26号
|
|
73
|
+
* @param value rg 输出路径
|
|
74
|
+
* @param root 搜索根(subprocess 执行世界)
|
|
75
|
+
* @returns 相对路径(根外路径原样返回)
|
|
76
|
+
*/
|
|
77
|
+
export function displayPathOf(value: string | undefined, root: string): string {
|
|
78
|
+
const path = pathText(String(value ?? ''))
|
|
79
|
+
if (!path) return path
|
|
80
|
+
const base = pathText(root).replace(/\/$/, '')
|
|
81
|
+
const lowerPath = path.toLocaleLowerCase('en-US')
|
|
82
|
+
const lowerBase = base.toLocaleLowerCase('en-US')
|
|
83
|
+
if (lowerPath === lowerBase) return '.'
|
|
84
|
+
if (lowerPath.startsWith(lowerBase + '/')) return path.slice(base.length + 1)
|
|
85
|
+
return path
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* 解析 rg --json 输出为扁平命中列表(多子匹配展开,列转 UTF-16)。
|
|
90
|
+
* @author ddj 2026年08月26号
|
|
91
|
+
* @param text stdout 文本
|
|
92
|
+
* @param root 搜索根
|
|
93
|
+
* @returns 命中列表
|
|
94
|
+
*/
|
|
95
|
+
export function parseRgJsonLines(text: string, root: string): ContentMatch[] {
|
|
96
|
+
const matches: ContentMatch[] = []
|
|
97
|
+
for (const line of text.split(/\r?\n/)) {
|
|
98
|
+
const trimmed = line.trim()
|
|
99
|
+
if (!trimmed) continue
|
|
100
|
+
let item: RgJsonItem
|
|
101
|
+
try {
|
|
102
|
+
item = JSON.parse(trimmed) as RgJsonItem
|
|
103
|
+
} catch {
|
|
104
|
+
continue
|
|
105
|
+
}
|
|
106
|
+
if (item?.type !== 'match' || !item.data) continue
|
|
107
|
+
const data = item.data
|
|
108
|
+
const path = displayPathOf(data.path?.text, root)
|
|
109
|
+
const lineText = (data.lines?.text ?? '').replace(/\r?\n$/, '').replace(/\r$/, '')
|
|
110
|
+
if (lineText.length > MAX_RECORD_LINE) continue
|
|
111
|
+
const lineNumber = data.line_number ?? 0
|
|
112
|
+
for (const sub of data.submatches ?? []) {
|
|
113
|
+
matches.push({
|
|
114
|
+
path,
|
|
115
|
+
line: lineNumber,
|
|
116
|
+
startColumn: byteToUtf16Col(lineText, sub.start ?? 0),
|
|
117
|
+
endColumn: byteToUtf16Col(lineText, sub.end ?? 0),
|
|
118
|
+
text: lineText,
|
|
119
|
+
})
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return matches
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* 命中截断:匹配数/文件数双上限,超限标记 truncated。
|
|
127
|
+
* @author ddj 2026年08月26号
|
|
128
|
+
* @param matches 全部命中
|
|
129
|
+
* @param maxMatches 匹配数上限
|
|
130
|
+
* @param maxFiles 文件数上限
|
|
131
|
+
* @returns 截断后的命中与标志
|
|
132
|
+
*/
|
|
133
|
+
export function applyCaps(matches: ContentMatch[], maxMatches: number, maxFiles: number): { matches: ContentMatch[]; truncated: boolean } {
|
|
134
|
+
const seen = new Set<string>()
|
|
135
|
+
const out: ContentMatch[] = []
|
|
136
|
+
let truncated = false
|
|
137
|
+
for (const match of matches) {
|
|
138
|
+
if (out.length >= maxMatches) { truncated = true; break }
|
|
139
|
+
if (!seen.has(match.path)) {
|
|
140
|
+
if (seen.size >= maxFiles) { truncated = true; break }
|
|
141
|
+
seen.add(match.path)
|
|
142
|
+
}
|
|
143
|
+
out.push(match)
|
|
144
|
+
}
|
|
145
|
+
return { matches: out, truncated }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* 构造 rg 内容搜索 argv(pattern 放 `--` 后首位置,支持前导 `-` 的查询)。
|
|
150
|
+
* include/exclude 为用户 glob(gitignore 式,原样透传不转义),
|
|
151
|
+
* 分别转正选 `--glob` 与排除 `--glob !`。
|
|
152
|
+
* @author ddj 2026年08月26号
|
|
153
|
+
* @param binary rg 路径
|
|
154
|
+
* @param root 搜索根
|
|
155
|
+
* @param input 搜索输入
|
|
156
|
+
* @returns argv 数组
|
|
157
|
+
*/
|
|
158
|
+
export function contentArgv(binary: string, root: string, input: ContentSearchInput): string[] {
|
|
159
|
+
const argv = [binary, '--no-config', '--json', '--line-number', '--no-heading', '--color', 'never', '--hidden', '--no-ignore']
|
|
160
|
+
if (input.matchCase === true) argv.push('--case-sensitive')
|
|
161
|
+
if (input.wholeWord === true) argv.push('--word-regexp')
|
|
162
|
+
if (input.regex !== true) argv.push('--fixed-strings')
|
|
163
|
+
for (const excluded of EXCLUDES) argv.push('--glob', '!**/' + excluded + '/**')
|
|
164
|
+
for (const excluded of FILE_EXCLUDES) argv.push('--glob', '!' + excluded)
|
|
165
|
+
for (const pattern of input.include ?? []) {
|
|
166
|
+
const glob = String(pattern).trim()
|
|
167
|
+
if (glob) argv.push('--glob', glob)
|
|
168
|
+
}
|
|
169
|
+
for (const pattern of input.exclude ?? []) {
|
|
170
|
+
const glob = String(pattern).trim()
|
|
171
|
+
if (glob) argv.push('--glob', '!' + glob)
|
|
172
|
+
}
|
|
173
|
+
argv.push('--', input.query, root)
|
|
174
|
+
return argv
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* 使用打包 ripgrep 搜索文件内容。
|
|
179
|
+
* @author ddj 2026年08月26号
|
|
180
|
+
* @param input provider 输入
|
|
181
|
+
* @returns 有界内容搜索结果
|
|
182
|
+
*/
|
|
183
|
+
export async function searchRipgrepContent(input: ContentSearchInput): Promise<ContentSearchResult> {
|
|
184
|
+
const sub = input.ctx.get('subprocess') as { spawn(spec: unknown): { done: Promise<{ exitCode: number | null; code?: number | null }>; collected?: { stdout?: { readFrom(offset: number): { text: string; lossy?: boolean } } } } } | undefined
|
|
185
|
+
if (!sub) throw new Error('缺少 subprocess')
|
|
186
|
+
const binary = ripgrepPath()
|
|
187
|
+
if (!binary) throw new Error('ripgrep 不可用')
|
|
188
|
+
const root = input.root ?? await searchRoot(input.ctx, input.session)
|
|
189
|
+
const handle = sub.spawn({
|
|
190
|
+
argv: contentArgv(binary, root, input),
|
|
191
|
+
cwd: root,
|
|
192
|
+
stdio: { stdin: 'ignore', stdout: { maxBytes: STDOUT_CAP }, stderr: { maxBytes: STDERR_CAP } },
|
|
193
|
+
graceMs: GRACE_MS,
|
|
194
|
+
signal: input.signal,
|
|
195
|
+
})
|
|
196
|
+
let outcome: { exitCode: number | null; code?: number | null }
|
|
197
|
+
try {
|
|
198
|
+
outcome = await handle.done
|
|
199
|
+
} catch (error) {
|
|
200
|
+
throw new Error('ripgrep 启动失败:' + String(error))
|
|
201
|
+
}
|
|
202
|
+
const code = outcome.exitCode ?? outcome.code
|
|
203
|
+
if (code !== 0 && code !== 1) throw new Error('ripgrep 退出码:' + String(code))
|
|
204
|
+
const reader = handle.collected?.stdout
|
|
205
|
+
if (!reader) throw new Error('ripgrep stdout 不可用')
|
|
206
|
+
const output = reader.readFrom(0)
|
|
207
|
+
const all = parseRgJsonLines(output.text, root)
|
|
208
|
+
const maxMatches = Math.max(1, Math.min(input.maxResults ?? DEFAULT_MAX_MATCHES, DEFAULT_MAX_MATCHES))
|
|
209
|
+
const capped = applyCaps(all, maxMatches, DEFAULT_MAX_FILES)
|
|
210
|
+
const truncated = Boolean(output.lossy) || capped.truncated
|
|
211
|
+
return { ...capped, complete: !truncated, source: 'ripgrep' }
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* 内容搜索编排器:短期缓存 + 同根在途取消 + 会话清理。
|
|
216
|
+
* @author ddj 2026年08月26号
|
|
217
|
+
*/
|
|
218
|
+
export class ContentSearcher {
|
|
219
|
+
private readonly cache = new SearchCache<ContentSearchResult>((r) => ({ ...r, matches: [...r.matches] }))
|
|
220
|
+
private readonly inflight = new Map<string, AbortController>()
|
|
221
|
+
private readonly rootsByCwd = new Map<string, Set<string>>()
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* 创建内容搜索编排器。
|
|
225
|
+
* @author ddj 2026年08月26号
|
|
226
|
+
* @param ctx DSH 上下文
|
|
227
|
+
* @param provider 内容搜索 provider(测试可替换)
|
|
228
|
+
*/
|
|
229
|
+
constructor(private readonly ctx: Ctx, private readonly provider: ContentSearchProvider = { search: searchRipgrepContent }) {}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* 执行一次工作区内容搜索。
|
|
233
|
+
* @author ddj 2026年08月26号
|
|
234
|
+
* @param request 搜索请求
|
|
235
|
+
* @returns RPC 响应字段(provider 失败抛错,abort 返回空)
|
|
236
|
+
*/
|
|
237
|
+
async search(request: ContentSearchRequest): Promise<ContentSearchResponse> {
|
|
238
|
+
const query = String(request.query ?? '').trim()
|
|
239
|
+
if (query.length < 2) return { matches: [], truncated: false }
|
|
240
|
+
let root: string
|
|
241
|
+
try {
|
|
242
|
+
root = await searchRoot(this.ctx, request.session)
|
|
243
|
+
} catch {
|
|
244
|
+
return { matches: [], truncated: false }
|
|
245
|
+
}
|
|
246
|
+
const rootKey = pathText(root)
|
|
247
|
+
const roots = this.rootsByCwd.get(request.cwd) ?? new Set<string>()
|
|
248
|
+
roots.add(rootKey)
|
|
249
|
+
this.rootsByCwd.set(request.cwd, roots)
|
|
250
|
+
const includeKey = (request.include ?? []).join(',')
|
|
251
|
+
const excludeKey = (request.exclude ?? []).join(',')
|
|
252
|
+
const key = [rootKey, query, request.matchCase ? 'mc' : '', request.wholeWord ? 'ww' : '', request.regex ? 'rx' : '', includeKey, excludeKey, PROVIDER_VERSION].join('|')
|
|
253
|
+
const cached = this.cache.get(key)
|
|
254
|
+
if (cached) return { matches: cached.matches, truncated: cached.truncated }
|
|
255
|
+
this.inflight.get(root)?.abort()
|
|
256
|
+
const controller = new AbortController()
|
|
257
|
+
this.inflight.set(root, controller)
|
|
258
|
+
try {
|
|
259
|
+
const result = await this.provider.search({
|
|
260
|
+
ctx: this.ctx,
|
|
261
|
+
session: request.session,
|
|
262
|
+
cwd: request.cwd,
|
|
263
|
+
query,
|
|
264
|
+
matchCase: request.matchCase,
|
|
265
|
+
wholeWord: request.wholeWord,
|
|
266
|
+
regex: request.regex,
|
|
267
|
+
maxResults: request.maxResults,
|
|
268
|
+
include: request.include,
|
|
269
|
+
exclude: request.exclude,
|
|
270
|
+
signal: controller.signal,
|
|
271
|
+
root,
|
|
272
|
+
})
|
|
273
|
+
if (controller.signal.aborted) return { matches: [], truncated: false }
|
|
274
|
+
this.cache.set(key, result)
|
|
275
|
+
return { matches: result.matches, truncated: result.truncated }
|
|
276
|
+
} catch (error) {
|
|
277
|
+
if (controller.signal.aborted) return { matches: [], truncated: false }
|
|
278
|
+
throw error
|
|
279
|
+
} finally {
|
|
280
|
+
if (this.inflight.get(root) === controller) this.inflight.delete(root)
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* 清理会话对应根目录的缓存和在途搜索。
|
|
286
|
+
* @author ddj 2026年08月26号
|
|
287
|
+
* @param cwd 会话工作区
|
|
288
|
+
*/
|
|
289
|
+
dispose(cwd: string): void {
|
|
290
|
+
const roots = this.rootsByCwd.get(cwd) ?? new Set<string>([cwd])
|
|
291
|
+
for (const root of roots) {
|
|
292
|
+
this.inflight.get(root)?.abort()
|
|
293
|
+
this.inflight.delete(root)
|
|
294
|
+
this.cache.clearRoot(root)
|
|
295
|
+
}
|
|
296
|
+
this.rootsByCwd.delete(cwd)
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* 清理全部状态。
|
|
301
|
+
* @author ddj 2026年08月26号
|
|
302
|
+
*/
|
|
303
|
+
disposeAll(): void {
|
|
304
|
+
for (const controller of this.inflight.values()) controller.abort()
|
|
305
|
+
this.inflight.clear()
|
|
306
|
+
this.rootsByCwd.clear()
|
|
307
|
+
this.cache.clear()
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* 创建默认内容搜索编排器。
|
|
313
|
+
* @author ddj 2026年08月26号
|
|
314
|
+
* @param ctx DSH 上下文
|
|
315
|
+
* @returns 内容搜索编排器
|
|
316
|
+
*/
|
|
317
|
+
export function newContentSearcher(ctx: Ctx): ContentSearcher {
|
|
318
|
+
return new ContentSearcher(ctx)
|
|
319
|
+
}
|
package/src/search/fallback.ts
CHANGED
|
@@ -17,7 +17,7 @@ interface SearchFs {
|
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
const RESULT_CAP = 500
|
|
20
|
-
const EXCLUDED = new Set(['node_modules', '.git', '.tmp', '.cache', 'dist', 'build', 'vendor', 'coverage', '__pycache__'])
|
|
20
|
+
const EXCLUDED = new Set(['node_modules', '.git', '.tmp', '.cache', 'dist', 'build', 'vendor', 'coverage', '__pycache__', '.pnpm-store', '.npm-cache', '.codegraph', '.workbuddy', '.dsh-edit-review.json', '.dsh-edit-review-archive.json', '.dsh-edit-review-debug.log'])
|
|
21
21
|
|
|
22
22
|
/**
|
|
23
23
|
* 在一个目录树中递归查找匹配文件。
|
|
@@ -18,17 +18,24 @@ const RESULT_LIMIT = 50
|
|
|
18
18
|
const PROVIDER_VERSION = 'ripgrep-v1'
|
|
19
19
|
const POLICY_VERSION = 'search-policy-v1'
|
|
20
20
|
|
|
21
|
-
type CacheEntry = { at: number; result:
|
|
21
|
+
type CacheEntry<T> = { at: number; result: T }
|
|
22
22
|
type SearchRequest = { session: Session; cwd: string; query: string; activePaths: string[] }
|
|
23
23
|
type SearchResponse = { files: string[]; truncated: boolean }
|
|
24
24
|
|
|
25
25
|
/**
|
|
26
|
-
*
|
|
26
|
+
* 短期有界搜索缓存(泛型:文件/内容搜索共用;clone 由调用方保证写隔离)。
|
|
27
27
|
* @author ddj 2026年08月24号
|
|
28
|
-
* @
|
|
28
|
+
* @param clone 结果写隔离克隆(缺省恒等)
|
|
29
29
|
*/
|
|
30
|
-
export class SearchCache {
|
|
31
|
-
private readonly entries = new Map<string, CacheEntry
|
|
30
|
+
export class SearchCache<T> {
|
|
31
|
+
private readonly entries = new Map<string, CacheEntry<T>>()
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 创建缓存。
|
|
35
|
+
* @author ddj 2026年08月24号
|
|
36
|
+
* @param clone 结果克隆函数(缺省直接引用)
|
|
37
|
+
*/
|
|
38
|
+
constructor(private readonly clone: (value: T) => T = (value) => value) {}
|
|
32
39
|
|
|
33
40
|
/**
|
|
34
41
|
* 读取未过期条目。
|
|
@@ -37,13 +44,13 @@ export class SearchCache {
|
|
|
37
44
|
* @param now 当前时间
|
|
38
45
|
* @returns 缓存结果或 undefined
|
|
39
46
|
*/
|
|
40
|
-
get(key: string, now = Date.now()):
|
|
47
|
+
get(key: string, now = Date.now()): T | undefined {
|
|
41
48
|
const entry = this.entries.get(key)
|
|
42
49
|
if (!entry) return undefined
|
|
43
50
|
if (now - entry.at >= CACHE_TTL) { this.entries.delete(key); return undefined }
|
|
44
51
|
this.entries.delete(key)
|
|
45
52
|
this.entries.set(key, entry)
|
|
46
|
-
return
|
|
53
|
+
return this.clone(entry.result)
|
|
47
54
|
}
|
|
48
55
|
|
|
49
56
|
/**
|
|
@@ -53,9 +60,9 @@ export class SearchCache {
|
|
|
53
60
|
* @param result provider 结果
|
|
54
61
|
* @param now 当前时间
|
|
55
62
|
*/
|
|
56
|
-
set(key: string, result:
|
|
63
|
+
set(key: string, result: T, now = Date.now()): void {
|
|
57
64
|
this.entries.delete(key)
|
|
58
|
-
this.entries.set(key, { at: now, result:
|
|
65
|
+
this.entries.set(key, { at: now, result: this.clone(result) })
|
|
59
66
|
while (this.entries.size > CACHE_LIMIT) this.entries.delete(this.entries.keys().next().value as string)
|
|
60
67
|
}
|
|
61
68
|
|
|
@@ -145,7 +152,7 @@ function mergeCandidates(result: WorkspaceSearchResult, activePaths: string[], r
|
|
|
145
152
|
* @author ddj 2026年08月24号
|
|
146
153
|
*/
|
|
147
154
|
export class SearchOrchestrator {
|
|
148
|
-
private readonly cache = new SearchCache()
|
|
155
|
+
private readonly cache = new SearchCache<WorkspaceSearchResult>((r) => ({ ...r, files: [...r.files] }))
|
|
149
156
|
private readonly inflight = new Map<string, AbortController>()
|
|
150
157
|
private readonly rootsByCwd = new Map<string, Set<string>>()
|
|
151
158
|
private readonly provider: WorkspaceSearchProvider
|
package/src/search/ripgrep.ts
CHANGED
|
@@ -14,9 +14,23 @@ import type { WorkspaceSearchInput, WorkspaceSearchProvider, WorkspaceSearchResu
|
|
|
14
14
|
const STDOUT_CAP = 4 << 20
|
|
15
15
|
const STDERR_CAP = 64 << 10
|
|
16
16
|
const GRACE_MS = 20_000
|
|
17
|
-
|
|
17
|
+
|
|
18
|
+
/** 默认排除目录(文件/内容搜索共用,导出供 provider 复用)。 */
|
|
19
|
+
export const EXCLUDES = [
|
|
18
20
|
'node_modules', '.git', '.tmp', '.cache', 'dist', 'build', 'vendor',
|
|
19
|
-
'coverage', '__pycache__',
|
|
21
|
+
'coverage', '__pycache__', '.pnpm-store', '.npm-cache', '.codegraph', '.workbuddy',
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 默认排除文件(glob 模式,文件/内容搜索共用)。
|
|
26
|
+
* 插件自身 sidecar(内容搜索会命中其历史编辑文本,且单行巨型 JSON 会撑爆输出上限);
|
|
27
|
+
* *.map 源映射(纯构建产物,内容搜索无意义且常为单行巨文件)。
|
|
28
|
+
*/
|
|
29
|
+
export const FILE_EXCLUDES = [
|
|
30
|
+
'**/.dsh-edit-review.json',
|
|
31
|
+
'**/.dsh-edit-review-archive.json',
|
|
32
|
+
'**/.dsh-edit-review-debug.log',
|
|
33
|
+
'**/*.map',
|
|
20
34
|
]
|
|
21
35
|
|
|
22
36
|
interface SearchHandle {
|
|
@@ -113,6 +127,7 @@ export async function searchRipgrep(input: WorkspaceSearchInput): Promise<Worksp
|
|
|
113
127
|
const query = prepareQuery(input.query)
|
|
114
128
|
const argv = [binary, '--no-config', '--files', '--hidden', '--no-ignore', '--glob-case-insensitive', '--glob', queryGlob(query.text)]
|
|
115
129
|
for (const excluded of EXCLUDES) argv.push('--glob', '!**/' + excluded + '/**')
|
|
130
|
+
for (const excluded of FILE_EXCLUDES) argv.push('--glob', '!' + excluded)
|
|
116
131
|
argv.push('--', root)
|
|
117
132
|
const handle = sub.spawn({
|
|
118
133
|
argv,
|
package/src/search/types.ts
CHANGED
|
@@ -45,3 +45,43 @@ export interface SearchCandidate {
|
|
|
45
45
|
export interface CandidateRanker {
|
|
46
46
|
rank(candidates: SearchCandidate[], query: PreparedQuery): SearchCandidate[]
|
|
47
47
|
}
|
|
48
|
+
|
|
49
|
+
/** 内容搜索单条命中(列 = 1-based UTF-16,直接供 Monaco 跳转/高亮)。 */
|
|
50
|
+
export interface ContentMatch {
|
|
51
|
+
path: string
|
|
52
|
+
line: number
|
|
53
|
+
startColumn: number
|
|
54
|
+
endColumn: number
|
|
55
|
+
text: string
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** 内容搜索输入(options 缺省 = 大小写不敏感、字面匹配、非全词;include/exclude 为 rg glob)。 */
|
|
59
|
+
export interface ContentSearchInput {
|
|
60
|
+
ctx: Ctx
|
|
61
|
+
session: Session
|
|
62
|
+
cwd: string
|
|
63
|
+
query: string
|
|
64
|
+
matchCase?: boolean
|
|
65
|
+
wholeWord?: boolean
|
|
66
|
+
regex?: boolean
|
|
67
|
+
maxResults?: number
|
|
68
|
+
/** 正选 glob(仅在这些文件内搜索;逗号已在客户端拆分)。 */
|
|
69
|
+
include?: string[]
|
|
70
|
+
/** 排除 glob(这些文件不参与搜索)。 */
|
|
71
|
+
exclude?: string[]
|
|
72
|
+
signal?: AbortSignal
|
|
73
|
+
root?: string
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** 内容搜索输出:扁平命中列表 + 截断标志。 */
|
|
77
|
+
export interface ContentSearchResult {
|
|
78
|
+
matches: ContentMatch[]
|
|
79
|
+
truncated: boolean
|
|
80
|
+
complete: boolean
|
|
81
|
+
source: SearchSource
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** 内容搜索 provider(rg JSON 主路径;无 fallback,失败抛错由调用方处理)。 */
|
|
85
|
+
export interface ContentSearchProvider {
|
|
86
|
+
search(input: ContentSearchInput): Promise<ContentSearchResult>
|
|
87
|
+
}
|