dsh-vscode-mode 0.1.19 → 0.1.21

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.
@@ -13,9 +13,11 @@ import { createDiffRenderer } from '../monaco/diffRender.js'
13
13
  import { ST, callIdAttr, noopHunk, summarize } from '../state/records.js'
14
14
  import { diffRegions } from '../state/regions.js'
15
15
  import { QuickOpen } from './QuickOpen.js'
16
- import { DiffBox } from './DiffBox.js'
17
- import { DiffBarEmpty } from './DiffBarEmpty.js'
18
16
  import { DiffLauncher } from './DiffLauncher.js'
17
+ import { SidebarView } from '../sidebar/SidebarView.js'
18
+ import { clearDiffDock, publishDiffDock } from '../diffDockStore.js'
19
+ import { displayDiffTotal, editorDockMode } from '../diffDock.js'
20
+ import { editorHeight } from '../editorLayout.js'
19
21
 
20
22
  /**
21
23
  * 中央编辑区:文件页签(脏点/关闭/打开路径)+ Ctrl+P 搜索 + Monaco 编辑器 +
@@ -34,6 +36,7 @@ export function EditorView(props) {
34
36
  const [active, setActive] = React.useState(null)
35
37
  const [dirtyMap, setDirtyMap] = React.useState({})
36
38
  const [content, setContent] = React.useState(null)
39
+ const [contentPath, setContentPath] = React.useState(null)
37
40
  const [status, setStatus] = React.useState('')
38
41
  const [error, setError] = React.useState(null)
39
42
  const [loadError, setLoadError] = React.useState(null)
@@ -47,7 +50,12 @@ export function EditorView(props) {
47
50
  const [diffIdx, setDiffIdx] = React.useState(0) // 当前文件内差异位置(x/x 显示)
48
51
  const [fileIdx, setFileIdx] = React.useState(0) // 全局差异文件位置(x/x 文件 显示)
49
52
  const [tabMenu, setTabMenu] = React.useState(null) // Tab 右键菜单 { x,y,path }(编辑区右键走 Monaco 原生菜单,无此浮层)
53
+ const [sidebarOn, setSidebarOn] = React.useState(true) // 侧边栏显隐
54
+ const [sidebarW, setSidebarW] = React.useState(240) // 侧边栏宽度
55
+ const [activePanel, setActivePanel] = React.useState('explorer') // 激活面板 id
56
+ const [focusRequest, setFocusRequest] = React.useState(0) // 外部差异聚焦请求版本
50
57
  const editorRef = React.useRef(null)
58
+ const viewRootRef = React.useRef(null)
51
59
  const monacoRef = React.useRef(null)
52
60
  const modelsRef = React.useRef(new Map())
53
61
  const saveTimerRef = React.useRef(null)
@@ -74,7 +82,8 @@ export function EditorView(props) {
74
82
  return list
75
83
  }, [records, active])
76
84
 
77
- const regions = React.useMemo(() => diffRegions(currentRecords, content).filter((r) => !r.superseded), [currentRecords, content])
85
+ const contentReady = content !== null && contentPath === active
86
+ const regions = React.useMemo(() => diffRegions(currentRecords, contentReady ? content : null).filter((r) => !r.superseded), [currentRecords, content, contentPath, active])
78
87
  // useMemo 稳定引用:否则 hover 等重渲染会让 view zone effect 反复重建(- 号闪烁)
79
88
  const pendingRegions = React.useMemo(() => regions.filter((r) => r.status === ST.PENDING && !r.stale), [regions])
80
89
  const staleRegions = React.useMemo(() => regions.filter((r) => r.status === ST.PENDING && r.stale), [regions])
@@ -132,6 +141,7 @@ export function EditorView(props) {
132
141
  if (seq !== loadSeqRef.current || path !== active) return
133
142
  if (res && res.ok) {
134
143
  setContent(res.content)
144
+ setContentPath(path)
135
145
  setLoadStage((prev) => ({ progress: Math.max(84, prev.progress), message: '文件已读取,准备创建编辑器…' }))
136
146
  setStatus('已加载')
137
147
  } else {
@@ -162,9 +172,18 @@ export function EditorView(props) {
162
172
  React.useEffect(() => {
163
173
  const onOpen = (e) => {
164
174
  const p = e?.detail?.path
165
- if (p) addTab(p, true)
175
+ if (!p) return
176
+ if (e?.detail?.focusDiff === true) {
177
+ pendingFocusRef.current = { path: p, region: null }
178
+ setFocusRequest((value) => value + 1)
179
+ }
180
+ addTab(p, true)
181
+ }
182
+ const onShowLauncher = (event) => {
183
+ const tab = event?.detail?.tab
184
+ if (tab === 'pending' || tab === 'archive') setLauncherTab(tab)
185
+ setLauncherOpen(true)
166
186
  }
167
- const onShowLauncher = () => setLauncherOpen(true)
168
187
  window.addEventListener('edrv:open-editor', onOpen)
169
188
  window.addEventListener('edrv:show-launcher', onShowLauncher)
170
189
  return () => {
@@ -173,6 +192,64 @@ export function EditorView(props) {
173
192
  }
174
193
  }, [sessionId])
175
194
 
195
+ // 对话输入区的唯一差异 dock 读取此会话快照;编辑器卸载时只清理自己的发布者令牌。
196
+ const dockSourceRef = React.useRef({})
197
+ React.useEffect(() => () => clearDiffDock(sessionId, dockSourceRef.current), [sessionId])
198
+
199
+ // DSH composer 保持原生布局;编辑区只读取几何边界并同步自己的高度。
200
+ React.useLayoutEffect(() => {
201
+ const root = viewRootRef.current
202
+ const scroll = root?.closest?.('[data-conversation-scroll]')
203
+ if (!root || !scroll) return
204
+ let seat = null
205
+ let frame = null
206
+ let resizeObserver = null
207
+ const update = () => {
208
+ frame = null
209
+ const rootRect = root.getBoundingClientRect()
210
+ const scrollRect = scroll.getBoundingClientRect()
211
+ const seatRect = seat?.getBoundingClientRect?.()
212
+ const height = editorHeight(rootRect.top, scrollRect.bottom, seatRect?.top)
213
+ root.style.setProperty('--edrv-editor-height', height + 'px')
214
+ }
215
+ const scheduleUpdate = () => {
216
+ if (frame !== null) return
217
+ frame = requestAnimationFrame(update)
218
+ }
219
+ const attachSeat = () => {
220
+ const next = scroll.querySelector?.('[data-composer-seat]')
221
+ if (next === seat) {
222
+ scheduleUpdate()
223
+ return
224
+ }
225
+ resizeObserver?.disconnect()
226
+ seat = next
227
+ resizeObserver = null
228
+ if (seat && typeof ResizeObserver === 'function') {
229
+ resizeObserver = new ResizeObserver(scheduleUpdate)
230
+ resizeObserver.observe(seat)
231
+ }
232
+ scheduleUpdate()
233
+ }
234
+ attachSeat()
235
+ const mutationObserver = typeof MutationObserver === 'function'
236
+ ? new MutationObserver(attachSeat)
237
+ : null
238
+ mutationObserver?.observe(scroll, { childList: true })
239
+ const scrollObserver = typeof ResizeObserver === 'function' ? new ResizeObserver(scheduleUpdate) : null
240
+ scrollObserver?.observe(scroll)
241
+ window.addEventListener('resize', scheduleUpdate)
242
+ scheduleUpdate()
243
+ return () => {
244
+ if (frame !== null) cancelAnimationFrame(frame)
245
+ resizeObserver?.disconnect()
246
+ scrollObserver?.disconnect()
247
+ mutationObserver?.disconnect()
248
+ window.removeEventListener('resize', scheduleUpdate)
249
+ root.style.removeProperty('--edrv-editor-height')
250
+ }
251
+ }, [sessionId])
252
+
176
253
  // localStorage v2 恢复页签
177
254
  React.useEffect(() => {
178
255
  if (bootRef.current || !sessionId) return
@@ -195,9 +272,50 @@ export function EditorView(props) {
195
272
  catch (e) { /* 忽略 */ }
