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.
- package/README.md +19 -2
- package/lib/client.js +8302 -7458
- package/lib/client.js.map +1 -1
- package/lib/index.js +373 -0
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/client/copyText.ts +28 -0
- package/src/client/fileClipboard.ts +46 -0
- package/src/client/index.ts +3 -3
- package/src/client/openWithDialog.ts +66 -0
- package/src/client/searchSeed.ts +36 -0
- package/src/client/sidebar/contextMenu.ts +51 -10
- package/src/client/sidebar/menuItems.ts +473 -50
- package/src/client/sidebar/panels/SearchPanel.ts +24 -3
- package/src/client/sidebar/types.ts +6 -0
- package/src/client/tabActions.ts +22 -10
- package/src/client/ui/EditorView.ts +81 -7
- package/src/client/ui/OfficialSideTab.ts +1 -0
- package/src/client/ui/PromptDialog.ts +63 -0
- package/src/client/ui/SideEditorTab.ts +1 -0
- package/src/client/ui/commandCatalog.ts +14 -0
- package/src/rpc.ts +198 -2
- package/src/shared/fsNames.ts +115 -0
- package/src/shared/keybindings.ts +2 -0
- package/src/shared/rpc.ts +12 -0
|
@@ -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
|
-
*
|
|
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
|
-
|
|
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 形态):配置下拉 + 启动/继续 + 单步组 + 停止 + 相位。
|
|
@@ -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
|
+
}
|
|
@@ -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
|