dsh-vscode-mode 0.6.0 → 0.7.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.
@@ -50,7 +50,7 @@ import { bindingsOf, chordOf, matchEvent, useKeybindingsVersion } from '../keybi
50
50
  import { getSidebarMinWidth } from '../sidebarMin.js'
51
51
  import { navHistoryFor } from '../navHistory.js'
52
52
  import { statusOfAdd } from '../addToConversation.js'
53
- import { setSearchSeed } from '../searchSeed.js'
53
+ import { setSearchScope, setSearchSeed } from '../searchSeed.js'
54
54
  import { CACHE_KEY } from '../paths.js'
55
55
  import { runGoToDefinition, runFindReferences, hideReferencesOverlay } from '../monaco/lsp/providers.js'
56
56
  import { bindLspUnderline } from '../monaco/lsp/underline.js'
@@ -62,7 +62,7 @@ import { setupLaunchJson, prefetchSnippets } from '../dap/launchSnippetProvider.
62
62
  import { findArrayPos, LAUNCH_JSON_RE } from '../dap/launchInsert.js'
63
63
  import {
64
64
  absoluteOf, ancestorDirsOf, applyClose, baseNameOf, closeAll, closeOthers, closeRight, closeSaved,
65
- evictPlan, insertTab, isTreeRevealable, normalizeTabs, pickActive, relativeOf, tabPathOf, togglePin,
65
+ evictPlan, insertTab, isTreeRevealable, normalizeTabs, pickActive, relativeOf, remapPathOf, tabPathOf, togglePin,
66
66
  } from '../tabActions.js'
67
67
  import { getMaxOpenEditors } from '../editorLimit.js'
68
68
  import { buildTabMenu } from '../tabMenu.js'
@@ -75,6 +75,9 @@ import { isSvnDiffable } from '../../shared/svn.js'
75
75
  import type { SvnAction } from '../../shared/svn.js'
76
76
  import { createSaveTimer } from '../saveDebounce.js'
77
77
  import { ContextMenu } from './ContextMenu.js'
78
+ import { promptName } from './PromptDialog.js'
79
+ import { openWithDialog } from '../openWithDialog.js'
80
+ import { copyText as copyClipText } from '../copyText.js'
78
81
  import { SvnLogDialog } from './SvnLogDialog.js'
79
82
  import { SvnPatchDialog } from './SvnPatchDialog.js'
80
83
  import { SvnSumDialog } from './SvnSumDialog.js'
@@ -630,6 +633,59 @@ export function EditorView(props) {
630
633
  setStatus(label + '(' + closing.length + ' 个)')
631
634
  }
632
635
 