196
273
  }, [tabs, active, sessionId])
197
274
 
275
+ // 侧边栏状态:恢复(显隐/宽度/激活面板)
276
+ React.useEffect(() => {
277
+ if (!sessionId) return
278
+ try {
279
+ const raw = localStorage.getItem('edrv.sidebar.' + String(sessionId))
280
+ if (raw) {
281
+ const saved = JSON.parse(raw)
282
+ if (typeof saved.on === 'boolean') setSidebarOn(saved.on)
283
+ if (typeof saved.width === 'number') setSidebarW(Math.max(180, Math.min(560, saved.width)))
284
+ if (typeof saved.panel === 'string') setActivePanel(saved.panel)
285
+ }
286
+ } catch (e) { /* 损坏忽略 */ }
287
+ }, [sessionId])
288
+
289
+ // 侧边栏状态持久化
290
+ React.useEffect(() => {
291
+ if (!sessionId) return
292
+ try { localStorage.setItem('edrv.sidebar.' + String(sessionId), JSON.stringify({ on: sidebarOn, width: sidebarW, panel: activePanel })) }
293
+ catch (e) { /* 忽略 */ }
294
+ }, [sidebarOn, sidebarW, activePanel, sessionId])
295
+
296
+ // Ctrl+B 切换侧边栏(capture 抢占,避免与 DSH 全局冲突)
297
+ React.useEffect(() => {
298
+ const onKey = (e) => {
299
+ if ((e.ctrlKey || e.metaKey) && String(e.key).toLowerCase() === 'b') {
300
+ e.preventDefault(); e.stopPropagation()
301
+ setSidebarOn((v) => !v)
302
+ }
303
+ }
304
+ window.addEventListener('keydown', onKey, true)
305
+ return () => window.removeEventListener('keydown', onKey, true)
306
+ }, [])
307
+
308
+ // 文件 → 待处理差异数 映射(侧边栏角标)
309
+ const pendingByPath = React.useMemo(() => {
310
+ const out = {}
311
+ for (const f of sum.files) if (f.pending > 0) out[f.path] = f.pending
312
+ return out
313
+ }, [sum])
314
+
198
315
  React.useEffect(() => {
199
316
  if (!active) return
200
317
  setContent(null)
318
+ setContentPath(null)
201
319
  setLoadError(null)
202
320
  setLoadStage({ progress: 10, message: '准备读取文件…' })
203
321
  setStatus('加载中…')
@@ -258,6 +376,7 @@ export function EditorView(props) {
258
376
  if (res && res.ok) {
259
377
  setStatus('已保存 ' + new Date().toTimeString().slice(0, 8))
260
378
  setContent(text)
379
+ setContentPath(active)
261
380
  setDirtyMap((d) => Object.assign({}, d, { [active]: false }))
262
381
  refreshRecords()
263
382
  emitRefresh()
@@ -418,7 +537,7 @@ export function EditorView(props) {
418
537
  ed.revealLineInCenter(Math.max(1, target.start ?? 1))
419
538
  ed.setPosition({ lineNumber: Math.max(1, target.start ?? 1), column: 1 })
420
539
  ed.focus()
421
- }, [active, content, pendingRegions])
540
+ }, [active, content, pendingRegions, focusRequest])
422
541
 
423
542
  const jumpTo = (region) => {
424
543
  if (editorRef.current) {
@@ -455,6 +574,12 @@ export function EditorView(props) {
455
574
  openFile(sum.pendingFiles[next].path, true)
456
575
  }
457
576
 
577
+ const openNextFile = () => {
578
+ if (!sum.pendingFiles.length) return
579
+ if (sum.pendingFiles.some((file) => file.path === active)) gotoFile(1)
580
+ else openFile(sum.pendingFiles[0].path, true)
581
+ }
582
+
458
583
  const reloadFile = (skipStale) => {
459
584
  if (!active) return
460
585
  loadContent(active, sessionId)
@@ -668,22 +793,19 @@ export function EditorView(props) {
668
793
 
669
794
  const pathBar = React.createElement('div', { className: 'edrv-pathbar', title: active || '' },
670
795
  React.createElement('span', { className: 'edrv-pb-name' }, active ? String(active).split(/[\\/]/).pop() : '未打开文件'),
671
- React.createElement('span', { className: 'edrv-pb-full' }, active || '使用右上搜索框 (Ctrl+P) 打开文件'))
796
+ React.createElement('span', { className: 'edrv-pb-full' }, active || '使用右上搜索框 (Ctrl+P) 打开文件'),
797
+ (active && langOf(active) ? React.createElement('span', { className: 'edrv-pb-meta' }, langOf(active)) : null),
798
+ (cursor ? React.createElement('span', { className: 'edrv-pb-meta' }, cursor) : null),
799
+ (status ? React.createElement('span', { className: 'edrv-pb-meta edrv-pb-status' }, status) : null))
672
800
 
673
801
  const tabRow = React.createElement('div', { style: { display: 'flex', alignItems: 'center', background: 'var(--dsw-alias-bg-layer-1,transparent)', flexShrink: 0 } },
674
802
  tabsEl,
675
- React.createElement(QuickOpen, { sessionId, onOpen: (p) => openFile(p, false) }))
676
-
677
- const statusBar = React.createElement('div', { className: 'edrv-statusbar' },
678
- React.createElement('span', null, '编辑'),
679
- React.createElement('span', null, active ? langOf(active) : ''),
680
- React.createElement('span', null, cursor),
681
- (status ? React.createElement('span', null, status) : null),
682
- React.createElement('span', { style: { flex: 1 } }),
683
803
  (sum.totalFiles > 0
684
804
  ? React.createElement('button', { className: 'edrv-diffchip', title: '打开差异总览/归档', onClick: () => setLauncherOpen((o) => !o) }, '⚠ 差异 ' + sum.totalFiles + ' 文件')
685
805
  : null),
686
- React.createElement('button', { className: 'edrv-chip-btn', title: '刷新', onClick: reloadFile }, '⟳'))
806
+ React.createElement('button', { className: 'edrv-chip-btn' + (sidebarOn ? ' edrv-chip-on' : ''), title: '切换侧边栏 (Ctrl+B)', onClick: () => setSidebarOn((v) => !v) }, '☰'),
807
+ React.createElement('button', { className: 'edrv-chip-btn', title: '刷新', onClick: reloadFile }, '⟳'),
808
+ React.createElement(QuickOpen, { sessionId, onOpen: (p) => openFile(p, false) }))
687
809
 
688
810
  const otherFiles = sum.pendingFiles.filter((f) => f.path !== active)
689
811
 
@@ -724,6 +846,7 @@ export function EditorView(props) {
724
846
  body = loadingBody('文件加载失败:' + loadError, 0, () => {
725
847
  setLoadError(null)
726
848
  setContent(null)
849
+ setContentPath(null)
727
850
  setLoadStage({ progress: 10, message: '重新读取文件…' })
728
851
  loadContent(active, sessionId)
729
852
  })
@@ -756,39 +879,43 @@ export function EditorView(props) {
756
879
  React.createElement('button', { className: 'edrv-pill edrv-pill-undo', onClick: () => { dismissHover(); actHunk(hoverAct.region, true) } }, '↩ Undo'))
757
880
  : null
758
881
 
882
+ React.useEffect(() => {
883
+ if (!sessionId) return
884
+ const mode = editorDockMode(active)
885
+ publishDiffDock(sessionId, {
886
+ mode,
887
+ pendingRegions,
888
+ staleRegions,
889
+ onAct: actHunk,
890
+ onAcceptFile: acceptFile,
891
+ onUndoFile: undoFile,
892
+ onAcceptAllFiles: acceptAllFiles,
893
+ onUndoAllFiles: undoAllFiles,
894
+ allPendingCount: allPending.length,
895
+ onRollback: rollbackFile,
896
+ onJump: jumpTo,
897
+ otherFiles,
898
+ onOpenOther: (path) => openFile(path, true),
899
+ onOpenLauncher: (tab) => { setLauncherTab(tab || 'pending'); setLauncherOpen(true) },
900
+ onRefresh: reloadFile,
901
+ activePath: active,
902
+ diffIdx: contentReady ? diffIdx : 0,
903
+ diffTotal: displayDiffTotal(contentReady, pendingRegions.length, sum.files.find((file) => file.path === active)?.pending ?? 0),
904
+ fileIdx,
905
+ fileTotal: sum.pendingFiles.length,
906
+ onPrevDiff: () => gotoDiff(-1),
907
+ onNextDiff: () => gotoDiff(1),
908
+ onPrevFile: () => gotoFile(-1),
909
+ onNextFile: () => gotoFile(1),
910
+ onOpenNextFile: openNextFile,
911
+ }, dockSourceRef.current)
912
+ })
913
+
759
914
  const overlay = launcherOpen
760
915
  ? React.createElement(React.Fragment, null,
761
916
  React.createElement('div', { style: { position: 'fixed', inset: 0, zIndex: 30 }, onClick: () => setLauncherOpen(false) }),
762
917
  React.createElement(DiffLauncher, { sessionId, sum, tab: launcherTab, onClose: () => setLauncherOpen(false), onOpenFile: (p) => { openFile(p, true); setLauncherOpen(false) } }))
763
- : (pendingRegions.length > 0
764
- ? React.createElement(DiffBox, {
765
- pendingRegions, staleRegions,
766
- onAct: actHunk,
767
- onAcceptFile: acceptFile,
768
- onUndoFile: undoFile,
769
- onAcceptAllFiles: acceptAllFiles,
770
- onUndoAllFiles: undoAllFiles,
771
- allPendingCount: allPending.length,
772
- onRollback: rollbackFile,
773
- onJump: jumpTo,
774
- otherFiles,
775
- onOpenOther: (p) => openFile(p, true),
776
- onOpenLauncher: (tab) => { setLauncherTab(tab || 'pending'); setLauncherOpen(true) },
777
- onRefresh: reloadFile,
778
- activePath: active,
779
- diffIdx, diffTotal: pendingRegions.length,
780
- fileIdx, fileTotal: sum.pendingFiles.length,
781
- onPrevDiff: () => gotoDiff(-1), onNextDiff: () => gotoDiff(1),
782
- onPrevFile: () => gotoFile(-1), onNextFile: () => gotoFile(1),
783
- })
784
- : (sum.pendingFiles.length > 0
785
- ? React.createElement(DiffBarEmpty, {
786
- sum, active, staleCount: staleRegions.length,
787
- onNextFile: () => { if (sum.pendingFiles.length) openFile(sum.pendingFiles[0].path, true) },
788
- onOpenLauncher: (tab) => { setLauncherTab(tab || 'pending'); setLauncherOpen(true) },
789
- onRefresh: reloadFile,
790
- })
791
- : null))
918
+ : null
792
919
 
793
920
  // Tab 右键菜单浮层:全屏遮罩(点击/右键关闭)+ 菜单(固定定位,clamp 防越界)。
794
921
  // 编辑区右键走 Monaco 原生 context menu(editor.addAction),不在此渲染浮层。
@@ -814,14 +941,44 @@ export function EditorView(props) {
814
941
  React.createElement('button', { className: 'edrv-ctxmenu-item edrv-ctxmenu-danger', onClick: () => { closeTab(tabMenu.path); dismissMenus() } }, '关闭标签页'))
815
942
  : null
816
943
 
817
- return React.createElement('div', { 'data-edrv-view': '1', style: { height: '100%', minHeight: 0, display: 'flex', flexDirection: 'column', background: 'var(--dsw-alias-bg-base,transparent)', overflow: 'hidden' } },
944
+ const sidebarPanels = props.sidebarPanels
945
+ const sidebarCtx = {
946
+ sessionId,
947
+ openFile: (p) => openFile(p, false),
948
+ activePath: active,
949
+ pendingByPath,
950
+ sum: { totalFiles: sum.totalFiles, files: sum.files },
951
+ refreshRecords: () => refreshRecords(),
952
+ editor: () => editorRef.current,
953
+ outlineSources: props.outlineSources,
954
+ }
955
+
956
+ // 主编辑列(侧边栏右侧):pathBar + tabRow + 编辑/差异区(底部整条留给 DSH 对话输入栏)
957
+ const editorArea = React.createElement('div', { className: 'edrv-editor-area' },
958
+ body,
959
+ hoverEl,
960
+ overlay)
961
+ const mainCol = React.createElement('div', { className: 'edrv-main-col', style: { flex: 1, minWidth: 0, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' } },
818
962
  pathBar,
819
963
  tabRow,
820
- React.createElement('div', { style: { position: 'relative', flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' } },
821
- body,
822
- hoverEl,
823
- overlay),
964
+ editorArea)
965
+ // 编辑器根节点按 composer 顶部边界动态限高,底部对话区域继续由 DSH 原生渲染。
966
+ const editorRow = React.createElement('div', { className: 'edrv-editor-row' },
967
+ (sidebarOn && sidebarPanels
968
+ ? React.createElement(SidebarView, {
969
+ registry: sidebarPanels,
970
+ ctx: sidebarCtx,
971
+ activePanel,
972
+ onActive: setActivePanel,
973
+ width: sidebarW,
974
+ onWidth: setSidebarW,
975
+ onHide: () => setSidebarOn(false),
976
+ })
977
+ : null),
978
+ mainCol)
979
+
980
+ return React.createElement('div', { ref: viewRootRef, 'data-edrv-view': '1', style: { height: 'var(--edrv-editor-height, 100%)', maxHeight: 'var(--edrv-editor-height, 100%)', minHeight: 0, display: 'flex', flexDirection: 'column', background: 'var(--dsw-alias-bg-base,transparent)', overflow: 'hidden' } },
981
+ editorRow,
824
982
  menuBackdrop,
825
- tabMenuEl,
826
- statusBar)
983
+ tabMenuEl)
827
984
  }
package/src/rpc.ts CHANGED
@@ -29,6 +29,7 @@ import { listProjects, projectRefresh, projectRemove, projectSave, projectToggle
29
29
  import { normalizeFileOpenTool, FILE_OPEN_DEFAULT, FILE_OPEN_SETTINGS_NS } from './fileOpenSettings.js'
30
30
  import { buildReport } from './compat.js'
31
31
  import { readDevForm, setDevForm } from './devForm.js'
32
+ import { normalizeRel, toTreeEntries } from './tree.js'
32
33
 
33
34
  /** cwd → Promise 链:串行化 debug 日志追加(fs read+write 非原子,避免并发丢行)。 */
34
35
  const debugWriteQueues = new Map<string, Promise<void>>()
@@ -315,6 +316,26 @@ export function buildHandlers(ctx: Ctx, registry: Registry, searcher = newSearch
315
316
  const result = await searcher.search({ session: sc.session, cwd: sc.cwd, query: args.query, activePaths })
316
317
  return { ok: true, ...result }
317
318
  },
319
+ 'edrv.listDir': async (args) => {
320
+ // 目录树(侧边栏文件管理用):按会话 cwd 解析,与 edrv.read 同基准,树内相对路径点开即打开。
321
+ const sc = await requireSession(ctx, args.sessionId)
322
+ if ('err' in sc) return { ok: false, error: sc.err }
323
+ const fs = ctx.get('fs')
324
+ if (!fs) return { ok: false, error: '缺少 fs' }
325
+ try {
326
+ const rel = normalizeRel(args.path)
327
+ if (rel === null) return { ok: false, error: '路径不合法' }
328
+ const target = await resolveTarget(ctx, sc.session, rel || '.')
329
+ const info = await fs.stat(target)
330
+ if (!info) return { ok: false, error: '目录不存在' }
331
+ if (info.type !== 'directory') return { ok: false, error: '不是目录' }
332
+ const children = await fs.listDir(target)
333
+ const root = fs.processPath(await fs.resolve('.', { cwd: sc.cwd }))
334
+ return { ok: true, root, path: rel, entries: toTreeEntries(rel, children) }
335
+ } catch (error) {
336
+ return { ok: false, error: '读取目录失败:' + String(error) }
337
+ }
338
+ },
318
339
  'mcp.list': async () => ({ ok: true, ...listMcp(ctx) }),
319
340
  'mcp.save': async (args) => {
320
341
  try { return { ok: true, server: await saveMcp(ctx, args.config) } }
package/src/shared/rpc.ts CHANGED
@@ -21,6 +21,14 @@ export const RPC_PATH = '/edrv/rpc'
21
21
  /** 决策作用域:call=整条记录,hunk=单个差异块。 */
22
22
  export type RpcScope = 'call' | 'hunk'
23
23
 
24
+ /** 目录树单项(edrv.listDir 返回;path 相对工作区根,'/' 分隔)。 */
25
+ export interface TreeEntry {
26
+ name: string
27
+ path: string
28
+ type: 'file' | 'directory' | 'other'
29
+ size?: number
30
+ }
31
+
24
32
  /** 一条批量决策项(decideBatch 的 items 元素,与 accept/reject 单条参数同构)。 */
25
33
  export interface DecideItem {
26
34
  callId: string
@@ -51,6 +59,7 @@ export interface RpcRequestMap {
51
59
  'edrv.rollback': { sessionId?: string; path: string; batch?: number }
52
60
  'edrv.debug': { sessionId?: string; text: string }
53
61
  'edrv.searchFiles': { sessionId?: string; query: string }
62
+ 'edrv.listDir': { sessionId?: string; path: string }
54
63
  'mcp.list': {}
55
64
  'mcp.save': { config: MpcConfig }
56
65
  'mcp.remove': { id: string }
@@ -84,6 +93,7 @@ export interface RpcOkMap {
84
93
  'edrv.rollback': { path: string; batch: number | null }
85
94
  'edrv.debug': object
86
95
  'edrv.searchFiles': { files: string[]; truncated: boolean }
96
+ 'edrv.listDir': { root: string; path: string; entries: TreeEntry[] }
87
97
  'mcp.list': { servers: MpcServer[] }
88
98
  'mcp.save': { server: MpcServer }
89
99
  'mcp.remove': object
package/src/tree.ts ADDED
@@ -0,0 +1,62 @@
1
+ /**
2
+ * dsh-vscode-mode host — 目录树纯函数(edrv.listDir 的数据整形与守卫)。
3
+ * 不触 fs/node,仅结构类型,可单测;RPC handler 只做装配,判定都收在这里。
4
+ * 作者 ddj 2026-08-26
5
+ */
6
+ import type { TreeEntry } from './shared/rpc.js'
7
+
8
+ /** fs.listDir 返回项的结构最小视图(不引 dsh-fs,保持纯函数零依赖)。 */
9
+ export interface DirChildLike {
10
+ name: string
11
+ type: 'file' | 'directory' | 'other'
12
+ size?: number
13
+ }
14
+
15
+ /** 目录在文件前、名称按小写字母序的比较器(对齐 VSCode 资源管理器习惯)。 */
16
+ function entryOrder(a: { name: string; type: string }, b: { name: string; type: string }): number {
17
+ const aDir = a.type === 'directory' ? 0 : 1
18
+ const bDir = b.type === 'directory' ? 0 : 1
19
+ if (aDir !== bDir) return aDir - bDir
20
+ const la = a.name.toLocaleLowerCase('en-US')
21
+ const lb = b.name.toLocaleLowerCase('en-US')
22
+ return la < lb ? -1 : la > lb ? 1 : 0
23
+ }
24
+
25
+ /**
26
+ * 归一化目录相对路径:去空白、反斜杠→斜杠、去首尾斜杠、`.`→''。
27
+ * 任一路径段为 `..` 返回 null(拒绝越界)。
28
+ * @author ddj 2026年08月26号
29
+ * @param path 原始相对路径('' 表示根目录)
30
+ * @returns 归一化相对路径('' = 根),非法返回 null
31
+ */
32
+ export function normalizeRel(path: string | undefined | null): string | null {
33
+ const rel = String(path ?? '').trim().replace(/\\/g, '/').replace(/^\/+|\/+$/g, '')
34
+ if (!rel) return ''
35
+ const segs = rel.split('/').filter((seg) => seg !== '.')
36
+ if (!segs.length) return ''
37
+ for (const seg of segs) {
38
+ if (seg === '..') return null
39
+ }
40
+ return segs.join('/')
41
+ }
42
+
43
+ /**
44
+ * fs.listDir 结果 → TreeEntry 数组:相对路径拼接 + 目录优先排序。
45
+ * @author ddj 2026年08月26号
46
+ * @param rel 父目录相对路径('' = 根)
47
+ * @param children 目录子项
48
+ * @returns 排序后的树条目
49
+ */
50
+ export function toTreeEntries(rel: string, children: DirChildLike[]): TreeEntry[] {
51
+ const out: TreeEntry[] = []
52
+ for (const child of children) {
53
+ if (!child || typeof child.name !== 'string' || !child.name) continue
54
+ out.push({
55
+ name: child.name,
56
+ path: rel ? rel + '/' + child.name : child.name,
57
+ type: child.type === 'directory' ? 'directory' : child.type === 'file' ? 'file' : 'other',
58
+ size: typeof child.size === 'number' ? child.size : undefined,
59
+ })
60
+ }
61
+ return out.sort(entryOrder)
62
+ }