dsh-vscode-mode 0.1.17 → 0.1.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-vscode-mode",
3
- "version": "0.1.17",
3
+ "version": "0.1.19",
4
4
  "description": "DSH 上的类 VSCode 编码体验:Monaco 中央编辑器(文件页签/QuickOpen/状态栏)+ Agent 编辑差异审查(整文件差异/采纳/拒绝/归档/回滚),状态持久化到工作区旁车",
5
5
  "keywords": [
6
6
  "dsh",
@@ -0,0 +1,211 @@
1
+ /**
2
+ * dsh-vscode-mode client — 把文件/选中内容注入会话对话输入框(composer draft)。
3
+ * 复用 DSH 官方输入门面:ctx.conversation.input.for(sessions.scope(sessionId)) → SessionInput。
4
+ * 引用 chip:insertReference(reference source,发送时序列化为 @path 文本);忙态降级为纯文本 setDraft。
5
+ * 纯函数部分 node 可单测。
6
+ * 作者 ddj 2026-08-25
7
+ */
8
+
9
+ /** 文件引用可选的行区间(选中内容用)。 */
10
+ export interface RefRange {
11
+ startLine: number
12
+ endLine: number
13
+ }
14
+
15
+ /** 引用动作的返回状态。 */
16
+ export type AddOutcome = 'ok' | 'busy' | 'unavailable'
17
+
18
+ /** DSH reference source 名(dsh-client-ui-reference 注册的 @file/@session 统一源)。 */
19
+ const REF_SOURCE = 'reference'
20
+
21
+ /**
22
+ * 生成 DSH @file 语法引用串:cwd 相对化、\ → /、含空白时按 @"path" 语法加引号。
23
+ * cwd 外/无法相对化的路径回退原路径。
24
+ * @author ddj 2026年08月25号
25
+ * @param path 打开的文件路径(相对 cwd 或绝对)
26
+ * @param cwd 会话工作区目录(可选)
27
+ * @returns 引用串(如 @src/index.ts 或 @"a b.ts")
28
+ */
29
+ export function mentionOf(path: string, cwd: string | undefined): string {
30
+ const raw = String(path)
31
+ let rel = raw
32
+ if (cwd) {
33
+ const base = String(cwd).replace(/[\\/]+$/, '')
34
+ const up = raw.replace(/\\/g, '/')
35
+ const baseUp = base.replace(/\\/g, '/')
36
+ if (up.startsWith(baseUp + '/')) rel = up.slice(baseUp.length + 1)
37
+ else if (up === baseUp) rel = ''
38
+ }
39
+ rel = rel.replace(/\\/g, '/').replace(/^\.\//, '')
40
+ if (!rel) rel = raw.replace(/\\/g, '/')
41
+ if (/[\u0000-\u001f\u007f-\u009f"]/u.test(rel)) return '@' + raw
42
+ return /\s/u.test(rel) ? `@"${rel}"` : `@${rel}`
43
+ }
44
+
45
+ /** 引用标签:文件名 + 可选行区间(chip 内展示,不带 @ 前缀)。
46
+ * @author ddj 2026年08月25号 */
47
+ export function labelOf(path: string, range: RefRange | undefined): string {
48
+ const base = String(path).split(/[\\/]/).pop() || String(path)
49
+ if (!range || range.endLine < range.startLine) return base
50
+ return range.startLine === range.endLine
51
+ ? `${base} L${range.startLine}`
52
+ : `${base} L${range.startLine}-${range.endLine}`
53
+ }
54
+
55
+ /** 引用 ref:引用串 + 可选行区间(序列化直出,agent 据此读取对应片段)。
56
+ * @author ddj 2026年08月25号 */
57
+ export function refOf(mention: string, range: RefRange | undefined): string {
58
+ if (!range || range.endLine < range.startLine) return mention
59
+ return range.startLine === range.endLine
60
+ ? `${mention} L${range.startLine}`
61
+ : `${mention} L${range.startLine}-${range.endLine}`
62
+ }
63
+
64
+ /** 构造 DSH 文件引用插入载荷(source=reference,发送序列化为 mention(+range) 文本)。 */
65
+ export function buildFileRef(
66
+ path: string,
67
+ cwd: string | undefined,
68
+ range?: RefRange,
69
+ ): { reference: ReferenceInsertLike; mention: string } {
70
+ const mention = mentionOf(path, cwd)
71
+ return {
72
+ mention,
73
+ reference: {
74
+ source: REF_SOURCE,
75
+ ref: refOf(mention, range),
76
+ label: labelOf(path, range),
77
+ appearance: 'file',
78
+ clipboardText: mention,
79
+ },
80
+ }
81
+ }
82
+
83
+ /** 输入门面最小形状(运行时来自 ctx.conversation.input,避免引入新类型依赖)。 */
84
+ export interface InputLike {
85
+ state: { getSnapshot: () => { draft: string; draftRev: number } }
86
+ insertReference: (ref: ReferenceInsertLike, span: TokenSpanLike) => boolean
87
+ setDraft: (text: string) => void
88
+ notify: (level: 'info' | 'error', text: string) => void
89
+ }
90
+
91
+ /** ReferenceInsert 结构形状(与 dsh-client-ui-input-trigger 类型一致)。 */
92
+ export interface ReferenceInsertLike {
93
+ source: string
94
+ ref: string
95
+ label: string
96
+ appearance?: 'session' | 'file' | 'folder'
97
+ clipboardText: string
98
+ }
99
+
100
+ /** TokenSpan 结构形状(start/end 为草稿字符偏移,draftRev 为 CAS 版本)。 */
101
+ export interface TokenSpanLike {
102
+ start: number
103
+ end: number
104
+ draftRev: number
105
+ }
106
+
107
+ /** ctx 依赖的最小面(sessions.scope + conversation.input)。 */
108
+ export interface CtxLike {
109
+ get: (name: string) => unknown
110
+ }
111
+
112
+ /** 注入器产物:给 EditorView 用的动作集合。 */
113
+ export interface AddToConversation {
114
+ /** 追加文件引用 chip(@path [Lstart-end]);忙态自动降级纯文本。 */
115
+ appendReference(sessionId: string | undefined, path: string, range?: RefRange): Promise<AddOutcome>
116
+ }
117
+
118
+ /**
119
+ * 解析会话输入门面;缺失时返回 undefined(动作侧守卫降级)。
120
+ * @author ddj 2026年08月25号
121
+ * @param ctx 客户端服务上下文
122
+ * @param sessionId 会话 id
123
+ * @returns SessionInput 或 undefined
124
+ */
125
+ export function inputFor(ctx: CtxLike, sessionId: string | undefined): InputLike | undefined {
126
+ if (!sessionId) return undefined
127
+ try {
128
+ const sessions = ctx.get('sessions') as { scope?: (id: string) => unknown } | undefined
129
+ const conversation = ctx.get('conversation') as { input?: { for: (actx: unknown) => unknown } } | undefined
130
+ const actx = sessions?.scope?.(sessionId)
131
+ const shell = actx && conversation?.input?.for?.(actx)
132
+ return (shell && typeof (shell as InputLike).setDraft === 'function') ? shell as InputLike : undefined
133
+ } catch {
134
+ return undefined
135
+ }
136
+ }
137
+
138
+ /** 取当前草稿长度与版本;无输入门面时返回 null。
139
+ * @author ddj 2026年08月25号 */
140
+ function draftCursor(input: InputLike | undefined): { draft: string; draftRev: number } | null {
141
+ try {
142
+ const s = input?.state?.getSnapshot?.()
143
+ if (!s) return null
144
+ return { draft: String(s.draft ?? ''), draftRev: Number(s.draftRev ?? 0) }
145
+ } catch {
146
+ return null
147
+ }
148
+ }
149
+
150
+ /** 从 sessions 列表快照读会话工作区目录(与 index.ts 既有读取方式一致)。
151
+ * @author ddj 2026年08月25号 */
152
+ function cwdOf(ctx: CtxLike, sessionId: string | undefined): string | undefined {
153
+ if (!sessionId) return undefined
154
+ try {
155
+ const sessions = ctx.get('sessions') as
156
+ | { list?: { getSnapshot?: () => { byId?: Record<string, { cwd?: string }> } } }
157
+ | undefined
158
+ return sessions?.list?.getSnapshot?.()?.byId?.[sessionId]?.cwd
159
+ } catch {
160
+ return undefined
161
+ }
162
+ }
163
+
164
+ /** 写入成功后的 composer 内联提示(门面可用时;失败静默)。
165
+ * @author ddj 2026年08月25号 */
166
+ function notify(input: InputLike, text: string): void {
167
+ try {
168
+ input.notify?.('info', text)
169
+ } catch {
170
+ /* 提示失败忽略 */
171
+ }
172
+ }
173
+
174
+ /** 输入门面插入引用(拦截异常视作未应用,走降级)。
175
+ * @author ddj 2026年08月25号 */
176
+ function safeInsert(input: InputLike, reference: ReferenceInsertLike, span: TokenSpanLike): boolean {
177
+ try {
178
+ return input.insertReference(reference, span) === true
179
+ } catch {
180
+ return false
181
+ }
182
+ }
183
+
184
+ /**
185
+ * 创建「添加到对话」动作集(apply 阶段构建一次,随 props 传给 EditorView)。
186
+ * @author ddj 2026年08月25号
187
+ * @param ctx 客户端服务上下文(sessions + conversation)
188
+ * @returns 动作集
189
+ */
190
+ export function createAddToConversation(ctx: CtxLike): AddToConversation {
191
+ const appendReference: AddToConversation['appendReference'] = async (sessionId, path, range) => {
192
+ const input = inputFor(ctx, sessionId)
193
+ if (!input) return 'unavailable'
194
+ const cur = draftCursor(input)
195
+ if (!cur) return 'unavailable'
196
+ const { reference, mention } = buildFileRef(path, cwdOf(ctx, sessionId), range)
197
+ const span: TokenSpanLike = { start: cur.draft.length, end: cur.draft.length, draftRev: cur.draftRev }
198
+ const ok = safeInsert(input, reference, span)
199
+ if (!ok) {
200
+ // 忙态(adjudicating/submitting)或 CAS 失败:降级纯文本追加,保证动作有落点。
201
+ const gap = cur.draft.length > 0 && !/\s$/.test(cur.draft) ? ' ' : ''
202
+ input.setDraft(cur.draft + gap + mention + ' ')
203
+ notify(input, '已添加文件引用 ' + mention)
204
+ return 'busy'
205
+ }
206
+ notify(input, '已添加文件引用 ' + mention)
207
+ return 'ok'
208
+ }
209
+
210
+ return { appendReference }
211
+ }
@@ -17,14 +17,16 @@ import { EditorView } from './ui/EditorView.js'
17
17
  import { DiffBadge } from './ui/DiffBadge.js'
18
18
  import { McpSettings } from './ui/McpSettings.js'
19
19
  import { rpc } from './rpc.js'
20
+ import { loadMonaco } from './monaco/loader.js'
20
21
  import { createFileOpenerRegistry, scanSidebar, type FileOpenContext } from './fileOpeners.js'
21
22
  import type { FileOpenerRegistry } from './fileOpeners.js'
22
23
  import { installOpenPathRouter, vscodeOpener, autoValue } from './openPathRouter.js'
23
24
  import { SettingsContext } from './settingsContext.js'
24
25
  import { SIDEBAR_PLUGIN, pickSettingsBinder, registerSlotSafely } from './compat.js'
26
+ import { createAddToConversation } from './addToConversation.js'
25
27
  import type { CompatAdapter } from '../shared/compat.js'
26
28
 
27
- export const inject = ['slots', 'timer', 'locale', 'connection', 'remote', 'workspaces', 'sessions', 'settingsScope', 'webUiSettings']
29
+ export const inject = ['slots', 'timer', 'locale', 'connection', 'remote', 'workspaces', 'sessions', 'conversation', 'settingsScope', 'webUiSettings']
28
30
 
29
31
  /**
30
32
  * 装配客户端:注册中央编辑区视图与 header 差异角标。
@@ -34,6 +36,16 @@ export const inject = ['slots', 'timer', 'locale', 'connection', 'remote', 'work
34
36
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
35
37
  export function apply(ctx: any): void {
36
38
  const schedule = (fn: () => void, ms: number) => ctx.timeout(fn, ms)
39
+
40
+ // Monaco 预热:DSH 启动后后台加载编辑器核心(模块级 promise 去重),用户点开「文件编辑」页签即用,
41
+ // 不再等页签挂载后才首次拉取 /edrv/vendor/*。requestIdleCallback 让出首屏带宽,缺省回退延时调度;
42
+ // 预热失败静默吞掉(loader 失败会重置 promise,页签打开时仍走原有加载/重试路径)。
43
+ if (typeof window !== 'undefined') {
44
+ const preloadMonaco = () => loadMonaco(() => {}).catch(() => {})
45
+ if (typeof window.requestIdleCallback === 'function') window.requestIdleCallback(preloadMonaco, { timeout: 2000 })
46
+ else schedule(preloadMonaco, 300)
47
+ }
48
+
37
49
  const registry: FileOpenerRegistry = createFileOpenerRegistry()
38
50
  const workspaces = ctx.get('workspaces')
39
51
  const sessions = ctx.get('sessions')
@@ -41,6 +53,8 @@ export function apply(ctx: any): void {
41
53
  const binder = pickSettingsBinder(ctx)
42
54
  const settings = binder.scope
43
55
  let selected = autoValue('auto')
56
+ /** 「添加到对话」动作集:编辑区/Tab 右键菜单注入文件引用与代码块(conversation 服务缺失时各动作安全降级)。 */
57
+ const addToConversation = createAddToConversation(ctx)
44
58
 
45
59
  /** 客户端侧兼容摘要(与 host 报告合并展示;惰性求值保证 HMR 后仍新鲜)。 */
46
60
  const compatSummary = (): CompatAdapter[] => [
@@ -92,7 +106,7 @@ export function apply(ctx: any): void {
92
106
  order: 5,
93
107
  label: '文件编辑',
94
108
  inject: (sessionId: string) => ({ sessionId }),
95
- }, (props: unknown) => React.createElement(EditorView, Object.assign({}, props, { schedule })))
109
+ }, (props: unknown) => React.createElement(EditorView, Object.assign({}, props, { schedule, addToConversation })))
96
110
 
97
111
  // header 差异角标:仅当前工作区存在差异时渲染
98
112
  registerSlotSafely(ctx, { name: 'conversation.session.header.utilities', id: 'edrv-diff-badge', order: 90, label: '差异' },
@@ -61,6 +61,12 @@
61
61
  [data-edrv-view] .edrv-diffmenu-item:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(15,157,88,.08)); }
62
62
  [data-edrv-view] .edrv-diffmenu-item.danger { color: var(--dsw-alias-state-error-primary, #d9534f); }
63
63
  [data-edrv-view] .edrv-diffmenu-sep { height: 1px; margin: 2px 4px; background: var(--dsw-alias-border-l1, #e0e6e8); }
64
+ /* 编辑区 / Tab 右键菜单(添加文件/选中内容到对话) */
65
+ [data-edrv-view] .edrv-ctxmenu { position: fixed; z-index: 71; min-width: 200px; max-width: 320px; border-radius: 10px; background: var(--dsw-alias-bg-overlay, #ffffff); border: 1px solid var(--dsw-alias-border-l2, #cbd2d9); box-shadow: 0 8px 26px rgba(31,41,51,.2); padding: 4px; display: flex; flex-direction: column; gap: 1px; }
66
+ [data-edrv-view] .edrv-ctxmenu-item { display: flex; align-items: center; gap: 8px; font-size: 12px; line-height: 18px; padding: 7px 10px; border: none; border-radius: 6px; background: transparent; color: var(--dsw-alias-label-primary, #1f2933); cursor: pointer; text-align: left; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
67
+ [data-edrv-view] .edrv-ctxmenu-item:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(15,157,88,.08)); }
68
+ [data-edrv-view] .edrv-ctxmenu-item.edrv-ctxmenu-danger { color: var(--dsw-alias-state-error-primary, #d9534f); }
69
+ [data-edrv-view] .edrv-ctxmenu-sep { height: 1px; margin: 3px 4px; background: var(--dsw-alias-border-l1, #e0e6e8); }
64
70
  [data-edrv-view] .edrv-diffbar-body { max-height: 130px; max-width: min(560px, calc(100vw - 40px)); overflow: auto; padding: 4px 8px 8px; display: flex; flex-direction: column; gap: 4px; border-top: 1px solid var(--dsw-alias-border-l1, #e0e6e8); }
65
71
  [data-edrv-view] .edrv-diffrow { display: flex; align-items: center; gap: 8px; padding: 3px 6px; border: 1px solid var(--dsw-alias-border-l1, #e0e6e8); border-radius: 6px; background: var(--dsw-alias-bg-layer-2, #f0f4f4); cursor: pointer; flex-wrap: wrap; }
66
72
  [data-edrv-view] .edrv-diffrow:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(15,157,88,.08)); }
@@ -26,6 +26,7 @@ import { DiffLauncher } from './DiffLauncher.js'
26
26
  export function EditorView(props) {
27
27
  const sessionId = props?.sessionId
28
28
  const schedule = props.schedule
29
+ const addToConversation = props.addToConversation
29
30
  const [monaco, setMonaco] = React.useState(null)
30
31
  const [monacoErr, setMonacoErr] = React.useState(null)
31
32
  const [records, setRecords] = React.useState({})
@@ -45,6 +46,7 @@ export function EditorView(props) {
45
46
  const [hoverAct, setHoverAct] = React.useState(null) // { region, top } 编辑区 hover 差异块的 Keep/Undo 浮层
46
47
  const [diffIdx, setDiffIdx] = React.useState(0) // 当前文件内差异位置(x/x 显示)
47
48
  const [fileIdx, setFileIdx] = React.useState(0) // 全局差异文件位置(x/x 文件 显示)
49
+ const [tabMenu, setTabMenu] = React.useState(null) // Tab 右键菜单 { x,y,path }(编辑区右键走 Monaco 原生菜单,无此浮层)
48
50
  const editorRef = React.useRef(null)
49
51
  const monacoRef = React.useRef(null)
50
52
  const modelsRef = React.useRef(new Map())
@@ -62,6 +64,7 @@ export function EditorView(props) {
62
64
  const hoverEditorRef = React.useRef(false) // 鼠标是否仍在编辑器命中区
63
65
  const hoverPanelRef = React.useRef(false) // 鼠标是否已进入 Keep/Undo 浮层
64
66
  const batchBusyRef = React.useRef(false) // 批量 Keep All/Undo All 防重入
67
+ const menuHandlersRef = React.useRef(null) // 右键菜单动作的最新闭包(Monaco addAction 空依赖回调读取)
65
68
  const diffRendererRef = React.useRef(null)
66
69
  if (!diffRendererRef.current) diffRendererRef.current = createDiffRenderer((sid, t) => dbg(sid, t))
67
70
 
@@ -108,9 +111,9 @@ export function EditorView(props) {
108
111
  })
109
112
  }
110
113
 
111
- const refreshRecords = () => {
114
+ const refreshRecords = (skipStale) => {
112
115
  if (!sessionId) return
113
- rpc('edrv.list', { sessionId }).then((res) => {
116
+ rpc('edrv.list', { sessionId, ...(skipStale ? { skipStale: true } : {}) }).then((res) => {
114
117
  if (!res || !res.ok || !Array.isArray(res.records)) return
115
118
  const map = {}
116
119
  for (const r of res.records) {
@@ -201,6 +204,14 @@ export function EditorView(props) {
201
204
  loadContent(active, sessionId)
202
205
  }, [active, sessionId])
203
206
 
207
+ // Tab 右键菜单打开时 Esc 关闭
208
+ React.useEffect(() => {
209
+ if (!tabMenu) return
210
+ const onKey = (e) => { if (e.key === 'Escape') dismissMenus() }
211
+ window.addEventListener('keydown', onKey, true)
212
+ return () => window.removeEventListener('keydown', onKey, true)
213
+ }, [tabMenu])
214
+
204
215
  React.useEffect(() => {
205
216
  if (monaco || monacoErr) return
206
217
  let alive = true
@@ -327,6 +338,40 @@ export function EditorView(props) {
327
338
  ed.onDidChangeCursorPosition((e) => {
328
339
  setCursor('Ln ' + e.position.lineNumber + ', Col ' + e.position.column)
329
340
  })
341
+ // 编辑区右键 → 注入 Monaco 原生 context menu(与 Go to Definition 等同菜单,避免分离浮层)。
342
+ // 有选区时额外显示「选中内容」项:context key edrvSelection 由光标选区变化驱动。
343
+ // 路径/选区在点击时从 model/editor 实时读取(不依赖闭包里的过期 active)。
344
+ const menuHandlers = () => menuHandlersRef.current
345
+ const pathOf = (edx) => {
346
+ const model = edx?.getModel?.()
347
+ const uriPath = model?.uri?.path
348
+ return uriPath ? decodeURIComponent(String(uriPath).replace(/^\//, '')) : null
349
+ }
350
+ const selectionOf = (edx) => {
351
+ const s = edx?.getSelection?.()
352
+ if (!s) return null
353
+ if (s.startLineNumber === s.endLineNumber && s.startColumn === s.endColumn) return null
354
+ return { startLine: s.startLineNumber, endLine: s.endLineNumber }
355
+ }
356
+ const selKey = ed.createContextKey('edrvSelection', false)
357
+ ed.onDidChangeCursorSelection((e) => {
358
+ const s = e?.selection
359
+ selKey.set(!!(s && (s.startLineNumber !== s.endLineNumber || s.startColumn !== s.endColumn)))
360
+ })
361
+ ed.addAction({
362
+ id: 'edrv.addFileRef', label: '添加文件到对话', contextMenuGroupId: '1_edrv',
363
+ precondition: 'editorTextFocus',
364
+ run: (edx) => menuHandlers()?.addRefToChat(pathOf(edx)),
365
+ })
366
+ ed.addAction({
367
+ id: 'edrv.addSelectionRef', label: '添加选中内容为引用', contextMenuGroupId: '1_edrv',
368
+ precondition: 'edrvSelection',
369
+ run: (edx) => {
370
+ const s = selectionOf(edx)
371
+ const p = pathOf(edx)
372
+ if (s && p) menuHandlers()?.addRefToChat(p, { startLine: s.startLine, endLine: s.endLine })
373
+ },
374
+ })
330
375
  // hover 差异块 → 浮出 Keep/Undo(req:鼠标移到编辑区差异块时显示)
331
376
  // 防闪烁:① 区域不变不 setState(浮窗锚定差异块起始行,不跟随鼠标);② 延迟隐藏;
332
377
  // ③ 浮窗自身 onMouseEnter 取消隐藏计时(鼠标在浮窗与编辑器间移动不闪)。
@@ -410,10 +455,10 @@ export function EditorView(props) {
410
455
  openFile(sum.pendingFiles[next].path, true)
411
456
  }
412
457
 
413
- const reloadFile = () => {
458
+ const reloadFile = (skipStale) => {
414
459
  if (!active) return
415
460
  loadContent(active, sessionId)
416
- refreshRecords()
461
+ refreshRecords(skipStale === true)
417
462
  }
418
463
 
419
464
  /**
@@ -454,8 +499,64 @@ export function EditorView(props) {
454
499
  })
455
500
  }
456
501
 
457
- const acceptFile = () => { for (const r of pendingRegions) actHunk(r, false) }
458
- const undoFile = () => { for (const r of [...pendingRegions].reverse()) actHunk(r, true) }
502
+ /**
503
+ * 批量采纳/不采纳:一次 RPC 处理多个差异(Keep 整文件 / Keep All / Undo All)。
504
+ * 单次 setRecords 合并全部结果,避免逐条往返读写整个 sidecar。
505
+ * @author ddj 2026年08月25号
506
+ * @param items 决策项数组(callId/scope/hunkIndex/decision)
507
+ * @returns Promise<{ok:number; fail:number}> 成功/失败计数
508
+ */
509
+ const actMany = (items) => {
510
+ if (!items.length) return Promise.resolve({ ok: 0, fail: 0 })
511
+ return rpc('edrv.decideBatch', { sessionId, items }).then((res) => {
512
+ if (!res || !res.ok || !Array.isArray(res.results)) {
513
+ setError(res?.error ? String(res.error) : '批量处理失败')
514
+ return { ok: 0, fail: items.length }
515
+ }
516
+ let ok = 0
517
+ let fail = 0
518
+ const next = {}
519
+ for (const item of res.results) {
520
+ if (item && item.ok) {
521
+ ok++
522
+ if (item.record) next[item.callId] = item.record
523
+ } else {
524
+ fail++
525
+ }
526
+ }
527
+ setRecords((prev) => Object.assign({}, prev, next))
528
+ return { ok, fail }
529
+ }).catch((e) => {
530
+ setError('批量处理异常:' + String(e))
531
+ return { ok: 0, fail: items.length }
532
+ })
533
+ }
534
+
535
+ /**
536
+ * 决策项构造(与单条 actHunk 的 scope 语义一致:create 记录走 call 作用域)。
537
+ * @author ddj 2026年08月25号
538
+ * @param r 差异区域/待处理项(callId/idx/create)
539
+ * @param reject true=不采纳
540
+ * @returns decideBatch items 元素
541
+ */
542
+ const itemOf = (r, reject) => ({ callId: r.callId, scope: r.create ? 'call' : 'hunk', hunkIndex: r.idx, decision: reject ? 'rejected' : 'accepted' })
543
+
544
+ const acceptFile = () => {
545
+ actMany(pendingRegions.map((r) => itemOf(r, false))).then(({ ok, fail }) => {
546
+ reloadFile(true)
547
+ emitRefresh()
548
+ setStatus('已采纳 ' + ok + ' 处差异' + (fail ? ',' + fail + ' 处失败' : ''))
549
+ if (fail) setError(fail + ' 处差异处理失败(可能已被后续修改影响),可刷新后重试')
550
+ })
551
+ }
552
+ const undoFile = () => {
553
+ actMany([...pendingRegions].reverse().map((r) => itemOf(r, true))).then(({ ok, fail }) => {
554
+ reloadFile(true)
555
+ emitRefresh()
556
+ setStatus('已不采纳 ' + ok + ' 处差异' + (fail ? ',' + fail + ' 处失败' : ''))
557
+ if (fail) setError(fail + ' 处差异处理失败(可能已被后续修改影响),可刷新后重试')
558
+ })
559
+ }
459
560
 
460
561
  // 所有差异文件的待处理 hunk 列表(二级菜单 Keep All / Undo All 用)
461
562
  const allPending = React.useMemo(() => {
@@ -473,7 +574,7 @@ export function EditorView(props) {
473
574
  }, [records])
474
575
 
475
576
  /**
476
- * 批量处理所有差异文件:采纳全部 / 不采纳全部。不采纳按 at 降序(新改先回滚)串行执行。
577
+ * 批量处理所有差异文件:采纳全部 / 不采纳全部。不采纳按 at 降序(新改先回滚)排序后一次 RPC。
477
578
  * @author ddj 2026年08月20号
478
579
  * @param reject true=全部不采纳(回滚),false=全部采纳
479
580
  */
@@ -481,19 +582,13 @@ export function EditorView(props) {
481
582
  if (batchBusyRef.current || !allPending.length) return
482
583
  batchBusyRef.current = true
483
584
  const list = reject ? [...allPending].sort((a, b) => (a.at < b.at ? 1 : a.at > b.at ? -1 : b.idx - a.idx)) : allPending
484
- let ok = 0, fail = 0
485
- const run = async () => {
486
- for (const r of list) {
487
- if (await actHunk(r, reject, true)) ok++
488
- else fail++
489
- }
585
+ actMany(list.map((r) => itemOf(r, reject))).then(({ ok, fail }) => {
490
586
  batchBusyRef.current = false
491
- reloadFile()
587
+ reloadFile(true)
492
588
  emitRefresh()
493
589
  setStatus((reject ? '已不采纳 ' : '已采纳 ') + ok + ' 处差异' + (fail ? ',' + fail + ' 处失败' : ''))
494
590
  if (fail) setError(fail + ' 处差异处理失败(可能已被后续修改影响),可刷新后重试')
495
- }
496
- run()
591
+ }).catch(() => { batchBusyRef.current = false })
497
592
  }
498
593
  const acceptAllFiles = () => actAllPending(false)
499
594
  const undoAllFiles = () => actAllPending(true)
@@ -508,6 +603,32 @@ export function EditorView(props) {
508
603
  }).catch((e) => setError('回滚异常:' + String(e)))
509
604
  }
510
605
 
606
+ /**
607
+ * 关闭 Tab 右键菜单。
608
+ * @author ddj 2026年08月25号
609
+ */
610
+ const dismissMenus = () => {
611
+ setTabMenu(null)
612
+ }
613
+
614
+ /** 把「添加到对话」返回状态映射为状态栏文案。
615
+ * @author ddj 2026年08月25号 */
616
+ const statusOfAdd = (outcome, okText) => {
617
+ if (outcome === 'ok') return okText
618
+ if (outcome === 'busy') return okText + '(输入框忙,已降级纯文本)'
619
+ return '无法添加到对话(无会话或输入框不可用)'
620
+ }
621
+
622
+ /** 添加文件/选中区引用到对话(异步,状态栏反馈)。 */
623
+ const addRefToChat = (path, range) => {
624
+ if (!path) { setStatus('无活动文件'); return }
625
+ if (!addToConversation) { setStatus('添加到对话不可用'); return }
626
+ addToConversation.appendReference(sessionId, path, range).then((o) => setStatus(statusOfAdd(o, '已添加文件引用')))
627
+ }
628
+
629
+ // 供 Monaco 原生右键菜单 addAction 读取的最新动作闭包(空依赖回调不随渲染重建)
630
+ menuHandlersRef.current = { addRefToChat }
631
+
511
632
  const openFile = (path, focusDiff) => {
512
633
  if (!path) return
513
634
  addTab(path, true)
@@ -532,6 +653,11 @@ export function EditorView(props) {
532
653
  className: 'edrv-tab' + (t.path === active ? ' edrv-tab-active' : ''),
533
654
  title: t.path,
534
655
  onClick: () => { if (t.path !== active) { flushSave(); setActive(t.path) } },
656
+ onContextMenu: (e) => {
657
+ e.preventDefault()
658
+ e.stopPropagation()
659
+ setTabMenu({ x: e.clientX, y: e.clientY, path: t.path })
660
+ },
535
661
  },
536
662
  React.createElement('span', { style: { overflow: 'hidden', textOverflow: 'ellipsis', maxWidth: 180 } }, t.path.split(/[\\/]/).pop() || t.path),
537
663
  (dirtyMap[t.path] ? React.createElement('span', { className: 'edrv-tab-dot' }) : null),
@@ -664,6 +790,30 @@ export function EditorView(props) {
664
790
  })
665
791
  : null))
666
792
 
793
+ // Tab 右键菜单浮层:全屏遮罩(点击/右键关闭)+ 菜单(固定定位,clamp 防越界)。
794
+ // 编辑区右键走 Monaco 原生 context menu(editor.addAction),不在此渲染浮层。
795
+ /**
796
+ * 计算右键菜单固定定位(clamp 防越出视口)。
797
+ * @author ddj 2026年08月25号
798
+ */
799
+ const menuPos = (x, y) => ({
800
+ left: Math.max(4, Math.min(x, (window.innerWidth || 800) - 224)),
801
+ top: Math.max(4, Math.min(y, (window.innerHeight || 600) - 176)),
802
+ })
803
+ const menuBackdrop = tabMenu
804
+ ? React.createElement('div', {
805
+ style: { position: 'fixed', inset: 0, zIndex: 70 },
806
+ onClick: dismissMenus,
807
+ onContextMenu: (e) => { e.preventDefault(); dismissMenus() },
808
+ })
809
+ : null
810
+ const tabMenuEl = tabMenu
811
+ ? React.createElement('div', { className: 'edrv-ctxmenu', style: menuPos(tabMenu.x, tabMenu.y) },
812
+ React.createElement('button', { className: 'edrv-ctxmenu-item', onClick: () => { addRefToChat(tabMenu.path); dismissMenus() } }, '添加文件到对话'),
813
+ React.createElement('div', { className: 'edrv-ctxmenu-sep' }),
814
+ React.createElement('button', { className: 'edrv-ctxmenu-item edrv-ctxmenu-danger', onClick: () => { closeTab(tabMenu.path); dismissMenus() } }, '关闭标签页'))
815
+ : null
816
+
667
817
  return React.createElement('div', { 'data-edrv-view': '1', style: { height: '100%', minHeight: 0, display: 'flex', flexDirection: 'column', background: 'var(--dsw-alias-bg-base,transparent)', overflow: 'hidden' } },
668
818
  pathBar,
669
819
  tabRow,
@@ -671,5 +821,7 @@ export function EditorView(props) {
671
821
  body,
672
822
  hoverEl,
673
823
  overlay),
824
+ menuBackdrop,
825
+ tabMenuEl,
674
826
  statusBar)
675
827
  }