636
+ /**
637
+ * 删除落盘后的页签收尾:关闭目标及其子树页签。
638
+ * 不走 closeTab 的「先落盘」:文件已删除,落盘会把删除的文件写回磁盘。
639
+ * @author ddj 2026年09月22号
640
+ * @param path 已删除的工作区相对路径
641
+ */
642
+ const dropDeletedTabs = (path) => {
643
+ const doomed = tabsRef.current.filter((t) => t.path === path || t.path.startsWith(path + '/'))
644
+ if (!doomed.length) return
645
+ for (const tab of doomed) releaseTab(tab.path)
646
+ commitClose(applyClose(tabsRef.current, new Set(doomed.map((t) => t.path)), activeRef.current))
647
+ }
648
+
649
+ /**
650
+ * 重命名落盘后的页签改写:目标及其子树页签路径整体改写(含脏标与活动页签)。
651
+ * @author ddj 2026年09月22号
652
+ * @param from 原路径
653
+ * @param to 新路径
654
+ */
655
+ const remapRenamedTabs = (from, to) => {
656
+ setTabs((prev) => prev.map((tab) => ({ ...tab, path: remapPathOf(tab.path, from, to) })))
657
+ setDirtyMap((prev) => {
658
+ const next = {}
659
+ for (const key of Object.keys(prev)) next[remapPathOf(key, from, to)] = prev[key]
660
+ return next
661
+ })
662
+ setActive((prev) => (prev ? remapPathOf(prev, from, to) : prev))
663
+ }
664
+
665
+ // 文件树右键的重命名/删除 → 页签同步(动作在 sidebar/menuItems.ts,经窗口事件桥接;
666
+ // 经 ref 读最新闭包,避免空依赖 effect 捕获陈旧状态)
667
+ const tabFsSyncRef = React.useRef({ rename: () => {}, drop: () => {} })
668
+ tabFsSyncRef.current = { rename: remapRenamedTabs, drop: dropDeletedTabs }
669
+ React.useEffect(() => {
670
+ const onRenamed = (event) => {
671
+ const from = event?.detail?.from
672
+ const to = event?.detail?.to
673
+ if (typeof from !== 'string' || !from || typeof to !== 'string' || !to || from === to) return
674
+ tabFsSyncRef.current.rename(from, to)
675
+ }
676
+ const onDeleted = (event) => {
677
+ const path = event?.detail?.path
678
+ if (typeof path !== 'string' || !path) return
679
+ tabFsSyncRef.current.drop(path)
680
+ }
681
+ window.addEventListener('edrv:path-renamed', onRenamed)
682
+ window.addEventListener('edrv:path-deleted', onDeleted)
683
+ return () => {
684
+ window.removeEventListener('edrv:path-renamed', onRenamed)
685
+ window.removeEventListener('edrv:path-deleted', onDeleted)
686
+ }
687
+ }, [])
688
+
633
689
  /**
634
690
  * 在已打开页签间循环切换(文件分页归编辑器自带页签栏)。
635
691
  * @author ddj 2026年09月10号
@@ -1453,6 +1509,11 @@ export function EditorView(props) {
1453
1509
  ['edrv.command.svnTortoiseDiff', () => runSvnTortoise('diff', activeRef.current)],
1454
1510
  ['edrv.command.svnTortoiseBlame', () => runSvnTortoise('blame', activeRef.current)],
1455
1511
  ['edrv.command.svnTortoiseRevert', () => runSvnTortoise('revert', activeRef.current)],
1512
+ // 转到行:转发 Monaco 原生 action(widget 本体/占位/跳转全原生,插件只补键位与命令栏入口)
1513
+ ['edrv.command.goToLine', () => {
1514
+ const ed = editorRef.current
1515
+ if (ed?.getModel?.()) ed.trigger('edrv-goto', 'editor.action.gotoLine', null)
1516
+ }],
1456
1517
  // 诊断日志弹窗(同片段选择器:执行一条命令即关命令栏,避免残浮层遮挡)
1457
1518
  ['edrv.command.showLogs', () => { closeCommandPalette(); setDlogOpen(true) }],
1458
1519
  ]
@@ -2798,17 +2859,15 @@ export function EditorView(props) {
2798
2859
 
2799
2860
  /**
2800
2861
  * 复制文本到剪贴板(状态栏反馈;浏览器拒绝时提示,不抛异常)。
2801
- * @author ddj 2026年09月11号
2862
+ * 剪贴板写入与降级文案收敛到 client/copyText.ts(文件树菜单同源共用)。
2863
+ * @author ddj 2026年09月11号 / 2026年09月22号
2802
2864
  * @param text 待复制文本
2803
2865
  * @param okText 成功文案
2804
2866
  */
2805
2867
  const copyText = (text, okText) => {
2806
2868
  const value = String(text ?? '')
2807
2869
  if (!value) { setStatus('无可复制内容'); return }
2808
- if (!navigator.clipboard?.writeText) { setStatus('剪贴板不可用'); return }
2809
- navigator.clipboard.writeText(value)
2810
- .then(() => setStatus(okText + ':' + value))
2811
- .catch(() => setStatus('复制失败(浏览器拒绝剪贴板写入)'))
2870
+ void copyClipText(value, okText + ':' + value, setStatus)
2812
2871
  }
2813
2872
 
