dsh-vscode-mode 0.1.30 → 0.1.32
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -0
- package/lib/client.js +591 -42
- package/lib/client.js.map +1 -1
- package/lib/index.js +1513 -123
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/capture.ts +3 -0
- package/src/client/index.ts +5 -0
- package/src/client/paths.ts +36 -0
- package/src/client/sidebar/panels/FileExplorer.ts +95 -30
- package/src/client/sidebar/panels/SearchPanel.ts +11 -2
- package/src/client/state/explorerCache.ts +3 -1
- package/src/client/state/explorerEntriesCache.ts +172 -0
- package/src/client/state/viewStateCache.ts +3 -1
- package/src/client/styles/editor.css +1 -0
- package/src/client/styles/mcp.css +48 -0
- package/src/client/ui/EditorView.ts +6 -5
- package/src/client/ui/McpSettings.ts +3 -1
- package/src/client/ui/PerfSettings.ts +242 -0
- package/src/client/ui/PerfSizeBadge.ts +54 -0
- package/src/compat.ts +3 -2
- package/src/index.ts +5 -0
- package/src/paths.ts +284 -0
- package/src/perf.ts +340 -0
- package/src/perfPatch.ts +76 -0
- package/src/routes.ts +7 -12
- package/src/rpc.ts +173 -34
- package/src/search/content.ts +14 -7
- package/src/search/ripgrep.ts +54 -1
- package/src/search/types.ts +4 -1
- package/src/shared/rpc.ts +70 -2
- package/src/store.ts +2 -2
- package/src/treeIndex.ts +397 -0
- package/src/treeRead.ts +87 -0
package/src/perfPatch.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-vscode-mode host — profile patch 压缩调优(文本行级,零 YAML 依赖)。
|
|
3
|
+
* 在 ~/.dsh/profiles/<profile>/cordis.patch.yml 末尾追加/移除一段带标记注释的
|
|
4
|
+
* id 定向覆盖块(compaction-basic / tool-result-pruner 更低阈值)。
|
|
5
|
+
* 纯文本函数可单测:幂等追加、整段移除、无块原样返回。
|
|
6
|
+
* 作者 ddj 2026-09-02
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** 性能块起始标记注释(文件内唯一锚点)。 */
|
|
10
|
+
export const PERF_MARK_START = '# dsh-vscode-mode perf tuning (start)'
|
|
11
|
+
/** 性能块结束标记注释。 */
|
|
12
|
+
export const PERF_MARK_END = '# dsh-vscode-mode perf tuning (end)'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* 建议的压缩调优 YAML 块(顶层 loader patch 条目,id 定向覆盖)。
|
|
16
|
+
* - compaction-basic:thresholdRatio 0.8→0.6、retainRatio 0.16→0.12(更早触发压缩);
|
|
17
|
+
* - tool-result-pruner:thresholdChars 8192→4096、head/tail 同步收窄(大 tool result 提前截断)。
|
|
18
|
+
* 注意:压缩只降低模型可见上下文压力,不重写已持久化会话日志(append-only)。
|
|
19
|
+
* @author ddj 2026年09月02号
|
|
20
|
+
* @returns 带前后标记注释与尾换行的 YAML 块
|
|
21
|
+
*/
|
|
22
|
+
export function perfConfigBlock(): string {
|
|
23
|
+
return [
|
|
24
|
+
PERF_MARK_START,
|
|
25
|
+
'# 更早触发会话内压缩:降低模型可见上下文压力(仅影响后续轮次,不缩小已持久化日志)。',
|
|
26
|
+
'- id: compaction-basic',
|
|
27
|
+
' config:',
|
|
28
|
+
' thresholdRatio: 0.6',
|
|
29
|
+
' retainRatio: 0.12',
|
|
30
|
+
'- id: tool-result-pruner',
|
|
31
|
+
' config:',
|
|
32
|
+
' thresholdChars: 4096',
|
|
33
|
+
' headChars: 2048',
|
|
34
|
+
' tailChars: 512',
|
|
35
|
+
PERF_MARK_END,
|
|
36
|
+
'',
|
|
37
|
+
].join('\n')
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** 当前文本是否已包含性能块。 */
|
|
41
|
+
export function patchHasPerfConfig(text: string): boolean {
|
|
42
|
+
return text.includes(PERF_MARK_START)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 移除性能块(起止标记注释之间的整段,含所在行),无块时原样返回。
|
|
47
|
+
* 移除后压缩多余空行、确保以单个换行结尾。
|
|
48
|
+
* @author ddj 2026年09月02号
|
|
49
|
+
* @param text 原 patch 文本
|
|
50
|
+
* @returns 移除后的文本
|
|
51
|
+
*/
|
|
52
|
+
export function patchRemovePerfConfig(text: string): string {
|
|
53
|
+
const start = text.indexOf(PERF_MARK_START)
|
|
54
|
+
if (start === -1) return text
|
|
55
|
+
const end = text.indexOf(PERF_MARK_END, start)
|
|
56
|
+
const lineStart = text.lastIndexOf('\n', start - 1) + 1
|
|
57
|
+
if (end === -1) return text.slice(0, lineStart).replace(/\n+$/, '\n')
|
|
58
|
+
const after = (() => {
|
|
59
|
+
const nl = text.indexOf('\n', end)
|
|
60
|
+
return nl === -1 ? text.length : nl + 1
|
|
61
|
+
})()
|
|
62
|
+
const removed = (text.slice(0, lineStart) + text.slice(after)).replace(/\n{3,}/g, '\n\n')
|
|
63
|
+
return removed.replace(/\n+$/, '\n')
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* 幂等追加性能块:已有块先整段移除再追加到文件末尾(保证全文件只有一份、位置固定)。
|
|
68
|
+
* @author ddj 2026年09月02号
|
|
69
|
+
* @param text 原 patch 文本
|
|
70
|
+
* @returns 追加后的文本
|
|
71
|
+
*/
|
|
72
|
+
export function patchInsertPerfConfig(text: string): string {
|
|
73
|
+
const base = patchRemovePerfConfig(text)
|
|
74
|
+
const trimmed = base.replace(/\n+$/, '')
|
|
75
|
+
return trimmed + '\n' + perfConfigBlock()
|
|
76
|
+
}
|
package/src/routes.ts
CHANGED
|
@@ -4,12 +4,12 @@
|
|
|
4
4
|
* 作者 ddj 2026-08-20
|
|
5
5
|
*/
|
|
6
6
|
import { readFile, stat } from 'node:fs/promises'
|
|
7
|
-
import {
|
|
8
|
-
import { fileURLToPath } from 'node:url'
|
|
7
|
+
import { extname, join, normalize, resolve, sep } from 'node:path'
|
|
9
8
|
import { ROUTE_PREFIX, noteOwnRoute, resetOwnRoutes, routeConflict } from './compat.js'
|
|
10
9
|
import { RPC_PATH } from './shared/rpc.js'
|
|
11
10
|
import type { RpcMethod, RpcRequestMap } from './shared/rpc.js'
|
|
12
11
|
import type { Ctx } from './store.js'
|
|
12
|
+
import { VENDOR_PREFIX, imageDirOf, vendorDirOf } from './paths.js'
|
|
13
13
|
|
|
14
14
|
const IMG_ROUTES = [
|
|
15
15
|
{ url: '/edrv/assets/compare-idle.png', file: 'compare_idle.png' },
|
|
@@ -17,7 +17,6 @@ const IMG_ROUTES = [
|
|
|
17
17
|
]
|
|
18
18
|
|
|
19
19
|
/** Monaco AMD 构建静态资源(随包发布,离线可用;前缀路由 /edrv/vendor/*)。 */
|
|
20
|
-
const VENDOR_PREFIX = '/edrv/vendor'
|
|
21
20
|
const VENDOR_MIME: Record<string, string> = {
|
|
22
21
|
'.js': 'text/javascript; charset=utf-8',
|
|
23
22
|
'.css': 'text/css; charset=utf-8',
|
|
@@ -35,15 +34,11 @@ const VENDOR_MIME: Record<string, string> = {
|
|
|
35
34
|
'.md': 'text/markdown',
|
|
36
35
|
}
|
|
37
36
|
|
|
38
|
-
/**
|
|
39
|
-
|
|
40
|
-
const cfg = config as { imageDir?: unknown } | undefined
|
|
41
|
-
if (cfg && typeof cfg.imageDir === 'string' && cfg.imageDir) return cfg.imageDir.replace(/\\/g, '/')
|
|
42
|
-
return join(dirname(fileURLToPath(import.meta.url)), '..', 'assets')
|
|
43
|
-
}
|
|
37
|
+
/** 图标目录(paths.ts 统一解析:config.imageDir 覆盖优先,否则插件包 assets/)。 */
|
|
38
|
+
const imgDirOf = (config: unknown): string => imageDirOf(config, import.meta.url)
|
|
44
39
|
|
|
45
|
-
/** Monaco AMD
|
|
46
|
-
const vendorDir =
|
|
40
|
+
/** Monaco AMD 静态目录(随包发布,paths.ts 统一解析)。 */
|
|
41
|
+
const vendorDir = vendorDirOf(import.meta.url)
|
|
47
42
|
|
|
48
43
|
/**
|
|
49
44
|
* 注册全部 webServer 路由(带兼容护栏:前缀冲突预警 + 注册失败降级跳过)。
|
|
@@ -59,7 +54,7 @@ export function registerRoutes(
|
|
|
59
54
|
handleRpc: <M extends RpcMethod>(method: M, args: RpcRequestMap[M]) => Promise<unknown>,
|
|
60
55
|
onWarning?: (warning: string) => void,
|
|
61
56
|
): void {
|
|
62
|
-
const imgDir =
|
|
57
|
+
const imgDir = imgDirOf(config)
|
|
63
58
|
const web = ctx.get('webServer')
|
|
64
59
|
if (!web) return
|
|
65
60
|
|
package/src/rpc.ts
CHANGED
|
@@ -18,6 +18,8 @@ import {
|
|
|
18
18
|
resolveTarget,
|
|
19
19
|
saveBucket,
|
|
20
20
|
} from './store.js'
|
|
21
|
+
import { mkdir, readFile, rm, writeFile, readdir } from 'node:fs/promises'
|
|
22
|
+
import { basename, join } from 'node:path'
|
|
21
23
|
import { archiveEntryFor, markDecision, recordResolved, reconstructOriginal } from './model.js'
|
|
22
24
|
import type { Registry } from './registry.js'
|
|
23
25
|
import { bucketOf, cwdOf, sessionOf } from './registry.js'
|
|
@@ -30,9 +32,13 @@ import { listMcp, refreshMcp, removeMcp, saveMcp, toggleMcp } from './mcp.js'
|
|
|
30
32
|
import { listProjects, projectRefresh, projectRemove, projectSave, projectToggle } from './mcpProject.js'
|
|
31
33
|
import { normalizeFileOpenTool, FILE_OPEN_DEFAULT, FILE_OPEN_SETTINGS_NS } from './fileOpenSettings.js'
|
|
32
34
|
import { buildReport } from './compat.js'
|
|
33
|
-
import { readDevForm, setDevForm } from './devForm.js'
|
|
34
|
-
import { normalizeRel
|
|
35
|
+
import { findProfileDir, readDevForm, setDevForm } from './devForm.js'
|
|
36
|
+
import { normalizeRel } from './tree.js'
|
|
37
|
+
import { invalidateIndex, listDirCached } from './treeIndex.js'
|
|
35
38
|
import { revealInExplorer } from './reveal.js'
|
|
39
|
+
import { DEBUG_LOG, debugLogFile, dshHome, pluginLogRoot } from './paths.js'
|
|
40
|
+
import { markActiveSessions, moveOutSessions, planMoveOut, purgeArchive, restoreSession, scanSessionInventory, sessionsArchiveRoot, sessionSizeOf, sidecarSummaryOf } from './perf.js'
|
|
41
|
+
import { patchHasPerfConfig, patchInsertPerfConfig, patchRemovePerfConfig, perfConfigBlock } from './perfPatch.js'
|
|
36
42
|
|
|
37
43
|
/** cwd → 内存缓冲:行数组 + 累计字节数 + 待触发 flush 定时器(攒批落盘,避免每条日志全文件读改写)。 */
|
|
38
44
|
const debugBuffers = new Map<string, { lines: string[]; len: number; timer: ReturnType<typeof setTimeout> | null }>()
|
|
@@ -52,13 +58,12 @@ const STALE_CHECK_MIN_MS = 10_000
|
|
|
52
58
|
/**
|
|
53
59
|
* 调试日志入队:先攒内存缓冲(行 + 字节数),满 DEBUG_BUF_CAP 立即落盘,
|
|
54
60
|
* 否则空闲 DEBUG_FLUSH_IDLE_MS 后落盘;落盘 = 读旧文件 + 追加整批 + 超限截断 + 一次写入。
|
|
55
|
-
* @author ddj 2026年08月26号
|
|
61
|
+
* @author ddj 2026年08月26号 / 2026年09月01号
|
|
56
62
|
* @param ctx DSH 上下文
|
|
57
|
-
* @param cwd
|
|
58
|
-
* @param policy 会话沙箱策略(入队时刻捕获)
|
|
63
|
+
* @param cwd 工作区(日志按 cwd hash 存 ~/.dsh/dsh-vscode-mode/logs/)
|
|
59
64
|
* @param line 单条日志文本(不含换行)
|
|
60
65
|
*/
|
|
61
|
-
function enqueueDebug(ctx: Ctx, cwd: string,
|
|
66
|
+
function enqueueDebug(ctx: Ctx, cwd: string, line: string): void {
|
|
62
67
|
const st = debugBuffers.get(cwd) ?? { lines: [], len: 0, timer: null }
|
|
63
68
|
st.lines.push(line)
|
|
64
69
|
st.len += line.length
|
|
@@ -67,7 +72,7 @@ function enqueueDebug(ctx: Ctx, cwd: string, policy: unknown, line: string): voi
|
|
|
67
72
|
const batch = st.lines
|
|
68
73
|
st.lines = []
|
|
69
74
|
st.len = 0
|
|
70
|
-
void flushDebug(
|
|
75
|
+
void flushDebug(cwd, batch)
|
|
71
76
|
}
|
|
72
77
|
if (st.len >= DEBUG_BUF_CAP) {
|
|
73
78
|
if (st.timer) clearTimeout(st.timer)
|
|
@@ -78,26 +83,31 @@ function enqueueDebug(ctx: Ctx, cwd: string, policy: unknown, line: string): voi
|
|
|
78
83
|
debugBuffers.set(cwd, st)
|
|
79
84
|
}
|
|
80
85
|
|
|
86
|
+
/** cwd → 已清理旧工作区 debug 日志标记(一次性)。 */
|
|
87
|
+
const debugLegacyCleaned = new Set<string>()
|
|
88
|
+
|
|
81
89
|
/**
|
|
82
90
|
* 批量落盘一条 debug 日志缓冲:读旧文件 → 追加 → 超上限截断保留尾部 → 一次写回。
|
|
83
91
|
* 串行链保证同一 cwd 的读改写不交错丢行;写失败静默忽略(调试日志不阻塞业务)。
|
|
84
|
-
* @author ddj 2026年08月26号
|
|
85
|
-
* @param
|
|
86
|
-
* @param cwd 工作区
|
|
87
|
-
* @param policy 会话沙箱策略(enqueue 时刻捕获)
|
|
92
|
+
* @author ddj 2026年08月26号 / 2026年09月01号
|
|
93
|
+
* @param cwd 工作区(日志落 ~/.dsh/dsh-vscode-mode/logs/debug.<cwdHash>.log)
|
|
88
94
|
* @param batch 本批日志行
|
|
89
95
|
*/
|
|
90
|
-
function flushDebug(
|
|
96
|
+
function flushDebug(cwd: string, batch: string[]): Promise<void> {
|
|
91
97
|
const prev = debugWriteQueues.get(cwd) ?? Promise.resolve()
|
|
92
98
|
const task = prev.then(async () => {
|
|
93
99
|
try {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
const
|
|
97
|
-
const old = await fs.readText(target).catch(() => '')
|
|
100
|
+
await mkdir(pluginLogRoot(), { recursive: true })
|
|
101
|
+
const target = debugLogFile(cwd)
|
|
102
|
+
const old = await readFile(target, 'utf8').catch(() => '')
|
|
98
103
|
const appended = old + batch.map((l) => new Date().toISOString() + ' ' + l + '\n').join('')
|
|
99
104
|
const next = appended.length > DEBUG_LOG_CAP ? appended.slice(-Math.floor(DEBUG_LOG_CAP / 2)) : appended
|
|
100
|
-
await
|
|
105
|
+
await writeFile(target, next, 'utf8')
|
|
106
|
+
// 迁移后一次性清理旧工作区 debug 日志(best-effort)
|
|
107
|
+
if (!debugLegacyCleaned.has(cwd)) {
|
|
108
|
+
debugLegacyCleaned.add(cwd)
|
|
109
|
+
await rm(join(cwd, DEBUG_LOG), { force: true }).catch(() => {})
|
|
110
|
+
}
|
|
101
111
|
} catch (e) { /* 写日志失败忽略 */ }
|
|
102
112
|
})
|
|
103
113
|
debugWriteQueues.set(cwd, task)
|
|
@@ -183,6 +193,28 @@ async function applyDecisions(
|
|
|
183
193
|
return results
|
|
184
194
|
}
|
|
185
195
|
|
|
196
|
+
/** 当前活跃会话 id 集合(live sessions,用于移出/恢复护栏)。 */
|
|
197
|
+
function activeSessionIds(ctx: Ctx): Set<string> {
|
|
198
|
+
const sessions = ctx.get('sessions')
|
|
199
|
+
if (!sessions || typeof sessions.list !== 'function') return new Set()
|
|
200
|
+
return new Set((sessions.list() as Session[]).map((s) => s.id).filter(Boolean))
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** profile patch 文件定位(依赖本插件的 profile,同 devForm)。 */
|
|
204
|
+
async function patchFileInfo(): Promise<{ profileDir?: string; patchPath?: string }> {
|
|
205
|
+
const profileDir = findProfileDir()
|
|
206
|
+
if (!profileDir) return {}
|
|
207
|
+
return { profileDir, patchPath: join(profileDir, 'cordis.patch.yml') }
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** 最新一次压缩配置备份文件名(cordis.patch.yml.bak-<ts>,按名倒序取新)。 */
|
|
211
|
+
async function latestPatchBackup(patchPath: string): Promise<string | undefined> {
|
|
212
|
+
const dir = join(patchPath, '..')
|
|
213
|
+
const base = basename(patchPath)
|
|
214
|
+
const names = await readdir(dir).catch(() => [])
|
|
215
|
+
return names.filter((n) => n.startsWith(base + '.bak-')).sort().reverse()[0]
|
|
216
|
+
}
|
|
217
|
+
|
|
186
218
|
/** 各方法 handler 表(类型由 shared/rpc 的 RpcHandlerMap 约束)。 */
|
|
187
219
|
export function buildHandlers(ctx: Ctx, registry: Registry, searcher = newSearcher(ctx), contentSearcher = newContentSearcher(ctx)): RpcHandlerMap {
|
|
188
220
|
return {
|
|
@@ -285,6 +317,8 @@ export function buildHandlers(ctx: Ctx, registry: Registry, searcher = newSearch
|
|
|
285
317
|
try {
|
|
286
318
|
const target = await resolveTarget(ctx, sc.session, args.path)
|
|
287
319
|
await fs.writeText(target, args.content, void 0, void 0, policyOf(ctx, sc.session))
|
|
320
|
+
// 手动保存后目录树可能变化(新建/删除文件):父目录+祖先进失效,后台自愈。
|
|
321
|
+
invalidateIndex(ctx, sc.cwd, args.path)
|
|
288
322
|
const bucket = await bucketOf(registry, ctx, sc.cwd)
|
|
289
323
|
let changed = false
|
|
290
324
|
for (const rec of bucket.values()) {
|
|
@@ -372,7 +406,7 @@ export function buildHandlers(ctx: Ctx, registry: Registry, searcher = newSearch
|
|
|
372
406
|
const sc = await requireSession(ctx, args.sessionId)
|
|
373
407
|
if ('err' in sc) return { ok: false, error: sc.err }
|
|
374
408
|
const text = String(args.text ?? '')
|
|
375
|
-
enqueueDebug(ctx, sc.cwd,
|
|
409
|
+
enqueueDebug(ctx, sc.cwd, text)
|
|
376
410
|
console.error('[edrv-debug] ' + text)
|
|
377
411
|
return { ok: true }
|
|
378
412
|
},
|
|
@@ -407,24 +441,15 @@ export function buildHandlers(ctx: Ctx, registry: Registry, searcher = newSearch
|
|
|
407
441
|
}
|
|
408
442
|
},
|
|
409
443
|
'edrv.listDir': async (args) => {
|
|
410
|
-
//
|
|
444
|
+
// 目录树(侧边栏文件管理用):树索引命中直出(内存,0 IO);失效/force 时
|
|
445
|
+
// 走快路径(resolve + listDirCheap + putIndex);同路径在途请求去重复用。
|
|
411
446
|
const sc = await requireSession(ctx, args.sessionId)
|
|
412
447
|
if ('err' in sc) return { ok: false, error: sc.err }
|
|
413
|
-
const
|
|
414
|
-
if (
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
const target = await resolveTarget(ctx, sc.session, rel || '.')
|
|
419
|
-
const info = await fs.stat(target)
|
|
420
|
-
if (!info) return { ok: false, error: '目录不存在' }
|
|
421
|
-
if (info.type !== 'directory') return { ok: false, error: '不是目录' }
|
|
422
|
-
const children = await fs.listDir(target)
|
|
423
|
-
const root = fs.processPath(await fs.resolve('.', { cwd: sc.cwd }))
|
|
424
|
-
return { ok: true, root, path: rel, entries: toTreeEntries(rel, children) }
|
|
425
|
-
} catch (error) {
|
|
426
|
-
return { ok: false, error: '读取目录失败:' + String(error) }
|
|
427
|
-
}
|
|
448
|
+
const rel = normalizeRel(args.path)
|
|
449
|
+
if (rel === null) return { ok: false, error: '路径不合法' }
|
|
450
|
+
const res = await listDirCached(ctx, sc.cwd, rel, args.force === true)
|
|
451
|
+
if ('error' in res) return { ok: false, error: res.error }
|
|
452
|
+
return { ok: true, root: res.root, path: rel, entries: res.entries }
|
|
428
453
|
},
|
|
429
454
|
'edrv.revealInExplorer': async (args) => {
|
|
430
455
|
// 在 OS 文件浏览器中打开/定位路径(树行/编辑器右键菜单用),相对工作区解析。
|
|
@@ -511,6 +536,120 @@ export function buildHandlers(ctx: Ctx, registry: Registry, searcher = newSearch
|
|
|
511
536
|
return { ok: false, error: String(error) }
|
|
512
537
|
}
|
|
513
538
|
},
|
|
539
|
+
'edrv.perf.inventory': async () => {
|
|
540
|
+
try {
|
|
541
|
+
const home = dshHome()
|
|
542
|
+
const inventory = await scanSessionInventory(home, sessionsArchiveRoot(home))
|
|
543
|
+
const active = activeSessionIds(ctx)
|
|
544
|
+
markActiveSessions(inventory.sessions, active)
|
|
545
|
+
return { ok: true, ...inventory, activeIds: [...active] }
|
|
546
|
+
} catch (error) {
|
|
547
|
+
return { ok: false, error: '盘点失败:' + String(error) }
|
|
548
|
+
}
|
|
549
|
+
},
|
|
550
|
+
'edrv.perf.sessionSize': async (args) => {
|
|
551
|
+
try {
|
|
552
|
+
const size = await sessionSizeOf(dshHome(), args.cwd, args.sessionId ?? '')
|
|
553
|
+
return { ok: true, ...size }
|
|
554
|
+
} catch (error) {
|
|
555
|
+
return { ok: false, error: '读取会话体积失败:' + String(error) }
|
|
556
|
+
}
|
|
557
|
+
},
|
|
558
|
+
'edrv.perf.movePlan': async (args) => {
|
|
559
|
+
try {
|
|
560
|
+
const home = dshHome()
|
|
561
|
+
const inventory = await scanSessionInventory(home, sessionsArchiveRoot(home))
|
|
562
|
+
markActiveSessions(inventory.sessions, activeSessionIds(ctx))
|
|
563
|
+
const plan = planMoveOut(inventory, { workspaceKey: args.workspaceKey, sessionIds: args.sessionIds, minBytes: args.minBytes, olderThanDays: args.olderThanDays })
|
|
564
|
+
return { ok: true, ...plan }
|
|
565
|
+
} catch (error) {
|
|
566
|
+
return { ok: false, error: '移出规划失败:' + String(error) }
|
|
567
|
+
}
|
|
568
|
+
},
|
|
569
|
+
'edrv.perf.moveOut': async (args) => {
|
|
570
|
+
try {
|
|
571
|
+
const home = dshHome()
|
|
572
|
+
const items = (args.sessionIds ?? []).map((sessionId) => ({ workspaceKey: args.workspaceKey, sessionId, bytes: 0 }))
|
|
573
|
+
const result = await moveOutSessions(home, sessionsArchiveRoot(home), items, activeSessionIds(ctx), args.dryRun === true)
|
|
574
|
+
return { ok: true, ...result }
|
|
575
|
+
} catch (error) {
|
|
576
|
+
return { ok: false, error: '移出失败:' + String(error) }
|
|
577
|
+
}
|
|
578
|
+
},
|
|
579
|
+
'edrv.perf.restore': async (args) => {
|
|
580
|
+
try {
|
|
581
|
+
const result = await restoreSession(dshHome(), sessionsArchiveRoot(dshHome()), args.workspaceKey, args.sessionId)
|
|
582
|
+
return result.ok ? { ok: true, restored: true } : { ok: false, error: result.error ?? '恢复失败' }
|
|
583
|
+
} catch (error) {
|
|
584
|
+
return { ok: false, error: '恢复失败:' + String(error) }
|
|
585
|
+
}
|
|
586
|
+
},
|
|
587
|
+
'edrv.perf.purgeArchive': async (args) => {
|
|
588
|
+
try {
|
|
589
|
+
const result = await purgeArchive(sessionsArchiveRoot(dshHome()), args.olderThanDays)
|
|
590
|
+
return { ok: true, ...result }
|
|
591
|
+
} catch (error) {
|
|
592
|
+
return { ok: false, error: '清除失败:' + String(error) }
|
|
593
|
+
}
|
|
594
|
+
},
|
|
595
|
+
'edrv.perf.sidecarSummary': async (args) => {
|
|
596
|
+
const sc = await requireSession(ctx, args.sessionId)
|
|
597
|
+
const cwd = 'err' in sc ? null : sc.cwd
|
|
598
|
+
try {
|
|
599
|
+
const summary = await sidecarSummaryOf(ctx, cwd)
|
|
600
|
+
return { ok: true, ...summary }
|
|
601
|
+
} catch (error) {
|
|
602
|
+
return { ok: false, error: '读取侧车摘要失败:' + String(error) }
|
|
603
|
+
}
|
|
604
|
+
},
|
|
605
|
+
'edrv.perf.configGet': async () => {
|
|
606
|
+
try {
|
|
607
|
+
const { profileDir, patchPath } = await patchFileInfo()
|
|
608
|
+
const block = perfConfigBlock()
|
|
609
|
+
if (!patchPath) return { ok: true, applied: false, block }
|
|
610
|
+
const text = await readFile(patchPath, 'utf8').catch(() => '')
|
|
611
|
+
const backup = await latestPatchBackup(patchPath)
|
|
612
|
+
return { ok: true, profileDir, patchPath, applied: patchHasPerfConfig(text), block, backup }
|
|
613
|
+
} catch (error) {
|
|
614
|
+
return { ok: false, error: '读取压缩配置失败:' + String(error) }
|
|
615
|
+
}
|
|
616
|
+
},
|
|
617
|
+
'edrv.perf.configApply': async () => {
|
|
618
|
+
try {
|
|
619
|
+
const { patchPath } = await patchFileInfo()
|
|
620
|
+
if (!patchPath) return { ok: false, error: '未找到依赖本插件的 profile(检查 DSH_HOME/profiles)' }
|
|
621
|
+
const text = await readFile(patchPath, 'utf8').catch(() => '')
|
|
622
|
+
const backup = patchPath + '.bak-' + Date.now()
|
|
623
|
+
if (text) await writeFile(backup, text, 'utf8')
|
|
624
|
+
await mkdir(join(patchPath, '..'), { recursive: true })
|
|
625
|
+
await writeFile(patchPath, patchInsertPerfConfig(text), 'utf8')
|
|
626
|
+
return { ok: true, applied: true, backup, restart: true }
|
|
627
|
+
} catch (error) {
|
|
628
|
+
return { ok: false, error: '写入压缩配置失败:' + String(error) }
|
|
629
|
+
}
|
|
630
|
+
},
|
|
631
|
+
'edrv.perf.configUndo': async () => {
|
|
632
|
+
try {
|
|
633
|
+
const { patchPath } = await patchFileInfo()
|
|
634
|
+
if (!patchPath) return { ok: false, error: '未找到依赖本插件的 profile(检查 DSH_HOME/profiles)' }
|
|
635
|
+
const backup = await latestPatchBackup(patchPath)
|
|
636
|
+
if (backup) {
|
|
637
|
+
const backupPath = join(join(patchPath, '..'), backup)
|
|
638
|
+
const saved = await readFile(backupPath, 'utf8').catch(() => null)
|
|
639
|
+
if (saved !== null) {
|
|
640
|
+
await writeFile(patchPath, saved, 'utf8')
|
|
641
|
+
await rm(backupPath, { force: true }).catch(() => {})
|
|
642
|
+
return { ok: true, restored: true, backup }
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
const text = await readFile(patchPath, 'utf8').catch(() => '')
|
|
646
|
+
if (!patchHasPerfConfig(text)) return { ok: false, error: '没有可撤销的压缩配置' }
|
|
647
|
+
await writeFile(patchPath, patchRemovePerfConfig(text), 'utf8')
|
|
648
|
+
return { ok: true, restored: true }
|
|
649
|
+
} catch (error) {
|
|
650
|
+
return { ok: false, error: '撤销压缩配置失败:' + String(error) }
|
|
651
|
+
}
|
|
652
|
+
},
|
|
514
653
|
}
|
|
515
654
|
}
|
|
516
655
|
|
package/src/search/content.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import type { Ctx, Session } from '../store.js'
|
|
8
8
|
import { pathText } from './query.js'
|
|
9
|
-
import { EXCLUDES, FILE_EXCLUDES, ripgrepPath, searchRoot } from './ripgrep.js'
|
|
9
|
+
import { EXCLUDES, FILE_EXCLUDES, readStderr, rgExitFailure, ripgrepPath, searchRoot } from './ripgrep.js'
|
|
10
10
|
import { SearchCache } from './orchestrator.js'
|
|
11
11
|
import type { ContentMatch, ContentSearchInput, ContentSearchProvider, ContentSearchResult } from './types.js'
|
|
12
12
|
|
|
@@ -47,7 +47,7 @@ export interface ContentSearchRequest {
|
|
|
47
47
|
exclude?: string[]
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
type ContentSearchResponse = { matches: ContentMatch[]; truncated: boolean }
|
|
50
|
+
type ContentSearchResponse = { matches: ContentMatch[]; truncated: boolean; warning?: string }
|
|
51
51
|
|
|
52
52
|
/**
|
|
53
53
|
* 把行内字节偏移转成 UTF-16 列(1-based;越界钳到行尾)。
|
|
@@ -181,7 +181,7 @@ export function contentArgv(binary: string, root: string, input: ContentSearchIn
|
|
|
181
181
|
* @returns 有界内容搜索结果
|
|
182
182
|
*/
|
|
183
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
|
|
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 } }; stderr?: { readFrom(offset: number): { text: string } } } } } | undefined
|
|
185
185
|
if (!sub) throw new Error('缺少 subprocess')
|
|
186
186
|
const binary = ripgrepPath()
|
|
187
187
|
if (!binary) throw new Error('ripgrep 不可用')
|
|
@@ -200,7 +200,8 @@ export async function searchRipgrepContent(input: ContentSearchInput): Promise<C
|
|
|
200
200
|
throw new Error('ripgrep 启动失败:' + String(error))
|
|
201
201
|
}
|
|
202
202
|
const code = outcome.exitCode ?? outcome.code
|
|
203
|
-
|
|
203
|
+
const failure = code !== 0 && code !== 1 ? rgExitFailure(code, readStderr(handle).text) : null
|
|
204
|
+
if (failure && failure.kind !== 'partial') throw new Error(failure.message)
|
|
204
205
|
const reader = handle.collected?.stdout
|
|
205
206
|
if (!reader) throw new Error('ripgrep stdout 不可用')
|
|
206
207
|
const output = reader.readFrom(0)
|
|
@@ -208,7 +209,13 @@ export async function searchRipgrepContent(input: ContentSearchInput): Promise<C
|
|
|
208
209
|
const maxMatches = Math.max(1, Math.min(input.maxResults ?? DEFAULT_MAX_MATCHES, DEFAULT_MAX_MATCHES))
|
|
209
210
|
const capped = applyCaps(all, maxMatches, DEFAULT_MAX_FILES)
|
|
210
211
|
const truncated = Boolean(output.lossy) || capped.truncated
|
|
211
|
-
|
|
212
|
+
const result: ContentSearchResult = { ...capped, complete: !truncated, source: 'ripgrep' }
|
|
213
|
+
// 遍历错误(退出码 2):rg 已输出的命中仍有效,保留结果、标记不完整并附部分结果提示
|
|
214
|
+
if (failure) {
|
|
215
|
+
result.complete = false
|
|
216
|
+
result.warning = failure.message
|
|
217
|
+
}
|
|
218
|
+
return result
|
|
212
219
|
}
|
|
213
220
|
|
|
214
221
|
/**
|
|
@@ -251,7 +258,7 @@ export class ContentSearcher {
|
|
|
251
258
|
const excludeKey = (request.exclude ?? []).join(',')
|
|
252
259
|
const key = [rootKey, query, request.matchCase ? 'mc' : '', request.wholeWord ? 'ww' : '', request.regex ? 'rx' : '', includeKey, excludeKey, PROVIDER_VERSION].join('|')
|
|
253
260
|
const cached = this.cache.get(key)
|
|
254
|
-
if (cached) return { matches: cached.matches, truncated: cached.truncated }
|
|
261
|
+
if (cached) return { matches: cached.matches, truncated: cached.truncated, warning: cached.warning }
|
|
255
262
|
this.inflight.get(root)?.abort()
|
|
256
263
|
const controller = new AbortController()
|
|
257
264
|
this.inflight.set(root, controller)
|
|
@@ -272,7 +279,7 @@ export class ContentSearcher {
|
|
|
272
279
|
})
|
|
273
280
|
if (controller.signal.aborted) return { matches: [], truncated: false }
|
|
274
281
|
this.cache.set(key, result)
|
|
275
|
-
return { matches: result.matches, truncated: result.truncated }
|
|
282
|
+
return { matches: result.matches, truncated: result.truncated, warning: result.warning }
|
|
276
283
|
} catch (error) {
|
|
277
284
|
if (controller.signal.aborted) return { matches: [], truncated: false }
|
|
278
285
|
throw error
|
package/src/search/ripgrep.ts
CHANGED
|
@@ -112,6 +112,53 @@ export function parseOutput(handle: SearchHandle, maxResults: number): { files:
|
|
|
112
112
|
return { files: unique.slice(0, maxResults).map(pathText), truncated, complete: !truncated }
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
+
/**
|
|
116
|
+
* 读取已收集的 stderr(无收集器 → 空文本)。
|
|
117
|
+
* @author ddj 2026年08月27号
|
|
118
|
+
* @param handle subprocess handle
|
|
119
|
+
* @returns stderr 文本
|
|
120
|
+
*/
|
|
121
|
+
export function readStderr(handle: SearchHandle): { text: string } {
|
|
122
|
+
const reader = handle.collected?.stderr
|
|
123
|
+
return reader ? reader.readFrom(0) : { text: '' }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* 取 stderr 首条非空行(去 \r,截断 300 字符防撑爆 UI)。
|
|
128
|
+
* @author ddj 2026年08月27号
|
|
129
|
+
* @param text stderr 全文
|
|
130
|
+
* @returns 首条诊断行(无则空串)
|
|
131
|
+
*/
|
|
132
|
+
export function firstStderrLine(text: string): string {
|
|
133
|
+
for (const raw of String(text ?? '').split(/\r?\n/)) {
|
|
134
|
+
const line = raw.trim()
|
|
135
|
+
if (!line) continue
|
|
136
|
+
return line.length > 300 ? line.slice(0, 300) + '…' : line
|
|
137
|
+
}
|
|
138
|
+
return ''
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* 分类 rg 非 0/1 退出:pattern=模式错误(正则/glob 无效,无结果),
|
|
143
|
+
* partial=遍历错误(rg 已输出的命中仍有效,结果不完整),hard=硬失败(其他退出码/超时被杀)。
|
|
144
|
+
* @author ddj 2026年08月27号
|
|
145
|
+
* @param code 退出码(null/undefined = 被终止,通常为 20s 超时)
|
|
146
|
+
* @param stderrText stderr 全文
|
|
147
|
+
* @returns 分类与中文提示文案(partial 的 message 即 warning 内容)
|
|
148
|
+
*/
|
|
149
|
+
export function rgExitFailure(code: number | null | undefined, stderrText: string): { kind: 'pattern' | 'partial' | 'hard'; message: string } {
|
|
150
|
+
const detail = firstStderrLine(stderrText)
|
|
151
|
+
if (/regex parse error/i.test(detail)) return { kind: 'pattern', message: '正则表达式无效:' + detail }
|
|
152
|
+
if (/error parsing glob/i.test(detail)) return { kind: 'pattern', message: '文件过滤模式无效:' + detail }
|
|
153
|
+
if (code === 2) {
|
|
154
|
+
const count = String(stderrText ?? '').split(/\r?\n/).filter((line) => line.trim()).length
|
|
155
|
+
const suffix = count > 1 ? '(共 ' + count + ' 处)' : ''
|
|
156
|
+
return { kind: 'partial', message: (detail ? '部分路径无法访问,结果可能不完整:' + detail + suffix : '部分路径无法访问,结果可能不完整') }
|
|
157
|
+
}
|
|
158
|
+
if (code === null || code === undefined) return { kind: 'hard', message: '搜索超时(20 秒内未完成,已终止)' }
|
|
159
|
+
return { kind: 'hard', message: 'ripgrep 退出码:' + String(code) + (detail ? '(' + detail + ')' : '') }
|
|
160
|
+
}
|
|
161
|
+
|
|
115
162
|
/**
|
|
116
163
|
* 使用打包 ripgrep 搜索工作区文件。
|
|
117
164
|
* @author ddj 2026年08月24号
|
|
@@ -143,7 +190,13 @@ export async function searchRipgrep(input: WorkspaceSearchInput): Promise<Worksp
|
|
|
143
190
|
throw new Error('ripgrep 启动失败:' + String(error))
|
|
144
191
|
}
|
|
145
192
|
const code = outcome.exitCode ?? outcome.code
|
|
146
|
-
if (code !== 0 && code !== 1)
|
|
193
|
+
if (code !== 0 && code !== 1) {
|
|
194
|
+
const failure = rgExitFailure(code, readStderr(handle).text)
|
|
195
|
+
if (failure.kind !== 'partial') throw new Error(failure.message)
|
|
196
|
+
// 遍历错误:已收集的文件列表仍有效,保留并标记不完整(warning 供上层展示,编排层丢弃不抛错)
|
|
197
|
+
const parsed = parseOutput(handle, input.maxResults)
|
|
198
|
+
return { ...parsed, warning: failure.message, complete: false, source: 'ripgrep' }
|
|
199
|
+
}
|
|
147
200
|
const parsed = parseOutput(handle, input.maxResults)
|
|
148
201
|
if (!handle.collected?.stdout) throw new Error('ripgrep stdout 不可用')
|
|
149
202
|
return { ...parsed, source: 'ripgrep' }
|
package/src/search/types.ts
CHANGED
|
@@ -22,6 +22,8 @@ export interface WorkspaceSearchResult {
|
|
|
22
22
|
truncated: boolean
|
|
23
23
|
complete: boolean
|
|
24
24
|
source: SearchSource
|
|
25
|
+
/** rg 遍历错误(退出码 2)时的部分结果提示;无则缺省。 */
|
|
26
|
+
warning?: string
|
|
25
27
|
}
|
|
26
28
|
|
|
27
29
|
export interface WorkspaceSearchProvider {
|
|
@@ -73,12 +75,13 @@ export interface ContentSearchInput {
|
|
|
73
75
|
root?: string
|
|
74
76
|
}
|
|
75
77
|
|
|
76
|
-
/** 内容搜索输出:扁平命中列表 +
|
|
78
|
+
/** 内容搜索输出:扁平命中列表 + 截断标志(warning = rg 遍历错误时的部分结果提示)。 */
|
|
77
79
|
export interface ContentSearchResult {
|
|
78
80
|
matches: ContentMatch[]
|
|
79
81
|
truncated: boolean
|
|
80
82
|
complete: boolean
|
|
81
83
|
source: SearchSource
|
|
84
|
+
warning?: string
|
|
82
85
|
}
|
|
83
86
|
|
|
84
87
|
/** 内容搜索 provider(rg JSON 主路径;无 fallback,失败抛错由调用方处理)。 */
|