dsh-vscode-mode 0.1.17 → 0.1.20
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 +17 -7
- package/lib/client.js +1261 -280
- package/lib/client.js.map +1 -1
- package/lib/index.js +169 -23
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/client/addToConversation.ts +211 -0
- package/src/client/diffDock.ts +52 -0
- package/src/client/diffDockStore.ts +87 -0
- package/src/client/editorLayout.ts +19 -0
- package/src/client/events.ts +40 -11
- package/src/client/index.ts +48 -5
- package/src/client/sidebar/SidebarView.ts +73 -0
- package/src/client/sidebar/panels/FileExplorer.ts +161 -0
- package/src/client/sidebar/panels/index.ts +24 -0
- package/src/client/sidebar/registry.ts +52 -0
- package/src/client/sidebar/types.ts +31 -0
- package/src/client/styles/editor.css +127 -5
- package/src/client/ui/ConversationDiffDock.ts +84 -0
- package/src/client/ui/DiffBox.ts +78 -29
- package/src/client/ui/EditorView.ts +375 -68
- package/src/rpc.ts +84 -18
- package/src/shared/rpc.ts +29 -1
- package/src/store.ts +6 -2
- package/src/tree.ts +62 -0
package/src/rpc.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* 新功能 = shared/rpc.ts 加方法 + 这里加一个 handler,不改其他模块。
|
|
5
5
|
* 作者 ddj 2026-08-20
|
|
6
6
|
*/
|
|
7
|
-
import type { RpcHandlerMap, RpcMethod, RpcRequestMap, RpcResult } from './shared/rpc.js'
|
|
7
|
+
import type { DecideItem, DecideResult, RpcHandlerMap, RpcMethod, RpcRequestMap, RpcResult, RpcScope } from './shared/rpc.js'
|
|
8
8
|
import type { DiffRecord, RecordView } from './shared/types.js'
|
|
9
9
|
import type { Ctx, Session } from './store.js'
|
|
10
10
|
import {
|
|
@@ -29,6 +29,7 @@ import { listProjects, projectRefresh, projectRemove, projectSave, projectToggle
|
|
|
29
29
|
import { normalizeFileOpenTool, FILE_OPEN_DEFAULT, FILE_OPEN_SETTINGS_NS } from './fileOpenSettings.js'
|
|
30
30
|
import { buildReport } from './compat.js'
|
|
31
31
|
import { readDevForm, setDevForm } from './devForm.js'
|
|
32
|
+
import { normalizeRel, toTreeEntries } from './tree.js'
|
|
32
33
|
|
|
33
34
|
/** cwd → Promise 链:串行化 debug 日志追加(fs read+write 非原子,避免并发丢行)。 */
|
|
34
35
|
const debugWriteQueues = new Map<string, Promise<void>>()
|
|
@@ -66,6 +67,52 @@ async function requireSession(ctx: Ctx, sessionId: string | undefined): Promise<
|
|
|
66
67
|
return { session, cwd }
|
|
67
68
|
}
|
|
68
69
|
|
|
70
|
+
/**
|
|
71
|
+
* 批量决策核心:一次会话/桶解析 + 逐项处理 + 统一落盘(accept/reject 单条与 decideBatch 共用)。
|
|
72
|
+
* rejected 项先做回滚,失败记入该项 error 且不改决策;本批次新增"已解决"记录一次性归档,
|
|
73
|
+
* 有任何成功项则整桶只写盘一次,避免逐条读写整个 sidecar。
|
|
74
|
+
* @author ddj 2026年08月25号
|
|
75
|
+
* @param items 决策项(按数组顺序处理,rejected 的先后即回滚顺序)
|
|
76
|
+
* @returns 逐项结果(ok 项含更新后的记录视图)
|
|
77
|
+
*/
|
|
78
|
+
async function applyDecisions(
|
|
79
|
+
ctx: Ctx,
|
|
80
|
+
session: Session,
|
|
81
|
+
cwd: string,
|
|
82
|
+
bucket: Map<string, DiffRecord>,
|
|
83
|
+
items: DecideItem[],
|
|
84
|
+
): Promise<DecideResult[]> {
|
|
85
|
+
const results: DecideResult[] = []
|
|
86
|
+
const resolved: DiffRecord[] = []
|
|
87
|
+
let changed = false
|
|
88
|
+
for (const item of items) {
|
|
89
|
+
const record = bucket.get(item.callId)
|
|
90
|
+
if (!record) {
|
|
91
|
+
results.push({ callId: item.callId, ok: false, error: '记录不存在' })
|
|
92
|
+
continue
|
|
93
|
+
}
|
|
94
|
+
if (item.decision === 'rejected') {
|
|
95
|
+
const scope: RpcScope = item.scope ?? 'call'
|
|
96
|
+
const outcome = scope === 'hunk' ? await revertHunk(ctx, session, record, item.hunkIndex ?? -1) : await revertCall(ctx, session, record)
|
|
97
|
+
if (!outcome.ok) {
|
|
98
|
+
results.push({ callId: item.callId, ok: false, error: outcome.error })
|
|
99
|
+
continue
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
const wasResolved = recordResolved(record)
|
|
103
|
+
markDecision(record, item.scope ?? 'call', item.hunkIndex, item.decision)
|
|
104
|
+
changed = true
|
|
105
|
+
if (!wasResolved && recordResolved(record)) resolved.push(record)
|
|
106
|
+
results.push({ callId: item.callId, ok: true, record: recView(record) })
|
|
107
|
+
}
|
|
108
|
+
if (resolved.length) {
|
|
109
|
+
const reason = items.some((item) => item.decision === 'rejected') ? '已处理(回滚)' : '已处理'
|
|
110
|
+
await archiveRecords(ctx, session, cwd, bucket, resolved, reason, { deferSave: true })
|
|
111
|
+
}
|
|
112
|
+
if (changed) await saveBucket(ctx, cwd, bucket, session)
|
|
113
|
+
return results
|
|
114
|
+
}
|
|
115
|
+
|
|
69
116
|
/** 各方法 handler 表(类型由 shared/rpc 的 RpcHandlerMap 约束)。 */
|
|
70
117
|
export function buildHandlers(ctx: Ctx, registry: Registry, searcher = newSearcher(ctx)): RpcHandlerMap {
|
|
71
118
|
return {
|
|
@@ -74,7 +121,7 @@ export function buildHandlers(ctx: Ctx, registry: Registry, searcher = newSearch
|
|
|
74
121
|
if ('err' in sc) return { ok: false, error: sc.err }
|
|
75
122
|
const bucket = await bucketOf(registry, ctx, sc.cwd)
|
|
76
123
|
const want = Array.isArray(args.callIds) ? new Set(args.callIds) : null
|
|
77
|
-
if (!want) await autoArchiveStale(ctx, sc.session, sc.cwd, bucket) // 全量轮询时自动清理 stale
|
|
124
|
+
if (!want && args.skipStale !== true) await autoArchiveStale(ctx, sc.session, sc.cwd, bucket) // 全量轮询时自动清理 stale 幽灵差异(批量决策后的即时刷新可 skipStale 跳过)
|
|
78
125
|
const out: RecordView[] = []
|
|
79
126
|
for (const rec of bucket.values()) {
|
|
80
127
|
// 面板全量查询过滤已归档;聊天条按 callId 查询保留(状态徽章仍需正确显示)
|
|
@@ -89,27 +136,26 @@ export function buildHandlers(ctx: Ctx, registry: Registry, searcher = newSearch
|
|
|
89
136
|
const sc = await requireSession(ctx, args.sessionId)
|
|
90
137
|
if ('err' in sc) return { ok: false, error: sc.err }
|
|
91
138
|
const bucket = await bucketOf(registry, ctx, sc.cwd)
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
else await saveBucket(ctx, sc.cwd, bucket, sc.session)
|
|
97
|
-
return { ok: true, record: recView(record) }
|
|
139
|
+
const results = await applyDecisions(ctx, sc.session, sc.cwd, bucket, [{ callId: args.callId, scope: args.scope, hunkIndex: args.hunkIndex, decision: 'accepted' }])
|
|
140
|
+
const item = results[0]
|
|
141
|
+
if (!item?.ok) return { ok: false, error: item?.error ?? '操作失败' }
|
|
142
|
+
return { ok: true, record: item.record! }
|
|
98
143
|
},
|
|
99
144
|
'edrv.reject': async (args) => {
|
|
100
145
|
const sc = await requireSession(ctx, args.sessionId)
|
|
101
146
|
if ('err' in sc) return { ok: false, error: sc.err }
|
|
102
147
|
const bucket = await bucketOf(registry, ctx, sc.cwd)
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
if (
|
|
111
|
-
|
|
112
|
-
|
|
148
|
+
const results = await applyDecisions(ctx, sc.session, sc.cwd, bucket, [{ callId: args.callId, scope: args.scope, hunkIndex: args.hunkIndex, decision: 'rejected' }])
|
|
149
|
+
const item = results[0]
|
|
150
|
+
if (!item?.ok) return { ok: false, error: item?.error ?? '操作失败' }
|
|
151
|
+
return { ok: true, record: item.record! }
|
|
152
|
+
},
|
|
153
|
+
'edrv.decideBatch': async (args) => {
|
|
154
|
+
const sc = await requireSession(ctx, args.sessionId)
|
|
155
|
+
if ('err' in sc) return { ok: false, error: sc.err }
|
|
156
|
+
const bucket = await bucketOf(registry, ctx, sc.cwd)
|
|
157
|
+
const results = await applyDecisions(ctx, sc.session, sc.cwd, bucket, Array.isArray(args.items) ? args.items : [])
|
|
158
|
+
return { ok: true, results }
|
|
113
159
|
},
|
|
114
160
|
'edrv.read': async (args) => {
|
|
115
161
|
const sc = await requireSession(ctx, args.sessionId)
|
|
@@ -270,6 +316,26 @@ export function buildHandlers(ctx: Ctx, registry: Registry, searcher = newSearch
|
|
|
270
316
|
const result = await searcher.search({ session: sc.session, cwd: sc.cwd, query: args.query, activePaths })
|
|
271
317
|
return { ok: true, ...result }
|
|
272
318
|
},
|
|
319
|
+
'edrv.listDir': async (args) => {
|
|
320
|
+
// 目录树(侧边栏文件管理用):按会话 cwd 解析,与 edrv.read 同基准,树内相对路径点开即打开。
|
|
321
|
+
const sc = await requireSession(ctx, args.sessionId)
|
|
322
|
+
if ('err' in sc) return { ok: false, error: sc.err }
|
|
323
|
+
const fs = ctx.get('fs')
|
|
324
|
+
if (!fs) return { ok: false, error: '缺少 fs' }
|
|
325
|
+
try {
|
|
326
|
+
const rel = normalizeRel(args.path)
|
|
327
|
+
if (rel === null) return { ok: false, error: '路径不合法' }
|
|
328
|
+
const target = await resolveTarget(ctx, sc.session, rel || '.')
|
|
329
|
+
const info = await fs.stat(target)
|
|
330
|
+
if (!info) return { ok: false, error: '目录不存在' }
|
|
331
|
+
if (info.type !== 'directory') return { ok: false, error: '不是目录' }
|
|
332
|
+
const children = await fs.listDir(target)
|
|
333
|
+
const root = fs.processPath(await fs.resolve('.', { cwd: sc.cwd }))
|
|
334
|
+
return { ok: true, root, path: rel, entries: toTreeEntries(rel, children) }
|
|
335
|
+
} catch (error) {
|
|
336
|
+
return { ok: false, error: '读取目录失败:' + String(error) }
|
|
337
|
+
}
|
|
338
|
+
},
|
|
273
339
|
'mcp.list': async () => ({ ok: true, ...listMcp(ctx) }),
|
|
274
340
|
'mcp.save': async (args) => {
|
|
275
341
|
try { return { ok: true, server: await saveMcp(ctx, args.config) } }
|
package/src/shared/rpc.ts
CHANGED
|
@@ -21,11 +21,36 @@ export const RPC_PATH = '/edrv/rpc'
|
|
|
21
21
|
/** 决策作用域:call=整条记录,hunk=单个差异块。 */
|
|
22
22
|
export type RpcScope = 'call' | 'hunk'
|
|
23
23
|
|
|
24
|
+
/** 目录树单项(edrv.listDir 返回;path 相对工作区根,'/' 分隔)。 */
|
|
25
|
+
export interface TreeEntry {
|
|
26
|
+
name: string
|
|
27
|
+
path: string
|
|
28
|
+
type: 'file' | 'directory' | 'other'
|
|
29
|
+
size?: number
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** 一条批量决策项(decideBatch 的 items 元素,与 accept/reject 单条参数同构)。 */
|
|
33
|
+
export interface DecideItem {
|
|
34
|
+
callId: string
|
|
35
|
+
scope?: RpcScope
|
|
36
|
+
hunkIndex?: number
|
|
37
|
+
decision: 'accepted' | 'rejected'
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** 批量决策逐项结果。 */
|
|
41
|
+
export interface DecideResult {
|
|
42
|
+
callId: string
|
|
43
|
+
ok: boolean
|
|
44
|
+
error?: string
|
|
45
|
+
record?: RecordView
|
|
46
|
+
}
|
|
47
|
+
|
|
24
48
|
/** 每个方法的请求参数(sessionId 为公共可选字段)。 */
|
|
25
49
|
export interface RpcRequestMap {
|
|
26
|
-
'edrv.list': { sessionId?: string; callIds?: string[] }
|
|
50
|
+
'edrv.list': { sessionId?: string; callIds?: string[]; skipStale?: boolean }
|
|
27
51
|
'edrv.accept': { sessionId?: string; callId: string; scope?: RpcScope; hunkIndex?: number }
|
|
28
52
|
'edrv.reject': { sessionId?: string; callId: string; scope?: RpcScope; hunkIndex?: number }
|
|
53
|
+
'edrv.decideBatch': { sessionId?: string; items: DecideItem[] }
|
|
29
54
|
'edrv.read': { sessionId?: string; path: string }
|
|
30
55
|
'edrv.original': { sessionId?: string; path: string }
|
|
31
56
|
'edrv.save': { sessionId?: string; path: string; content: string }
|
|
@@ -34,6 +59,7 @@ export interface RpcRequestMap {
|
|
|
34
59
|
'edrv.rollback': { sessionId?: string; path: string; batch?: number }
|
|
35
60
|
'edrv.debug': { sessionId?: string; text: string }
|
|
36
61
|
'edrv.searchFiles': { sessionId?: string; query: string }
|
|
62
|
+
'edrv.listDir': { sessionId?: string; path: string }
|
|
37
63
|
'mcp.list': {}
|
|
38
64
|
'mcp.save': { config: MpcConfig }
|
|
39
65
|
'mcp.remove': { id: string }
|
|
@@ -58,6 +84,7 @@ export interface RpcOkMap {
|
|
|
58
84
|
'edrv.list': { records: RecordView[] }
|
|
59
85
|
'edrv.accept': { record: RecordView }
|
|
60
86
|
'edrv.reject': { record: RecordView }
|
|
87
|
+
'edrv.decideBatch': { results: DecideResult[] }
|
|
61
88
|
'edrv.read': { content: string; size: number }
|
|
62
89
|
'edrv.original': { content: string; size: number; stale: StaleHunk[]; fallback: boolean }
|
|
63
90
|
'edrv.save': object
|
|
@@ -66,6 +93,7 @@ export interface RpcOkMap {
|
|
|
66
93
|
'edrv.rollback': { path: string; batch: number | null }
|
|
67
94
|
'edrv.debug': object
|
|
68
95
|
'edrv.searchFiles': { files: string[]; truncated: boolean }
|
|
96
|
+
'edrv.listDir': { root: string; path: string; entries: TreeEntry[] }
|
|
69
97
|
'mcp.list': { servers: MpcServer[] }
|
|
70
98
|
'mcp.save': { server: MpcServer }
|
|
71
99
|
'mcp.remove': object
|
package/src/store.ts
CHANGED
|
@@ -223,7 +223,10 @@ export async function appendArchiveEntries(ctx: Ctx, cwd: string, entries: Array
|
|
|
223
223
|
}
|
|
224
224
|
}
|
|
225
225
|
|
|
226
|
-
/**
|
|
226
|
+
/**
|
|
227
|
+
* 归档记录:标记 archived、按批次写入归档、落盘工作区桶。
|
|
228
|
+
* @param opts.deferSave 为 true 时只写归档、不落盘桶(由批量调用方统一 saveBucket 一次)
|
|
229
|
+
*/
|
|
227
230
|
export async function archiveRecords(
|
|
228
231
|
ctx: Ctx,
|
|
229
232
|
session: Session,
|
|
@@ -231,6 +234,7 @@ export async function archiveRecords(
|
|
|
231
234
|
bucket: Map<string, DiffRecord>,
|
|
232
235
|
recs: DiffRecord[],
|
|
233
236
|
reason: string,
|
|
237
|
+
opts?: { deferSave?: boolean },
|
|
234
238
|
): Promise<void> {
|
|
235
239
|
if (!recs.length) return
|
|
236
240
|
const fresh = recs.filter((r) => !r.archived)
|
|
@@ -238,5 +242,5 @@ export async function archiveRecords(
|
|
|
238
242
|
const entries: Array<ReturnType<typeof archiveEntryFor>> = []
|
|
239
243
|
for (const list of groupByBatch(recs).values()) entries.push(archiveEntryFor(list, cwd, reason))
|
|
240
244
|
await appendArchiveEntries(ctx, cwd, entries, session)
|
|
241
|
-
if (fresh.length) await saveBucket(ctx, cwd, bucket, session)
|
|
245
|
+
if (fresh.length && !opts?.deferSave) await saveBucket(ctx, cwd, bucket, session)
|
|
242
246
|
}
|
package/src/tree.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-vscode-mode host — 目录树纯函数(edrv.listDir 的数据整形与守卫)。
|
|
3
|
+
* 不触 fs/node,仅结构类型,可单测;RPC handler 只做装配,判定都收在这里。
|
|
4
|
+
* 作者 ddj 2026-08-26
|
|
5
|
+
*/
|
|
6
|
+
import type { TreeEntry } from './shared/rpc.js'
|
|
7
|
+
|
|
8
|
+
/** fs.listDir 返回项的结构最小视图(不引 dsh-fs,保持纯函数零依赖)。 */
|
|
9
|
+
export interface DirChildLike {
|
|
10
|
+
name: string
|
|
11
|
+
type: 'file' | 'directory' | 'other'
|
|
12
|
+
size?: number
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** 目录在文件前、名称按小写字母序的比较器(对齐 VSCode 资源管理器习惯)。 */
|
|
16
|
+
function entryOrder(a: { name: string; type: string }, b: { name: string; type: string }): number {
|
|
17
|
+
const aDir = a.type === 'directory' ? 0 : 1
|
|
18
|
+
const bDir = b.type === 'directory' ? 0 : 1
|
|
19
|
+
if (aDir !== bDir) return aDir - bDir
|
|
20
|
+
const la = a.name.toLocaleLowerCase('en-US')
|
|
21
|
+
const lb = b.name.toLocaleLowerCase('en-US')
|
|
22
|
+
return la < lb ? -1 : la > lb ? 1 : 0
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 归一化目录相对路径:去空白、反斜杠→斜杠、去首尾斜杠、`.`→''。
|
|
27
|
+
* 任一路径段为 `..` 返回 null(拒绝越界)。
|
|
28
|
+
* @author ddj 2026年08月26号
|
|
29
|
+
* @param path 原始相对路径('' 表示根目录)
|
|
30
|
+
* @returns 归一化相对路径('' = 根),非法返回 null
|
|
31
|
+
*/
|
|
32
|
+
export function normalizeRel(path: string | undefined | null): string | null {
|
|
33
|
+
const rel = String(path ?? '').trim().replace(/\\/g, '/').replace(/^\/+|\/+$/g, '')
|
|
34
|
+
if (!rel) return ''
|
|
35
|
+
const segs = rel.split('/').filter((seg) => seg !== '.')
|
|
36
|
+
if (!segs.length) return ''
|
|
37
|
+
for (const seg of segs) {
|
|
38
|
+
if (seg === '..') return null
|
|
39
|
+
}
|
|
40
|
+
return segs.join('/')
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* fs.listDir 结果 → TreeEntry 数组:相对路径拼接 + 目录优先排序。
|
|
45
|
+
* @author ddj 2026年08月26号
|
|
46
|
+
* @param rel 父目录相对路径('' = 根)
|
|
47
|
+
* @param children 目录子项
|
|
48
|
+
* @returns 排序后的树条目
|
|
49
|
+
*/
|
|
50
|
+
export function toTreeEntries(rel: string, children: DirChildLike[]): TreeEntry[] {
|
|
51
|
+
const out: TreeEntry[] = []
|
|
52
|
+
for (const child of children) {
|
|
53
|
+
if (!child || typeof child.name !== 'string' || !child.name) continue
|
|
54
|
+
out.push({
|
|
55
|
+
name: child.name,
|
|
56
|
+
path: rel ? rel + '/' + child.name : child.name,
|
|
57
|
+
type: child.type === 'directory' ? 'directory' : child.type === 'file' ? 'file' : 'other',
|
|
58
|
+
size: typeof child.size === 'number' ? child.size : undefined,
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
return out.sort(entryOrder)
|
|
62
|
+
}
|