2814
2873
  /**
@@ -3393,6 +3452,21 @@ export function EditorView(props) {
3393
3452
  openSvnLog: (p) => openSvnLog(p),
3394
3453
  confirm: (message) => (typeof window === 'undefined' ? false : window.confirm(message)),
3395
3454
  notify: (message) => setStatus(message),
3455
+ // 名称输入弹窗(资源管理器右键的新建文件/新建文件夹/重命名)
3456
+ prompt: promptName,
3457
+ // 「打开方式…」(资源管理器右键、文件目标):打开器选择弹窗(注册表由 index 装配传入)
3458
+ openWith: (p) => {
3459
+ const openers = props.fileOpeners
3460
+ if (!openers) return
3461
+ openWithDialog(openers, { sessionId, cwd: cwd ?? undefined }, p, (opener) => {
3462
+ if (opener) setStatus('已用「' + opener.label + '」打开:' + baseNameOf(p))
3463
+ })
3464
+ },
3465
+ // 「在文件夹中查找…」(资源管理器右键、目录目标):目录过滤种子 + 跳搜索面板
3466
+ searchInFolder: (dir) => {
3467
+ setSearchScope(dir)
3468
+ openSearchPanel()
3469
+ },
3396
3470
  }
3397
3471
 
3398
3472
  // 调试工具条(VS Code 浮动条紧凑版,图2 形态):配置下拉 + 启动/继续 + 单步组 + 停止 + 相位。
@@ -86,6 +86,7 @@ export function OfficialSideTab(props) {
86
86
  sidebarPanels: props?.sidebarPanels,
87
87
  outlineSources: props?.outlineSources,
88
88
  fileMenuItems: props?.fileMenuItems,
89
+ fileOpeners: props?.fileOpeners,
89
90
  sessions: props?.sessions,
90
91
  })
91
92
  }
@@ -0,0 +1,63 @@
1
+ // @ts-nocheck
2
+ /**
3
+ * dsh-vscode-mode client — 名称输入弹窗(新建文件/新建文件夹/重命名共用)。
4
+ * 复用 ModalShell(.vsm-mcp-dialog 视觉 + data-edrv-view 作用域 + Esc/遮罩关闭);
5
+ * 确认返回输入文本(首尾空白已去),取消/Esc/遮罩/空输入返回 null。
6
+ * 重命名时默认选中主名(不含扩展名),与 VS Code 行为一致。
7
+ * 作者 ddj 2026年09月22号
8
+ */
9
+ import React from 'react'
10
+ import { createRoot } from 'react-dom/client'
11
+ import { ModalShell } from './ModalShell.js'
12
+
13
+ /** 输入弹窗卡片(ModalShell 内容)。 */
14
+ function PromptCard({ title, initial, done }) {
15
+ const inputRef = React.useRef(null)
16
+ React.useEffect(() => {
17
+ const el = inputRef.current
18
+ if (!el) return
19
+ el.focus()
20
+ const dot = String(initial).lastIndexOf('.')
21
+ el.setSelectionRange(0, dot > 0 ? dot : String(initial).length)
22
+ }, [])
23
+ const submit = () => done(String(inputRef.current?.value ?? '').trim())
24
+ return React.createElement(React.Fragment, null,
25
+ React.createElement('h3', null, title),
26
+ React.createElement('label', null, '名称',
27
+ React.createElement('input', {
28
+ ref: inputRef,
29
+ defaultValue: initial,
30
+ onKeyDown: (event) => {
31
+ if (event.key !== 'Enter') return
32
+ event.preventDefault()
33
+ submit()
34
+ },
35
+ })),
36
+ React.createElement('div', { className: 'vsm-mcp-dialog-actions' },
37
+ React.createElement('button', { onClick: () => done(null) }, '取消'),
38
+ React.createElement('button', { className: 'vsm-primary', onClick: submit }, '确定')))
39
+ }
40
+
41
+ /**
42
+ * 弹出名称输入弹窗。
43
+ * @author ddj 2026年09月22号
44
+ * @param title 弹窗标题
45
+ * @param initial 初始名称(重命名预填原名;新建为空串)
46
+ * @returns 用户输入(已去首尾空白);取消/空输入返回 null
47
+ */
48
+ export function promptName(title, initial) {
49
+ return new Promise((resolve) => {
50
+ const host = document.createElement('div')
51
+ document.body.appendChild(host)
52
+ const root = createRoot(host)
53
+ const done = (value) => {
54
+ root.unmount()
55
+ host.remove()
56
+ resolve(value ? value : null)
57
+ }
58
+ root.render(React.createElement(ModalShell, {
59
+ onClose: () => done(null),
60
+ width: 'min(420px, calc(100vw - 40px))',
61
+ }, React.createElement(PromptCard, { title, initial: String(initial ?? ''), done })))
62
+ })
63
+ }
@@ -44,6 +44,7 @@ export function SideEditorTab(props) {
44
44
  sidebarPanels: props?.sidebarPanels,
45
45
  outlineSources: props?.outlineSources,
46
46
  fileMenuItems: props?.fileMenuItems,
47
+ fileOpeners: props?.fileOpeners,
47
48
  sessions: props?.sessions,
48
49
  })
49
50
  }
@@ -80,6 +80,19 @@ function prevEditorRowDef(): CommandDef {
80
80
  }
81
81
  }
82
82
 
