dsh-vscode-mode 0.16.0 → 0.18.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 (45) hide show
  1. package/README.md +15 -9
  2. package/lib/client.js +954 -133
  3. package/lib/client.js.map +1 -1
  4. package/lib/index.js +5026 -4387
  5. package/lib/index.js.map +1 -1
  6. package/locale/en.json +4 -2
  7. package/locale/zh.json +4 -2
  8. package/package.json +4 -2
  9. package/skills/dsh-vscodemode-mcp/SKILL.md +24 -7
  10. package/src/capture.ts +2 -2
  11. package/src/client/compat.ts +2 -1
  12. package/src/client/index.ts +37 -7
  13. package/src/client/localeClient.ts +97 -0
  14. package/src/client/mcpSessionState.ts +108 -0
  15. package/src/client/monaco/lsp/providers.ts +32 -3
  16. package/src/client/officialSidebar.ts +4 -1
  17. package/src/client/state/modelWarmer.ts +198 -0
  18. package/src/client/state/recordsFeed.ts +241 -0
  19. package/src/client/styles/editor.css +5 -1
  20. package/src/client/ui/ConversationDiffDock.ts +10 -19
  21. package/src/client/ui/DiffBadge.ts +12 -19
  22. package/src/client/ui/EditorPane.ts +16 -4
  23. package/src/client/ui/EditorView.ts +114 -51
  24. package/src/client/ui/McpSessionStatus.ts +75 -0
  25. package/src/client/ui/McpSettings.ts +47 -13
  26. package/src/client/ui/PerfSizeBadge.ts +7 -4
  27. package/src/client/ui/PluginDetailSection.ts +82 -0
  28. package/src/dshVersion.ts +4 -2
  29. package/src/fileVersions.ts +2 -2
  30. package/src/lsp/rpc.ts +2 -2
  31. package/src/mcp.ts +1 -0
  32. package/src/mcpBridge.ts +163 -0
  33. package/src/mcpDiscover.ts +38 -0
  34. package/src/mcpPool.ts +245 -0
  35. package/src/mcpProject.ts +15 -12
  36. package/src/mcpRuntime.ts +301 -255
  37. package/src/registry.ts +15 -2
  38. package/src/rpc.ts +15 -3
  39. package/src/rules.ts +9 -4
  40. package/src/search/fallback.ts +2 -2
  41. package/src/search/ripgrep.ts +2 -2
  42. package/src/shared/mcp.ts +47 -0
  43. package/src/shared/rpc.ts +5 -1
  44. package/src/store.ts +26 -1
  45. package/src/svn.ts +2 -2
@@ -19,6 +19,7 @@ import { useOpenReceipt } from '../useOpenReceipt.js'
19
19
  import { acceptsLoad, receiptErrorFor, receiptPath, receiptReadyFor, receiptTabPath } from '../receiptState.js'
20
20
  import { bindPanelCommand } from '../panelCommands.js'
21
21
  import { dbg, isDebug, rpc } from '../rpc.js'
22
+ import { refreshFeed, subscribeErrors, subscribeRecords } from '../state/recordsFeed.js'
22
23
  import { emitFileChanged, emitRefresh } from '../events.js'
23
24
  import { langOf, loadMonaco, snippetLanguageOf } from '../monaco/loader.js'
24
25
  import { bindEditActions, runEditAction } from '../monaco/editActions.js'
@@ -67,6 +68,7 @@ import { mdPreviewLoad, mdPreviewSave } from '../state/mdPreviewCache.js'
67
68
  import { onUiStateReady, reportUiStateEvent, uiStateSettled } from '../state/uiStatePersist.js'
68
69
  import { migrateScopedKeys, workspaceScopeOf } from '../state/scopeStore.js'
69
70
  import { modelsForScope, rememberModel, retainModels } from '../state/modelCache.js'
71
+ import { clearModelWarmer, setModelWarmer } from '../state/modelWarmer.js'
70
72
  import { navFlashRangeOf } from '../navHighlight.js'
71
73
  import { chordOf, useKeybindingsVersion } from '../keybindings.js'
72
74
  import { getSidebarMinWidth } from '../sidebarMin.js'
