dsh-vscode-mode 0.6.0 → 0.8.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 +40 -6
- package/assets/icon.svg +7 -0
- package/cordis.patch.yml +12 -0
- package/lib/client.js +9317 -7494
- package/lib/client.js.map +1 -1
- package/lib/index.js +1295 -173
- package/lib/index.js.map +1 -1
- package/locale/en.json +4 -0
- package/locale/zh.json +4 -0
- package/package.json +3 -1
- package/src/client/compat.ts +134 -4
- package/src/client/copyText.ts +28 -0
- package/src/client/fileClipboard.ts +46 -0
- package/src/client/imagePreview.ts +76 -2
- package/src/client/index.ts +68 -39
- package/src/client/monaco/lsp/lspClient.ts +46 -0
- package/src/client/monaco/lsp/providers.ts +245 -1
- package/src/client/nativeOpenStore.ts +91 -0
- package/src/client/officialSidebar.ts +11 -45
- package/src/client/openWithDialog.ts +66 -0
- package/src/client/outline/index.ts +1 -1
- package/src/client/pdf/pdfPanel.ts +18 -0
- package/src/client/searchSeed.ts +36 -0
- package/src/client/settingsContext.ts +6 -4
- package/src/client/sidebar/contextMenu.ts +51 -10
- package/src/client/sidebar/menuItems.ts +473 -50
- package/src/client/sidebar/panels/FileExplorer.ts +1 -1
- package/src/client/sidebar/panels/SearchPanel.ts +24 -3
- package/src/client/sidebar/panels/SvnPanel.ts +1 -1
- package/src/client/sidebar/panels/index.ts +1 -1
- package/src/client/sidebar/types.ts +6 -0
- package/src/client/styles/editor.css +5 -3
- package/src/client/svnStore.ts +6 -1
- package/src/client/tabActions.ts +22 -10
- package/src/client/ui/EditorView.ts +198 -25
- package/src/client/ui/McpSettings.ts +30 -0
- package/src/client/ui/OfficialSideTab.ts +1 -0
- package/src/client/ui/PerfSettings.ts +4 -1
- 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/client/ui/icons.ts +72 -0
- package/src/compat.ts +2 -2
- package/src/dshVersion.ts +2 -1
- package/src/fileOpenSettings.ts +163 -40
- package/src/index.ts +15 -1
- package/src/lsp/client.ts +24 -0
- package/src/lsp/rpc.ts +57 -0
- package/src/lsp/server.ts +272 -2
- package/src/perf.ts +90 -4
- package/src/routes.ts +13 -1
- package/src/rpc.ts +294 -36
- package/src/shared/fsNames.ts +115 -0
- package/src/shared/keybindings.ts +2 -0
- package/src/shared/lsp.ts +79 -0
- package/src/shared/nativeOpen.ts +91 -0
- package/src/shared/rpc.ts +158 -1
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'
|
|
@@ -34,7 +34,7 @@ import { rulesList, rulesRead, rulesRemove, rulesSave, rulesToggle } from './rul
|
|
|
34
34
|
import { isSnippetFilePath, snippetsEntries, snippetsList, snippetsRead, snippetsRemove, snippetsSave } from './snippets.js'
|
|
35
35
|
import { listMcp, refreshMcp, removeMcp, saveMcp, toggleMcp } from './mcp.js'
|
|
36
36
|
import { listProjects, projectRefresh, projectRemove, projectSave, projectToggle } from './mcpProject.js'
|
|
37
|
-
import { normalizeFileOpenTool, FILE_OPEN_DEFAULT, FILE_OPEN_SETTINGS_NS } from './fileOpenSettings.js'
|
|
37
|
+
import { normalizeFileOpenTool, FILE_OPEN_DEFAULT, FILE_OPEN_SETTINGS_NS, sectionOf, updateSection } from './fileOpenSettings.js'
|
|
38
38
|
import { INTEGRATION_BASE_DEFAULT } from './shared/integration.js'
|
|
39
39
|
import { shellMenuRegister, shellMenuRemove, shellMenuStatus } from './integrate.js'
|
|
40
40
|
import { unityAdd, unityInstall, unityList, unityRemove } from './unityBridge.js'
|
|
@@ -42,11 +42,12 @@ 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'
|
|
48
49
|
import { clearDebugLog, debugRecord, isDebugLogName, listDebugLogs, readDebugLog } from './debugLog.js'
|
|
49
|
-
import { markActiveSessions, moveOutSessions, planMoveOut, purgeArchive, restoreSession, scanSessionInventory, sessionsArchiveRoot, sessionSizeOf, sidecarSummaryOf } from './perf.js'
|
|
50
|
+
import { markActiveSessions, markOfficialFlags, moveOutSessions, planMoveOut, purgeArchive, restoreSession, scanSessionInventory, sessionsArchiveRoot, sessionSizeOf, sidecarSummaryOf, unarchiveOfficial } from './perf.js'
|
|
50
51
|
import { patchHasPerfConfig, patchInsertPerfConfig, patchRemovePerfConfig, perfConfigBlock } from './perfPatch.js'
|
|
51
52
|
import type { FileVersions } from './fileVersions.js'
|
|
52
53
|
|
|
@@ -106,6 +107,57 @@ function snippetTargetOf(path: string): string | null {
|
|
|
106
107
|
return isSnippetFilePath(path) ? path.replace(/\\/g, '/') : null
|
|
107
108
|
}
|
|
108
109
|
|
|
110
|
+
/** readTargetOf 成功形态:解析后的目标路径、stat 信息与 fs 服务句柄。 */
|
|
111
|
+
interface ReadTargetOk {
|
|
112
|
+
target: string
|
|
113
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
114
|
+
fs: any
|
|
115
|
+
info: { version?: unknown; size?: number }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* 二进制/文本读取共用前置(edrv.read 与 edrv.readBinary):解析路径 + debugRecord +
|
|
120
|
+
* stat + 文件类型校验 + 32MB 二进制上限,保证两条读通道的错误口径完全一致。
|
|
121
|
+
* @author ddj 2026年09月22号
|
|
122
|
+
* @param ctx DSH 上下文
|
|
123
|
+
* @param sc requireSession 成功结果(session + cwd)
|
|
124
|
+
* @param path 客户端请求路径
|
|
125
|
+
* @returns 成功返回解析目标与 stat;失败返回错误文案(文件不存在时附 resolvedPath)
|
|
126
|
+
*/
|
|
127
|
+
async function readTargetOf(
|
|
128
|
+
ctx: Ctx,
|
|
129
|
+
sc: { session: Session; cwd: string },
|
|
130
|
+
path: string,
|
|
131
|
+
): Promise<ReadTargetOk | { err: string; resolvedPath?: string }> {
|
|
132
|
+
const fs = ctx.get('fs')
|
|
133
|
+
if (!fs) return { err: '缺少 fs' }
|
|
134
|
+
try {
|
|
135
|
+
const target = await resolveTarget(ctx, sc.session, path)
|
|
136
|
+
debugRecord(ctx, sc.cwd, '[DEBUG path.resolve] input=' + String(path ?? '') + ' resolved=' + fs.processPath(target), 'debug')
|
|
137
|
+
const info = await fs.stat(target)
|
|
138
|
+
if (!info || info.type !== 'file') {
|
|
139
|
+
// 带上解析后的真实路径:跳转失败时可直接看出是路径解析错还是目标本身不存在
|
|
140
|
+
return { err: '文件不存在', resolvedPath: fs.processPath(target) }
|
|
141
|
+
}
|
|
142
|
+
if ((info.size ?? 0) > BINARY_READ_CAP) return { err: '文件过大(>32MB),不支持整文件预览' }
|
|
143
|
+
return { target, fs, info }
|
|
144
|
+
} catch (error) {
|
|
145
|
+
return { err: '读取失败:' + String(error) }
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* readTargetOf 失败结果 → RPC 错误载荷(文件不存在时保留 resolvedPath 供诊断)。
|
|
151
|
+
* @author ddj 2026年09月22号
|
|
152
|
+
* @param prep readTargetOf 的失败分支
|
|
153
|
+
* @returns { ok:false } 形态载荷
|
|
154
|
+
*/
|
|
155
|
+
function targetErrOf(prep: { err: string; resolvedPath?: string }): { ok: false; error: string; resolvedPath?: string } {
|
|
156
|
+
return prep.resolvedPath !== undefined
|
|
157
|
+
? { ok: false, error: prep.err, resolvedPath: prep.resolvedPath }
|
|
158
|
+
: { ok: false, error: prep.err }
|
|
159
|
+
}
|
|
160
|
+
|
|
109
161
|
/**
|
|
110
162
|
* 本地文件系统版本令牌(mtime+size):与 ctx.fs 的不透明版本串用途相同,
|
|
111
163
|
* 仅供全局片段文件(走 node:fs 直读、不经 ctx.fs)在 edrv.read 时回带。
|
|
@@ -240,12 +292,111 @@ async function latestPatchBackup(patchPath: string): Promise<string | undefined>
|
|
|
240
292
|
/** 读取设置中的深链基址(缺省/非法回退默认 3080)。 */
|
|
241
293
|
async function integrationBaseUrlOf(ctx: Ctx): Promise<string> {
|
|
242
294
|
const settings = ctx.get('settings')
|
|
243
|
-
const
|
|
244
|
-
const value = descriptor?.value as { integrationBaseUrl?: unknown } | undefined
|
|
295
|
+
const value = sectionOf(settings, FILE_OPEN_SETTINGS_NS)?.value as { integrationBaseUrl?: unknown } | undefined
|
|
245
296
|
const raw = typeof value?.integrationBaseUrl === 'string' ? value.integrationBaseUrl.trim() : ''
|
|
246
297
|
return raw || INTEGRATION_BASE_DEFAULT
|
|
247
298
|
}
|
|
248
299
|
|
|
300
|
+
// --region 文件操作(edrv.fs.*:资源管理器右键的新建/重命名/删除/复制/移动)
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* 文件操作目标解析:工作区相对路径 → 绝对路径,并做工作区边界检查。
|
|
304
|
+
* edrv.fs.* 各方法共用(对齐 edrv.saveBinary 的 contains 护栏口径)。
|
|
305
|
+
* @author ddj 2026年09月22号
|
|
306
|
+
* @param ctx DSH 上下文
|
|
307
|
+
* @param session 当前会话
|
|
308
|
+
* @param rel 工作区相对路径
|
|
309
|
+
* @returns 绝对路径;缺 fs/越界/解析失败返回错误文案
|
|
310
|
+
*/
|
|
311
|
+
async function fsOpsTarget(ctx: Ctx, session: Session, rel: string): Promise<{ abs: string } | { err: string }> {
|
|
312
|
+
const fs = ctx.get('fs')
|
|
313
|
+
if (!fs) return { err: '缺少 fs' }
|
|
314
|
+
try {
|
|
315
|
+
const target = await resolveTarget(ctx, session, rel)
|
|
316
|
+
const rootTarget = await fs.resolve(policyOf(ctx, session)?.workspaceRoot ?? '.', {})
|
|
317
|
+
if (!fs.contains(rootTarget, target)) return { err: '拒绝操作:目标不在会话工作区内' }
|
|
318
|
+
return { abs: fs.processPath(target) }
|
|
319
|
+
} catch (error) {
|
|
320
|
+
return { err: '路径解析失败:' + String(error) }
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* 取异常的系统错误码(EEXIST/EXDEV 等分支判定用)。
|
|
326
|
+
* @author ddj 2026年09月22号
|
|
327
|
+
* @param error 捕获到的异常
|
|
328
|
+
* @returns 错误码;取不到返回空串
|
|
329
|
+
*/
|
|
330
|
+
function fsErrCode(error: unknown): string {
|
|
331
|
+
const code = (error as { code?: unknown } | null)?.code
|
|
332
|
+
return typeof code === 'string' ? code : ''
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* 复制/移动共用核心(edrv.fsCopy / edrv.fsMove):目标目录存在 + 同名拒绝覆盖 +
|
|
337
|
+
* 「不能以自身或其子目录为目标」护栏;move 跨设备(EXDEV)回退「递归复制 + 删除源」。
|
|
338
|
+
* @author ddj 2026年09月22号
|
|
339
|
+
* @param ctx DSH 上下文
|
|
340
|
+
* @param sc requireSession 成功结果(session + cwd)
|
|
341
|
+
* @param args 请求载荷(from / toDir 均为工作区相对路径)
|
|
342
|
+
* @param mode copy = 复制;move = 移动
|
|
343
|
+
* @returns 成功返回 from/to 相对路径;失败返回错误文案
|
|
344
|
+
*/
|
|
345
|
+
async function fsTransfer(
|
|
346
|
+
ctx: Ctx,
|
|
347
|
+
sc: { session: Session; cwd: string },
|
|
348
|
+
args: { from: string; toDir: string },
|
|
349
|
+
mode: 'copy' | 'move',
|
|
350
|
+
): Promise<{ from: string; to: string } | { err: string }> {
|
|
351
|
+
const fromRel = normalizeRel(args.from)
|
|
352
|
+
const toDirRel = normalizeRel(args.toDir)
|
|
353
|
+
if (fromRel === null || fromRel === '' || toDirRel === null) return { err: '路径不合法' }
|
|
354
|
+
if (isSubPath(fromRel, toDirRel)) return { err: '不能以自身或其子目录为目标' }
|
|
355
|
+
const src = await fsOpsTarget(ctx, sc.session, fromRel)
|
|
356
|
+
if ('err' in src) return src
|
|
357
|
+
const dir = await fsOpsTarget(ctx, sc.session, toDirRel)
|
|
358
|
+
if ('err' in dir) return dir
|
|
359
|
+
const dirInfo = await stat(dir.abs).catch(() => null)
|
|
360
|
+
if (!dirInfo || !dirInfo.isDirectory()) return { err: '目标目录不存在' }
|
|
361
|
+
const toRel = joinRelPath(toDirRel, baseNameOf(fromRel))
|
|
362
|
+
const dst = await fsOpsTarget(ctx, sc.session, toRel)
|
|
363
|
+
if ('err' in dst) return dst
|
|
364
|
+
const srcInfo = await stat(src.abs).catch(() => null)
|
|
365
|
+
if (!srcInfo) return { err: '源不存在' }
|
|
366
|
+
if (await stat(dst.abs).catch(() => null)) return { err: '目标目录已存在同名文件或目录' }
|
|
367
|
+
try {
|
|
368
|
+
await transferEntry(src.abs, dst.abs, mode)
|
|
369
|
+
} catch (error) {
|
|
370
|
+
return { err: (mode === 'copy' ? '复制失败:' : '移动失败:') + String(error) }
|
|
371
|
+
}
|
|
372
|
+
invalidateIndex(ctx, sc.cwd, fromRel)
|
|
373
|
+
invalidateIndex(ctx, sc.cwd, toRel)
|
|
374
|
+
return { from: fromRel, to: toRel }
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* 单条目复制/移动落盘(文件/目录通用;move 跨设备回退复制+删除源)。
|
|
379
|
+
* @author ddj 2026年09月22号
|
|
380
|
+
* @param absFrom 源绝对路径
|
|
381
|
+
* @param absTo 目标绝对路径
|
|
382
|
+
* @param mode copy = 复制;move = 移动
|
|
383
|
+
*/
|
|
384
|
+
async function transferEntry(absFrom: string, absTo: string, mode: 'copy' | 'move'): Promise<void> {
|
|
385
|
+
if (mode === 'copy') {
|
|
386
|
+
await cp(absFrom, absTo, { recursive: true, force: false, errorOnExist: true })
|
|
387
|
+
return
|
|
388
|
+
}
|
|
389
|
+
try {
|
|
390
|
+
await rename(absFrom, absTo)
|
|
391
|
+
} catch (error) {
|
|
392
|
+
if (fsErrCode(error) !== 'EXDEV') throw error
|
|
393
|
+
await cp(absFrom, absTo, { recursive: true, force: false, errorOnExist: true })
|
|
394
|
+
await rm(absFrom, { recursive: true })
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// --endregion
|
|
399
|
+
|
|
249
400
|
/** 各方法 handler 表(类型由 shared/rpc 的 RpcHandlerMap 约束)。 */
|
|
250
401
|
export function buildHandlers(
|
|
251
402
|
ctx: Ctx,
|
|
@@ -332,26 +483,36 @@ export function buildHandlers(
|
|
|
332
483
|
}
|
|
333
484
|
const sc = await requireSession(ctx, args.sessionId)
|
|
334
485
|
if ('err' in sc) return { ok: false, error: sc.err }
|
|
335
|
-
const
|
|
336
|
-
if (
|
|
486
|
+
const prep = await readTargetOf(ctx, sc, args.path)
|
|
487
|
+
if ('err' in prep) return targetErrOf(prep)
|
|
337
488
|
try {
|
|
338
|
-
const target = await resolveTarget(ctx, sc.session, args.path)
|
|
339
|
-
debugRecord(ctx, sc.cwd, '[DEBUG path.resolve] input=' + String(args.path ?? '') + ' resolved=' + fs.processPath(target), 'debug')
|
|
340
|
-
const info = await fs.stat(target)
|
|
341
|
-
if (!info || info.type !== 'file') {
|
|
342
|
-
// 带上解析后的真实路径:跳转失败时可直接看出是路径解析错还是目标本身不存在
|
|
343
|
-
return { ok: false, error: '文件不存在', resolvedPath: fs.processPath(target) }
|
|
344
|
-
}
|
|
345
|
-
if ((info.size ?? 0) > BINARY_READ_CAP) return { ok: false, error: '文件过大(>32MB),不支持整文件预览' }
|
|
346
489
|
if (args.encoding === 'base64') {
|
|
347
|
-
// 图片/PDF 等二进制预览:readBytes
|
|
348
|
-
const bytes = await fs.readBytes(target, undefined, BINARY_READ_CAP)
|
|
490
|
+
// 图片/PDF 等二进制预览:readBytes 无解码、无二进制拒绝;超上限已由 readTargetOf 拦截
|
|
491
|
+
const bytes = await prep.fs.readBytes(prep.target, undefined, BINARY_READ_CAP)
|
|
349
492
|
const content = Buffer.from(bytes).toString('base64')
|
|
350
|
-
return { ok: true, content, size: bytes.byteLength, encoding: 'base64', mime: binaryMimeOf(args.path), version: String(info.version ?? '') }
|
|
493
|
+
return { ok: true, content, size: bytes.byteLength, encoding: 'base64', mime: binaryMimeOf(args.path), version: String(prep.info.version ?? '') }
|
|
494
|
+
}
|
|
495
|
+
if ((prep.info.size ?? 0) > READ_CAP) return { ok: false, error: '文件过大(>8MB),不支持整文件预览' }
|
|
496
|
+
const content = await prep.fs.readText(prep.target)
|
|
497
|
+
return { ok: true, content, size: content.length, version: String(prep.info.version ?? '') }
|
|
498
|
+
} catch (error) {
|
|
499
|
+
return { ok: false, error: '读取失败:' + String(error) }
|
|
500
|
+
}
|
|
501
|
+
},
|
|
502
|
+
'edrv.readBinary': async (args) => {
|
|
503
|
+
// 二进制直读通道(图片/PDF 预览):与 edrv.read base64 分支共用 readTargetOf 前置,
|
|
504
|
+
// 返回 Uint8Array 信封;routes 接线后以 octet-stream + x-edrv-* 头直出,
|
|
505
|
+
// 未接线时信封被 JSON 化,由 client 探测到后自动回退 base64 路径(见 shared/rpc readBinaryPreview)。
|
|
506
|
+
const sc = await requireSession(ctx, args.sessionId)
|
|
507
|
+
if ('err' in sc) return { ok: false, error: sc.err }
|
|
508
|
+
const prep = await readTargetOf(ctx, sc, args.path)
|
|
509
|
+
if ('err' in prep) return targetErrOf(prep)
|
|
510
|
+
try {
|
|
511
|
+
const bytes = await prep.fs.readBytes(prep.target, undefined, BINARY_READ_CAP)
|
|
512
|
+
return {
|
|
513
|
+
ok: true,
|
|
514
|
+
binary: { bytes, mime: binaryMimeOf(args.path), size: bytes.byteLength, version: String(prep.info.version ?? '') },
|
|
351
515
|
}
|
|
352
|
-
if ((info.size ?? 0) > READ_CAP) return { ok: false, error: '文件过大(>8MB),不支持整文件预览' }
|
|
353
|
-
const content = await fs.readText(target)
|
|
354
|
-
return { ok: true, content, size: content.length, version: String(info.version ?? '') }
|
|
355
516
|
} catch (error) {
|
|
356
517
|
return { ok: false, error: '读取失败:' + String(error) }
|
|
357
518
|
}
|
|
@@ -612,6 +773,101 @@ export function buildHandlers(
|
|
|
612
773
|
return { ok: false, error: '打开失败:' + String(error) }
|
|
613
774
|
}
|
|
614
775
|
},
|
|
776
|
+
'edrv.fsCreateFile': async (args) => {
|
|
777
|
+
// 新建文件(资源管理器右键「新建文件…」):空内容独占创建(同名拒绝),
|
|
778
|
+
// 名称可含 a/b.c 嵌套段(父目录按需创建);名称校验与客户端弹窗共用 shared/fsNames。
|
|
779
|
+
const sc = await requireSession(ctx, args.sessionId)
|
|
780
|
+
if ('err' in sc) return { ok: false, error: sc.err }
|
|
781
|
+
const bad = checkNewName(args.path)
|
|
782
|
+
if (bad) return { ok: false, error: bad }
|
|
783
|
+
const r = await fsOpsTarget(ctx, sc.session, args.path)
|
|
784
|
+
if ('err' in r) return { ok: false, error: r.err }
|
|
785
|
+
try {
|
|
786
|
+
await mkdir(dirname(r.abs), { recursive: true })
|
|
787
|
+
await writeFile(r.abs, '', { flag: 'wx' })
|
|
788
|
+
} catch (error) {
|
|
789
|
+
const code = fsErrCode(error)
|
|
790
|
+
return { ok: false, error: code === 'EEXIST' ? '已存在同名文件或目录' : '新建文件失败:' + String(error) }
|
|
791
|
+
}
|
|
792
|
+
invalidateIndex(ctx, sc.cwd, args.path)
|
|
793
|
+
return { ok: true, path: args.path }
|
|
794
|
+
},
|
|
795
|
+
'edrv.fsCreateDir': async (args) => {
|
|
796
|
+
// 新建文件夹(资源管理器右键「新建文件夹」):名称可含嵌套段(父目录按需创建),已存在拒绝。
|
|
797
|
+
const sc = await requireSession(ctx, args.sessionId)
|
|
798
|
+
if ('err' in sc) return { ok: false, error: sc.err }
|
|
799
|
+
const bad = checkNewName(args.path)
|
|
800
|
+
if (bad) return { ok: false, error: bad }
|
|
801
|
+
const r = await fsOpsTarget(ctx, sc.session, args.path)
|
|
802
|
+
if ('err' in r) return { ok: false, error: r.err }
|
|
803
|
+
try {
|
|
804
|
+
await mkdir(dirname(r.abs), { recursive: true })
|
|
805
|
+
await mkdir(r.abs)
|
|
806
|
+
} catch (error) {
|
|
807
|
+
const code = fsErrCode(error)
|
|
808
|
+
return { ok: false, error: code === 'EEXIST' ? '已存在同名文件或目录' : '新建文件夹失败:' + String(error) }
|
|
809
|
+
}
|
|
810
|
+
invalidateIndex(ctx, sc.cwd, args.path)
|
|
811
|
+
return { ok: true, path: args.path }
|
|
812
|
+
},
|
|
813
|
+
'edrv.fsRename': async (args) => {
|
|
814
|
+
// 重命名(仅名称段、同父目录内):目标已存在拒绝;名称未变化按幂等成功返回。
|
|
815
|
+
const sc = await requireSession(ctx, args.sessionId)
|
|
816
|
+
if ('err' in sc) return { ok: false, error: sc.err }
|
|
817
|
+
const bad = checkRenameName(args.newName)
|
|
818
|
+
if (bad) return { ok: false, error: bad }
|
|
819
|
+
const fromRel = normalizeRel(args.path)
|
|
820
|
+
if (fromRel === null || fromRel === '') return { ok: false, error: '路径不合法' }
|
|
821
|
+
const toRel = joinRelPath(parentRelOf(fromRel), args.newName)
|
|
822
|
+
if (toRel === fromRel) return { ok: true, from: fromRel, to: fromRel }
|
|
823
|
+
const src = await fsOpsTarget(ctx, sc.session, fromRel)
|
|
824
|
+
if ('err' in src) return { ok: false, error: src.err }
|
|
825
|
+
const dst = await fsOpsTarget(ctx, sc.session, toRel)
|
|
826
|
+
if ('err' in dst) return { ok: false, error: dst.err }
|
|
827
|
+
if (!(await stat(src.abs).catch(() => null))) return { ok: false, error: '源不存在' }
|
|
828
|
+
try {
|
|
829
|
+
await rename(src.abs, dst.abs)
|
|
830
|
+
} catch (error) {
|
|
831
|
+
const code = fsErrCode(error)
|
|
832
|
+
const exists = code === 'EEXIST' || code === 'ENOTEMPTY' || code === 'EPERM'
|
|
833
|
+
return { ok: false, error: exists ? '已存在同名文件或目录' : '重命名失败:' + String(error) }
|
|
834
|
+
}
|
|
835
|
+
invalidateIndex(ctx, sc.cwd, fromRel)
|
|
836
|
+
invalidateIndex(ctx, sc.cwd, toRel)
|
|
837
|
+
return { ok: true, from: fromRel, to: toRel }
|
|
838
|
+
},
|
|
839
|
+
'edrv.fsDelete': async (args) => {
|
|
840
|
+
// 删除(文件/文件夹递归;「删除/永久删除」共用):根拒绝;破坏性由客户端确认框把关。
|
|
841
|
+
const sc = await requireSession(ctx, args.sessionId)
|
|
842
|
+
if ('err' in sc) return { ok: false, error: sc.err }
|
|
843
|
+
const rel = normalizeRel(args.path)
|
|
844
|
+
if (rel === null || rel === '') return { ok: false, error: '路径不合法' }
|
|
845
|
+
const r = await fsOpsTarget(ctx, sc.session, rel)
|
|
846
|
+
if ('err' in r) return { ok: false, error: r.err }
|
|
847
|
+
try {
|
|
848
|
+
await rm(r.abs, { recursive: true })
|
|
849
|
+
} catch (error) {
|
|
850
|
+
return { ok: false, error: '删除失败:' + String(error) }
|
|
851
|
+
}
|
|
852
|
+
invalidateIndex(ctx, sc.cwd, rel)
|
|
853
|
+
return { ok: true, path: rel }
|
|
854
|
+
},
|
|
855
|
+
'edrv.fsCopy': async (args) => {
|
|
856
|
+
// 复制到目标目录(文件/文件夹递归):同名拒绝覆盖(细节见 fsTransfer)。
|
|
857
|
+
const sc = await requireSession(ctx, args.sessionId)
|
|
858
|
+
if ('err' in sc) return { ok: false, error: sc.err }
|
|
859
|
+
const r = await fsTransfer(ctx, sc, args, 'copy')
|
|
860
|
+
if ('err' in r) return { ok: false, error: r.err }
|
|
861
|
+
return { ok: true, from: r.from, to: r.to }
|
|
862
|
+
},
|
|
863
|
+
'edrv.fsMove': async (args) => {
|
|
864
|
+
// 移动到目标目录(文件/文件夹):同名拒绝覆盖(细节见 fsTransfer)。
|
|
865
|
+
const sc = await requireSession(ctx, args.sessionId)
|
|
866
|
+
if ('err' in sc) return { ok: false, error: sc.err }
|
|
867
|
+
const r = await fsTransfer(ctx, sc, args, 'move')
|
|
868
|
+
if ('err' in r) return { ok: false, error: r.err }
|
|
869
|
+
return { ok: true, from: r.from, to: r.to }
|
|
870
|
+
},
|
|
615
871
|
'mcp.list': async () => ({ ok: true, ...listMcp(ctx) }),
|
|
616
872
|
'mcp.save': async (args) => {
|
|
617
873
|
try { return { ok: true, server: await saveMcp(ctx, args.config) } }
|
|
@@ -651,22 +907,20 @@ export function buildHandlers(
|
|
|
651
907
|
},
|
|
652
908
|
'vscode.fileOpenSettingsGet': async () => {
|
|
653
909
|
const settings = ctx.get('settings')
|
|
654
|
-
const
|
|
655
|
-
const value =
|
|
656
|
-
return { ok: true, fileOpenTool: normalizeFileOpenTool(value?.fileOpenTool ?? FILE_OPEN_DEFAULT), integrationBaseUrl: await integrationBaseUrlOf(ctx), revision:
|
|
910
|
+
const section = sectionOf(settings, FILE_OPEN_SETTINGS_NS)
|
|
911
|
+
const value = section?.value as { fileOpenTool?: unknown } | undefined
|
|
912
|
+
return { ok: true, fileOpenTool: normalizeFileOpenTool(value?.fileOpenTool ?? FILE_OPEN_DEFAULT), integrationBaseUrl: await integrationBaseUrlOf(ctx), revision: section?.revision }
|
|
657
913
|
},
|
|
658
914
|
'vscode.fileOpenSettingsUpdate': async (args) => {
|
|
659
915
|
const settings = ctx.get('settings')
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
} catch (error) { return { ok: false, error: String(error) }
|
|
669
|
-
}
|
|
916
|
+
const patch: Record<string, unknown> = { fileOpenTool: normalizeFileOpenTool(args.fileOpenTool) }
|
|
917
|
+
if (typeof args.integrationBaseUrl === 'string' && args.integrationBaseUrl.trim()) patch.integrationBaseUrl = args.integrationBaseUrl.trim()
|
|
918
|
+
// 冲突自愈(0.1.7 SettingsConflictError 重读重试一次)与形状校验读取统一走 fileOpenSettings 工具
|
|
919
|
+
const result = await updateSection(settings, FILE_OPEN_SETTINGS_NS, patch, args.expectedRevision)
|
|
920
|
+
if (!result.ok) return { ok: false, error: result.error ?? '设置服务不可用' }
|
|
921
|
+
const section = sectionOf(settings, FILE_OPEN_SETTINGS_NS)
|
|
922
|
+
const value = section?.value as { fileOpenTool?: unknown } | undefined
|
|
923
|
+
return { ok: true, fileOpenTool: normalizeFileOpenTool(value?.fileOpenTool), integrationBaseUrl: await integrationBaseUrlOf(ctx), revision: section?.revision }
|
|
670
924
|
},
|
|
671
925
|
'compat': async () => ({ ok: true, report: await buildReport(ctx) }),
|
|
672
926
|
'vscode.devFormGet': async () => ({ ok: true, devForm: readDevForm() }),
|
|
@@ -757,6 +1011,7 @@ export function buildHandlers(
|
|
|
757
1011
|
const inventory = await scanSessionInventory(home, sessionsArchiveRoot(home))
|
|
758
1012
|
const active = activeSessionIds(ctx)
|
|
759
1013
|
markActiveSessions(inventory.sessions, active)
|
|
1014
|
+
markOfficialFlags(inventory.sessions, ctx)
|
|
760
1015
|
return { ok: true, ...inventory, activeIds: [...active] }
|
|
761
1016
|
} catch (error) {
|
|
762
1017
|
return { ok: false, error: '盘点失败:' + String(error) }
|
|
@@ -794,7 +1049,10 @@ export function buildHandlers(
|
|
|
794
1049
|
'edrv.perf.restore': async (args) => {
|
|
795
1050
|
try {
|
|
796
1051
|
const result = await restoreSession(dshHome(), sessionsArchiveRoot(dshHome()), args.workspaceKey, args.sessionId)
|
|
797
|
-
|
|
1052
|
+
if (!result.ok) return { ok: false, error: result.error ?? '恢复失败' }
|
|
1053
|
+
// 目录搬回后对齐官方归档标志(best-effort:服务缺失/调用失败不影响恢复结果,见 perf.unarchiveOfficial)
|
|
1054
|
+
await unarchiveOfficial(ctx, args.sessionId)
|
|
1055
|
+
return { ok: true, restored: true }
|
|
798
1056
|
} catch (error) {
|
|
799
1057
|
return { ok: false, error: '恢复失败:' + String(error) }
|
|
800
1058
|
}
|
|
@@ -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/lsp.ts
CHANGED
|
@@ -125,6 +125,82 @@ export function decodeSemanticTokens(
|
|
|
125
125
|
return out
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
+
/**
|
|
129
|
+
* LSP CompletionItemKind(协议 1-based)→ Monaco CompletionItemKind 枚举名(0-based)。
|
|
130
|
+
*
|
|
131
|
+
* 为什么按**名称**而不是编号直传:两侧枚举编号并不一致 —— LSP 2=Method,而 Monaco
|
|
132
|
+
* 0=Method。若把协议编号当 Monaco 编号塞回去,草稿纸上的「只差一位」会变成全表错位
|
|
133
|
+
* (属性显示成方法图标等)。用名称映射让两侧各自的编号在各自类型系统里解析,杜绝硬编码。
|
|
134
|
+
* 索引 i 对应 LSP kind i(索引 0 占位,协议从 1 起)。
|
|
135
|
+
* @author ddj 2026年09月22号
|
|
136
|
+
*/
|
|
137
|
+
export const LSP_COMPLETION_KIND_NAMES = [
|
|
138
|
+
'', 'Text', 'Method', 'Function', 'Constructor', 'Field', 'Variable', 'Class',
|
|
139
|
+
'Interface', 'Module', 'Property', 'Unit', 'Value', 'Enum', 'Keyword', 'Snippet',
|
|
140
|
+
'Color', 'File', 'Reference', 'Folder', 'EnumMember', 'Constant', 'Struct',
|
|
141
|
+
'Event', 'Operator', 'TypeParameter',
|
|
142
|
+
] as const
|
|
143
|
+
|
|
144
|
+
/** LSP InsertTextFormat:1=PlainText,2=Snippet。 */
|
|
145
|
+
export const LSP_INSERT_TEXT_FORMAT_SNIPPET = 2
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* LSP 补全项文本编辑范围(单 range 或 insert/replace 双 range 形态)。
|
|
149
|
+
* 两种形态由服务器任选,必须都能解析:只认一种会让另一家服务器的补全落点错位。
|
|
150
|
+
*/
|
|
151
|
+
export interface LspCompletionTextEdit {
|
|
152
|
+
range?: LspRange
|
|
153
|
+
insert?: LspRange
|
|
154
|
+
replace?: LspRange
|
|
155
|
+
newText: string
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** 归一化后的补全项(host 归一化;client 映射为 Monaco suggestion)。 */
|
|
159
|
+
export interface LspCompletionItem {
|
|
160
|
+
label: string
|
|
161
|
+
kind?: number // LSP CompletionItemKind 原始编号(client 经名称表转 Monaco)
|
|
162
|
+
detail?: string
|
|
163
|
+
documentation?: string
|
|
164
|
+
insertText?: string
|
|
165
|
+
insertTextFormat?: number
|
|
166
|
+
textEdit?: LspCompletionTextEdit
|
|
167
|
+
additionalTextEdits?: Array<{ range: LspRange; newText: string }>
|
|
168
|
+
sortText?: string
|
|
169
|
+
filterText?: string
|
|
170
|
+
preselect?: boolean
|
|
171
|
+
commitCharacters?: string[]
|
|
172
|
+
deprecated?: boolean
|
|
173
|
+
data?: unknown // 原样透传,completionItem/resolve 需回传
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** 归一化后的补全列表(incomplete:服务器要求按已输入内容重查)。 */
|
|
177
|
+
export interface LspCompletionList {
|
|
178
|
+
items: LspCompletionItem[]
|
|
179
|
+
incomplete: boolean
|
|
180
|
+
truncated?: boolean
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** 签名帮助中的单个参数(label 可为字符串或 [start, end] 元组,原样透传)。 */
|
|
184
|
+
export interface LspParameterInformation {
|
|
185
|
+
label: string | [number, number]
|
|
186
|
+
documentation?: string
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** 归一化后的单个签名。 */
|
|
190
|
+
export interface LspSignatureInformation {
|
|
191
|
+
label: string
|
|
192
|
+
documentation?: string
|
|
193
|
+
parameters: LspParameterInformation[]
|
|
194
|
+
activeParameter?: number
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** 归一化后的签名帮助结果。 */
|
|
198
|
+
export interface LspSignatureHelp {
|
|
199
|
+
signatures: LspSignatureInformation[]
|
|
200
|
+
activeSignature: number
|
|
201
|
+
activeParameter: number
|
|
202
|
+
}
|
|
203
|
+
|
|
128
204
|
/** LSP 服务器能力子集:本插件会用到的方法。 */
|
|
129
205
|
export interface LspServerCapabilities {
|
|
130
206
|
definition: boolean
|
|
@@ -134,6 +210,9 @@ export interface LspServerCapabilities {
|
|
|
134
210
|
workspaceSymbol: boolean
|
|
135
211
|
hover: boolean
|
|
136
212
|
semanticTokens: boolean
|
|
213
|
+
completion: boolean
|
|
214
|
+
completionResolve: boolean
|
|
215
|
+
signatureHelp: boolean
|
|
137
216
|
semanticTokenTypes?: string[]
|
|
138
217
|
semanticTokenModifiers?: string[]
|
|
139
218
|
}
|