dsh-vscode-mode 0.17.0 → 0.19.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 (43) hide show
  1. package/README.md +8 -4
  2. package/lib/client.debug.js +934 -0
  3. package/lib/client.debug.js.map +1 -0
  4. package/lib/client.js +1425 -1724
  5. package/lib/client.js.map +1 -1
  6. package/lib/client.rules.js +793 -0
  7. package/lib/client.rules.js.map +1 -0
  8. package/lib/index.js +1078 -286
  9. package/lib/index.js.map +1 -1
  10. package/package.json +1 -1
  11. package/skills/dsh-vscodemode-mcp/SKILL.md +24 -7
  12. package/src/client/index.ts +24 -0
  13. package/src/client/lazyPanel.ts +68 -0
  14. package/src/client/mcpSessionState.ts +108 -0
  15. package/src/client/monaco/lsp/providers.ts +32 -3
  16. package/src/client/sidebar/PanelBoundary.ts +57 -0
  17. package/src/client/sidebar/SidebarView.ts +58 -21
  18. package/src/client/sidebar/panels/FileExplorer.ts +80 -27
  19. package/src/client/sidebar/panels/SvnPanel.ts +24 -8
  20. package/src/client/sidebar/panels/index.ts +3 -4
  21. package/src/client/sidebar/panels/treeWindow.ts +106 -0
  22. package/src/client/sidebar/panels/useExplorerReveal.ts +5 -2
  23. package/src/client/sidebar/registry.ts +4 -1
  24. package/src/client/state/modelWarmer.ts +198 -0
  25. package/src/client/styles/editor.css +13 -1
  26. package/src/client/textPages.ts +135 -0
  27. package/src/client/ui/EditorPane.ts +16 -4
  28. package/src/client/ui/EditorView.ts +147 -29
  29. package/src/client/ui/LargeTextView.ts +94 -0
  30. package/src/client/ui/McpSessionStatus.ts +75 -0
  31. package/src/client/ui/McpSettings.ts +47 -13
  32. package/src/mcp.ts +1 -0
  33. package/src/mcpBridge.ts +163 -0
  34. package/src/mcpDiscover.ts +38 -0
  35. package/src/mcpPool.ts +245 -0
  36. package/src/mcpProject.ts +15 -12
  37. package/src/mcpRuntime.ts +299 -254
  38. package/src/rpc.ts +42 -8
  39. package/src/shared/mcp.ts +47 -0
  40. package/src/shared/rpc.ts +8 -2
  41. package/src/shared/textPages.ts +28 -0
  42. package/src/textPages.ts +149 -0
  43. package/src/treeIndex.ts +62 -37
@@ -28,7 +28,7 @@ import { useExplorerSvn } from './useExplorerSvn.js'
28
28
  import { useExplorerReveal, treeRevealPath } from './useExplorerReveal.js'
29
29
  import type { SidebarCtx } from '../types.js'
30
30
 
31
- const DIR_CAP = 4000
31
+ import { flattenTree, treeAnchorTop, treeRevealTop, treeWindow, TREE_ROW_HEIGHT } from './treeWindow.js'
32
32
  const SAVE_DEBOUNCE_MS = 300
33
33
  const FOLLOW_INTERVAL_MS = 10_000
34
34
  const PREFETCH_MAX = 4
@@ -126,7 +126,8 @@ export function FileExplorer(props) {
126
126
  const saveTimerRef = React.useRef(null)
127
127
  const loadDirRef = React.useRef(null)
128
128
  const refreshRef = React.useRef(null)
129
- const treeRef = React.useRef(null) // 目录树容器(定位时按 data-edrv-path 查行)
129
+ const treeRef = React.useRef(null) // 唯一目录树滚动容器
130
+ const [viewport, setViewport] = React.useState({ scope, top: 0, height: 0 })
130
131
  const reloadDirRef = React.useRef(null) // loadDir 最新闭包(文件变化监听读取,防陈旧闭包)
131
132
  const changedTimerRef = React.useRef(null) // 文件变化合并去抖计时器
132
133
  const changedRelRef = React.useRef(new Set()) // 待重列的相对目录集合(去抖窗口内合并)
@@ -338,8 +339,6 @@ export function FileExplorer(props) {
338
339
  // eslint-disable-next-line react-hooks/exhaustive-deps
339
340
  }, [])
340
341
 
341
- // 在恢复工作区展开状态之后跟随活动文件;隐藏期间目标由编辑器保留,重新挂载时兑现。
342
- const { revealPath } = useExplorerReveal({ scope, activePath, treeRef, prepare: prepareReveal })
343
342
  const activeTreePath = treeRevealPath(activePath)
344
343
 
345
344
  // 10s 轻量跟随:已展开目录后台刷新(命中 host 索引,近零成本),树跟随 agent 写入
@@ -355,24 +354,71 @@ export function FileExplorer(props) {
355
354
  // eslint-disable-next-line react-hooks/exhaustive-deps
356
355
  }, [scope])
357
356
 
