dsh-vscode-mode 0.7.0 → 0.9.0
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 +37 -6
- package/assets/icon.svg +7 -0
- package/cordis.patch.yml +16 -0
- package/lib/client.js +2804 -300
- package/lib/client.js.map +1 -1
- package/lib/index.js +1975 -191
- package/lib/index.js.map +1 -1
- package/locale/en.json +4 -0
- package/locale/zh.json +4 -0
- package/package.json +4 -2
- package/src/ai/inline.ts +19 -2
- package/src/ai/svnTriage.ts +150 -0
- package/src/capture.ts +225 -51
- package/src/client/addToConversation.ts +203 -1
- package/src/client/compat.ts +134 -4
- package/src/client/imagePreview.ts +76 -2
- package/src/client/index.ts +65 -36
- package/src/client/monaco/lsp/lspClient.ts +46 -0
- package/src/client/monaco/lsp/providers.ts +245 -1
- package/src/client/nativeOpenStore.ts +91 -0
- package/src/client/officialSidebar.ts +11 -45
- package/src/client/outline/index.ts +1 -1
- package/src/client/pdf/pdfPanel.ts +18 -0
- package/src/client/pendingNav.ts +141 -0
- package/src/client/settingsContext.ts +6 -4
- package/src/client/sidebar/panels/FileExplorer.ts +2 -15
- package/src/client/sidebar/panels/SvnPanel.ts +682 -72
- package/src/client/sidebar/panels/index.ts +1 -1
- package/src/client/sidebar/panels/svnListBudget.ts +58 -0
- package/src/client/state/records.ts +7 -1
- package/src/client/styles/editor.css +47 -5
- package/src/client/svnStatus.ts +206 -1
- package/src/client/svnStore.ts +6 -1
- package/src/client/ui/AiSettings.ts +47 -14
- package/src/client/ui/DiffBox.ts +1 -1
- package/src/client/ui/DiffLauncher.ts +6 -4
- package/src/client/ui/EditorView.ts +149 -22
- package/src/client/ui/McpSettings.ts +30 -0
- package/src/client/ui/PerfSettings.ts +4 -1
- package/src/client/ui/SvnAiPlanDialog.ts +293 -0
- package/src/client/ui/icons.ts +91 -0
- package/src/compat.ts +26 -5
- package/src/dshVersion.ts +2 -1
- package/src/fileOpenSettings.ts +274 -49
- package/src/index.ts +18 -1
- package/src/lsp/client.ts +24 -0
- package/src/lsp/rpc.ts +57 -0
- package/src/lsp/server.ts +272 -2
- package/src/model.ts +2 -1
- package/src/perf.ts +90 -4
- package/src/remoteWorkspace.ts +177 -0
- package/src/routes.ts +13 -1
- package/src/rpc.ts +115 -39
- package/src/shared/ai.ts +12 -0
- package/src/shared/lsp.ts +79 -0
- package/src/shared/nativeOpen.ts +91 -0
- package/src/shared/rpc.ts +162 -2
- package/src/shared/svn.ts +296 -10
- package/src/shared/types.ts +5 -1
- package/src/svn.ts +235 -13
|
@@ -264,6 +264,138 @@ export interface CtxLike {
|
|
|
264
264
|
get: (name: string) => unknown
|
|
265
265
|
}
|
|
266
266
|
|
|
267
|
+
/** 新建会话结果(ok=false 供调用方降级回当前对话注入)。 */
|
|
268
|
+
export interface DraftSessionResult {
|
|
269
|
+
ok: boolean
|
|
270
|
+
/** 新会话 id(ok=true 时存在;投递命令按它内嵌)。 */
|
|
271
|
+
id?: string
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** apiproxy wire 端点(POST /api/<method>;浏览器同源 fetch 自带 cookie 鉴权;实证 401=未鉴权而非 404)。 */
|
|
275
|
+
const SESSION_CREATE_PATH = '/api/session/create'
|
|
276
|
+
/** apiproxy wire 端点:发送任务(session/prompt;点击即发送方案)。 */
|
|
277
|
+
const SESSION_PROMPT_PATH = '/api/session/prompt'
|
|
278
|
+
|
|
279
|
+
/** mint RPC 请求 id(RpcId 品牌类型运行时即普通字符串)。 */
|
|
280
|
+
function mintRpcId(): string {
|
|
281
|
+
try {
|
|
282
|
+
return globalThis.crypto?.randomUUID?.() ?? ('rpc-' + Date.now())
|
|
283
|
+
} catch {
|
|
284
|
+
return 'rpc-' + Date.now()
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* RPC 返回解析(照官方 dsh-client-runtime 调用形态:`const { result } = await api.sessions.create(...)`)。
|
|
290
|
+
* 官方 api.sessions 面返回**信封** `{ rpcId, result: RpcResult }`——须解 .result;
|
|
291
|
+
* 兼容裸 RpcResult 形态(个别补丁面直接回 {ok, value})。
|
|
292
|
+
* @author ddj 2026年09月23号
|
|
293
|
+
* @param res 信封或裸 RpcResult
|
|
294
|
+
* @returns 归一后的 { ok, value }
|
|
295
|
+
*/
|
|
296
|
+
function rpcOk(res: unknown): { ok: boolean; value?: unknown } {
|
|
297
|
+
const envelope = (res as { result?: unknown } | null)?.result
|
|
298
|
+
const r = (envelope ?? res) as { ok?: boolean; value?: unknown } | null
|
|
299
|
+
return { ok: r?.ok === true, value: r?.value }
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* wire 直调 session/create(主路径):ClientRequest 信封 → ServerResponse.result(RpcResult 形态)。
|
|
304
|
+
* 契约以当前安装版 dsh-host-apiproxy 为准(api/sessions.d.ts「Method signatures are the
|
|
305
|
+
* source of truth」):`create({cwd}) → {sessionId}`;信封 = `{type:'client-request', rpcId,
|
|
306
|
+
* method, payload}` → `{result: {ok:true, value:{sessionId}} | {ok:false, error}}`。
|
|
307
|
+
* @author ddj 2026年09月23号
|
|
308
|
+
* @param cwd 新会话工作区目录
|
|
309
|
+
* @returns 新会话 id(失败空串)
|
|
310
|
+
*/
|
|
311
|
+
async function apiSessionCreate(cwd: string | undefined): Promise<string> {
|
|
312
|
+
const res = await fetch(SESSION_CREATE_PATH, {
|
|
313
|
+
method: 'POST',
|
|
314
|
+
headers: { 'content-type': 'application/json' },
|
|
315
|
+
body: JSON.stringify({
|
|
316
|
+
type: 'client-request',
|
|
317
|
+
rpcId: mintRpcId(),
|
|
318
|
+
method: 'session/create',
|
|
319
|
+
payload: cwd ? { cwd } : {},
|
|
320
|
+
}),
|
|
321
|
+
})
|
|
322
|
+
const json = await res.json().catch(() => null) as { result?: { ok?: boolean; value?: { sessionId?: unknown } } } | null
|
|
323
|
+
const id = json?.result?.value?.sessionId
|
|
324
|
+
return json?.result?.ok === true && typeof id === 'string' ? id : ''
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* wire 直发 session/prompt(点击即发送方案):任务文本投给目标会话 agent。
|
|
329
|
+
* 契约 = 当前安装树 SessionsApi.prompt:`{sessionId, mode:'queue', content:[{type:'text',text}]}`。
|
|
330
|
+
* 发送成功会话即有内容 → 不再被 blank 隐藏(列表可见)。
|
|
331
|
+
* @author ddj 2026年09月23号
|
|
332
|
+
* @param sessionId 目标会话 id(新会话)
|
|
333
|
+
* @param text 任务文本
|
|
334
|
+
* @returns 是否受理
|
|
335
|
+
*/
|
|
336
|
+
export async function apiSessionPrompt(sessionId: string, text: string): Promise<boolean> {
|
|
337
|
+
try {
|
|
338
|
+
const res = await fetch(SESSION_PROMPT_PATH, {
|
|
339
|
+
method: 'POST',
|
|
340
|
+
headers: { 'content-type': 'application/json' },
|
|
341
|
+
body: JSON.stringify({
|
|
342
|
+
type: 'client-request',
|
|
343
|
+
rpcId: mintRpcId(),
|
|
344
|
+
method: 'session/prompt',
|
|
345
|
+
payload: { sessionId, mode: 'queue', content: [{ type: 'text', text }] },
|
|
346
|
+
}),
|
|
347
|
+
})
|
|
348
|
+
const json = await res.json().catch(() => null) as { result?: { ok?: boolean } } | null
|
|
349
|
+
return json?.result?.ok === true
|
|
350
|
+
} catch {
|
|
351
|
+
return false
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* 新建独立会话承载旁路任务(02-deep-session-prompt:深度分析不污染当前对话)。
|
|
357
|
+
* 主路径 = apiproxy wire 直调(不依赖 service 面形态猜测);回落 remote.session.create
|
|
358
|
+
* 与旧 sessions.create 两代 service 面(宽松解析)。任一步不符返回 ok:false,调用方降级。
|
|
359
|
+
* @author ddj 2026年09月23号
|
|
360
|
+
* @param ctx 客户端服务上下文(remote.session + sessions,仅回落用)
|
|
361
|
+
* @param cwd 新会话工作区目录(与当前会话同工作区)
|
|
362
|
+
* @returns 新会话结果(含 id)
|
|
363
|
+
*/
|
|
364
|
+
export async function newDraftSession(ctx: CtxLike, cwd: string | undefined): Promise<DraftSessionResult> {
|
|
365
|
+
// 主路径:wire 直调
|
|
366
|
+
try {
|
|
367
|
+
const id = await apiSessionCreate(cwd)
|
|
368
|
+
if (id) return { ok: true, id }
|
|
369
|
+
} catch { /* wire 不可用:回落 service 面 */ }
|
|
370
|
+
try {
|
|
371
|
+
// 回落 1:remote.session service 面(官方返回信封 {rpcId, result},见 rpcOk;实参双形态兼容)
|
|
372
|
+
const remote = ctx.get('remote.session') as {
|
|
373
|
+
create?: (req: unknown) => Promise<unknown>
|
|
374
|
+
} | undefined
|
|
375
|
+
if (typeof remote?.create === 'function') {
|
|
376
|
+
const payload = cwd ? { cwd } : {}
|
|
377
|
+
for (const arg of [payload, { rpcId: mintRpcId(), payload }]) {
|
|
378
|
+
const r = rpcOk(await remote.create(arg))
|
|
379
|
+
const id = r.value as { sessionId?: unknown } | undefined
|
|
380
|
+
if (r.ok && typeof id?.sessionId === 'string') return { ok: true, id: id.sessionId }
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
// 回落 2:旧版 client store 形态(id / {id} / {sessionId} / {session:{id}} 四解)
|
|
384
|
+
const sessions = ctx.get('sessions') as { create?: (opts?: { cwd?: string }) => unknown } | undefined
|
|
385
|
+
if (typeof sessions?.create === 'function') {
|
|
386
|
+
const created = await sessions.create(cwd ? { cwd } : undefined)
|
|
387
|
+
const id = typeof created === 'string' && created ? created
|
|
388
|
+
: typeof (created as { id?: unknown } | null)?.id === 'string' ? (created as { id: string }).id
|
|
389
|
+
: typeof (created as { sessionId?: unknown } | null)?.sessionId === 'string' ? (created as { sessionId: string }).sessionId
|
|
390
|
+
: typeof (created as { session?: { id?: unknown } } | null)?.session?.id === 'string'
|
|
391
|
+
? (created as { session: { id: string } }).session.id
|
|
392
|
+
: ''
|
|
393
|
+
if (id) return { ok: true, id }
|
|
394
|
+
}
|
|
395
|
+
} catch { /* 全部失败:调用方降级 */ }
|
|
396
|
+
return { ok: false }
|
|
397
|
+
}
|
|
398
|
+
|
|
267
399
|
/** 注入器产物:给 EditorView 用的动作集合。 */
|
|
268
400
|
export interface AddToConversation {
|
|
269
401
|
/** 追加文件/文件夹引用 chip(@path [Lstart-end]);忙态自动降级纯文本。 */
|
|
@@ -273,6 +405,12 @@ export interface AddToConversation {
|
|
|
273
405
|
range?: RefRange,
|
|
274
406
|
appearance?: RefAppearance,
|
|
275
407
|
): Promise<AddOutcome>
|
|
408
|
+
/** 追加纯文本(AI 深度分析任务等长文本注入;插到当前光标,失败不整篇重建)。 */
|
|
409
|
+
appendText(sessionId: string | undefined, text: string): Promise<AddOutcome>
|
|
410
|
+
/** 新建独立会话并切换(旁路任务隔离;失败 ok:false 供降级回当前对话)。 */
|
|
411
|
+
startDraftSession(cwd?: string): Promise<DraftSessionResult>
|
|
412
|
+
/** 发送任务给目标会话 agent(点击即发送;失败 false 供降级)。 */
|
|
413
|
+
sendTask(sessionId: string | undefined, text: string): Promise<boolean>
|
|
276
414
|
}
|
|
277
415
|
|
|
278
416
|
/**
|
|
@@ -392,5 +530,69 @@ export function createAddToConversation(ctx: CtxLike): AddToConversation {
|
|
|
392
530
|
return 'failed'
|
|
393
531
|
}
|
|
394
532
|
|
|
395
|
-
|
|
533
|
+
/**
|
|
534
|
+
* 追加纯文本(AI 深度分析任务注入):走官方 plain-text 通道插到当前光标。
|
|
535
|
+
* 失败语义与 appendReference 一致:宁可不写也不整篇重建(保护既有 chip)。
|
|
536
|
+
* @author ddj 2026年09月23号
|
|
537
|
+
* @param sessionId 会话 id
|
|
538
|
+
* @param text 待注入文本
|
|
539
|
+
* @returns 注入结果(ok / unavailable / failed)
|
|
540
|
+
*/
|
|
541
|
+
const appendText: AddToConversation['appendText'] = async (sessionId, text) => {
|
|
542
|
+
const input = inputFor(ctx, sessionId)
|
|
543
|
+
if (!input) return 'unavailable'
|
|
544
|
+
const cur = draftCursor(input)
|
|
545
|
+
if (!cur) return 'unavailable'
|
|
546
|
+
const at = insertOffsetOf(caretOf(input), detectEndOf(cur))
|
|
547
|
+
const span: TokenSpanLike = { start: at, end: at, draftRev: cur.draftRev }
|
|
548
|
+
return safeInsertText(input, text, span) ? 'ok' : 'failed'
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* 新建独立会话(02-deep-session-prompt:旁路任务不污染当前对话)。
|
|
553
|
+
* 闭包持有真服务 ctx(面板 SidebarCtx 无 .get,会话操作必须经此门面);
|
|
554
|
+
* 创建后尽力 `sessions.open(id)` 切换视图(缺失不致命,草稿仍可写入)。
|
|
555
|
+
* @author ddj 2026年09月23号
|
|
556
|
+
* @param cwd 新会话工作区目录(与当前会话同工作区)
|
|
557
|
+
* @returns 新会话结果(失败供降级)
|
|
558
|
+
*/
|
|
559
|
+
const startDraftSession: AddToConversation['startDraftSession'] = async (cwd) => {
|
|
560
|
+
const result = await newDraftSession(ctx, cwd)
|
|
561
|
+
if (result.ok && result.id) {
|
|
562
|
+
// 尽力切换到新会话(缺失/异常不影响结果——仅视图不切换)
|
|
563
|
+
try {
|
|
564
|
+
const sessions = ctx.get('sessions') as { open?: (id: string) => void } | undefined
|
|
565
|
+
sessions?.open?.(result.id)
|
|
566
|
+
} catch { /* 切换失败仅视图停留 */ }
|
|
567
|
+
}
|
|
568
|
+
return result
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* 发送任务给目标会话 agent(点击即发送方案:任务落地会话即非 blank,列表可见)。
|
|
573
|
+
* 主路径 wire 直发 session/prompt;回落 remote.session.prompt service 面。
|
|
574
|
+
* @author ddj 2026年09月23号
|
|
575
|
+
* @param sessionId 目标会话 id
|
|
576
|
+
* @param text 任务文本
|
|
577
|
+
* @returns 是否受理
|
|
578
|
+
*/
|
|
579
|
+
const sendTask: AddToConversation['sendTask'] = async (sessionId, text) => {
|
|
580
|
+
if (!sessionId) return false
|
|
581
|
+
try {
|
|
582
|
+
if (await apiSessionPrompt(sessionId, text)) return true
|
|
583
|
+
} catch { /* 回落 service 面 */ }
|
|
584
|
+
try {
|
|
585
|
+
const remote = ctx.get('remote.session') as { prompt?: (req: unknown) => Promise<unknown> } | undefined
|
|
586
|
+
if (typeof remote?.prompt === 'function') {
|
|
587
|
+
// 官方 api.sessions.prompt(request) 返回信封(rpcOk 解 .result);实参双形态兼容
|
|
588
|
+
const payload = { sessionId, mode: 'queue', content: [{ type: 'text', text }] }
|
|
589
|
+
for (const arg of [payload, { rpcId: mintRpcId(), payload }]) {
|
|
590
|
+
if (rpcOk(await remote.prompt(arg)).ok) return true
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
} catch { /* 全失败 */ }
|
|
594
|
+
return false
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
return { appendReference, appendText, startDraftSession, sendTask }
|
|
396
598
|
}
|
package/src/client/compat.ts
CHANGED
|
@@ -10,21 +10,97 @@ export const PLUGIN_NAME = 'dsh-vscode-mode'
|
|
|
10
10
|
/** 外部侧栏插件名(dsh-web-ui-all 家族的文件打开能力提供方)。 */
|
|
11
11
|
export const SIDEBAR_PLUGIN = 'dsh-better-sidebar'
|
|
12
12
|
|
|
13
|
-
/**
|
|
13
|
+
/** 设置值的已知业务字段(各设置桥透传的 namespace 内容)。 */
|
|
14
|
+
export interface SettingsValueLike {
|
|
15
|
+
fileOpenTool?: unknown
|
|
16
|
+
keybindings?: Record<string, string>
|
|
17
|
+
sidebarMinWidth?: unknown
|
|
18
|
+
maxOpenEditors?: unknown
|
|
19
|
+
/** 原生打开范围(逗号分隔后缀;0.1.7 批次新增,旧版 undefined → 默认集)。 */
|
|
20
|
+
nativeOpenExts?: unknown
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** 设置 scope 的跨组件上下文形状(与设置桥绑定的最小面:settingsScope / configForms 适配后同形)。 */
|
|
14
24
|
export interface SettingsScopeLike {
|
|
15
|
-
getSnapshot: () => { status?: string; value?:
|
|
25
|
+
getSnapshot: () => { status?: string; value?: SettingsValueLike; writable?: boolean }
|
|
16
26
|
subscribe: (listener: () => void) => () => void
|
|
17
27
|
set: (field: string, value: unknown) => Promise<void>
|
|
18
28
|
}
|
|
19
29
|
|
|
30
|
+
/** DSH 0.1.7+ configForms 服务最小面(0.1.6 无此服务,探测必须可降级)。 */
|
|
31
|
+
interface ConfigFormsService {
|
|
32
|
+
get?: (entryId: string) => unknown
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** DSH 0.1.7+ 单个 ConfigForm 最小面(适配 SettingsScopeLike 所需子集)。 */
|
|
36
|
+
interface ConfigFormLike {
|
|
37
|
+
getSnapshot: () => { status?: string; value?: unknown; writable?: boolean }
|
|
38
|
+
subscribe: (listener: () => void) => () => void
|
|
39
|
+
set: (field: string, value: unknown) => Promise<unknown>
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* 形状守卫:判断 configForms.get 返回值是否具备适配所需的最小面。
|
|
44
|
+
* @author ddj 2026年09月22号
|
|
45
|
+
* @param form configForms.get(entryId) 的返回值
|
|
46
|
+
* @returns 是否为可用 ConfigForm
|
|
47
|
+
*/
|
|
48
|
+
function isConfigForm(form: unknown): form is ConfigFormLike {
|
|
49
|
+
if (!form || typeof form !== 'object') return false
|
|
50
|
+
const candidate = form as Partial<ConfigFormLike>
|
|
51
|
+
return typeof candidate.getSnapshot === 'function'
|
|
52
|
+
&& typeof candidate.subscribe === 'function'
|
|
53
|
+
&& typeof candidate.set === 'function'
|
|
54
|
+
}
|
|
55
|
+
|
|
20
56
|
/**
|
|
21
|
-
*
|
|
57
|
+
* 将 DSH 0.1.7 ConfigForm 适配为既有 SettingsScopeLike(调用方零改动)。
|
|
58
|
+
* status/value/writable 直接透传('ready'|'loading'|'unavailable' 与既有 status 词汇一致);
|
|
59
|
+
* set 丢弃返回的 boolean(避免给未 catch 的既有调用方新增拒绝;form.set 自身 reject 仍传导)。
|
|
60
|
+
* @author ddj 2026年09月22号
|
|
61
|
+
* @param form configForms.get(PLUGIN_NAME) 返回的表单
|
|
62
|
+
* @returns 适配后的设置 scope
|
|
63
|
+
*/
|
|
64
|
+
function adaptConfigForm(form: ConfigFormLike): SettingsScopeLike {
|
|
65
|
+
return {
|
|
66
|
+
getSnapshot: () => {
|
|
67
|
+
const snap = form.getSnapshot()
|
|
68
|
+
return { status: snap.status, value: snap.value as SettingsValueLike | undefined, writable: snap.writable }
|
|
69
|
+
},
|
|
70
|
+
subscribe: (listener) => form.subscribe(listener),
|
|
71
|
+
set: async (field, value) => { await form.set(field, value) },
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* 探测 DSH 0.1.7+ configForms 官方新设置桥(entryId = PLUGIN_NAME)。
|
|
77
|
+
* 0.1.6 无该服务、ctx.get/表单 get 抛错或形状不符时返回 undefined,交由原两级桥降级。
|
|
78
|
+
* @author ddj 2026年09月22号
|
|
79
|
+
* @param ctx 客户端服务上下文
|
|
80
|
+
* @returns 适配后的设置 scope 或 undefined
|
|
81
|
+
*/
|
|
82
|
+
function bindConfigForm(ctx: { get: (name: string) => unknown }): SettingsScopeLike | undefined {
|
|
83
|
+
try {
|
|
84
|
+
const forms = ctx.get('configForms') as ConfigFormsService | undefined
|
|
85
|
+
const form = forms?.get?.(PLUGIN_NAME)
|
|
86
|
+
if (!isConfigForm(form)) return undefined
|
|
87
|
+
return adaptConfigForm(form)
|
|
88
|
+
} catch {
|
|
89
|
+
return undefined
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* 设置桥优先序:configForms(DSH 0.1.7+ 官方新桥)→ webUiSettings(linxin666 兼容桥,
|
|
95
|
+
* 旧版现网优先级不变)→ settingsScope(DSH 0.1.6 官方旧桥)→ 无。
|
|
22
96
|
* 桥绑定抛错时降级到下一级,不让单个桥异常拖垮设置装配。
|
|
23
|
-
* @author ddj 2026年08月24号
|
|
97
|
+
* @author ddj 2026年08月24号(2026年09月22号 增补 configForms 桥)
|
|
24
98
|
* @param ctx 客户端服务上下文
|
|
25
99
|
* @returns 激活的桥名称与绑定 scope
|
|
26
100
|
*/
|
|
27
101
|
export function pickSettingsBinder(ctx: { get: (name: string) => unknown }): { service: string; scope: SettingsScopeLike | undefined } {
|
|
102
|
+
const configForm = bindConfigForm(ctx)
|
|
103
|
+
if (configForm) return { service: 'configForms', scope: configForm }
|
|
28
104
|
for (const service of ['webUiSettings', 'settingsScope']) {
|
|
29
105
|
try {
|
|
30
106
|
const binder = ctx.get(service) as { bind?: (spec: { namespace: string }) => SettingsScopeLike } | undefined
|
|
@@ -37,6 +113,60 @@ export function pickSettingsBinder(ctx: { get: (name: string) => unknown }): { s
|
|
|
37
113
|
return { service: 'none', scope: undefined }
|
|
38
114
|
}
|
|
39
115
|
|
|
116
|
+
/** 设置桥形状(探测 + 晚到有界重试 + 就绪通知;调用方按需取用)。 */
|
|
117
|
+
export interface SettingsBridge {
|
|
118
|
+
scope: () => SettingsScopeLike | undefined
|
|
119
|
+
service: () => string
|
|
120
|
+
whenReady: (listener: () => void) => () => void
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* 设置桥装配(带晚到有界重试):设置桥服务一律不进 inject(0.1.6 settingsScope /
|
|
125
|
+
* 0.1.7 configForms 互斥,写死任一桥名都会在另一版本停等),因此提供方可能晚于本插件
|
|
126
|
+
* apply 就绪——构造时探测一次,未命中则按 schedule 节奏有界重试,命中即通知 whenReady
|
|
127
|
+
* 订阅者(重挂设置同步 effect);重试用尽保持未就绪(与旧行为一致,不无限轮询)。
|
|
128
|
+
* @author ddj 2026年09月22号
|
|
129
|
+
* @param ctx 客户端服务上下文
|
|
130
|
+
* @param options schedule 注入(缺省不重试=旧行为);attempts/intervalMs 重试节奏
|
|
131
|
+
* @returns 设置桥
|
|
132
|
+
*/
|
|
133
|
+
export function settingsBridge(
|
|
134
|
+
ctx: { get: (name: string) => unknown },
|
|
135
|
+
options?: { schedule?: (fn: () => void, ms: number) => void; attempts?: number; intervalMs?: number },
|
|
136
|
+
): SettingsBridge {
|
|
137
|
+
let hit = pickSettingsBinder(ctx)
|
|
138
|
+
let attemptsLeft = options?.attempts ?? 0
|
|
139
|
+
const interval = options?.intervalMs ?? 2000
|
|
140
|
+
const waiters: Array<() => void> = []
|
|
141
|
+
const notify = (): void => {
|
|
142
|
+
for (const listener of waiters.splice(0)) {
|
|
143
|
+
try { listener() } catch { /* 单个订阅者异常不拖垮其余 */ }
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
const retry = (): void => {
|
|
147
|
+
if (hit.scope || attemptsLeft <= 0 || !options?.schedule) return
|
|
148
|
+
attemptsLeft -= 1
|
|
149
|
+
options.schedule(() => {
|
|
150
|
+
hit = pickSettingsBinder(ctx)
|
|
151
|
+
if (hit.scope) { notify(); return }
|
|
152
|
+
retry()
|
|
153
|
+
}, interval)
|
|
154
|
+
}
|
|
155
|
+
retry()
|
|
156
|
+
return {
|
|
157
|
+
scope: () => hit.scope,
|
|
158
|
+
service: () => hit.service,
|
|
159
|
+
whenReady: (listener) => {
|
|
160
|
+
if (hit.scope) { listener(); return () => {} }
|
|
161
|
+
waiters.push(listener)
|
|
162
|
+
return () => {
|
|
163
|
+
const at = waiters.indexOf(listener)
|
|
164
|
+
if (at >= 0) waiters.splice(at, 1)
|
|
165
|
+
}
|
|
166
|
+
},
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
40
170
|
/** slot 注册描述(对齐 ctx.slots.register 的形状)。 */
|
|
41
171
|
export interface SlotSpec {
|
|
42
172
|
name: string
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* dsh-vscode-mode client —
|
|
3
|
-
* 纯函数、无 DOM 依赖,便于单测;MIME 表收敛在 shared/rpc.ts(host base64
|
|
2
|
+
* dsh-vscode-mode client — 图片文件判定、data URL 组装与缩放纯逻辑(编辑区图片预览面板用)。
|
|
3
|
+
* 纯函数、无 DOM 依赖,便于单测;MIME 表收敛在 shared/rpc.ts(host base64 响应同源);
|
|
4
|
+
* 缩放:步进/clamp 为纯函数,页签记忆为模块级 Map(会话+路径键,EditorView 卸载清空、不持久化)。
|
|
4
5
|
* @author ddj 2026年09月08号
|
|
5
6
|
*/
|
|
6
7
|
import { IMAGE_MIME } from '../shared/rpc.js'
|
|
@@ -43,3 +44,76 @@ export function isSvgPath(path: string): boolean {
|
|
|
43
44
|
export function dataUrlOf(base64: string, mime: string): string {
|
|
44
45
|
return 'data:' + (mime || 'application/octet-stream') + ';base64,' + base64
|
|
45
46
|
}
|
|
47
|
+
|
|
48
|
+
/** 图片缩放下限(25%,低于此档不再缩小)。 */
|
|
49
|
+
export const ZOOM_MIN = 0.25
|
|
50
|
+
|
|
51
|
+
/** 图片缩放上限(400%,高于此档不再放大)。 */
|
|
52
|
+
export const ZOOM_MAX = 4
|
|
53
|
+
|
|
54
|
+
/** 图片缩放步进(10%/档,−/+ 按钮每点一次的变化量)。 */
|
|
55
|
+
export const ZOOM_STEP = 0.1
|
|
56
|
+
|
|
57
|
+
/** 页签缩放记忆表:键 = zoomKeyOf(会话, 路径),值 = 显式缩放或 null(适应宽度)。 */
|
|
58
|
+
const zoomMem: Map<string, number | null> = new Map()
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* 图片缩放收敛:clamp 到 25%–400% 并按整百分比取整(消除浮点步进噪声)。
|
|
62
|
+
* @author ddj 2026年09月22号
|
|
63
|
+
* @param zoom 目标缩放(非有限数值按 100% 处理)
|
|
64
|
+
* @returns 收敛后的缩放比例
|
|
65
|
+
*/
|
|
66
|
+
export function clampZoom(zoom: number): number {
|
|
67
|
+
const value = Number.isFinite(zoom) ? zoom : 1
|
|
68
|
+
return Math.round(Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, value)) * 100) / 100
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* 图片缩放步进:按 10%/档变化并 clamp(dir 为负即缩小,其余按放大处理)。
|
|
73
|
+
* @author ddj 2026年09月22号
|
|
74
|
+
* @param base 起始缩放(适应宽度态由调用方实测当前渲染缩放传入)
|
|
75
|
+
* @param dir 方向:< 0 缩小,≥ 0 放大
|
|
76
|
+
* @returns 步进后的缩放比例
|
|
77
|
+
*/
|
|
78
|
+
export function zoomStepOf(base: number, dir: number): number {
|
|
79
|
+
return clampZoom(base + (dir < 0 ? -ZOOM_STEP : ZOOM_STEP))
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* 页签缩放记忆键(会话 + 路径;NUL 分隔,防止两段拼接撞键)。
|
|
84
|
+
* @author ddj 2026年09月22号
|
|
85
|
+
* @param sessionId 会话 id
|
|
86
|
+
* @param path 图片路径
|
|
87
|
+
* @returns 记忆键
|
|
88
|
+
*/
|
|
89
|
+
export function zoomKeyOf(sessionId: string | null | undefined, path: string | null | undefined): string {
|
|
90
|
+
return String(sessionId ?? '') + '\u0000' + String(path ?? '')
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* 写入页签缩放记忆(null = 适应宽度;会话级生命周期,不进 localStorage)。
|
|
95
|
+
* @author ddj 2026年09月22号
|
|
96
|
+
* @param key zoomKeyOf 产出的记忆键
|
|
97
|
+
* @param zoom 记忆缩放(null = 适应宽度)
|
|
98
|
+
*/
|
|
99
|
+
export function rememberZoom(key: string, zoom: number | null): void {
|
|
100
|
+
zoomMem.set(key, zoom)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* 读取页签缩放记忆(无记忆返回 null,调用方按初始适应宽度处理)。
|
|
105
|
+
* @author ddj 2026年09月22号
|
|
106
|
+
* @param key zoomKeyOf 产出的记忆键
|
|
107
|
+
* @returns 记忆缩放或 null
|
|
108
|
+
*/
|
|
109
|
+
export function recallZoom(key: string): number | null {
|
|
110
|
+
return zoomMem.get(key) ?? null
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* 清空全部页签缩放记忆(EditorView 卸载时调用)。
|
|
115
|
+
* @author ddj 2026年09月22号
|
|
116
|
+
*/
|
|
117
|
+
export function clearZoomMem(): void {
|
|
118
|
+
zoomMem.clear()
|
|
119
|
+
}
|
package/src/client/index.ts
CHANGED
|
@@ -29,9 +29,10 @@ import { installOpenPathRouter, vscodeOpener, autoValue } from './openPathRouter
|
|
|
29
29
|
import { patchRemoteOpen, probeRemoteOpen } from './remoteOpenRouter.js'
|
|
30
30
|
import { setupExtOpen } from './externalOpen.js'
|
|
31
31
|
import { SettingsContext } from './settingsContext.js'
|
|
32
|
-
import { SIDEBAR_PLUGIN,
|
|
32
|
+
import { SIDEBAR_PLUGIN, registerSlotSafely, settingsBridge } from './compat.js'
|
|
33
33
|
import { detectSidebarService, installSideEditor, setEnsureSideEditor, SIDEBAR_INSTALL_CMD } from './sidebarBridge.js'
|
|
34
|
-
import { detectOfficial, installOfficial, registerOfficialFileClaim, OFFICIAL_TAB_KIND, OFFICIAL_TAB_TITLE, isEditorTabActive, restoreEditorTab } from './officialSidebar.js'
|
|
34
|
+
import { detectOfficial, installOfficial, registerOfficialFileClaim, OFFICIAL_TAB_KIND, OFFICIAL_TAB_TITLE, isEditorTabActive, restoreEditorTab, buildFileAddress } from './officialSidebar.js'
|
|
35
|
+
import { setNativeCsv, setNativeOpenHandler, resetNativeOpen } from './nativeOpenStore.js'
|
|
35
36
|
import { SideEditorTab } from './ui/SideEditorTab.js'
|
|
36
37
|
import { OfficialSideTab } from './ui/OfficialSideTab.js'
|
|
37
38
|
import { createClaimRouter } from './ui/ClaimRouter.js'
|
|
@@ -64,7 +65,9 @@ import type { CompatAdapter } from '../shared/compat.js'
|
|
|
64
65
|
// ⚠️ inject 只列必需服务:webUiSettings 是 @linxin666/dsh-client-ui-web-ui-settings 提供的
|
|
65
66
|
// 可选兼容桥(非官方服务),列入 inject 会让未装该桥的部署启动时 entry 永久 pending,
|
|
66
67
|
// web boot 直接失败('1 entry did not activate')。兼容层用 ctx.get 运行时探测 + 降级,不靠 inject。
|
|
67
|
-
|
|
68
|
+
// 设置桥服务同理不进 inject(DSH 0.1.7 移除 settingsScope、新增 configForms,0.1.6 反之;
|
|
69
|
+
// 写死任一桥名都会在另一版本停等)——设置桥由 compat.pickSettingsBinder 运行时探测三级降级。
|
|
70
|
+
export const inject = ['slots', 'timer', 'locale', 'connection', 'remote', 'workspaces', 'sessions', 'conversation']
|
|
68
71
|
|
|
69
72
|
/** 指令桥装配幂等标记(同一 document 重复 apply 只装配一次,避免重复键位监听)。 */
|
|
70
73
|
let commandsMounted = false
|
|
@@ -155,8 +158,9 @@ export function apply(ctx: any): void {
|
|
|
155
158
|
return subscribeScope(ctx, () => schedule(check, 0))
|
|
156
159
|
}, 'vscode-mode: editor tab restore')
|
|
157
160
|
const originalOpenPath = workspaces?.openPath
|
|
158
|
-
|
|
159
|
-
|
|
161
|
+
// 设置桥:服务不进 inject(0.1.6 settingsScope / 0.1.7 configForms 桥名互斥,写死任一
|
|
162
|
+
// 都会在另一版本停等)→ compat.settingsBridge 探测 + 晚到 15×2s 有界重试,命中后重挂同步
|
|
163
|
+
const bridge = settingsBridge(ctx, { schedule, attempts: 15, intervalMs: 2000 })
|
|
160
164
|
let selected = autoValue('auto')
|
|
161
165
|
/** 0.1.3+ 会话文件链接路由(remote.session.openWorkspacePath)是否已安装(compatSummary 展示用)。 */
|
|
162
166
|
let remoteOpenInstalled = false
|
|
@@ -175,7 +179,7 @@ export function apply(ctx: any): void {
|
|
|
175
179
|
|
|
176
180
|
/** 客户端侧兼容摘要(与 host 报告合并展示;惰性求值保证 HMR 后仍新鲜)。 */
|
|
177
181
|
const compatSummary = (): CompatAdapter[] => [
|
|
178
|
-
{ name: '设置桥', active:
|
|
182
|
+
{ name: '设置桥', active: bridge.scope() !== undefined, note: bridge.scope() !== undefined ? '使用 ' + bridge.service() + ' 桥' : '未绑定设置服务(fileOpenTool 持久化不可用,晚到重试窗口内自愈)' },
|
|
179
183
|
{ name: '文件打开路由(workspaces.openPath)', active: Boolean(workspaces?.openPath), note: 'vscode 打开器优先,失败回退系统打开(0.1.2 及更早 DSH 的对话文件链接主路径)' },
|
|
180
184
|
{ name: '会话文件链接路由(remote.session.openWorkspacePath)', active: remoteOpenInstalled, note: '0.1.3+ 对话文件引用/产物打开优先走插件打开器,失败回退系统打开' },
|
|
181
185
|
{ name: '侧边栏打开器(' + SIDEBAR_PLUGIN + ')', active: registry.get(SIDEBAR_PLUGIN) !== undefined, note: registry.get(SIDEBAR_PLUGIN) !== undefined ? '已注册(优先级 80)' : '未检测到侧边栏打开能力' },
|
|
@@ -241,35 +245,47 @@ export function apply(ctx: any): void {
|
|
|
241
245
|
claimDisposer = null
|
|
242
246
|
}
|
|
243
247
|
}
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
248
|
+
// 设置同步(fileOpenTool / 快捷键 / 侧宽 / 页签上限):桥可能晚于本插件 apply 就绪
|
|
249
|
+
// (设置桥服务不进 inject),首次命中即挂两条 effect;晚到经 bridge.whenReady 重挂一次
|
|
250
|
+
// (settingsBridge 15×2s 有界重试内命中才会触发,用尽后保持旧行为不轮询)。
|
|
251
|
+
let settingsSyncMounted = false
|
|
252
|
+
const mountSettingsSyncs = (): void => {
|
|
253
|
+
if (settingsSyncMounted) return
|
|
254
|
+
const scope = bridge.scope()
|
|
255
|
+
if (!scope) return
|
|
256
|
+
settingsSyncMounted = true
|
|
257
|
+
ctx.effect(() => {
|
|
258
|
+
const sync = (): void => {
|
|
259
|
+
const snapshot = scope.getSnapshot()
|
|
260
|
+
if (snapshot.status === 'loading') return
|
|
261
|
+
selected = autoValue(snapshot.value?.fileOpenTool)
|
|
262
|
+
syncFileClaim()
|
|
263
|
+
window.dispatchEvent(new CustomEvent('edrv:file-open-tool-change', { detail: { value: selected } }))
|
|
264
|
+
}
|
|
265
|
+
sync()
|
|
266
|
+
return scope.subscribe(sync)
|
|
267
|
+
}, 'vscode-mode: file opener setting sync')
|
|
268
|
+
// 快捷键配置同步:设置提交后立即刷新键位匹配(编辑器/QuickOpen 按事件时读取)
|
|
269
|
+
// 同一订阅里顺带同步侧边栏最小宽度(sidebarMinWidth)→ 派发 edrv:sidebar-min-width 通知编辑器重夹
|
|
270
|
+
// 以及页签上限(maxOpenEditors)→ 派发 edrv:max-open-editors 通知编辑器复算淘汰
|
|
271
|
+
ctx.effect(() => {
|
|
272
|
+
const sync = (): void => {
|
|
273
|
+
const snapshot = scope.getSnapshot()
|
|
274
|
+
if (snapshot.status === 'loading') return
|
|
275
|
+
keybindingsApply(snapshot.value?.keybindings)
|
|
276
|
+
const minW = sidebarMinApply(snapshot.value?.sidebarMinWidth)
|
|
277
|
+
window.dispatchEvent(new CustomEvent('edrv:sidebar-min-width', { detail: { value: minW } }))
|
|
278
|
+
const limit = editorLimitApply(snapshot.value?.maxOpenEditors)
|
|
279
|
+
window.dispatchEvent(new CustomEvent('edrv:max-open-editors', { detail: { value: limit } }))
|
|
280
|
+
// 原生打开范围同拍推送(nativeOpenStore 解析缓存;旧版无该字段 → 保持默认集)
|
|
281
|
+
setNativeCsv(snapshot.value?.nativeOpenExts)
|
|
282
|
+
}
|
|
283
|
+
sync()
|
|
284
|
+
return scope.subscribe(sync)
|
|
285
|
+
}, 'vscode-mode: keybindings setting sync')
|
|
286
|
+
}
|
|
287
|
+
mountSettingsSyncs()
|
|
288
|
+
if (!settingsSyncMounted) ctx.effect(() => bridge.whenReady(mountSettingsSyncs), 'vscode-mode: settings bridge wait')
|
|
273
289
|
if (workspaces?.openPath) {
|
|
274
290
|
ctx.effect(() => installOpenPathRouter({
|
|
275
291
|
workspaces,
|
|
@@ -371,6 +387,17 @@ export function apply(ctx: any): void {
|
|
|
371
387
|
// 官方打开器(下拉「官方侧边栏」选项,动态出现;openResource 能力缺失时跳过)+ 按当前设置同步 file 地址认领
|
|
372
388
|
if (typeof official.service.openResource === 'function') {
|
|
373
389
|
ctx.effect(() => registry.register(officialSidebarOpener(official.service)), 'vscode-mode: official sidebar opener')
|
|
390
|
+
// 原生打开 handler:文件树 openFile 命中 nativeOpenExts 时经 openResource 落官方渲染器
|
|
391
|
+
//(claim 的 canOpen 同步让位该范围,否则地址又被认领转发回本插件);形态撤下时解绑
|
|
392
|
+
ctx.effect(() => {
|
|
393
|
+
setNativeOpenHandler((path) => {
|
|
394
|
+
const scope = readSessionScope(ctx)
|
|
395
|
+
if (!scope.sessionId) return false
|
|
396
|
+
official.service.openResource?.(buildFileAddress(path, scope.sessionId))
|
|
397
|
+
return typeof official.service.openResource === 'function'
|
|
398
|
+
})
|
|
399
|
+
return () => setNativeOpenHandler(null)
|
|
400
|
+
}, 'vscode-mode: native open handler')
|
|
374
401
|
}
|
|
375
402
|
syncFileClaim()
|
|
376
403
|
}
|
|
@@ -449,7 +476,7 @@ export function apply(ctx: any): void {
|
|
|
449
476
|
id: 'vscode-mode',
|
|
450
477
|
order: 30,
|
|
451
478
|
label: 'VSCodeMode',
|
|
452
|
-
}, () => React.createElement(SettingsContext.Provider, { value:
|
|
479
|
+
}, () => React.createElement(SettingsContext.Provider, { value: bridge.scope() }, React.createElement(McpSettings, { openerRegistry: registry, compatSummary })))
|
|
453
480
|
|
|
454
481
|
// 卸载收尾(G4,DSH 0.1.6-alpha.2 起支持运行时卸载/重载):
|
|
455
482
|
// 注销挂在存活的 window.monaco 上的全部 Monaco provider 并复位模块状态。
|
|
@@ -459,5 +486,7 @@ export function apply(ctx: any): void {
|
|
|
459
486
|
try { disposeLaunchJson() } catch { /* 同上 */ }
|
|
460
487
|
try { disposeAiInline() } catch { /* 同上 */ }
|
|
461
488
|
try { disposeLsp() } catch { /* 同上 */ }
|
|
489
|
+
// 原生打开范围复位回默认集(handler 解绑已由其自身 ctx.effect disposer 承担)
|
|
490
|
+
try { resetNativeOpen() } catch { /* 同上 */ }
|
|
462
491
|
}, 'vscode-mode: monaco providers teardown')
|
|
463
492
|
}
|