83
+ /**
84
+ * 转到行(转发 Monaco 原生 gotoLine widget;补命令栏与快捷键设置页入口)。
85
+ * @author ddj 2026年09月22号
86
+ * @returns 命令定义
87
+ */
88
+ function gotoLineDef(): CommandDef {
89
+ return {
90
+ id: 'edrv.goToLine', label: '转到行', category: '导航', order: 45,
91
+ keybinding: 'Ctrl+G', available: needsModel,
92
+ run: () => emit('goToLine'),
93
+ }
94
+ }
95
+
83
96
  /**
84
97
  * 添加选中内容为引用(把当前选区追加进对话输入框;无选区则状态栏提示)。
85
98
  * 有活动编辑器模型才可用,保证对话框内按 Ctrl+U 不被本命令吞掉。
@@ -317,6 +330,7 @@ function debugPaletteDefs(): CommandDef[] {
317
330
  export const BRIDGE_COMMANDS: readonly CommandDef[] = [
318
331
  nextEditorRowDef(),
319
332
  prevEditorRowDef(),
333
+ gotoLineDef(),
320
334
  addSelectionRefDef(),
321
335
  closeTabDef(),
322
336
  ...debugPaletteDefs(),
package/src/rpc.ts CHANGED
@@ -20,8 +20,8 @@ import {
20
20
  resolveTarget,
21
21
  saveBucket,
22
22
  } from './store.js'
23
- import { mkdir, readFile, rm, stat, writeFile, readdir } from 'node:fs/promises'
24
- import { basename, join } from 'node:path'
23
+ import { cp, mkdir, readFile, rename, rm, stat, writeFile, readdir } from 'node:fs/promises'
24
+ import { basename, dirname, join } from 'node:path'
25
25
  import { archiveEntryFor, markDecision, recordResolved, reconstructOriginal } from './model.js'
26
26
  import type { Registry } from './registry.js'
27
27
  import { bucketOf, cwdOf, sessionOf } from './registry.js'
@@ -42,6 +42,7 @@ import { handoffOpen, pendingState, pollPending } from './externalHandoff.js'
42
42
  import { buildReport } from './compat.js'
43
43
  import { findProfileDir, readDevForm, setDevForm } from './devForm.js'
44
44
  import { normalizeRel } from './tree.js'
45
+ import { baseNameOf, checkNewName, checkRenameName, isSubPath, joinRelPath, parentRelOf } from './shared/fsNames.js'
45
46
  import { invalidateIndex, listDirCached } from './treeIndex.js'
46
47
  import { revealInExplorer } from './reveal.js'
47
48
  import { dshHome, debugLogFile, pluginLogRoot } from './paths.js'
@@ -246,6 +247,106 @@ async function integrationBaseUrlOf(ctx: Ctx): Promise<string> {
246
247
  return raw || INTEGRATION_BASE_DEFAULT
247
248
  }
248
249
 
250
+ // --region 文件操作(edrv.fs.*:资源管理器右键的新建/重命名/删除/复制/移动)
251
+
252
+ /**
253
+ * 文件操作目标解析:工作区相对路径 → 绝对路径,并做工作区边界检查。
254
+ * edrv.fs.* 各方法共用(对齐 edrv.saveBinary 的 contains 护栏口径)。
255
+ * @author ddj 2026年09月22号
256
+ * @param ctx DSH 上下文
257
+ * @param session 当前会话
258
+ * @param rel 工作区相对路径
259
+ * @returns 绝对路径;缺 fs/越界/解析失败返回错误文案
260
+ */
261
+ async function fsOpsTarget(ctx: Ctx, session: Session, rel: string): Promise<{ abs: string } | { err: string }> {
262
+ const fs = ctx.get('fs')
263
+ if (!fs) return { err: '缺少 fs' }
264
+ try {
265
+ const target = await resolveTarget(ctx, session, rel)
266
+ const rootTarget = await fs.resolve(policyOf(ctx, session)?.workspaceRoot ?? '.', {})
267
+ if (!fs.contains(rootTarget, target)) return { err: '拒绝操作:目标不在会话工作区内' }
268
+ return { abs: fs.processPath(target) }
269
+ } catch (error) {
270
+ return { err: '路径解析失败:' + String(error) }
271
+ }
272
+ }
273
+
274
+ /**
275
+ * 取异常的系统错误码(EEXIST/EXDEV 等分支判定用)。
276
+ * @author ddj 2026年09月22号
277
+ * @param error 捕获到的异常
278
+ * @returns 错误码;取不到返回空串
279
+ */
280
+ function fsErrCode(error: unknown): string {
281
+ const code = (error as { code?: unknown } | null)?.code
282
+ return typeof code === 'string' ? code : ''
283
+ }
284
+
285
+ /**
286
+ * 复制/移动共用核心(edrv.fsCopy / edrv.fsMove):目标目录存在 + 同名拒绝覆盖 +
287
+ * 「不能以自身或其子目录为目标」护栏;move 跨设备(EXDEV)回退「递归复制 + 删除源」。
288
+ * @author ddj 2026年09月22号
289
+ * @param ctx DSH 上下文
290
+ * @param sc requireSession 成功结果(session + cwd)
291
+ * @param args 请求载荷(from / toDir 均为工作区相对路径)
292
+ * @param mode copy = 复制;move = 移动
293
+ * @returns 成功返回 from/to 相对路径;失败返回错误文案
294
+ */
295
+ async function fsTransfer(
296
+ ctx: Ctx,
297
+ sc: { session: Session; cwd: string },
298
+ args: { from: string; toDir: string },
299
+ mode: 'copy' | 'move',
300
+ ): Promise<{ from: string; to: string } | { err: string }> {
301
+ const fromRel = normalizeRel(args.from)
302
+ const toDirRel = normalizeRel(args.toDir)
303
+ if (fromRel === null || fromRel === '' || toDirRel === null) return { err: '路径不合法' }
304
+ if (isSubPath(fromRel, toDirRel)) return { err: '不能以自身或其子目录为目标' }
305
+ const src = await fsOpsTarget(ctx, sc.session, fromRel)
306
+ if ('err' in src) return src
307
+ const dir = await fsOpsTarget(ctx, sc.session, toDirRel)
308
+ if ('err' in dir) return dir
309
+ const dirInfo = await stat(dir.abs).catch(() => null)
310
+ if (!dirInfo || !dirInfo.isDirectory()) return { err: '目标目录不存在' }
311
+ const toRel = joinRelPath(toDirRel, baseNameOf(fromRel))
312
+ const dst = await fsOpsTarget(ctx, sc.session, toRel)
313
+ if ('err' in dst) return dst
314
+ const srcInfo = await stat(src.abs).catch(() => null)
315
+ if (!srcInfo) return { err: '源不存在' }
316
+ if (await stat(dst.abs).catch(() => null)) return { err: '目标目录已存在同名文件或目录' }
317
+ try {
318
+ await transferEntry(src.abs, dst.abs, mode)
319
+ } catch (error) {
320
+ return { err: (mode === 'copy' ? '复制失败:' : '移动失败:') + String(error) }
321
+ }
322
+ invalidateIndex(ctx, sc.cwd, fromRel)
323
+ invalidateIndex(ctx, sc.cwd, toRel)
324
+ return { from: fromRel, to: toRel }
325
+ }
326
+
327
+ /**
328
+ * 单条目复制/移动落盘(文件/目录通用;move 跨设备回退复制+删除源)。
329
+ * @author ddj 2026年09月22号
330
+ * @param absFrom 源绝对路径
331
+ * @param absTo 目标绝对路径
332
+ * @param mode copy = 复制;move = 移动
333
+ */
334
+ async function transferEntry(absFrom: string, absTo: string, mode: 'copy' | 'move'): Promise<void> {
335
+ if (mode === 'copy') {
336
+ await cp(absFrom, absTo, { recursive: true, force: false, errorOnExist: true })
337
+ return
338
+ }
339
+ try {
340
+ await rename(absFrom, absTo)
341
+ } catch (error) {
342
+ if (fsErrCode(error) !== 'EXDEV') throw error
343
+ await cp(absFrom, absTo, { recursive: true, force: false, errorOnExist: true })
344
+ await rm(absFrom, { recursive: true })
345
+ }
346
+ }
347
+
348
+ // --endregion
349
+
249
350
  /** 各方法 handler 表(类型由 shared/rpc 的 RpcHandlerMap 约束)。 */