358
- /** @private @author ddj 2026年10月10号 @returns Expanded tree descriptors; icon refreshes never re-walk the tree. */
359
- function flatten() {
360
- const result = []
361
- /** @private @author ddj 2026年10月10号 @param rel Directory. @param depth Indent level. Appends only expanded rows and placeholders. */
362
- function visit(rel, depth) {
363
- const entries = dirs[rel] ?? entriesCacheGet(scope, rel)
364
- if (!entries) { result.push({ key: rel + ':loading', depth, text: loading[rel] ? '加载中…' : '无法加载' }); return }
365
- for (const e of entries) {
366
- const isDir = e.type === 'directory', isOpen = expanded[e.path] === true
367
- result.push({ key: e.path, e, depth, isDir, isOpen })
368
- if (isDir && isOpen) visit(e.path, depth + 1)
369
- }
370
- if (entries.length >= DIR_CAP) result.push({ key: rel + ':cap', depth, text: '目录条目过多,仅显示前 ' + DIR_CAP + ' 项' })
357
+ /** @private @author ddj 2026年10月22号 @returns Complete expanded model; icon-only updates do not flatten again. */
358
+ function flatten() { return flattenTree(entriesOf, expanded, loading) }
359
+ const treeRows = React.useMemo(flatten, [dirs, expanded, loading, scope])
360
+ const previousRows = React.useRef({ scope, rows: treeRows })
361
+ let anchoredTop = 0
362
+ if (viewport.scope === scope) {
363
+ anchoredTop = viewport.top
364
+ if (previousRows.current.scope === scope && previousRows.current.rows !== treeRows) {
365
+ anchoredTop = treeAnchorTop(previousRows.current.rows, treeRows, viewport.top)
371
366
  }
372
- visit('', 0)
373
- return result
374
367
  }
375
- const treeRows = React.useMemo(flatten, [dirs, expanded, loading, scope])
368
+ const currentViewport = { top: anchoredTop, height: viewport.height }
369
+ const windowRows = treeWindow(treeRows.length, currentViewport)
370
+
371
+ /** @private @author ddj 2026年10月22号 Read current geometry after scroll, resize or scope restoration. */
372
+ function measureTree() {
373
+ const host = treeRef.current
374
+ if (!host || host.clientHeight <= 0) return
375
+ setViewport({ scope, top: host.scrollTop, height: host.clientHeight })
376
+ }
377
+ const scrollFrame = React.useRef(null)
378
+ /** @private @author ddj 2026年10月22号 Coalesce high-frequency scrolling into one measured render per frame. */
379
+ function scrollTree() {
380
+ if (scrollFrame.current !== null) return
381
+ scrollFrame.current = requestAnimationFrame(readScroll)
382
+ }
383
+ /** @private @author ddj 2026年10月22号 Read only after the pending animation frame fires. */
384
+ function readScroll() { scrollFrame.current = null; measureTree() }
385
+ /** @private @author ddj 2026年10月22号 @returns Resize observer and pending frame cleanup. Keep hidden trees measurable when shown. */
386
+ function watchTree() {
387
+ const observer = typeof ResizeObserver === 'function' ? new ResizeObserver(measureTree) : null
388
+ if (treeRef.current) observer?.observe(treeRef.current)
389
+ measureTree()
390
+ /** @private @author ddj 2026年10月22号 Stop all viewport callbacks owned by this scope. */
391
+ function cleanup() {
392
+ observer?.disconnect()
393
+ if (scrollFrame.current !== null) cancelAnimationFrame(scrollFrame.current)
394
+ scrollFrame.current = null
395
+ }
396
+ return cleanup
397
+ }
398
+ React.useLayoutEffect(watchTree, [scope])
399
+ /** @private @author ddj 2026年10月22号 Clamp actual scroll after collapse, filtering, or workspace switch. */
400
+ function clampTree() {
401
+ const host = treeRef.current
402
+ previousRows.current = { scope, rows: treeRows }
403
+ if (!host || host.clientHeight <= 0) return
404
+ if (host.scrollTop !== windowRows.top) host.scrollTop = windowRows.top
405
+ if (viewport.scope !== scope || viewport.top !== windowRows.top) {
406
+ setViewport({ scope, top: windowRows.top, height: host.clientHeight })
407
+ }
408
+ }
409
+ React.useLayoutEffect(clampTree, [scope, windowRows.top, treeRows])
410
+ /** @private @author ddj 2026年10月22号 @param path Requested file. @returns Whether its complete model row is available. */
411
+ function locateTree(path) {
412
+ const host = treeRef.current
413
+ if (!host) return false
414
+ const top = treeRevealTop(treeRows, path, { top: host.scrollTop, height: host.clientHeight })
415
+ if (top === null) return false
416
+ host.scrollTop = top
417
+ setViewport({ scope, top, height: host.clientHeight })
418
+ return true
419
+ }
420
+ // 定位由完整数据索引兑现,目标无需预先挂载 DOM;异步祖先加载后 hook 会重试。
421
+ const { revealPath } = useExplorerReveal({ scope, activePath, treeRef, prepare: prepareReveal, locate: locateTree })
376
422
  /** @private @author ddj 2026年10月10号 @returns Stable RPC row projection, independent of icon-only renders. */
377
423
  function projectRows() { return treeRows.filter((row) => row.e).map((row) => ({ path: row.e.path, isDir: row.isDir })) }
378
424
  const svnRows = React.useMemo(projectRows, [treeRows])
@@ -384,10 +430,10 @@ export function FileExplorer(props) {
384
430
  /** @private @author ddj 2026年10月10号 @param path Row path. @param type Entry type. @param event Context trigger. */
385
431
  function contextRow(path, type, event) {
386
432
  event.preventDefault(); event.stopPropagation()
387
- setMenu({ x: event.clientX, y: event.clientY, target: { path, type } })
433
+ setMenu({ scope, x: event.clientX, y: event.clientY, target: { path, type } })
388
434
  }
389
435
  const onSelect = React.useCallback((path, isDir) => selectRef.current(path, isDir), [])
390
- const onContext = React.useCallback(contextRow, [])
436
+ const onContext = React.useCallback(contextRow, [scope])
391
437
  const rootName = root ? String(root).split(/[\\/]/).pop() || root : ''
392
438
 
393
439
  /** @private @author ddj 2026年10月10号 @param row Flat descriptor. @returns Memo row; unaffected status objects retain identity. */
@@ -405,10 +451,14 @@ export function FileExplorer(props) {
405
451
  ev.preventDefault()
406
452
  ev.stopPropagation()
407
453
  if (!ev.currentTarget.contains(ev.target)) return
408
- setMenu({ x: ev.clientX, y: ev.clientY, target: { path: '', type: 'directory' } })
454
+ setMenu({ scope, x: ev.clientX, y: ev.clientY, target: { path: '', type: 'directory' } })
409
455
  }
456
+ const menuValid = menu?.scope === scope && (menu.target.path === '' || treeRows.some((row) => row.e?.path === menu.target.path))
457
+ /** @private @author ddj 2026年10月22号 Close stale path menus instead of rebinding recycled rows. */
458
+ function closeLostMenu() { if (menu && !menuValid) setMenu(null) }
459
+ React.useEffect(closeLostMenu, [menu, menuValid])
410
460
  // 当前右键目标的可视菜单项(构建时过滤/排序,registry 变化下次打开生效)。
411
- const menuEntries = menu
461
+ const menuEntries = menuValid
412
462
  ? buildTreeMenu(ctx?.fileMenuItems, menu.target, ctx).map((item) => ({
413
463
  id: item.id,
414
464
  label: item.label,
@@ -443,13 +493,16 @@ export function FileExplorer(props) {
443
493
  }, '↓') : null,
444
494
  React.createElement('button', { className: 'edrv-side-btn', title: '刷新目录树', onClick: () => emitRefresh({ scope, kind: 'unknown' }) }, refreshIconEl())),
445
495
  svn.message ? React.createElement('div', { className: 'edrv-tree-status', role: 'status' }, svn.message) : null,
446
- React.createElement('div', { className: 'edrv-tree', ref: treeRef },
447
- (errorText
496
+ (errorText
448
497
  ? React.createElement('div', { className: 'edrv-tree-error' },
449
498
  React.createElement('span', null, String(errorText)),
450
499
  React.createElement('button', { className: 'edrv-side-btn', onClick: () => emitRefresh({ scope, kind: 'unknown' }) }, '重试'))
451
500
  : null),
452
- treeRows.map(renderRow)),
501
+ React.createElement('div', { className: 'edrv-tree edrv-tree-virtual', ref: treeRef, onScroll: scrollTree },
502
+ React.createElement('div', { 'aria-hidden': true, style: { height: windowRows.start * TREE_ROW_HEIGHT } }),
503
+ treeRows.slice(windowRows.start, windowRows.end).map(renderRow),
504
+ React.createElement('div', { 'aria-hidden': true, style: { height: (treeRows.length - windowRows.end) * TREE_ROW_HEIGHT } })),
505
+
453
506
  (menu && menuEntries.length
454
507
  ? React.createElement(ContextMenu, { x: menu.x, y: menu.y, entries: menuEntries, onClose: () => setMenu(null) })
455
508
  : null))
@@ -484,7 +484,7 @@ export function SvnPanel(props) {
484
484
  const [remote, setRemote] = React.useState(null)
485
485
  const [remoteBusy, setRemoteBusy] = React.useState(false)
486
486
  // W2-5:配对候选的文件大小(path → size|null)与成对结果
487
- const [sizes, setSizes] = React.useState({})
487
+ const [sizeSnapshot, setSizes] = React.useState(null)
488
488
  // 渐进渲染预算:过滤/作用域变化时重置,避免过滤后仍停留在旧的大预算
489
489
  const [renderLimit, setRenderLimit] = React.useState(RENDER_STEP)
490
490
  // ctx 由编辑区每轮渲染重建;行动作回调改读 ref 取最新值,回调身份才能稳定(行 React.memo 生效前提)
@@ -606,14 +606,30 @@ export function SvnPanel(props) {
606
606
  () => [...missingEntries, ...unversionedEntries].slice(0, FILE_SIZES_CAP).map((entry) => entry.path),
607
607
  [missingEntries, unversionedEntries],
608
608
  )
609
- const candidateKey = React.useMemo(() => requested.join('\n'), [requested])
610
- const pairsNeeded = missingEntries.length > 0 && unversionedEntries.length > 0
609
+ const candidateKey = JSON.stringify(requested)
610
+ const sizeOwner = JSON.stringify([scope, sessionId, candidateKey, managed])
611
+ const sizeOwnerRef = React.useRef(sizeOwner)
612
+ sizeOwnerRef.current = sizeOwner
613
+ const sizes = sizeSnapshot?.owner === sizeOwner ? sizeSnapshot.table : {}
614
+ const pairsNeeded = managed && missingEntries.length > 0 && unversionedEntries.length > 0
611
615
  const pairsKnown = pairsNeeded && requested.every((path) => path in sizes)
612
- React.useEffect(() => {
613
- if (pairsKnown || !requested.length) return
614
- void svnFileSizes(sessionId, requested).then((table) => setSizes(table))
615
- // requested 内容由 candidateKey 代理:同 key 必同内容,闭包取旧数组无碍(去重语义与旧 join 依赖一致)
616
- }, [pairsKnown, candidateKey, sessionId]) // eslint-disable-line react-hooks/exhaustive-deps
616
+
617
+ /** @private @author ddj 2026年10月22号 @returns Cleanup invalidating this request. @description Keep sizes scoped to this candidate generation; failures do not retry in a render loop. */
618
+ function loadPairSizes() {
619
+ if (!pairsNeeded || !requested.length) return
620
+ let disposed = false
621
+ /** @author ddj 2026年10月22号 @param table Candidate sizes. @description Publish only to the current request owner. */
622
+ function acceptSizes(table) {
623
+ if (!disposed && sizeOwnerRef.current === sizeOwner) setSizes({ owner: sizeOwner, table })
624
+ }
625
+ /** @author ddj 2026年10月22号 @description Failed reads leave pairing unknown without breaking the changes list. */
626
+ function ignoreFailure() { /* A future candidate/session change may retry. */ }
627
+ /** @author ddj 2026年10月22号 @description Revoke this request on dependency change or unmount. */
628
+ function cancelSizes() { disposed = true }
629
+ void svnFileSizes(sessionId, requested).then(acceptSizes, ignoreFailure)
630
+ return cancelSizes
631
+ }
632
+ React.useEffect(loadPairSizes, [sizeOwner, pairsNeeded])
617
633
 
618
634
  const pairNoteOf = React.useMemo(() => {
619
635
  if (!pairsKnown) return {}
@@ -10,9 +10,8 @@ import { IconBranchOutline16, IconFolderOpenOutline16, IconListPenOutline16, Ico
10
10
  import { dapStore } from '../../dap/store.js'
11
11
  import { FileExplorer } from './FileExplorer.js'
12
12
  import { SearchPanel } from './SearchPanel.js'
13
- import { RulesPanel } from './RulesPanel.js'
13
+ import { LazyPanel } from '../../lazyPanel.js'
14
14
  import { SvnPanel } from './SvnPanel.js'
15
- import { DebugPanel } from './DebugPanel.js'
16
15
  import type { SidebarPanelDef, SidebarCtx } from '../types.js'
17
16
 
18
17
  /**
@@ -57,7 +56,7 @@ export function createRulesPanel(): SidebarPanelDef {
57
56
  title: '规则',
58
57
  icon: IconListPenOutline16,
59
58
  order: 20,
60
- render: (ctx: SidebarCtx) => React.createElement(RulesPanel, { ctx }),
59
+ render: (ctx: SidebarCtx) => React.createElement(LazyPanel, { key: 'rules', panel: 'rules', ctx }),
61
60
  }
62
61
  }
63
62
 
@@ -113,6 +112,6 @@ export function createDebugPanel(): SidebarPanelDef {
113
112
  icon: '🐞',
114
113
  order: 22,
115
114
  badge: () => (dapStore.getSnapshot().phase === 'paused' ? 1 : null),
116
- render: (ctx: SidebarCtx) => React.createElement(DebugPanel, { ctx }),
115
+ render: (ctx: SidebarCtx) => React.createElement(LazyPanel, { key: 'debug', panel: 'debug', ctx }),
117
116
  }
118
117
  }
@@ -0,0 +1,106 @@
1
+ /** File explorer fixed-height row model. @author ddj 2026年10月22号 */
2
+ import type { TreeEntry } from '../../../shared/rpc.js'
3
+ import { updateRange } from '../../ui/svnUpdateModel.js'
4
+
5
+ /** Fixed geometry shared with file and loading row CSS. */
6
+ export const TREE_ROW_HEIGHT = 24
7
+
8
+ export interface TreeRow {
9
+ key: string
10
+ depth: number
11
+ e?: TreeEntry
12
+ isDir?: boolean
13
+ isOpen?: boolean
14
+ text?: string
15
+ }
16
+
17
+ export interface TreeViewport {
18
+ top: number
19
+ height: number
20
+ }
21
+
22
+ /**
23
+ * @public @author ddj 2026年10月22号
24
+ * @description Preserve the first visible path when rows above it expand or collapse.
25
+ * @param previous Previous complete model.
26
+ * @param rows New complete model.
27
+ * @param top Previous scroll offset.
28
+ * @returns Anchored offset, or original offset for subsequent clamping if path disappeared.
29
+ */
30
+ export function treeAnchorTop(previous: TreeRow[], rows: TreeRow[], top: number): number {
31
+ const index = Math.floor(Math.max(0, top) / TREE_ROW_HEIGHT)
32
+ const anchor = previous[index]?.key
33
+ if (!anchor) return top
34
+ const nextIndex = rows.findIndex((row) => row.key === anchor)
35
+ if (nextIndex < 0) return top
36
+ return nextIndex * TREE_ROW_HEIGHT + top % TREE_ROW_HEIGHT
37
+ }
38
+
39
+ /**
40
+ * @public @author ddj 2026年10月22号
41
+ * @description Flatten all expanded entries in their supplied order, including metadata files.
42
+ * @param entriesOf Directory snapshot lookup; null means unavailable.
43
+ * @param expanded Current directory expansion flags.
44
+ * @param loading Current loading flags.
45
+ * @returns Complete row model, independent of viewport mounting.
46
+ */
47
+ export function flattenTree(entriesOf: (rel: string) => TreeEntry[] | null,
48
+ expanded: Record<string, boolean>, loading: Record<string, boolean>): TreeRow[] {
49
+ const rows: TreeRow[] = []
50
+ const pending: { rel: string; depth: number; entry?: TreeEntry }[] = [{ rel: '', depth: 0 }]
51
+ while (pending.length) {
52
+ const current = pending.pop()!
53
+ const entry = current.entry
54
+ if (entry) {
55
+ const isDir = entry.type === 'directory'
56
+ const isOpen = expanded[entry.path] === true
57
+ rows.push({ key: entry.path, e: entry, depth: current.depth, isDir, isOpen })
58
+ if (isDir && isOpen) pending.push({ rel: entry.path, depth: current.depth + 1 })
59
+ continue
60
+ }
61
+
62
+ const entries = entriesOf(current.rel)
63
+ if (!entries) {
64
+ rows.push({ key: current.rel + ':loading', depth: current.depth,
65
+ text: loading[current.rel] ? '加载中…' : '无法加载' })
66
+ continue
67
+ }
68
+ // Stack reverses insertion order; push backwards to preserve host sorting.
69
+ for (let index = entries.length - 1; index >= 0; index--) {
70
+ pending.push({ rel: current.rel, depth: current.depth, entry: entries[index] })
71
+ }
72
+ }
73
+ return rows
74
+ }
75
+
76
+ /**
77
+ * @public @author ddj 2026年10月22号
78
+ * @description Clamp scroll state before reusing the existing fixed-height range calculation.
79
+ * @param count Complete row count.
80
+ * @param viewport Latest scroll position and measured viewport height.
81
+ * @returns Clamped scroll offset and overscanned exclusive row range.
82
+ */
83
+ export function treeWindow(count: number, viewport: TreeViewport) {
84
+ const height = Math.max(0, viewport.height)
85
+ const top = Math.min(Math.max(0, viewport.top), Math.max(0, count * TREE_ROW_HEIGHT - height))
86
+ return { top, ...updateRange(count, top, height, TREE_ROW_HEIGHT) }
87
+ }
88
+
89
+ /**
90
+ * @public @author ddj 2026年10月22号
91
+ * @description Locate an unmounted row using model indices, without querying row DOM.
92
+ * @param rows Complete flattened model.
93
+ * @param path Requested relative file path.
94
+ * @param viewport Current viewport geometry.
95
+ * @returns New scroll offset, or null while ancestors/viewport are unavailable.
96
+ */
97
+ export function treeRevealTop(rows: TreeRow[], path: string, viewport: TreeViewport): number | null {
98
+ if (viewport.height <= 0) return null
99
+ const index = rows.findIndex((row) => row.e?.path === path)
100
+ if (index < 0) return null
101
+ const current = treeWindow(rows.length, viewport).top
102
+ const rowTop = index * TREE_ROW_HEIGHT
103
+ if (rowTop >= current && rowTop + TREE_ROW_HEIGHT <= current + viewport.height) return current
104
+ return treeWindow(rows.length, { top: rowTop - (viewport.height - TREE_ROW_HEIGHT) / 2,
105
+ height: viewport.height }).top
106
+ }
@@ -27,6 +27,8 @@ interface RevealOptions {
27
27
  activePath: string | null
28
28
  treeRef: React.RefObject<HTMLElement>
29
29
  prepare: (path: string) => void
30
+ /** Optional virtual-list locator; success does not depend on mounted row DOM. */
31
+ locate?: (path: string) => boolean
30
32
  }
31
33
  interface RevealTarget {
32
34
  scope: string
@@ -60,12 +62,13 @@ export function useExplorerReveal(options: RevealOptions) {
60
62
  setTarget(next)
61
63
  if (next) current.prepare(next.path)
62
64
  }
63
- /** @private @author ddj 2026年10月14号 Attempts the latest target after row commit or container resize. Consumes only a successful, current request. */
65
+ /** @private @author ddj 2026年10月22号 Attempts virtual data-index location or the original mounted-row fallback after commit/resize; consumes only a successful current request. */
64
66
  function tryReveal(): void {
65
67
  const next = pending.current
66
68
  const current = latest.current
67
69
  if (!next || next.scope !== current.scope || next.active !== current.activePath) return
68
- if (!revealTreeRow(current.treeRef.current, next.path)) return
70
+ const located = current.locate ? current.locate(next.path) : revealTreeRow(current.treeRef.current, next.path)
71
+ if (!located) return
69
72
  pending.current = null
70
73
  setRevealPath(next.path)
71
74
  clearHighlight()
@@ -28,8 +28,11 @@ function panelRegister(entries: Map<string, SidebarPanelDef>, notify: () => void
28
28
  export function createSidebarPanelRegistry(): SidebarPanelRegistry {
29
29
  const entries = new Map<string, SidebarPanelDef>()
30
30
  const listeners = new Set<() => void>()
31
+ /** @private @author ddj 2026年10月22号 @description Isolate subscribers and snapshot notification membership against reentrant changes. */
31
32
  const notify = (): void => {
32
- for (const listener of listeners) listener()
33
+ for (const listener of [...listeners]) {
34
+ try { listener() } catch { /* One extension must not block other subscribers or registration teardown. */ }
35
+ }
33
36
  }
34
37
  const list = (): readonly SidebarPanelDef[] =>
35
38
  [...entries.values()].sort((a, b) => (a.order ?? 100) - (b.order ?? 100))
@@ -0,0 +1,198 @@
1
+ /**
2
+ * dsh-vscode-mode client — Monaco model 预热调度层。
3
+ *
4
+ * 背景:Monaco 的 GotoDefinitionAtPositionEditorContribution 在 Ctrl+hover 渲染定义链接
5
+ * 预览时,对目标的 edrv:// uri 调 textModelResolverService.createModelReference 后
6
+ * **只挂 then、没有 catch**;目标文件若从未在本会话打开过,该 uri 没有已注册 model,
7
+ * createModelReference 直接 reject("Model not found"),成为未捕获的 promise 异常。
8
+ * 本构建的 vendored Monaco 也没有 registerTextModelContentProvider 可供惰性喂内容,
9
+ * 因此修法是在 provideDefinition 返回前把目标 model 建出来(预热)。
10
+ *
11
+ * 本模块只负责「让某路径的 model 存在」这件事的调度:安装/卸载实现、并发单飞、
12
+ * 失败冷却与等待上限。真正建 model 的实现由 EditorView 注入(它才持有 monaco 与文档缓存)。
13
+ * 纯逻辑,不依赖 monaco,可单测。
14
+ * 作者 ddj 2026年10月20号
15
+ */
16
+
17
+ // --region 常量与状态
18
+
19
+ /** 单次预热调用的等待上限:超过即放行 hover(预热仍在后台继续)。 */
20
+ export const WARM_TIMEOUT_MS = 400
21
+
22
+ /** 预热失败后该路径的冷却窗口,避免每次 hover 都重试同一失败目标。 */
23
+ export const WARM_COOLDOWN_MS = 10_000
24
+
25
+ /** 冷却表条目上限(超出按插入序丢最旧),防长会话里无限增长。 */
26
+ export const WARM_COOLDOWN_CAP = 64
27
+
28
+ /** 预热实现:给定工作区相对路径,成功让 model 存在则返回 true。 */
29
+ export type WarmImpl = (path: string) => Promise<boolean>
30
+
31
+ /** 当前安装的预热实现(EditorView 挂载时注入,未挂载则为 null)。 */
32
+ let impl: WarmImpl | null = null
33
+ /** path → 进行中的预热(单飞:连续 hover 同一目标只读一次盘)。 */
34
+ const inFlight = new Map<string, Promise<boolean>>()
35
+ /** path → 冷却截止时间戳(毫秒)。 */
36
+ const cooled = new Map<string, number>()
37
+ // --endregion
38
+
39
+ // --region 状态维护
40
+
41
+ /**
42
+ * 清空进行中/冷却状态(安装或卸载实现时复位,避免跨实现串味)。
43
+ * @author ddj 2026年10月20号
44
+ */
45
+ function resetWarmerState(): void {
46
+ inFlight.clear()
47
+ cooled.clear()
48
+ }
49
+
50
+ /**
51
+ * 该路径是否处于失败冷却窗口内(过期的顺手清掉)。
52
+ * @author ddj 2026年10月20号
53
+ * @param path 工作区相对路径
54
+ * @returns 仍在冷却中返回 true
55
+ */
56
+ function isCooling(path: string): boolean {
57
+ const until = cooled.get(path)
58
+ if (until === undefined) return false
59
+ if (until > Date.now()) return true
60
+
61
+ cooled.delete(path)
62
+ return false
63
+ }
64
+
65
+ /**
66
+ * 记录一次失败冷却,并把冷却表裁剪到上限以内。
67
+ * @author ddj 2026年10月20号
68
+ * @param path 工作区相对路径
69
+ */
70
+ function coolDown(path: string): void {
71
+ cooled.delete(path)
72
+ cooled.set(path, Date.now() + WARM_COOLDOWN_MS)
73
+
74
+ while (cooled.size > WARM_COOLDOWN_CAP) {
75
+ const oldest = cooled.keys().next()
76
+ if (oldest.done) break
77
+
78
+ cooled.delete(oldest.value)
79
+ }
80
+ }
81
+
82
+ /**
83
+ * 结算一次预热:清 inFlight(仅当仍是自己那条)、按结果维护冷却。
84
+ * @author ddj 2026年10月20号
85
+ * @param path 工作区相对路径
86
+ * @param run 该次预热的 promise(用于识别是否已被更新的一次替换)
87
+ * @param ok 实现返回的成功与否
88
+ */
89
+ function settleWarm(path: string, run: Promise<boolean>, ok: boolean): void {
90
+ if (inFlight.get(path) === run) inFlight.delete(path)
91
+ if (ok) {
92
+ cooled.delete(path)
93
+ return
94
+ }
95
+
96
+ coolDown(path)
97
+ }
98
+ // --endregion
99
+
100
+ // --region 底层预热
101
+
102
+ /**
103
+ * 启动一次底层预热:登记 inFlight、把实现异常折叠成 false(绝不向调用方抛)。
104
+ * @author ddj 2026年10月20号
105
+ * @param path 工作区相对路径
106
+ * @param fn 预热实现
107
+ * @returns 永不 reject 的布尔 promise(已登记到 inFlight)
108
+ */
109
+ function startWarm(path: string, fn: WarmImpl): Promise<boolean> {
110
+ const run = Promise.resolve()
111
+ .then(() => fn(path))
112
+ .then(
113
+ (ok) => ok === true,
114
+ () => false,
115
+ )
116
+
117
+ inFlight.set(path, run)
118
+ run.then((ok) => settleWarm(path, run, ok))
119
+ return run
120
+ }
121
+
122
+ /**
123
+ * 给底层预热挂一个等待上限:超时只是本次调用放行,底层继续跑(仍可能建成 model
124
+ * 惠及下一次 hover),因此超时既不取消 promise,也不提前摘掉 inFlight。
125
+ * @author ddj 2026年10月20号
126
+ * @param path 工作区相对路径
127
+ * @param run 底层预热 promise(永不 reject)
128
+ * @returns 最多 WARM_TIMEOUT_MS 内结算的布尔结果
129
+ */
130
+ function withTimeout(path: string, run: Promise<boolean>): Promise<boolean> {
131
+ return new Promise<boolean>((resolve) => {
132
+ let settled = false
133
+ let timer: ReturnType<typeof setTimeout> | null = null
134
+
135
+ const finish = (ok: boolean): void => {
136
+ if (settled) return
137
+
138
+ settled = true
139
+ if (timer) clearTimeout(timer)
140
+ resolve(ok)
141
+ }
142
+
143
+ timer = setTimeout(() => {
144
+ coolDown(path)
145
+ finish(false)
146
+ }, WARM_TIMEOUT_MS)
147
+
148
+ run.then(finish, () => finish(false))
149
+ })
150
+ }
151
+ // --endregion
152
+
153
+ // --region 安装与卸载
154
+
155
+ /**
156
+ * 安装预热实现(EditorView 挂载时调用;传 null 视同卸载)。
157
+ * @author ddj 2026年10月20号
158
+ * @param fn 预热实现,或 null 表示卸载
159
+ */
160
+ export function setModelWarmer(fn: WarmImpl | null): void {
161
+ impl = typeof fn === 'function' ? fn : null
162
+ if (!impl) resetWarmerState()
163
+ }
164
+
165
+ /**
166
+ * 卸载预热实现:仅当传入的仍是当前实现才清(防卸载竞态误清新挂载的实现)。
167
+ * @author ddj 2026年10月20号
168
+ * @param fn 当初安装的实现
169
+ */
170
+ export function clearModelWarmer(fn: WarmImpl): void {
171
+ if (impl !== fn) return
172
+
173
+ impl = null
174
+ resetWarmerState()
175
+ }
176
+ // --endregion
177
+
178
+ // --region 预热调度
179
+
180
+ /**
181
+ * 确保目标路径的 Monaco model 存在(供 hover 定义预览的 createModelReference 命中)。
182
+ *
183
+ * 降级语义:未安装实现(编辑器页签未挂载)、路径为空、处于失败冷却、实现失败或超时,
184
+ * 一律返回 false 且不抛异常——此时行为等价于修复前(预览不显示,但不再产生未捕获异常)。
185
+ * @author ddj 2026年10月20号
186
+ * @param path 工作区相对路径(或绝对路径,由实现决定如何解析)
187
+ * @returns 预热成功返回 true;被跳过/失败/超时返回 false
188
+ */
189
+ export function warmModelFor(path: string): Promise<boolean> {
190
+ if (!path || !impl) return Promise.resolve(false)
191
+
192
+ const running = inFlight.get(path)
193
+ if (running) return withTimeout(path, running)
194
+ if (isCooling(path)) return Promise.resolve(false)
195
+
196
+ return withTimeout(path, startWarm(path, impl))
197
+ }
198
+ // --endregion
@@ -16,6 +16,11 @@
16
16
  /* 固定页签:📌 标记 + 免关闭按钮(固定页签不渲染 ×,仅可经右键菜单取消固定) */
17
17
  [data-edrv-view] .edrv-tab .edrv-tab-pin { flex-shrink: 0; font-size: 10px; line-height: 1; opacity: .85; }
18
18
  [data-edrv-view] .edrv-tab.edrv-tab-pinned { background: var(--dsw-alias-bg-layer-2, rgba(255,255,255,.04)); }
19
+ /* 页签行 = 可横滚的页签区 + 行尾固定工具列(计划预览页签与磁盘页签同处这一行的滚动区)@author ddj 2026年10月20号 */
20
+ [data-edrv-view] .edrv-tabs-row { display: flex; align-items: center; min-width: 0; flex: 0 0 auto; background: var(--dsw-alias-bg-layer-1, transparent); }
21
+ /* 特异性需盖过下面 .edrv-group .edrv-tabs 的 flex: 0 0 auto,页签区才能吃掉行走宽、把工具列压到行尾 */
22
+ [data-edrv-view] .edrv-group .edrv-tabs-row > .edrv-tabs { flex: 1 1 auto; min-width: 0; background: transparent; }
23
+ [data-edrv-view] .edrv-tabs-tools { display: flex; align-items: center; gap: 2px; flex: 0 0 auto; padding: 0 6px 0 2px; }
19
24
  [data-edrv-view] .edrv-tab-add { display: inline-flex; align-items: center; justify-content: center; width: 24px; height: 24px; margin-left: 2px; border: none; border-radius: 6px; background: transparent; color: var(--dsw-alias-label-secondary, #aaa); font-size: 14px; cursor: pointer; flex-shrink: 0; }
20
25
  [data-edrv-view] .edrv-tab-add:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(255,255,255,.06)); }
21
26
  [data-edrv-view] .edrv-pathbar { display: flex; align-items: center; gap: 8px; height: 26px; padding: 0 10px; background: var(--dsw-alias-bg-layer-1, #ffffff); border-bottom: 1px solid var(--dsw-alias-border-l1, #e0e6e8); font-size: 12px; flex-shrink: 0; overflow: hidden; }
@@ -300,6 +305,14 @@
300
305
  [data-edrv-view] .edrv-side-btn { display: inline-flex; align-items: center; justify-content: center; width: 22px; height: 22px; border: none; border-radius: 6px; background: transparent; color: var(--dsw-alias-label-secondary, #52606d); font-size: 12px; cursor: pointer; flex-shrink: 0; }
301
306
  [data-edrv-view] .edrv-side-btn:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(15,157,88,.08)); }
302
307
  [data-edrv-view] .edrv-tree { flex: 1; min-height: 0; overflow: auto; padding: 4px 6px 12px; font-size: 12px; }
308
+ /* Virtual rows and placeholders share the same geometry as treeWindow.ts. @author ddj 2026年10月22号 */
309
+ [data-edrv-view] .edrv-tree.edrv-tree-virtual { padding-top: 0; padding-bottom: 0; }
310
+ [data-edrv-view] .edrv-tree-virtual .edrv-tree-row,
311
+ [data-edrv-view] .edrv-tree-virtual .edrv-tree-loading { height: 24px; min-height: 24px; box-sizing: border-box; }
312
+ [data-edrv-view] .edrv-tree-virtual .edrv-tree-loading { line-height: 24px; }
313
+ [data-edrv-view] .edrv-large-text { flex: 1; min-width: 0; min-height: 0; display: flex; flex-direction: column; }
314
+ [data-edrv-view] .edrv-large-text-toolbar { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
315
+ [data-edrv-view] .edrv-large-text-content { margin: 0; flex: 1; min-height: 0; overflow: auto; white-space: pre; font-family: var(--ds-font-family-code, ui-monospace, monospace); user-select: text; }
303
316
  [data-edrv-view] .edrv-tree-row { display: flex; align-items: center; gap: 4px; height: 22px; padding-right: 6px; border-radius: 5px; color: var(--dsw-alias-label-primary, #1f2933); cursor: pointer; white-space: nowrap; }
304
317
  [data-edrv-view] .edrv-tree-row:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(15,157,88,.08)); }
305
318
  [data-edrv-view] .edrv-tree-row.edrv-tree-active { background: var(--dsw-alias-interactive-bg-hover, rgba(15,157,88,.14)); }
@@ -988,7 +1001,6 @@
988
1001
  [data-edrv-view] .edrv-group { border: 0; }
989
1002
  [data-edrv-view] .edrv-group .edrv-tabs { flex: 0 0 auto; touch-action: none; }
990
1003
  [data-edrv-view] .edrv-group-tab-name { overflow: hidden; text-overflow: ellipsis; max-width: 180px; }
991
- [data-edrv-view] .edrv-workbench-tools { flex: 0 0 auto; }
992
1004
  [data-edrv-view] .edrv-split { display: flex; flex: 1 1 auto; min-width: 0; min-height: 0; overflow: hidden; }
993
1005
  [data-edrv-view] .edrv-split-vertical { flex-direction: column; }
994
1006
  [data-edrv-view] .edrv-split-child { display: flex; min-width: 0; min-height: 0; overflow: hidden; }