@@ -259,6 +261,8 @@ export function EditorView(props) {
259
261
  const planActiveRef = React.useRef(null)
260
262
  planActiveRef.current = planActive
261
263
  const planHostRef = React.useRef(null)
264
+ // 进入只读计划预览前的磁盘选中页签,关闭该计划页签时恢复,避免跳到第一个页签。
265
+ const planReturnRef = React.useRef(null)
262
266
  React.useEffect(() => { setPlanTabs([]); setPlanActive(null) }, [sessionId, cwd])
263
267
  /** @private @author ddj 2026年10月08号 @param path Disk tab selection. Leave read-only plan mode before any disk navigation. */
264
268
  function setActive(path) {
@@ -270,6 +274,10 @@ export function EditorView(props) {
270
274
  const [layoutScope, setLayoutScope] = React.useState(null)
271
275
  const benchRef = React.useRef(workbench)
272
276
  benchRef.current = workbench
277
+ // 页签行归属的分组:焦点落在浮窗时行仍留在页面主组上(浮窗自带标题栏),与 renderGroup 同一判据。
278
+ // 行节点会随焦点在分组间迁移,绑定滚轮/滚入视野的 effect 必须跟着这个归属重跑。
279
+ const rowOnFloat = workbench.floats.some((item) => item.group === workbench.focused)
280
+ const rowOwner = rowOnFloat ? MAIN_GROUP : workbench.focused
273
281
  const [dragPreview, setDragPreview] = React.useState(null)
274
282
  const dragRef = React.useRef(null)
275
283
  const benchOps = React.useRef(null)
@@ -870,38 +878,43 @@ export function EditorView(props) {
870
878
  return true
871
879
  }
872
880
 
873
- const refreshRecords = (skipStale) => {
874
- if (!sessionId) return
881
+ /**
882
+ * 把共享订阅拿到的记录落进组件状态(内容一致时保持引用不变,避免整树重渲染)。
883
+ * @private @author ddj 2026年10月19号
884
+ * @param list 差异记录数组
885
+ */
886
+ const applyRecords = (list) => {
875
887
  // #region debug log
876
888
  const trace = isDebug()
877
- const start = trace ? performance.now() : 0
889
+ const compareStart = trace ? performance.now() : 0
878
890
  // #endregion
879
- rpc('edrv.list', { sessionId, ...(skipStale ? { skipStale: true } : {}) }).then((res) => {
891
+ const map = {}
892
+ for (const r of list) {
893
+ if (r.archived === true) continue
894
+ map[r.callId] = r
895
+ }
896
+ setRecords((prev) => {
897
+ const equal = sameRecords(prev, map)
880
898
  // #region debug log
881
- const received = trace ? performance.now() : 0
882
- // #endregion
883
- if (!res || !res.ok || !Array.isArray(res.records)) return
884
- const map = {}
885
- for (const r of res.records) {
886
- if (r.archived === true) continue
887
- map[r.callId] = r
899
+ if (trace) {
900
+ try {
901
+ const compareMs = performance.now() - compareStart
902
+ if (compareMs > 20) dbg(sessionId, `[DEBUG diffList] count=${list.length} compareMs=${compareMs.toFixed(1)} changed=${!equal}`)
903
+ } catch (e) { /* 诊断不可影响轮询 */ }
888
904
  }
889
- setRecords((prev) => {
890
- // #region debug log
891
- const compareStart = trace ? performance.now() : 0
892
- // #endregion
893
- const equal = sameRecords(prev, map)
894
- // #region debug log
895
- if (trace) {
896
- try {
897
- const compareMs = performance.now() - compareStart
898
- if (compareMs > 20 || received - start > 100) dbg(sessionId, `[DEBUG diffList] count=${res.records.length} skipStale=${skipStale === true} rpcMs=${(received - start).toFixed(1)} compareMs=${compareMs.toFixed(1)} changed=${!equal}`)
899
- } catch (e) { /* 诊断不可影响轮询 */ }
900
- }
901
- // #endregion
902
- return equal ? prev : map
903
- })
904
- }).catch((e) => setError('list异常:' + String(e)))
905
+ // #endregion
906
+ return equal ? prev : map
907
+ })
908
+ }
909
+
910
+ /**
911
+ * 主动刷新一次差异记录(走会话级共享通道,同会话多消费方共享一次 RPC)。
912
+ * @private @author ddj 2026年10月19号
913
+ * @param skipStale 是否跳过过期判定
914
+ */
915
+ const refreshRecords = (skipStale) => {
916
+ if (!sessionId) return
917
+ void refreshFeed(sessionId, skipStale === true)
905
918
  }
906
919
 
907
920
  /**
@@ -1185,14 +1198,13 @@ export function EditorView(props) {
1185
1198
  })
1186
1199
  }
1187
1200
 
1188
- // 挂载轮询 + 事件订阅
1201
+ // 挂载:接入会话级共享订阅(单定时器由 recordsFeed 持有,本组件不再自建 interval)
1189
1202
  React.useEffect(() => {
1190
- if (!sessionId) return
1203
+ if (!sessionId) return undefined
1204
+ const offRecords = subscribeRecords(sessionId, applyRecords)
1205
+ const offErrors = subscribeErrors(sessionId, (message) => setError('list异常:' + message))
1191
1206
  refreshRecords()
1192
- const t = setInterval(refreshRecords, 5000)
1193
- const onRefresh = () => refreshRecords()
1194
- window.addEventListener('edrv:refresh', onRefresh)
1195
- return () => { clearInterval(t); window.removeEventListener('edrv:refresh', onRefresh) }
1207
+ return () => { offRecords(); offErrors() }
1196
1208
  }, [sessionId])
1197
1209
 
1198
1210
  /**
@@ -1848,11 +1860,11 @@ export function EditorView(props) {
1848
1860
  const right = left + el.offsetWidth
1849
1861
  if (left < host.scrollLeft) host.scrollLeft = left
1850
1862
  else if (right > host.scrollLeft + host.clientWidth) host.scrollLeft = right - host.clientWidth
1851
- }, [active, tabs.length])
1863
+ }, [active, planActive, planTabs.length, tabs.length, rowOwner])
1852
1864
 
1853
1865
  // 页签栏滚轮横向滚动(需求 3):页签栏溢出时,鼠标滚轮(含触控板纵向手势)横滚页签栏,
1854
1866
  // 不溢出时不接管(保持页面垂直滚动);Shift+滚轮交由浏览器原生横滚。详见 horizontalWheel.ts。
1855
- React.useEffect(() => attachHorizontalWheel(tabsHostRef.current), [])
1867
+ React.useEffect(() => attachHorizontalWheel(tabsHostRef.current), [rowOwner])
1856
1868
 
1857
1869
  // 鼠标侧键后退/前进:Logitech 官方默认「后退/前进」= XButton 鼠标事件(button 3/4),不产生键盘事件。
1858
1870
  // pointerdown 触发导航(preventDefault 后兼容 mousedown 可能不再触发,避免双触发);
@@ -2060,6 +2072,42 @@ export function EditorView(props) {
2060
2072
  return model
2061
2073
  }
2062
2074
 
2075
+ const warmRef = React.useRef(null)
2076
+ /**
2077
+ * 让目标文件的 Monaco model 存在(预热入口,供 Ctrl+hover 定义链接预览)。
2078
+ *
2079
+ * 为什么需要:Monaco 在渲染定义链接的源码预览时对目标 uri 调 `createModelReference`
2080
+ * 后只挂 then、没有 catch;本插件的定义目标是自造 `edrv://` uri,目标文件若从未打开过
2081
+ * 就没有已注册 model,会 reject("Model not found") 成为未捕获异常(见 state/modelWarmer.ts)。
2082
+ * 已缓存的路径直接返回成功:模型已在 Monaco 注册表里,无需再读盘。
2083
+ * 预热出的 model 走 modelsRef 常规 LRU(40 条、未附着超限被 dispose);正式打开该文件时
2084
+ * 由 loadContent 的 cached-model 分支 + checkCached 后台校验接管,不会显示陈旧内容。
2085
+ * @private
2086
+ * @author ddj 2026年10月20号
2087
+ * @param path 工作区相对路径
2088
+ * @returns 模型是否已存在/成功建出(失败一律 false,绝不抛)
2089
+ */
2090
+ warmRef.current = (path) => {
2091
+ if (!path) return Promise.resolve(false)
2092
+ if (modelsRef.current.get(path)) return Promise.resolve(true)
2093
+ // 图片/PDF 不建文本 model(各自走预览控件),预热对它们没有意义
2094
+ if (isImagePath(path) || isPdfPath(path)) return Promise.resolve(false)
2095
+ const sid = sessionId
2096
+ if (!sid) return Promise.resolve(false)
2097
+
2098
+ return rpc('edrv.read', { sessionId: sid, path }).then((res) => {
2099
+ if (!res?.ok || typeof res.content !== 'string') return false
2100
+ getModel(path, res.content)
2101
+ return true
2102
+ }).catch(() => false)
2103
+ }
2104
+ // 预热实现随挂载安装、卸载移除:编辑器未挂载时 warmModelFor 直接返回 false(退化为修复前行为)
2105
+ React.useEffect(() => {
2106
+ const warmImpl = (path) => warmRef.current?.(path) ?? Promise.resolve(false)
2107
+ setModelWarmer(warmImpl)
2108
+ return () => clearModelWarmer(warmImpl)
2109
+ }, [])
2110
+
2063
2111
  /**
2064
2112
  * 提交待执行的防抖保存(**真正执行保存**,不是取消)。
2065
2113
  * 语义与缺陷背景见 saveDebounce.ts:`schedule` 的返回值是只 clearTimeout 的 disposer,
@@ -3590,6 +3638,7 @@ export function EditorView(props) {
3590
3638
  if (!document) return
3591
3639
  flushSave()
3592
3640
  saveViewState(active)
3641
+ if (!planActiveRef.current) planReturnRef.current = active
3593
3642
  setDiskActive(null)
3594
3643
  setPlanTabs((prev) => [...prev.filter((item) => item.address !== document.address), document].slice(-32))
3595
3644
  setPlanActive(document.address)
@@ -3600,7 +3649,10 @@ export function EditorView(props) {
3600
3649
  function closePlan(address, event) {
3601
3650
  event?.stopPropagation()
3602
3651
  setPlanTabs((prev) => prev.filter((item) => item.address !== address))
3603
- if (planActiveRef.current === address) setActive(tabsRef.current[0]?.path ?? null)
3652
+ if (planActiveRef.current !== address) return
3653
+ const previous = planReturnRef.current
3654
+ planReturnRef.current = null
3655
+ setActive(tabsRef.current.some((tab) => tab.path === previous) ? previous : (tabsRef.current[0]?.path ?? null))
3604
3656
  }
3605
3657
  /** @private @author ddj 2026年10月08号 @param request Claimed disk or virtual open. Flushes saves without replacing dirty models. */
3606
3658
  function openReceiptFile(request) {
@@ -3911,11 +3963,17 @@ export function EditorView(props) {
3911
3963
  setWorkbench((current) => ({ ...current, tree: replace(current.tree) }))
3912
3964
  }
3913
3965
  /** @private @author ddj 2026年10月14号 @param id Leaf id. @returns Tabs and either focused full-feature editor or independent passive view. */
3914
- function renderGroup(id) {
3966
+ function renderGroup(id, floating) {
3915
3967
  const group = workbench.groups.find((item) => item.id === id)
3916
3968
  if (!group) return null
3917
3969
  const focused = id === workbench.focused
3918
- return React.createElement(EditorPane, { key: id, group, focused, dirty: dirtyMap, onSelect: selectGroup, onClose: closeTab, onMenu: paneMenu, onDrag: dragTab, consumeClick: consumeDrag },
3970
+ // 同一行:计划预览页签并入该组自己的页签行(extras),与磁盘页签共用一条滚动区,
3971
+ // 页面工具列固定在行尾(滚动区之外,横滚不带走)。焦点落在浮窗时,这一行仍留在页面分组上,
3972
+ // 避免整条工具栏跟着搬进浮窗;页签缓存 ref 只认页面上的这条行。
3973
+ const chrome = !floating && id === rowOwner
3974
+ // 计划预览占用的正文只会在焦点组里;承载页签行的组同时收起磁盘选中态,保证整屏只有计划页签一条下划线。
3975
+ const hideDiskActive = Boolean(planActive) && (chrome || focused)
3976
+ return React.createElement(EditorPane, { key: id, group, focused, dirty: dirtyMap, suppressActive: hideDiskActive, tabsRef: chrome ? tabsHostRef : undefined, extras: chrome ? planTabExtras : undefined, tools: chrome ? rowTools : undefined, onSelect: selectGroup, onClose: closeTab, onMenu: paneMenu, onDrag: dragTab, consumeClick: consumeDrag },
3919
3977
  focused ? React.createElement('div', { className: 'edrv-full-slot', ref: mountFull }) : React.createElement(React.Fragment, null,
3920
3978
  React.createElement(GroupPathbar, { path: group.active, onFocus: () => selectGroup(id) }),
3921
3979
  React.createElement(PassiveEditor, { key: String(scope) + ':' + group.active, path: group.active, groupId: id, mount: passiveMount })))
@@ -3927,15 +3985,18 @@ export function EditorView(props) {
3927
3985
  }
3928
3986
  // --endregion
3929
3987
 
3930
- const tabsEl = React.createElement('div', { className: 'edrv-tabs', ref: tabsHostRef, style: { flex: '1 1 auto', minWidth: 0 } },
3931
- planTabs.map((plan) => React.createElement('div', {
3932
- key: plan.address, className: 'edrv-tab' + (plan.address === planActive ? ' edrv-tab-active' : ''),
3988
+ // 计划预览页签不再是独立一行的工具条:它作为焦点分组页签行的尾部内容(EditorPane 的 extras)渲染,
3989
+ // 与磁盘页签同处一条滚动区,因此整块工作台只有一行页签。
3990
+ const planTabExtras = planTabs.length
3991
+ ? planTabs.map((plan) => React.createElement('div', {
3992
+ key: plan.address, className: 'edrv-tab edrv-tab-plan' + (plan.address === planActive ? ' edrv-tab-active' : ''),
3933
3993
  title: plan.title + '(计划预览 · 只读)', onClick: () => openPlanDoc(plan),
3934
- }, React.createElement('span', { style: { overflow: 'hidden', textOverflow: 'ellipsis', maxWidth: 180 } }, plan.title),
3935
- React.createElement('span', { className: 'edrv-tab-x', title: '关闭', onClick: (event) => closePlan(plan.address, event) }, '×'))),
3936
- (openInput
3937
- ? React.createElement('input', { className: 'edrv-path-input', autoFocus: true, placeholder: '输入工作区相对/绝对路径,回车打开', value: pathDraft, onChange: (e) => setPathDraft(e.target.value), onKeyDown: (e) => { if (e.key === 'Enter') openPath(); if (e.key === 'Escape') setOpenInput(false) } })
3938
- : React.createElement('button', { className: 'edrv-tab-add', title: '打开文件(输入路径)', onClick: () => setOpenInput(true) }, '+')))
3994
+ }, React.createElement('span', { className: 'edrv-group-tab-name' }, plan.title),
3995
+ React.createElement('span', { className: 'edrv-tab-x', title: '关闭', onClick: (event) => closePlan(plan.address, event) }, '×')))
3996
+ : null
3997
+ const openControl = openInput
3998
+ ? React.createElement('input', { className: 'edrv-path-input', autoFocus: true, placeholder: '输入工作区相对/绝对路径,回车打开', value: pathDraft, onChange: (e) => setPathDraft(e.target.value), onKeyDown: (e) => { if (e.key === 'Enter') openPath(); if (e.key === 'Escape') setOpenInput(false) } })
3999
+ : React.createElement('button', { className: 'edrv-tab-add', title: '打开文件(输入路径)', onClick: () => setOpenInput(true) }, '+')
3939
4000
 
3940
4001
  const activePlan = planTabs.find((plan) => plan.address === planActive)
3941
4002
  const pathBar = activePlan ? React.createElement('div', { className: 'edrv-pathbar', title: activePlan.address },
@@ -4017,8 +4078,9 @@ export function EditorView(props) {
4017
4078
  const navBackTarget = navRef.current.peekBack()
4018
4079
  const navForwardTarget = navRef.current.peekForward()
4019
4080
  const navNameOf = (entry) => (entry && entry.path ? String(entry.path).split(/[\\/]/).pop() : '')
4020
- const tabRow = React.createElement('div', { style: { display: 'flex', alignItems: 'center', background: 'var(--dsw-alias-bg-layer-1,transparent)', flexShrink: 0 } },
4021
- tabsEl,
4081
+ // 行尾固定工具列:位于焦点分组页签行的滚动区右侧,横滚页签时不跟着滚走。
4082
+ const rowTools = React.createElement('div', { className: 'edrv-tabs-tools' },
4083
+ openControl,
4022
4084
  (sum.totalFiles > 0
4023
4085
  ? React.createElement('button', { className: 'edrv-diffchip', title: '打开差异总览/归档', onClick: () => setLauncherOpen((o) => !o) }, '⚠ 差异 ' + sum.totalFiles + ' 文件')
4024
4086
  : null),
@@ -4436,7 +4498,8 @@ export function EditorView(props) {
4436
4498
  'data-edrv-diff-dock-shell': '1',
4437
4499
  }, React.createElement(DiffBox, { ...dockSnapshot, dock: true }))
4438
4500
  : null
4439
- // 主编辑列(侧边栏右侧):pathBar + tabRow + 编辑/差异区。
4501
+ // 编辑区容器:pathBar 和页签行现在都由焦点分组自己渲染,这里只放编辑正文与各种浮层。
4502
+
4440
4503
  const editorArea = React.createElement('div', { className: 'edrv-editor-area' },
4441
4504
  dapBar,
4442
4505
  addCfgBtn,
@@ -4461,11 +4524,11 @@ export function EditorView(props) {
4461
4524
  : null
4462
4525
  // 底部状态栏共用一整条:挂在 mainCol 层级(所有分组之下),不随焦点分组搬家(ddj 2026-10-16)
4463
4526
  const fullView = createPortal(React.createElement(React.Fragment, null, pathBar, sideHintEl, diskBanner(), editorArea), fullHost, 'edrv-full-editor')
4527
+ // 页签行已并入焦点分组自身(EditorPane 内一行),不再有独立的顶部工具条。
4464
4528
  const mainCol = React.createElement('div', { className: 'edrv-main-col edrv-workbench', style: { flex: 1, minWidth: 0, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' } },
4465
- React.createElement('div', { className: 'edrv-workbench-tools' }, tabRow),
4466
4529
  renderTree(workbench.tree), fullView,
4467
4530
  statusBar,
4468
- workbench.floats.map((item, layer) => React.createElement(FloatingEditor, { key: item.group, owner: String(scope), group: item.group, rect: item, layer, focused: item.group === workbench.focused, onBounds: floatBounds, onFocus: selectGroup, onReturn: returnPane }, renderGroup(item.group))),
4531
+ workbench.floats.map((item, layer) => React.createElement(FloatingEditor, { key: item.group, owner: String(scope), group: item.group, rect: item, layer, focused: item.group === workbench.focused, onBounds: floatBounds, onFocus: selectGroup, onReturn: returnPane }, renderGroup(item.group, true))),
4469
4532
  dragPreview ? createPortal(React.createElement(React.Fragment, null,
4470
4533
  dragPreview.rect ? React.createElement('div', { className: 'edrv-drag-overlay', style: dragPreview.rect }, React.createElement('span', { className: 'edrv-drop-label' }, dragPreview.label)) : null,
4471
4534
  React.createElement('div', { className: 'edrv-drag-ghost', style: { left: dragPreview.point.x + 12, top: dragPreview.point.y + 12 } }, baseNameOf(dragPreview.path) + ' · ' + dragPreview.label)), document.body) : null)
@@ -0,0 +1,75 @@
1
+ /** Official session-row slots; no DOM patching or session activation. @author ddj 2026年10月20号 */
2
+ import React, { useCallback, useEffect, useState, useSyncExternalStore } from 'react'
3
+ import type { McpPhase } from '../../shared/mcp.js'
4
+ import { McpSessionState, type SessionMcpView } from '../mcpSessionState.js'
5
+
6
+ const labels: Record<McpPhase, string> = {
7
+ idle: '待使用,尚未启动', preparing: '读取项目配置', queued: '等待 MCP 启动名额', loading: '加载 MCP 插件模块',
8
+ starting: '等待 MCP 插件启动', stopping: '释放旧 MCP 实例', ready: '初始化完成,连接健康未验证',
9
+ error: '初始化异常', none: '无需加载项目 MCP',
10
+ }
11
+ const busy = new Set<McpPhase>(['preparing', 'queued', 'loading', 'starting', 'stopping'])
12
+ export interface McpRowProps { sessionId: string; store: McpSessionState }
13
+
14
+ /** @private @author ddj 2026年10月20号 @param props Root row identity and shared poller. @returns Stable per-session observation. */
15
+ function useMcpView({ sessionId, store }: McpRowProps): SessionMcpView {
16
+ const subscribe = useCallback((notify: () => void) => store.subscribe(sessionId, notify), [store, sessionId])
17
+ const snapshot = useCallback(() => store.get(sessionId), [store, sessionId])
18
+ return useSyncExternalStore(subscribe, snapshot, snapshot)
19
+ }
20
+
21
+ /** @public @author ddj 2026年10月20号 @param view Current observation. @returns Accessible row label; steady states deliberately do not occupy the leading slot. */
22
+ export function mcpRowLabel(view: SessionMcpView): string | undefined {
23
+ if (view.stale) return '项目 MCP 状态暂不可用(上次状态已过期)'
24
+ if (!view.status || (!busy.has(view.status.phase) && view.status.phase !== 'error')) return undefined
25
+ return '项目 MCP:' + labels[view.status.phase]
26
+ }
27
+
28
+ /** @public @author ddj 2026年10月20号 @param props Official row sessionId and poller. @returns 16px loading/error marker; official running/approval states retain precedence. */
29
+ export function McpSessionIcon(props: McpRowProps): React.ReactElement | null {
30
+ const view = useMcpView(props)
31
+ const label = mcpRowLabel(view)
32
+ if (!label) return null
33
+ const warning = view.stale || view.status?.phase === 'error'
34
+ return React.createElement('span', {
35
+ role: 'img', 'aria-label': label, title: label,
36
+ style: { display: 'inline-flex', width: 16, height: 16, alignItems: 'center', justifyContent: 'center',
37
+ color: warning ? 'var(--color-warning, #b7791f)' : 'var(--color-text-secondary, currentColor)', fontSize: 13 },
38
+ }, warning ? '⚠' : '◌')
39
+ }
40
+
41
+ /** @private @author ddj 2026年10月20号 @param start Stage start. @param end Stage finish or local clock. @returns Human-readable elapsed time, without claiming a timeout. */
42
+ function elapsed(start: number, end: number): string { return Math.max(0, (end - start) / 1000).toFixed(1) + ' 秒' }
43
+
44
+ /** @public @author ddj 2026年10月20号 @param props Official hover sessionId and poller. @returns Project-only diagnostic detail, never transport credentials. */
45
+ export function McpSessionDetail(props: McpRowProps): React.ReactElement {
46
+ const view = useMcpView(props)
47
+ const [now, setNow] = useState(Date.now)
48
+ const status = view.status
49
+ useEffect(() => {
50
+ if (!status || view.stale || !busy.has(status.phase)) return
51
+ const tick = setInterval(() => setNow(Date.now()), 1000)
52
+ return () => clearInterval(tick)
53
+ }, [status, view.stale])
54
+
55
+ const children: React.ReactNode[] = [React.createElement('strong', { key: 'title' }, '项目 MCP')]
56
+ if (view.stale) children.push(React.createElement('div', { key: 'stale', role: 'status' }, '状态暂不可用;以下如有记录,为上次观测结果'))
57
+ if (!status) children.push(React.createElement('div', { key: 'empty' }, view.stale ? '等待状态查询' : '当前无活跃 MCP 实例'))
58
+ else {
59
+ const end = status.finishedAt ?? now
60
+ const completed = status.servers.filter((server) => server.phase === 'ready').length
61
+ const slow = !view.stale && busy.has(status.phase) && now - status.phaseAt > 10000
62
+ children.push(React.createElement('div', { key: 'phase' }, labels[status.phase], slow ? '(耗时较长,不代表失败)' : ''))
63
+ children.push(React.createElement('div', { key: 'time' }, `${completed}/${status.servers.length} 已初始化 · 总耗时 ${elapsed(status.startedAt, end)} · 当前阶段 ${elapsed(status.phaseAt, end)}`))
64
+ if (status.configMs !== undefined) children.push(React.createElement('div', { key: 'config' }, `配置准备 ${elapsed(0, status.configMs)}`))
65
+ if (status.error) children.push(React.createElement('div', { key: 'error' }, status.error))
66
+ for (const server of status.servers) children.push(React.createElement('div', { key: 'server:' + server.serverName },
67
+ `${server.serverName}:${labels[server.phase]}${server.reused ? ' · 复用已有连接' : ''}${server.phase === 'idle' ? '' : ' · ' + elapsed(server.startedAt, server.finishedAt ?? now)}`,
68
+ server.queuedMs === undefined ? '' : ' · 排队 ' + elapsed(0, server.queuedMs),
69
+ server.connectMs === undefined ? '' : ' · 初始化 ' + elapsed(0, server.connectMs),
70
+ server.firstCallMs === undefined ? '' : ' · 首次工具执行 ' + elapsed(0, server.firstCallMs),
71
+ server.error ? ' · ' + server.error : ''))
72
+ }
73
+ children.push(React.createElement('small', { key: 'scope' }, '仅 VSCodeMode 管理的项目 MCP;不含全局或 preset MCP。'))
74
+ return React.createElement('section', { 'aria-label': '项目 MCP 状态', style: { display: 'grid', gap: 4, maxWidth: 320, overflowWrap: 'anywhere', fontSize: 12 } }, children)
75
+ }
@@ -10,6 +10,7 @@ import React from 'react'
10
10
  import { rpc } from '../rpc.js'
11
11
  import type { MpcProject, MpcServer } from '../../shared/mcp.js'
12
12
  import { configOf } from '../mcpForm.js'
13
+ import { parseMcpPolicy } from '../../shared/mcp.js'
13
14
  import { McpState } from '../mcpState.js'
14
15
  import { statusOf, statusTone } from '../../shared/mcpStatus.js'
15
16
  import '../styles/mcp.css'
@@ -25,10 +26,10 @@ import { PerfSettings } from './PerfSettings.js'
25
26
  import { IntegrationSettings } from './IntegrationSettings.js'
26
27
  import { AiSettings } from './AiSettings.js'
27
28
 
28
- const EMPTY = { serverName: '', transport: 'stdio', command: '', args: '', cwd: '', url: '', headers: '' }
29
+ const EMPTY = { serverName: '', transport: 'stdio', command: '', args: '', cwd: '', url: '', headers: '', startup: 'on-demand', sharing: 'agent', startupTimeoutMs: 60000 }
29
30
 
30
31
  /** @private @author ddj 2026年10月08号 @param props Server snapshot, page-wide busy state and actions. @returns Lifecycle-evidence card; enabled does not imply online. */
31
- function ServerCard({ server, busy, onRefresh, onToggle, onRemove }) {
32
+ function ServerCard({ server, busy, onRefresh, onToggle, onRemove, onConnect, onPolicy }) {
32
33
  return React.createElement('article', { className: 'vsm-mcp-card' },
33
34
  React.createElement('div', { className: 'vsm-mcp-card-head' },
34
35
  React.createElement('div', { className: 'vsm-mcp-title' }, React.createElement('span', { className: 'vsm-mcp-dot ' + statusTone(server) }), server.serverName),
@@ -39,12 +40,33 @@ function ServerCard({ server, busy, onRefresh, onToggle, onRemove }) {
39
40
  ),
40
41
  ),
41
42
  React.createElement('div', { className: 'vsm-mcp-meta' }, statusOf(server), ' · ', server.toolCount, ' 个工具 · ', server.transport,
42
- server.instanceCount !== undefined ? ' · ' + server.instanceCount + ' 个会话实例(各自连接)' : ''),
43
+ server.instanceCount !== undefined ? ' · ' + server.instanceCount + ' 个连接实例' : '',
44
+ server.sessionCount !== undefined ? ' · ' + server.sessionCount + ' 个会话使用' : ''),
45
+ server.policy && React.createElement(ProjectPolicy, { server, busy, onConnect, onPolicy }),
43
46
  server.error && React.createElement('div', { className: 'vsm-mcp-error' }, server.error),
44
47
  React.createElement('div', { className: 'vsm-mcp-tools' }, server.tools.map((tool) => React.createElement('span', { key: tool.name, className: 'vsm-mcp-chip', title: tool.description || tool.name }, tool.name))),
45
48
  )
46
49
  }
47
50
 
51
+ /** @private @author ddj 2026年10月20号 @param props Project policy and locked actions. @returns Explicit strategy controls and sharing risk notice. */
52
+ function ProjectPolicy({ server, busy, onConnect, onPolicy }) {
53
+ /** @author ddj 2026年10月20号 @param event Strategy selection. Saves only an explicit user change. */
54
+ const startup = (event) => onPolicy?.(server, { ...server.policy, startup: event.target.value })
55
+ /** @author ddj 2026年10月20号 @param event Sharing selection. Requires acknowledgement before state sharing. */
56
+ const sharing = (event) => {
57
+ const mode = event.target.value
58
+ if (mode === 'workspace' && !window.confirm('同一工作区的会话将共享此 MCP 的连接和内部状态;工具串行不等于业务状态隔离。确认服务适合共享?')) return
59
+ onPolicy?.(server, { ...server.policy, sharing: mode })
60
+ }
61
+ return React.createElement('div', { className: 'vsm-mcp-meta' },
62
+ React.createElement('label', null, '启动 ', React.createElement('select', { disabled: !!busy, value: server.policy.startup, onChange: startup },
63
+ React.createElement('option', { value: 'on-demand' }, '按需启动'), React.createElement('option', { value: 'background' }, '后台预热'))),
64
+ React.createElement('label', null, ' 连接 ', React.createElement('select', { disabled: !!busy, value: server.policy.sharing, onChange: sharing },
65
+ React.createElement('option', { value: 'agent' }, '逐会话隔离'), React.createElement('option', { value: 'workspace' }, '工作区共享(共享状态)'))),
66
+ React.createElement('button', { disabled: !!busy || !server.enabled, onClick: onConnect }, '连接当前活跃会话'),
67
+ React.createElement('small', null, '默认按需启动,不阻塞新对话;首次使用仍需等待。共享仅显式启用。'))
68
+ }
69
+
48
70
  /** 按项目标题或路径过滤,大小写不敏感。 */
49
71
  export function filterProjects(projects, query) {
50
72
  const needle = String(query ?? '').trim().toLowerCase()
@@ -94,7 +116,7 @@ function ProjectPicker({ projects, value, onChange }) {
94
116
  }
95
117
 
96
118
  /** @private @author ddj 2026年10月08号 @param props Project view, shared busy lock and actions. @returns Project cards with actions disabled for any active mutation. */
97
- function ProjectGroup({ project, busy, onAdd, onRefresh, onToggle, onRemove }) {
119
+ function ProjectGroup({ project, busy, onAdd, onRefresh, onToggle, onRemove, onConnect, onPolicy }) {
98
120
  const head = React.createElement('div', { className: 'vsm-project-head' },
99
121
  React.createElement('div', { className: 'vsm-project-title' }, React.createElement('span', { className: 'vsm-project-icon' }, '▣'), React.createElement('div', null, React.createElement('span', { className: 'vsm-project-name' }, project.title), React.createElement('span', { className: 'vsm-project-path' }, project.workspacePath))),
100
122
  React.createElement('button', { className: 'vsm-primary vsm-small', disabled: !!busy, onClick: () => onAdd(project) }, '+ 添加 MCP'),
@@ -103,7 +125,7 @@ function ProjectGroup({ project, busy, onAdd, onRefresh, onToggle, onRemove }) {
103
125
  if (project.missingDir) body = React.createElement('div', { className: 'vsm-project-empty' }, '项目目录已不存在,无法管理 MCP')
104
126
  else if (project.fileError) body = React.createElement('div', { className: 'vsm-mcp-error vsm-mcp-banner' }, project.fileError)
105
127
  else if (!project.servers.length) body = React.createElement('div', { className: 'vsm-project-empty' }, '此项目未配置 MCP')
106
- else body = project.servers.map((server) => React.createElement(ServerCard, { key: server.serverName, server, busy, onRefresh: (id) => onRefresh(project, server, id), onToggle: () => onToggle(project, server), onRemove: (id) => onRemove(project, server, id) }))
128
+ else body = project.servers.map((server) => React.createElement(ServerCard, { key: server.serverName, server, busy, onConnect: () => onConnect(project, server), onPolicy: (server, policy) => onPolicy(project, server, policy), onRefresh: (id) => onRefresh(project, server, id), onToggle: () => onToggle(project, server), onRemove: (id) => onRemove(project, server, id) }))
107
129
  return React.createElement('section', { className: 'vsm-project' }, head, body)
108
130
  }
109
131
 
@@ -513,7 +535,7 @@ function McpManagePanel(props) {
513
535
  const form = showForm
514
536
  ? React.createElement(McpForm, { title: '添加全局 MCP', draft, busy, edit, save: () => saveGlobal(closeGlobalForm), close: closeGlobalForm })
515
537
  : projectForm
516
- ? React.createElement(McpForm, { title: '添加项目 MCP · ' + projectForm.title, draft, busy, edit, save: () => saveProject(projectForm.workspacePath, closeProjectForm), close: closeProjectForm })
538
+ ? React.createElement(McpForm, { project: true, title: '添加项目 MCP · ' + projectForm.title, draft, busy, edit, save: () => saveProject(projectForm.workspacePath, closeProjectForm), close: closeProjectForm })
517
539
  : null
518
540
  return React.createElement(React.Fragment, null,
519
541
  React.createElement('div', { className: 'vsm-mcp-subhead' },
@@ -538,7 +560,7 @@ function manageBody(props, tab, path, select, add) {
538
560
  const project = projects.find((item) => item.workspacePath === path)
539
561
  return React.createElement(React.Fragment, null,
540
562
  React.createElement(ProjectPicker, { projects, value: path, onChange: select }),
541
- project ? React.createElement(ProjectGroup, { project, busy, onAdd: add, onRefresh: refreshProject, onToggle: toggleProject, onRemove: removeProject }) : React.createElement('div', { className: 'vsm-mcp-empty' }, '请选择项目'),
563
+ project ? React.createElement(ProjectGroup, { project, busy, onAdd: add, onRefresh: refreshProject, onToggle: toggleProject, onRemove: removeProject, onConnect: props.connectProject, onPolicy: props.policyProject }) : React.createElement('div', { className: 'vsm-mcp-empty' }, '请选择项目'),
542
564
  )
543
565
  }
544
566
 
@@ -593,7 +615,7 @@ function projectAction(state, path, name, method, extra = {}) {
593
615
  }
594
616
 
595
617
  /** @private @author ddj 2026年10月08号 @param ref Mounted controller reference. @returns Global/project card actions sharing a synchronous lock. */
596
- function cardActions(ref) {
618
+ function cardActions(ref, getSession) {
597
619
  /** @author ddj 2026年10月08号 @param id Global identity. @returns Locked refresh. */
598
620
  const refreshGlobal = (id) => globalAction(ref.current, id, 'mcp.refresh', { id })
599
621
  /** @author ddj 2026年10月08号 @param server Current configuration. @returns Locked enable change. */
@@ -606,7 +628,15 @@ function cardActions(ref) {
606
628
  const toggleProject = (project, server) => projectAction(ref.current, project.workspacePath, server.serverName, 'mcp.projectToggle', { enabled: !server.enabled })
607
629
  /** @author ddj 2026年10月08号 @param project Project. @param server Configuration. @returns Confirmed locked deletion. */
608
630
  const removeProject = (project, server) => !ref.current?.view.busy && window.confirm('确认删除此项目的 MCP「' + server.serverName + '」?') && projectAction(ref.current, project.workspacePath, server.serverName, 'mcp.projectRemove')
609
- return { refreshGlobal, toggleGlobal, removeGlobal, refreshProject, toggleProject, removeProject }
631
+ /** @author ddj 2026年10月20号 @param project Owner workspace. @param server Enabled service. @returns Explicit connection for an already-active current session. */
632
+ const connectProject = (project, server) => {
633
+ const sessionId = getSession?.()
634
+ if (!sessionId) return ref.current?.mutate('connect', async () => ({ ok: false, error: '请先打开此项目的活跃会话' }), () => undefined)
635
+ return projectAction(ref.current, project.workspacePath, server.serverName, 'mcp.projectConnect', { sessionId })
636
+ }
637
+ /** @author ddj 2026年10月20号 @param project Owner. @param server Masked editable config. @param policy Explicit user strategy. @returns Serialized strategy persistence. */
638
+ const policyProject = (project, server, policy) => projectAction(ref.current, project.workspacePath, server.serverName, 'mcp.projectSave', { config: server.config, policy })
639
+ return { refreshGlobal, toggleGlobal, removeGlobal, refreshProject, toggleProject, removeProject, connectProject, policyProject }
610
640
  }
611
641
 
612
642
  /** @private @author ddj 2026年10月08号 @param state Controller. @param draft Editable fields. @param setDraft Draft setter. @param path Optional project target. @param close Success-only close callback. @returns Locked save, validation included. */
@@ -614,7 +644,7 @@ function saveDraft(state, draft, setDraft, path, close) {
614
644
  /** @author ddj 2026年10月08号 @returns Validated save result. @throws Malformed arguments/headers. */
615
645
  const request = () => {
616
646
  const config = configOf(draft)
617
- return path === null ? rpc('mcp.save', { config }) : rpc('mcp.projectSave', { workspacePath: path, serverName: config.serverName, config })
647
+ return path === null ? rpc('mcp.save', { config }) : rpc('mcp.projectSave', { workspacePath: path, serverName: config.serverName, config, policy: parseMcpPolicy({ startup: draft.startup, sharing: draft.sharing, startupTimeoutMs: Number(draft.startupTimeoutMs) }) })
618
648
  }
619
649
  /** @author ddj 2026年10月08号 @param result Save result. @description Update current view and only then reset/close the form. */
620
650
  const commit = (result) => {
@@ -649,7 +679,7 @@ export function McpSettings(props) {
649
679
  return cleanup
650
680
  }
651
681
  React.useEffect(selectTab, [tab])
652
- const { refreshGlobal, toggleGlobal, removeGlobal, refreshProject, toggleProject, removeProject } = cardActions(ref)
682
+ const { refreshGlobal, toggleGlobal, removeGlobal, refreshProject, toggleProject, removeProject, connectProject, policyProject } = cardActions(ref, props?.getMcpSession)
653
683
  /** @author ddj 2026年10月08号 @param close Success-only close callback. @returns Global save completion. */
654
684
  const saveGlobal = (close) => saveDraft(ref.current, draft, setDraft, null, close)
655
685
  /** @author ddj 2026年10月08号 @param path Target project. @param close Success-only close callback. @returns Project save completion. */
@@ -662,7 +692,7 @@ export function McpSettings(props) {
662
692
  if (loading) body = React.createElement('div', { className: 'vsm-mcp-empty' }, '正在读取 MCP 服务…')
663
693
  else if (tab === 'general') body = React.createElement(GeneralSettings, { registry: openerRegistry, getShortcuts })
664
694
  else if (tab === 'mcp') body = React.createElement(McpManagePanel, { servers, projects, busy, draft, edit, resetDraft,
665
- saveGlobal, saveProject,
695
+ saveGlobal, saveProject, connectProject, policyProject,
666
696
  refreshGlobal, toggleGlobal, removeGlobal, refreshProject, toggleProject, removeProject })
667
697
  else if (tab === 'compat') body = React.createElement(CompatSection, { getSummary: compatSummary })
668
698
  else if (tab === 'lsp') body = React.createElement(LspSettings, null)
@@ -677,7 +707,7 @@ export function McpSettings(props) {
677
707
  }
678
708
 
679
709
  /** @private @author ddj 2026年10月08号 @param props Draft, shared busy lock, edit/save/close callbacks. @returns Lossless argument/header form; failed save stays open. */
680
- function McpForm({ title, draft, busy, edit, save, close }) {
710
+ function McpForm({ title, draft, busy, edit, save, close, project }) {
681
711
  /** @author ddj 2026年10月08号 @param key Draft field. @returns Short input change callback. */
682
712
  const change = (key) => (event) => edit(key, event.target.value)
683
713
  /** @author ddj 2026年10月08号 @param key Draft field. @param placeholder Input hint. @returns Disabled-while-busy input. */
@@ -697,6 +727,10 @@ function McpForm({ title, draft, busy, edit, save, close }) {
697
727
  React.createElement('label', null, '名称', input('serverName', '例如 codegraph')),
698
728
  React.createElement('label', null, '传输方式', React.createElement('select', { disabled: !!busy, value: draft.transport, onChange: change('transport') }, React.createElement('option', { value: 'stdio' }, 'stdio'), React.createElement('option', { value: 'streamable-http' }, 'streamable-http'))),
699
729
  fields,
730
+ project && React.createElement('label', null, '启动策略', React.createElement('select', { disabled: !!busy, value: draft.startup, onChange: change('startup') }, React.createElement('option', { value: 'on-demand' }, '按需启动(默认)'), React.createElement('option', { value: 'background' }, '后台预热'))),
731
+ project && React.createElement('label', null, '连接模式', React.createElement('select', { disabled: !!busy, value: draft.sharing, onChange: change('sharing') }, React.createElement('option', { value: 'agent' }, '逐会话隔离(默认)'), React.createElement('option', { value: 'workspace' }, '工作区共享(共享服务内部状态)'))),
732
+ project && React.createElement('label', null, '初始化预算(毫秒,1000–300000)', input('startupTimeoutMs', '60000')),
733
+ project && React.createElement('small', null, '新对话不等待连接。共享必须确认服务适合共享状态;不为 CodeGraph 自动启用。'),
700
734
  React.createElement('div', { className: 'vsm-mcp-dialog-actions' }, React.createElement('button', { disabled: !!busy, onClick: close }, '取消'), React.createElement('button', { className: 'vsm-primary', disabled: !!busy, onClick: save }, '保存配置')),
701
735
  ))
702
736
  }
@@ -1,12 +1,14 @@
1
1
  // @ts-nocheck
2
2
  /**
3
3
  * dsh-vscode-mode client — PerfSizeBadge:对话 header 会话体积指示器。
4
- * 5s 轮询当前会话持久化体积(host 仅 stat,近零成本),≥1MB 显示、超阈值变色,
4
+ * 定时器复用 state/recordsFeed.ts 的会话级心跳(单会话只保留 1 个定时器),
5
+ * 轮询当前会话持久化体积(host 仅 stat,近零成本),≥1MB 显示、超阈值变色,
5
6
  * 引导"一次任务一个短会话":会话过大时建议 /compact 或新开会话。
6
7
  * 作者 ddj 2026-09-02
7
8
  */
8
9
  import React from 'react'
9
10
  import { rpc } from '../rpc.js'
11
+ import { subscribeTick } from '../state/recordsFeed.js'
10
12
 
11
13
  /** 软阈值:≥ 此值琥珀色(默认 2MB)。 */
12
14
  const SOFT = 2 * 1024 * 1024
@@ -38,10 +40,11 @@ export function PerfSizeBadge(props) {
38
40
  }, [sessionId, sessions])
39
41
 
40
42
  React.useEffect(() => {
43
+ if (!sessionId) return undefined
41
44
  load()
42
- const t = setInterval(load, 5000)
43
- return () => { clearInterval(t) }
44
- }, [load])
45
+ // 共享心跳:recordsFeed 每周期回调一次,避免本组件再自建 setInterval。
46
+ return subscribeTick(sessionId, load)
47
+ }, [load, sessionId])
45
48
 
46
49
  if (!size || size < SHOW_MIN) return null
47
50
  const label = size >= 1024 * 1024 ? (size / (1024 * 1024)).toFixed(1) + 'MB' : Math.round(size / 1024) + 'KB'