250
351
  export function buildHandlers(
251
352
  ctx: Ctx,
@@ -612,6 +713,101 @@ export function buildHandlers(
612
713
  return { ok: false, error: '打开失败:' + String(error) }
613
714
  }
614
715
  },
716
+ 'edrv.fsCreateFile': async (args) => {
717
+ // 新建文件(资源管理器右键「新建文件…」):空内容独占创建(同名拒绝),
718
+ // 名称可含 a/b.c 嵌套段(父目录按需创建);名称校验与客户端弹窗共用 shared/fsNames。
719
+ const sc = await requireSession(ctx, args.sessionId)
720
+ if ('err' in sc) return { ok: false, error: sc.err }
721
+ const bad = checkNewName(args.path)
722
+ if (bad) return { ok: false, error: bad }
723
+ const r = await fsOpsTarget(ctx, sc.session, args.path)
724
+ if ('err' in r) return { ok: false, error: r.err }
725
+ try {
726
+ await mkdir(dirname(r.abs), { recursive: true })
727
+ await writeFile(r.abs, '', { flag: 'wx' })
728
+ } catch (error) {
729
+ const code = fsErrCode(error)
730
+ return { ok: false, error: code === 'EEXIST' ? '已存在同名文件或目录' : '新建文件失败:' + String(error) }
731
+ }
732
+ invalidateIndex(ctx, sc.cwd, args.path)
733
+ return { ok: true, path: args.path }
734
+ },
735
+ 'edrv.fsCreateDir': async (args) => {
736
+ // 新建文件夹(资源管理器右键「新建文件夹」):名称可含嵌套段(父目录按需创建),已存在拒绝。
737
+ const sc = await requireSession(ctx, args.sessionId)
738
+ if ('err' in sc) return { ok: false, error: sc.err }
739
+ const bad = checkNewName(args.path)
740
+ if (bad) return { ok: false, error: bad }
741
+ const r = await fsOpsTarget(ctx, sc.session, args.path)
742
+ if ('err' in r) return { ok: false, error: r.err }
743
+ try {
744
+ await mkdir(dirname(r.abs), { recursive: true })
745
+ await mkdir(r.abs)
746
+ } catch (error) {
747
+ const code = fsErrCode(error)
748
+ return { ok: false, error: code === 'EEXIST' ? '已存在同名文件或目录' : '新建文件夹失败:' + String(error) }
749
+ }
750
+ invalidateIndex(ctx, sc.cwd, args.path)
751
+ return { ok: true, path: args.path }
752
+ },
753
+ 'edrv.fsRename': async (args) => {
754
+ // 重命名(仅名称段、同父目录内):目标已存在拒绝;名称未变化按幂等成功返回。
755
+ const sc = await requireSession(ctx, args.sessionId)
756
+ if ('err' in sc) return { ok: false, error: sc.err }
757
+ const bad = checkRenameName(args.newName)
758
+ if (bad) return { ok: false, error: bad }
759
+ const fromRel = normalizeRel(args.path)
760
+ if (fromRel === null || fromRel === '') return { ok: false, error: '路径不合法' }
761
+ const toRel = joinRelPath(parentRelOf(fromRel), args.newName)
762
+ if (toRel === fromRel) return { ok: true, from: fromRel, to: fromRel }
763
+ const src = await fsOpsTarget(ctx, sc.session, fromRel)
764
+ if ('err' in src) return { ok: false, error: src.err }
765
+ const dst = await fsOpsTarget(ctx, sc.session, toRel)
766
+ if ('err' in dst) return { ok: false, error: dst.err }
767
+ if (!(await stat(src.abs).catch(() => null))) return { ok: false, error: '源不存在' }
768
+ try {
769
+ await rename(src.abs, dst.abs)
770
+ } catch (error) {
771
+ const code = fsErrCode(error)
772
+ const exists = code === 'EEXIST' || code === 'ENOTEMPTY' || code === 'EPERM'
773
+ return { ok: false, error: exists ? '已存在同名文件或目录' : '重命名失败:' + String(error) }
774
+ }
775
+ invalidateIndex(ctx, sc.cwd, fromRel)
776
+ invalidateIndex(ctx, sc.cwd, toRel)
777
+ return { ok: true, from: fromRel, to: toRel }
778
+ },
779
+ 'edrv.fsDelete': async (args) => {
780
+ // 删除(文件/文件夹递归;「删除/永久删除」共用):根拒绝;破坏性由客户端确认框把关。
781
+ const sc = await requireSession(ctx, args.sessionId)
782
+ if ('err' in sc) return { ok: false, error: sc.err }
783
+ const rel = normalizeRel(args.path)
784
+ if (rel === null || rel === '') return { ok: false, error: '路径不合法' }
785
+ const r = await fsOpsTarget(ctx, sc.session, rel)
786
+ if ('err' in r) return { ok: false, error: r.err }
787
+ try {
788
+ await rm(r.abs, { recursive: true })
789
+ } catch (error) {
790
+ return { ok: false, error: '删除失败:' + String(error) }
791
+ }
792
+ invalidateIndex(ctx, sc.cwd, rel)
793
+ return { ok: true, path: rel }
794
+ },
795
+ 'edrv.fsCopy': async (args) => {
796
+ // 复制到目标目录(文件/文件夹递归):同名拒绝覆盖(细节见 fsTransfer)。
797
+ const sc = await requireSession(ctx, args.sessionId)
798
+ if ('err' in sc) return { ok: false, error: sc.err }
799
+ const r = await fsTransfer(ctx, sc, args, 'copy')
800
+ if ('err' in r) return { ok: false, error: r.err }
801
+ return { ok: true, from: r.from, to: r.to }
802
+ },
803
+ 'edrv.fsMove': async (args) => {
804
+ // 移动到目标目录(文件/文件夹):同名拒绝覆盖(细节见 fsTransfer)。
805
+ const sc = await requireSession(ctx, args.sessionId)
806
+ if ('err' in sc) return { ok: false, error: sc.err }
807
+ const r = await fsTransfer(ctx, sc, args, 'move')
808
+ if ('err' in r) return { ok: false, error: r.err }
809
+ return { ok: true, from: r.from, to: r.to }
810
+ },
615
811
  'mcp.list': async () => ({ ok: true, ...listMcp(ctx) }),
