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.
Files changed (60) hide show
  1. package/README.md +37 -6
  2. package/assets/icon.svg +7 -0
  3. package/cordis.patch.yml +16 -0
  4. package/lib/client.js +2804 -300
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +1975 -191
  7. package/lib/index.js.map +1 -1
  8. package/locale/en.json +4 -0
  9. package/locale/zh.json +4 -0
  10. package/package.json +4 -2
  11. package/src/ai/inline.ts +19 -2
  12. package/src/ai/svnTriage.ts +150 -0
  13. package/src/capture.ts +225 -51
  14. package/src/client/addToConversation.ts +203 -1
  15. package/src/client/compat.ts +134 -4
  16. package/src/client/imagePreview.ts +76 -2
  17. package/src/client/index.ts +65 -36
  18. package/src/client/monaco/lsp/lspClient.ts +46 -0
  19. package/src/client/monaco/lsp/providers.ts +245 -1
  20. package/src/client/nativeOpenStore.ts +91 -0
  21. package/src/client/officialSidebar.ts +11 -45
  22. package/src/client/outline/index.ts +1 -1
  23. package/src/client/pdf/pdfPanel.ts +18 -0
  24. package/src/client/pendingNav.ts +141 -0
  25. package/src/client/settingsContext.ts +6 -4
  26. package/src/client/sidebar/panels/FileExplorer.ts +2 -15
  27. package/src/client/sidebar/panels/SvnPanel.ts +682 -72
  28. package/src/client/sidebar/panels/index.ts +1 -1
  29. package/src/client/sidebar/panels/svnListBudget.ts +58 -0
  30. package/src/client/state/records.ts +7 -1
  31. package/src/client/styles/editor.css +47 -5
  32. package/src/client/svnStatus.ts +206 -1
  33. package/src/client/svnStore.ts +6 -1
  34. package/src/client/ui/AiSettings.ts +47 -14
  35. package/src/client/ui/DiffBox.ts +1 -1
  36. package/src/client/ui/DiffLauncher.ts +6 -4
  37. package/src/client/ui/EditorView.ts +149 -22
  38. package/src/client/ui/McpSettings.ts +30 -0
  39. package/src/client/ui/PerfSettings.ts +4 -1
  40. package/src/client/ui/SvnAiPlanDialog.ts +293 -0
  41. package/src/client/ui/icons.ts +91 -0
  42. package/src/compat.ts +26 -5
  43. package/src/dshVersion.ts +2 -1
  44. package/src/fileOpenSettings.ts +274 -49
  45. package/src/index.ts +18 -1
  46. package/src/lsp/client.ts +24 -0
  47. package/src/lsp/rpc.ts +57 -0
  48. package/src/lsp/server.ts +272 -2
  49. package/src/model.ts +2 -1
  50. package/src/perf.ts +90 -4
  51. package/src/remoteWorkspace.ts +177 -0
  52. package/src/routes.ts +13 -1
  53. package/src/rpc.ts +115 -39
  54. package/src/shared/ai.ts +12 -0
  55. package/src/shared/lsp.ts +79 -0
  56. package/src/shared/nativeOpen.ts +91 -0
  57. package/src/shared/rpc.ts +162 -2
  58. package/src/shared/svn.ts +296 -10
  59. package/src/shared/types.ts +5 -1
  60. 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
- return { appendReference }
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
  }
@@ -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
- /** 设置 scope 的跨组件上下文形状(与官方 settingsScope 绑定的最小面)。 */
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?: { fileOpenTool?: unknown; keybindings?: Record<string, string>; sidebarMinWidth?: unknown; maxOpenEditors?: unknown }; writable?: boolean }
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
- * 设置桥三级优先:webUiSettings(linxin666 兼容桥)→ settingsScope(官方)→ 无。
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 — 图片文件判定与 data URL 组装(编辑区图片预览面板用)。
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
+ }
@@ -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, pickSettingsBinder, registerSlotSafely } from './compat.js'
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
- export const inject = ['slots', 'timer', 'locale', 'connection', 'remote', 'workspaces', 'sessions', 'conversation', 'settingsScope']
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
- const binder = pickSettingsBinder(ctx)
159
- const settings = binder.scope
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: settings !== undefined, note: settings !== undefined ? '使用 ' + binder.service + ' 桥' : '未绑定设置服务(fileOpenTool 持久化不可用)' },
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
- ctx.effect(() => {
245
- if (!settings) return undefined
246
- const sync = (): void => {
247
- const snapshot = settings.getSnapshot()
248
- if (snapshot.status === 'loading') return
249
- selected = autoValue(snapshot.value?.fileOpenTool)
250
- syncFileClaim()
251
- window.dispatchEvent(new CustomEvent('edrv:file-open-tool-change', { detail: { value: selected } }))
252
- }
253
- sync()
254
- return settings.subscribe(sync)
255
- }, 'vscode-mode: file opener setting sync')
256
- // 快捷键配置同步:设置提交后立即刷新键位匹配(编辑器/QuickOpen 按事件时读取)
257
- // 同一订阅里顺带同步侧边栏最小宽度(sidebarMinWidth)→ 派发 edrv:sidebar-min-width 通知编辑器重夹
258
- // 以及页签上限(maxOpenEditors)→ 派发 edrv:max-open-editors 通知编辑器复算淘汰
259
- ctx.effect(() => {
260
- if (!settings) return undefined
261
- const sync = (): void => {
262
- const snapshot = settings.getSnapshot()
263
- if (snapshot.status === 'loading') return
264
- keybindingsApply(snapshot.value?.keybindings)
265
- const minW = sidebarMinApply(snapshot.value?.sidebarMinWidth)
266
- window.dispatchEvent(new CustomEvent('edrv:sidebar-min-width', { detail: { value: minW } }))
267
- const limit = editorLimitApply(snapshot.value?.maxOpenEditors)
268
- window.dispatchEvent(new CustomEvent('edrv:max-open-editors', { detail: { value: limit } }))
269
- }
270
- sync()
271
- return settings.subscribe(sync)
272
- }, 'vscode-mode: keybindings setting sync')
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: settings }, React.createElement(McpSettings, { openerRegistry: registry, compatSummary })))
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
  }