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/lib/client.js +359 -17
- package/lib/client.js.map +1 -1
- package/lib/index.js +82 -23
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/client/addToConversation.ts +211 -0
- package/src/client/index.ts +16 -2
- package/src/client/styles/editor.css +6 -0
- package/src/client/ui/EditorView.ts +168 -16
- package/src/rpc.ts +63 -18
- package/src/shared/rpc.ts +19 -1
- package/src/store.ts +6 -2
package/package.json
CHANGED
|
@@ -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
|
+
}
|
package/src/client/index.ts
CHANGED
|
@@ -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
|
-
|
|
458
|
-
|
|
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
|
-
|
|
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
|
}
|