616
812
  'mcp.save': async (args) => {
617
813
  try { return { ok: true, server: await saveMcp(ctx, args.config) } }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * dsh-vscode-mode shared — 文件操作名称/路径校验(纯函数,可单测)。
3
+ * 新建/重命名输入护栏(客户端弹窗校验与 host 端二次校验共用一份):
4
+ * 空名、绝对路径、盘符、'.'/'..' 路径段、空路径段、控制字符、超长一律拒绝;
5
+ * 新建允许 a/b.c 嵌套段(对齐 VS Code「新建文件…」输入语义),重命名仅允许单个名称段。
6
+ * 作者 ddj 2026年09月22号
7
+ */
8
+
9
+ /** 单个名称段最大长度(防误贴超长串直接打到文件系统)。 */
10
+ export const NAME_MAX = 255
11
+
12
+ /**
13
+ * 路径归一:去首尾空白 + 反斜杠转 '/'(Windows 输入等价处理)。
14
+ * @author ddj 2026年09月22号
15
+ * @param raw 原始输入
16
+ * @returns 归一后的字符串
17
+ */
18
+ function normPath(raw: unknown): string {
19
+ return String(raw ?? '').trim().replace(/\\/g, '/')
20
+ }
21
+
22
+ /**
23
+ * 单个名称段的通用违规检查(两套校验共用)。
24
+ * @author ddj 2026年09月22号
25
+ * @param seg 名称段
26
+ * @returns 错误文案;null = 通过
27
+ */
28
+ function checkSegment(seg: string): string | null {
29
+ if (!seg) return '名称包含空路径段'
30
+ if (seg === '.' || seg === '..') return '名称不能包含 . 或 .. 路径段'
31
+ if (seg.length > NAME_MAX) return '名称过长(单段超过 ' + NAME_MAX + ' 字符)'
32
+ if (/[\0-\x1f]/.test(seg)) return '名称包含非法控制字符'
33
+ return null
34
+ }
35
+
36
+ /**
37
+ * 校验新建文件/文件夹的相对路径(允许 a/b.c 嵌套段)。
38
+ * @author ddj 2026年09月22号
39
+ * @param raw 用户输入的名称或相对路径
40
+ * @returns 错误文案;null = 校验通过
41
+ */
42
+ export function checkNewName(raw: unknown): string | null {
43
+ const text = normPath(raw)
44
+ if (!text) return '名称不能为空'
45
+ if (text.startsWith('/')) return '名称不能是绝对路径'
46
+ if (/^[a-zA-Z]:/.test(text)) return '名称不能带盘符'
47
+ for (const seg of text.split('/')) {
48
+ const bad = checkSegment(seg)
49
+ if (bad) return bad
50
+ }
51
+ return null
52
+ }
53
+
54
+ /**
55
+ * 校验重命名的新名称(仅单个名称段,不接受路径)。
56
+ * @author ddj 2026年09月22号
57
+ * @param raw 用户输入的新名称
58
+ * @returns 错误文案;null = 校验通过
59
+ */
60
+ export function checkRenameName(raw: unknown): string | null {
61
+ const text = normPath(raw)
62
+ if (!text) return '名称不能为空'
63
+ if (text.includes('/')) return '新名称不能包含路径分隔符'
64
+ return checkSegment(text)
65
+ }
66
+
67
+ /**
68
+ * 拼接工作区相对路径(dir 为空 = 根;name 新建时可含多段)。
69
+ * @author ddj 2026年09月22号
70
+ * @param dir 目标目录(工作区相对)
71
+ * @param name 名称
72
+ * @returns 拼接后的相对路径
73
+ */
74
+ export function joinRelPath(dir: string, name: string): string {
75
+ const base = normPath(dir).replace(/\/+$/, '')
76
+ const add = normPath(name).replace(/^\/+/, '')
77
+ return base ? base + '/' + add : add
78
+ }
79
+
80
+ /**
81
+ * 取相对路径的父目录(顶层条目返回空串 = 根)。
82
+ * @author ddj 2026年09月22号
83
+ * @param path 工作区相对路径
84
+ * @returns 父目录相对路径
85
+ */
86
+ export function parentRelOf(path: string): string {
87
+ const parts = normPath(path).split('/').filter(Boolean)
88
+ parts.pop()
89
+ return parts.join('/')
90
+ }
91
+
92
+ /**
93
+ * 取相对路径的名称段(末段)。
94
+ * @author ddj 2026年09月22号
95
+ * @param path 工作区相对路径
96
+ * @returns 末段名称
97
+ */
98
+ export function baseNameOf(path: string): string {
99
+ const parts = normPath(path).split('/').filter(Boolean)
100
+ return parts.pop() ?? ''
101
+ }
102
+
103
+ /**
104
+ * other 是否为 base 本身或其内部路径(复制/移动「不能以自身或其子目录为目标」护栏)。
105
+ * @author ddj 2026年09月22号
106
+ * @param base 基准相对路径(源)
107
+ * @param other 待判定相对路径(目标目录)
108
+ * @returns 是否为自身或子路径
109
+ */
110
+ export function isSubPath(base: string, other: string): boolean {
111
+ const b = normPath(base).replace(/\/+$/, '')
112
+ const o = normPath(other).replace(/\/+$/, '')
113
+ if (!b) return false
114
+ return o === b || o.startsWith(b + '/')
115
+ }
@@ -22,6 +22,8 @@ export const KEYBINDING_DEFAULTS: Record<string, string> = {
22
22
  // 页签循环:主候选避开浏览器保留键(Ctrl+Tab / Ctrl+PgUp/PgDn 会被浏览器截获)
23
23
  'edrv.nextTab': 'Ctrl+Alt+ArrowRight|Ctrl+PageDown',
24
24
  'edrv.prevTab': 'Ctrl+Alt+ArrowLeft|Ctrl+PageUp',
25
+ // 转到行:插件只补键位与命令栏入口,widget 本体转发 Monaco 原生 editor.action.gotoLine(VS Code 同款 Ctrl+G)
26
+ 'edrv.goToLine': 'Ctrl+G',
25
27
  // 命令栏(Ctrl+Shift+P 主候选;F1 为 VS Code 同款第二候选)与编辑行导航
26
28
  'edrv.showCommands': 'Ctrl+Shift+P|F1',
27
29
  'edrv.nextEditorRow': 'Ctrl+Alt+ArrowDown',
package/src/shared/rpc.ts CHANGED
@@ -216,6 +216,12 @@ export interface RpcRequestMap {
216
216
  'edrv.searchContent': { sessionId?: string; query: string; matchCase?: boolean; wholeWord?: boolean; regex?: boolean; maxResults?: number; include?: string[]; exclude?: string[] }
217
217
  'edrv.listDir': { sessionId?: string; path: string; force?: boolean }
218
218
  'edrv.revealInExplorer': { sessionId?: string; path: string }
219
+ 'edrv.fsCreateFile': { sessionId?: string; path: string }
220
+ 'edrv.fsCreateDir': { sessionId?: string; path: string }
221
+ 'edrv.fsRename': { sessionId?: string; path: string; newName: string }
222
+ 'edrv.fsDelete': { sessionId?: string; path: string }
223
+ 'edrv.fsCopy': { sessionId?: string; from: string; toDir: string }
224
+ 'edrv.fsMove': { sessionId?: string; from: string; toDir: string }
219
225
  'mcp.list': {}
220
226
  'mcp.save': { config: MpcConfig }
221
227
  'mcp.remove': { id: string }
@@ -345,6 +351,12 @@ export interface RpcOkMap {
345
351
  'edrv.searchContent': { matches: SearchContentMatch[]; truncated: boolean; warning?: string }
346
352
  'edrv.listDir': { root: string; path: string; entries: TreeEntry[] }
347
353
  'edrv.revealInExplorer': { revealed: string }
354
+ 'edrv.fsCreateFile': { path: string }
355
+ 'edrv.fsCreateDir': { path: string }
356
+ 'edrv.fsRename': { from: string; to: string }
357
+ 'edrv.fsDelete': { path: string }
358
+ 'edrv.fsCopy': { from: string; to: string }
359
+ 'edrv.fsMove': { from: string; to: string }
348
360
  'mcp.list': { servers: MpcServer[] }
349
361
  'mcp.save': { server: MpcServer }
350
362
  'mcp.remove': object