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.
Files changed (57) hide show
  1. package/README.md +40 -6
  2. package/assets/icon.svg +7 -0
  3. package/cordis.patch.yml +12 -0
  4. package/lib/client.js +9317 -7494
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +1295 -173
  7. package/lib/index.js.map +1 -1
  8. package/locale/en.json +4 -0
  9. package/locale/zh.json +4 -0
  10. package/package.json +3 -1
  11. package/src/client/compat.ts +134 -4
  12. package/src/client/copyText.ts +28 -0
  13. package/src/client/fileClipboard.ts +46 -0
  14. package/src/client/imagePreview.ts +76 -2
  15. package/src/client/index.ts +68 -39
  16. package/src/client/monaco/lsp/lspClient.ts +46 -0
  17. package/src/client/monaco/lsp/providers.ts +245 -1
  18. package/src/client/nativeOpenStore.ts +91 -0
  19. package/src/client/officialSidebar.ts +11 -45
  20. package/src/client/openWithDialog.ts +66 -0
  21. package/src/client/outline/index.ts +1 -1
  22. package/src/client/pdf/pdfPanel.ts +18 -0
  23. package/src/client/searchSeed.ts +36 -0
  24. package/src/client/settingsContext.ts +6 -4
  25. package/src/client/sidebar/contextMenu.ts +51 -10
  26. package/src/client/sidebar/menuItems.ts +473 -50
  27. package/src/client/sidebar/panels/FileExplorer.ts +1 -1
  28. package/src/client/sidebar/panels/SearchPanel.ts +24 -3
  29. package/src/client/sidebar/panels/SvnPanel.ts +1 -1
  30. package/src/client/sidebar/panels/index.ts +1 -1
  31. package/src/client/sidebar/types.ts +6 -0
  32. package/src/client/styles/editor.css +5 -3
  33. package/src/client/svnStore.ts +6 -1
  34. package/src/client/tabActions.ts +22 -10
  35. package/src/client/ui/EditorView.ts +198 -25
  36. package/src/client/ui/McpSettings.ts +30 -0
  37. package/src/client/ui/OfficialSideTab.ts +1 -0
  38. package/src/client/ui/PerfSettings.ts +4 -1
  39. package/src/client/ui/PromptDialog.ts +63 -0
  40. package/src/client/ui/SideEditorTab.ts +1 -0
  41. package/src/client/ui/commandCatalog.ts +14 -0
  42. package/src/client/ui/icons.ts +72 -0
  43. package/src/compat.ts +2 -2
  44. package/src/dshVersion.ts +2 -1
  45. package/src/fileOpenSettings.ts +163 -40
  46. package/src/index.ts +15 -1
  47. package/src/lsp/client.ts +24 -0
  48. package/src/lsp/rpc.ts +57 -0
  49. package/src/lsp/server.ts +272 -2
  50. package/src/perf.ts +90 -4
  51. package/src/routes.ts +13 -1
  52. package/src/rpc.ts +294 -36
  53. package/src/shared/fsNames.ts +115 -0
  54. package/src/shared/keybindings.ts +2 -0
  55. package/src/shared/lsp.ts +79 -0
  56. package/src/shared/nativeOpen.ts +91 -0
  57. 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 descriptor = settings?.describe?.({ redactSecrets: true })?.find((item: { ns?: string }) => item.ns === FILE_OPEN_SETTINGS_NS)
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 fs = ctx.get('fs')
336
- if (!fs) return { ok: false, error: '缺少 fs' }
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 无解码、无二进制拒绝;超上限已由上方 stat 拦截
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 descriptor = settings?.describe?.({ redactSecrets: true })?.find((item: { ns?: string }) => item.ns === FILE_OPEN_SETTINGS_NS)
655
- const value = descriptor?.value as { fileOpenTool?: unknown } | undefined
656
- return { ok: true, fileOpenTool: normalizeFileOpenTool(value?.fileOpenTool ?? FILE_OPEN_DEFAULT), integrationBaseUrl: await integrationBaseUrlOf(ctx), revision: descriptor?.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
- if (!settings?.update) return { ok: false, error: '设置服务不可用' }
661
- try {
662
- const patch: Record<string, unknown> = { fileOpenTool: normalizeFileOpenTool(args.fileOpenTool) }
663
- if (typeof args.integrationBaseUrl === 'string' && args.integrationBaseUrl.trim()) patch.integrationBaseUrl = args.integrationBaseUrl.trim()
664
- await settings.update(FILE_OPEN_SETTINGS_NS, patch, args.expectedRevision)
665
- const descriptor = settings.describe?.({ redactSecrets: true })?.find((item: { ns?: string }) => item.ns === FILE_OPEN_SETTINGS_NS)
666
- const value = descriptor?.value as { fileOpenTool?: unknown } | undefined
667
- return { ok: true, fileOpenTool: normalizeFileOpenTool(value?.fileOpenTool), integrationBaseUrl: await integrationBaseUrlOf(ctx), revision: descriptor?.revision }
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
- return result.ok ? { ok: true, restored: true } : { ok: false, error: result.error ?? '恢复失败' }
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
  }