dsh-vscode-mode 0.7.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 +23 -6
- package/assets/icon.svg +7 -0
- package/cordis.patch.yml +12 -0
- package/lib/client.js +1167 -188
- package/lib/client.js.map +1 -1
- package/lib/index.js +845 -96
- 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/imagePreview.ts +76 -2
- package/src/client/index.ts +65 -36
- 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/outline/index.ts +1 -1
- package/src/client/pdf/pdfPanel.ts +18 -0
- package/src/client/settingsContext.ts +6 -4
- package/src/client/sidebar/panels/FileExplorer.ts +1 -1
- package/src/client/sidebar/panels/SvnPanel.ts +1 -1
- package/src/client/sidebar/panels/index.ts +1 -1
- package/src/client/styles/editor.css +5 -3
- package/src/client/svnStore.ts +6 -1
- package/src/client/ui/EditorView.ts +117 -18
- package/src/client/ui/McpSettings.ts +30 -0
- package/src/client/ui/PerfSettings.ts +4 -1
- 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 +96 -34
- package/src/shared/lsp.ts +79 -0
- package/src/shared/nativeOpen.ts +91 -0
- package/src/shared/rpc.ts +146 -1
package/src/lsp/server.ts
CHANGED
|
@@ -11,11 +11,18 @@ import { deriveDefinitionFromLocations } from './derive.js'
|
|
|
11
11
|
import type { LspProviderSpec } from './providers.js'
|
|
12
12
|
import { LSP_SEMANTIC_TOKEN_MODIFIERS, LSP_SEMANTIC_TOKEN_TYPES } from '../shared/lsp.js'
|
|
13
13
|
import type {
|
|
14
|
+
LspCompletionItem,
|
|
15
|
+
LspCompletionList,
|
|
16
|
+
LspCompletionTextEdit,
|
|
14
17
|
LspLocation,
|
|
18
|
+
LspParameterInformation,
|
|
19
|
+
LspRange,
|
|
15
20
|
LspSemanticTokens,
|
|
16
21
|
LspServerCapabilities,
|
|
17
22
|
LspServerPhase,
|
|
18
23
|
LspServerStatus,
|
|
24
|
+
LspSignatureHelp,
|
|
25
|
+
LspSignatureInformation,
|
|
19
26
|
LspSymbol,
|
|
20
27
|
} from '../shared/lsp.js'
|
|
21
28
|
|
|
@@ -34,6 +41,9 @@ export interface LspServer {
|
|
|
34
41
|
documentSymbol(path: string): Promise<LspSymbol[]>
|
|
35
42
|
workspaceSymbol(query: string): Promise<LspSymbol[]>
|
|
36
43
|
hover(path: string, line: number, character: number): Promise<{ contents: string[] } | null>
|
|
44
|
+
completion(path: string, line: number, character: number, context?: unknown): Promise<LspCompletionList | null>
|
|
45
|
+
resolveCompletion(path: string, item: LspCompletionItem): Promise<LspCompletionItem | null>
|
|
46
|
+
signatureHelp(path: string, line: number, character: number): Promise<LspSignatureHelp | null>
|
|
37
47
|
semanticTokens(path: string): Promise<LspSemanticTokens | null>
|
|
38
48
|
dispose(): Promise<void>
|
|
39
49
|
}
|
|
@@ -86,6 +96,9 @@ export function createLspServer(spec: LspProviderSpec, root: string, languageId:
|
|
|
86
96
|
workspaceSymbol: false,
|
|
87
97
|
hover: false,
|
|
88
98
|
semanticTokens: false,
|
|
99
|
+
completion: false,
|
|
100
|
+
completionResolve: false,
|
|
101
|
+
signatureHelp: false,
|
|
89
102
|
semanticTokenTypes: [...LSP_SEMANTIC_TOKEN_TYPES],
|
|
90
103
|
semanticTokenModifiers: [...LSP_SEMANTIC_TOKEN_MODIFIERS],
|
|
91
104
|
}
|
|
@@ -346,6 +359,69 @@ export function createLspServer(spec: LspProviderSpec, root: string, languageId:
|
|
|
346
359
|
return { contents: stringifyHoverContents(result.contents) }
|
|
347
360
|
},
|
|
348
361
|
|
|
362
|
+
/**
|
|
363
|
+
* 查询补全列表(textDocument/completion)。
|
|
364
|
+
*
|
|
365
|
+
* 列表阶段**剥离 documentation**:EmmyLua 把文档挂在 resolve 阶段(我方 initialize 已声明
|
|
366
|
+
* resolveSupport),但并非所有服务器都遵守;每条都带长文档会让单次 RPC 载荷暴涨。
|
|
367
|
+
* `data` 原样透传,resolve 时由服务器回认该条目。
|
|
368
|
+
* @author ddj 2026年09月22号
|
|
369
|
+
* @param path 工作区相对路径
|
|
370
|
+
* @param line 0-based 行
|
|
371
|
+
* @param character 0-based 列
|
|
372
|
+
* @param context LSP CompletionContext(触发字符/触发类型)
|
|
373
|
+
* @returns 归一化补全列表;不支持或文档未打开时 null
|
|
374
|
+
*/
|
|
375
|
+
async completion(path: string, line: number, character: number, context?: unknown): Promise<LspCompletionList | null> {
|
|
376
|
+
const doc = docs.get(path)
|
|
377
|
+
if (!doc || !(await readyWait) || !capabilities.completion) return null
|
|
378
|
+
const params: Record<string, unknown> = {
|
|
379
|
+
textDocument: { uri: doc.uri },
|
|
380
|
+
position: { line, character },
|
|
381
|
+
}
|
|
382
|
+
if (context) params.context = context
|
|
383
|
+
const result = await ensureClient().request<unknown>('textDocument/completion', params)
|
|
384
|
+
return normCompletion(result)
|
|
385
|
+
},
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* 补全项惰性补全(completionItem/resolve):按 `data` 取回 documentation/detail/additionalTextEdits。
|
|
389
|
+
*
|
|
390
|
+
* 必须回传**完整条目**(服务器按 data + label 精确匹配),只发 label/data 可能被服务器忽略。
|
|
391
|
+
* @author ddj 2026年09月22号
|
|
392
|
+
* @param path 工作区相对路径
|
|
393
|
+
* @param item 列表阶段返回的补全项
|
|
394
|
+
* @returns 补全后的条目;服务器不支持时原样返回
|
|
395
|
+
*/
|
|
396
|
+
async resolveCompletion(path: string, item: LspCompletionItem): Promise<LspCompletionItem | null> {
|
|
397
|
+
if (!item || typeof item.label !== 'string') return null
|
|
398
|
+
if (!(await readyWait) || !capabilities.completionResolve) return item
|
|
399
|
+
const result = await ensureClient().request<unknown>('completionItem/resolve', toLspCompItem(item))
|
|
400
|
+
const resolved = normCompItem(result)
|
|
401
|
+
return resolved ?? item
|
|
402
|
+
},
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* 查询签名帮助(textDocument/signatureHelp)。
|
|
406
|
+
*
|
|
407
|
+
* activeSignature/activeParameter 做边界裁剪:服务器可能给越界值(多签名时按实参推进出错),
|
|
408
|
+
* Monaco 拿到越界索引会取不到签名而不渲染 —— 裁剪后至少显示首个签名。
|
|
409
|
+
* @author ddj 2026年09月22号
|
|
410
|
+
* @param path 工作区相对路径
|
|
411
|
+
* @param line 0-based 行
|
|
412
|
+
* @param character 0-based 列
|
|
413
|
+
* @returns 归一化签名帮助;无签名/不支持时 null
|
|
414
|
+
*/
|
|
415
|
+
async signatureHelp(path: string, line: number, character: number): Promise<LspSignatureHelp | null> {
|
|
416
|
+
const doc = docs.get(path)
|
|
417
|
+
if (!doc || !(await readyWait) || !capabilities.signatureHelp) return null
|
|
418
|
+
const result = await ensureClient().request<unknown>(
|
|
419
|
+
'textDocument/signatureHelp',
|
|
420
|
+
{ textDocument: { uri: doc.uri }, position: { line, character } },
|
|
421
|
+
)
|
|
422
|
+
return toSignatureHelp(result)
|
|
423
|
+
},
|
|
424
|
+
|
|
349
425
|
/** 查询全文 semantic tokens,并将服务器 legend 归一到插件固定 legend。 */
|
|
350
426
|
async semanticTokens(path: string): Promise<LspSemanticTokens | null> {
|
|
351
427
|
const doc = docs.get(path)
|
|
@@ -440,8 +516,202 @@ function normalizeSymbol(item: unknown): LspSymbol | null {
|
|
|
440
516
|
return symbol
|
|
441
517
|
}
|
|
442
518
|
|
|
443
|
-
/** hover
|
|
444
|
-
function
|
|
519
|
+
/** hover/markdown 内容 → 纯文本(string | MarkupContent | MarkedString[] | MarkedString)。 */
|
|
520
|
+
function plainText(value: unknown): string | undefined {
|
|
521
|
+
if (typeof value === 'string') return value || undefined
|
|
522
|
+
if (Array.isArray(value)) {
|
|
523
|
+
const parts = value.map(plainText).filter((part): part is string => Boolean(part))
|
|
524
|
+
return parts.length ? parts.join('\n\n') : undefined
|
|
525
|
+
}
|
|
526
|
+
if (value && typeof value === 'object') {
|
|
527
|
+
// MarkedString 对象形态:{ language, value };MarkupContent 形态:{ kind, value }
|
|
528
|
+
const text = (value as { value?: unknown }).value
|
|
529
|
+
if (typeof text === 'string') return text || undefined
|
|
530
|
+
}
|
|
531
|
+
return undefined
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* 归一化补全文本编辑范围(单 range 与 insert/replace 双 range 两种形态)。
|
|
536
|
+
*
|
|
537
|
+
* 两种形态必须都认:只认一种会让采用另一种形态的服务器补全落点错位(替换范围算错,
|
|
538
|
+
* 已输入的前缀被重复插入)。双 range 时 insert 用于「保留前缀插入」,replace 用于覆盖。
|
|
539
|
+
* @author ddj 2026年09月22号
|
|
540
|
+
* @param raw 原始 textEdit
|
|
541
|
+
* @returns 归一化文本编辑;无有效范围或文本时 null
|
|
542
|
+
*/
|
|
543
|
+
function normalizeTextEdit(raw: unknown): LspCompletionTextEdit | null {
|
|
544
|
+
if (!raw || typeof raw !== 'object') return null
|
|
545
|
+
const obj = raw as { range?: unknown; insert?: unknown; replace?: unknown; newText?: unknown }
|
|
546
|
+
const newText = typeof obj.newText === 'string' ? obj.newText : ''
|
|
547
|
+
if (!newText) return null
|
|
548
|
+
const single = normalizeRange(obj.range)
|
|
549
|
+
if (single) return { range: single, newText }
|
|
550
|
+
const insert = normalizeRange(obj.insert)
|
|
551
|
+
const replace = normalizeRange(obj.replace)
|
|
552
|
+
if (!insert && !replace) return null
|
|
553
|
+
return { insert: insert ?? replace!, replace: replace ?? insert!, newText }
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/** 归一化 LSP Range(缺字段返回 null,避免产出畸形范围)。 */
|
|
557
|
+
function normalizeRange(raw: unknown): LspRange | null {
|
|
558
|
+
if (!raw || typeof raw !== 'object') return null
|
|
559
|
+
const r = raw as { start?: { line?: unknown; character?: unknown }; end?: { line?: unknown; character?: unknown } }
|
|
560
|
+
if (!r.start || !r.end) return null
|
|
561
|
+
return {
|
|
562
|
+
start: { line: toInt(r.start.line), character: toInt(r.start.character) },
|
|
563
|
+
end: { line: toInt(r.end.line), character: toInt(r.end.character) },
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* 归一化单个补全项。
|
|
569
|
+
* @author ddj 2026年09月22号
|
|
570
|
+
* @param raw 原始条目
|
|
571
|
+
* @returns 归一化条目;缺 label 时 null
|
|
572
|
+
*/
|
|
573
|
+
export function normCompItem(raw: unknown): LspCompletionItem | null {
|
|
574
|
+
if (!raw || typeof raw !== 'object') return null
|
|
575
|
+
const obj = raw as Record<string, unknown>
|
|
576
|
+
const label = typeof obj.label === 'string' ? obj.label : ''
|
|
577
|
+
if (!label) return null
|
|
578
|
+
const item: LspCompletionItem = { label }
|
|
579
|
+
if (typeof obj.kind === 'number') item.kind = toInt(obj.kind)
|
|
580
|
+
if (typeof obj.detail === 'string' && obj.detail) item.detail = obj.detail
|
|
581
|
+
const documentation = plainText(obj.documentation)
|
|
582
|
+
if (documentation) item.documentation = documentation
|
|
583
|
+
if (typeof obj.insertText === 'string') item.insertText = obj.insertText
|
|
584
|
+
if (typeof obj.insertTextFormat === 'number') item.insertTextFormat = toInt(obj.insertTextFormat)
|
|
585
|
+
const textEdit = normalizeTextEdit(obj.textEdit)
|
|
586
|
+
if (textEdit) item.textEdit = textEdit
|
|
587
|
+
if (obj.data !== undefined) item.data = obj.data
|
|
588
|
+
if (typeof obj.sortText === 'string') item.sortText = obj.sortText
|
|
589
|
+
if (typeof obj.filterText === 'string') item.filterText = obj.filterText
|
|
590
|
+
if (obj.preselect === true) item.preselect = true
|
|
591
|
+
if (Array.isArray(obj.commitCharacters)) {
|
|
592
|
+
const chars = obj.commitCharacters.filter((c): c is string => typeof c === 'string')
|
|
593
|
+
if (chars.length) item.commitCharacters = chars
|
|
594
|
+
}
|
|
595
|
+
// 弃用标记两处来源:顶层 deprecated(旧草案)与 tags 含 1(CompletionItemTag.Deprecated)
|
|
596
|
+
const tags = Array.isArray(obj.tags) ? obj.tags : []
|
|
597
|
+
if (obj.deprecated === true || tags.some((tag) => toInt(tag) === 1)) item.deprecated = true
|
|
598
|
+
if (Array.isArray(obj.additionalTextEdits)) {
|
|
599
|
+
const edits = obj.additionalTextEdits
|
|
600
|
+
.map((edit) => {
|
|
601
|
+
const range = normalizeRange((edit as { range?: unknown } | null)?.range)
|
|
602
|
+
const text = (edit as { newText?: unknown } | null)?.newText
|
|
603
|
+
if (!range || typeof text !== 'string') return null
|
|
604
|
+
return { range, newText: text }
|
|
605
|
+
})
|
|
606
|
+
.filter((edit): edit is { range: LspRange; newText: string } => edit !== null)
|
|
607
|
+
if (edits.length) item.additionalTextEdits = edits
|
|
608
|
+
}
|
|
609
|
+
return item
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* 归一化补全结果(CompletionList | CompletionItem[])。
|
|
614
|
+
*
|
|
615
|
+
* 两种顶层形态都要认:`{ isIncomplete, items }` 与裸数组。裸数组是最常见形态,
|
|
616
|
+
* 只认 CompletionList 会让「补全列表恒为空」且无报错。
|
|
617
|
+
* @author ddj 2026年09月22号
|
|
618
|
+
* @param result 原始响应
|
|
619
|
+
* @returns 归一化列表;无效时 null
|
|
620
|
+
*/
|
|
621
|
+
export function normCompletion(result: unknown): LspCompletionList | null {
|
|
622
|
+
if (result == null) return null
|
|
623
|
+
const list = Array.isArray(result)
|
|
624
|
+
? { items: result, isIncomplete: false }
|
|
625
|
+
: (result as { items?: unknown; isIncomplete?: unknown })
|
|
626
|
+
if (!Array.isArray(list.items)) return null
|
|
627
|
+
const items = list.items
|
|
628
|
+
.map(normCompItem)
|
|
629
|
+
.filter((item): item is LspCompletionItem => item !== null)
|
|
630
|
+
// 截断由 RPC 层按统一上限处理(与 definition/references 同口径),此处不下刀
|
|
631
|
+
return { items, incomplete: list.isIncomplete === true }
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/**
|
|
635
|
+
* 补全项 → LSP 载荷(resolve 回传用)。
|
|
636
|
+
* 只回传协议字段,剔除 client 侧衍生字段;`data` 必须原样带上,服务器靠它认条目。
|
|
637
|
+
* @author ddj 2026年09月22号
|
|
638
|
+
* @param item 归一化条目
|
|
639
|
+
* @returns LSP CompletionItem 载荷
|
|
640
|
+
*/
|
|
641
|
+
export function toLspCompItem(item: LspCompletionItem): Record<string, unknown> {
|
|
642
|
+
const out: Record<string, unknown> = { label: item.label }
|
|
643
|
+
if (item.kind !== undefined) out.kind = item.kind
|
|
644
|
+
if (item.detail !== undefined) out.detail = item.detail
|
|
645
|
+
if (item.documentation !== undefined) out.documentation = item.documentation
|
|
646
|
+
if (item.insertText !== undefined) out.insertText = item.insertText
|
|
647
|
+
if (item.insertTextFormat !== undefined) out.insertTextFormat = item.insertTextFormat
|
|
648
|
+
if (item.data !== undefined) out.data = item.data
|
|
649
|
+
if (item.sortText !== undefined) out.sortText = item.sortText
|
|
650
|
+
if (item.filterText !== undefined) out.filterText = item.filterText
|
|
651
|
+
if (item.textEdit) out.textEdit = item.textEdit.range ? { range: item.textEdit.range, newText: item.textEdit.newText } : item.textEdit
|
|
652
|
+
if (item.additionalTextEdits) out.additionalTextEdits = item.additionalTextEdits
|
|
653
|
+
if (item.commitCharacters) out.commitCharacters = item.commitCharacters
|
|
654
|
+
return out
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/** 归一化单个签名参数(label 支持字符串与 [start, end] 元组,两者原样透传)。 */
|
|
658
|
+
function toSignatureParam(raw: unknown): LspParameterInformation | null {
|
|
659
|
+
if (!raw || typeof raw !== 'object') return null
|
|
660
|
+
const obj = raw as { label?: unknown; documentation?: unknown }
|
|
661
|
+
const tuple = Array.isArray(obj.label) && obj.label.length >= 2
|
|
662
|
+
? [toInt(obj.label[0]), toInt(obj.label[1])] as [number, number]
|
|
663
|
+
: null
|
|
664
|
+
if (typeof obj.label !== 'string' && !tuple) return null
|
|
665
|
+
const param: LspParameterInformation = { label: typeof obj.label === 'string' ? obj.label : tuple! }
|
|
666
|
+
const documentation = plainText(obj.documentation)
|
|
667
|
+
if (documentation) param.documentation = documentation
|
|
668
|
+
return param
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/** 归一化单个签名。 */
|
|
672
|
+
function toSignature(raw: unknown): LspSignatureInformation | null {
|
|
673
|
+
if (!raw || typeof raw !== 'object') return null
|
|
674
|
+
const obj = raw as { label?: unknown; documentation?: unknown; parameters?: unknown; activeParameter?: unknown }
|
|
675
|
+
if (typeof obj.label !== 'string') return null
|
|
676
|
+
const signature: LspSignatureInformation = { label: obj.label, parameters: [] }
|
|
677
|
+
const documentation = plainText(obj.documentation)
|
|
678
|
+
if (documentation) signature.documentation = documentation
|
|
679
|
+
if (Array.isArray(obj.parameters)) {
|
|
680
|
+
signature.parameters = obj.parameters
|
|
681
|
+
.map(toSignatureParam)
|
|
682
|
+
.filter((param): param is LspParameterInformation => param !== null)
|
|
683
|
+
}
|
|
684
|
+
if (typeof obj.activeParameter === 'number') signature.activeParameter = toInt(obj.activeParameter)
|
|
685
|
+
return signature
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/**
|
|
689
|
+
* 归一化签名帮助,并裁剪越界的 activeSignature / activeParameter。
|
|
690
|
+
*
|
|
691
|
+
* 为什么必须裁:Monaco 用这两个索引直接取签名与参数,越界时取不到签名就整个浮窗不渲染;
|
|
692
|
+
* 服务器在「实参刚敲下逗号」等边界时刻常给超前一位的索引。裁剪后至少显示首个签名/末个参数,
|
|
693
|
+
* 观感是「高亮没跟上」,而不是「参数提示整个不见」。
|
|
694
|
+
* @author ddj 2026年09月22号
|
|
695
|
+
* @param result 原始响应
|
|
696
|
+
* @returns 归一化结果;无有效签名时 null
|
|
697
|
+
*/
|
|
698
|
+
export function toSignatureHelp(result: unknown): LspSignatureHelp | null {
|
|
699
|
+
if (!result || typeof result !== 'object') return null
|
|
700
|
+
const obj = result as { signatures?: unknown; activeSignature?: unknown; activeParameter?: unknown }
|
|
701
|
+
if (!Array.isArray(obj.signatures)) return null
|
|
702
|
+
const signatures = obj.signatures
|
|
703
|
+
.map(toSignature)
|
|
704
|
+
.filter((signature): signature is LspSignatureInformation => signature !== null)
|
|
705
|
+
if (!signatures.length) return null
|
|
706
|
+
const activeSignature = Math.min(Math.max(0, toInt(obj.activeSignature)), signatures.length - 1)
|
|
707
|
+
const current = signatures[activeSignature]!
|
|
708
|
+
const rawActive = typeof obj.activeParameter === 'number' ? toInt(obj.activeParameter) : (current.activeParameter ?? 0)
|
|
709
|
+
const count = current.parameters.length
|
|
710
|
+
const activeParameter = count > 0 ? Math.min(Math.max(0, rawActive), count - 1) : 0
|
|
711
|
+
return { signatures, activeSignature, activeParameter }
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/** hover contents → 文本行(MarkupContent | MarkedString | MarkedString[])。 */function stringifyHoverContents(contents: unknown): string[] {
|
|
445
715
|
if (typeof contents === 'string') return [contents]
|
|
446
716
|
if (Array.isArray(contents)) return contents.map(stringifyHoverContents).flat().filter(Boolean)
|
|
447
717
|
if (contents && typeof contents === 'object') {
|
package/src/perf.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { mkdir, readdir, readFile, rename, rm, stat, writeFile } from 'node:fs/p
|
|
|
12
12
|
import { dirname, join, resolve, sep } from 'node:path'
|
|
13
13
|
import { parseSidecar } from './store.js'
|
|
14
14
|
import { recSummary } from './model.js'
|
|
15
|
+
import { log } from './log.js'
|
|
15
16
|
import { SIDECAR, SIDECAR_ARCHIVE, dshHome, sessionIdSegment, sessionWorkspaceKey, sessionsRoot } from './paths.js'
|
|
16
17
|
import type { PerfMoveFailure, PerfMoveItem, PerfSession, PerfTotals, PerfWorkspace, SidecarPerfSummary } from './shared/rpc.js'
|
|
17
18
|
import type { Ctx } from './store.js'
|
|
@@ -112,13 +113,76 @@ export async function scanSessionInventory(home = dshHome(), archive = sessionsA
|
|
|
112
113
|
return { workspaces, sessions, totals: { workspaces: workspaces.length, sessions: totalCount, totalBytes } }
|
|
113
114
|
}
|
|
114
115
|
|
|
115
|
-
/**
|
|
116
|
-
|
|
117
|
-
const raw = new Set(
|
|
116
|
+
/** id 双向匹配器:原始 id 与编码段名任一命中(兼容编码差异)。 */
|
|
117
|
+
function idMatcher(ids: Iterable<string>): (id: string) => boolean {
|
|
118
|
+
const raw = new Set(ids)
|
|
118
119
|
const encoded = new Set<string>()
|
|
119
120
|
for (const id of raw) encoded.add(sessionIdSegment(id))
|
|
121
|
+
return (id) => raw.has(id) || encoded.has(id)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** 标记活跃会话(live id 与目录段名双向匹配,兼容编码差异)。 */
|
|
125
|
+
export function markActiveSessions(sessions: PerfSession[], activeIds: Iterable<string>): void {
|
|
126
|
+
const match = idMatcher(activeIds)
|
|
120
127
|
for (const s of sessions) {
|
|
121
|
-
if (
|
|
128
|
+
if (match(s.sessionId)) s.active = true
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** 官方归档/置顶标志 id 集合(registry 缺服务/缺字段时为空集)。 */
|
|
133
|
+
interface OfficialFlagIds {
|
|
134
|
+
archived: Set<string>
|
|
135
|
+
pinned: Set<string>
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** 空标志集合(服务/字段缺失的统一降级返回值)。 */
|
|
139
|
+
function emptyFlagIds(): OfficialFlagIds {
|
|
140
|
+
return { archived: new Set(), pinned: new Set() }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** 任意值 → id 字符串集合(非数组或非字符串元素一律丢弃)。 */
|
|
144
|
+
function toIdSet(ids: unknown): Set<string> {
|
|
145
|
+
if (!Array.isArray(ids)) return new Set()
|
|
146
|
+
return new Set(ids.filter((id): id is string => typeof id === 'string'))
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* 一次读取 workspaceRegistry 的官方归档/置顶 id 集合(摊到盘点各行使用)。
|
|
151
|
+
* 服务缺失、字段缺失或非数组(DSH 0.1.6 及更旧没有)一律视为空集,绝不抛错。
|
|
152
|
+
* @author ddj 2026年09月22号
|
|
153
|
+
* @param ctx DSH 上下文
|
|
154
|
+
* @returns archived/pinned 两组 id 集合(缺失即空集)
|
|
155
|
+
*/
|
|
156
|
+
export function registryFlags(ctx: Ctx): OfficialFlagIds {
|
|
157
|
+
try {
|
|
158
|
+
const registry = ctx.get('workspaceRegistry')
|
|
159
|
+
if (!registry) return emptyFlagIds()
|
|
160
|
+
return { archived: toIdSet(registry.archivedSessionIds), pinned: toIdSet(registry.pinnedSessionIds) }
|
|
161
|
+
} catch (error) {
|
|
162
|
+
log.debug('registryFlags 跳过(服务缺失或读取失败):' + String(error))
|
|
163
|
+
return emptyFlagIds()
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* 给盘点行附官方归档/置顶标志(best-effort:仅命中附 true,缺失/未命中不附字段),
|
|
169
|
+
* 让 PerfSettings 会话行能看到官方侧栏的归档/置顶状态。id 匹配复用 markActiveSessions
|
|
170
|
+
* 的双向(原始 id / 编码段名)口径;服务/字段缺失不附字段、绝不抛错(旧 host 兼容降级)。
|
|
171
|
+
* @author ddj 2026年09月22号
|
|
172
|
+
* @param sessions 盘点会话行(就地附加 archived/pinned)
|
|
173
|
+
* @param ctx DSH 上下文
|
|
174
|
+
*/
|
|
175
|
+
export function markOfficialFlags(sessions: PerfSession[], ctx: Ctx): void {
|
|
176
|
+
try {
|
|
177
|
+
const flags = registryFlags(ctx)
|
|
178
|
+
const archived = idMatcher(flags.archived)
|
|
179
|
+
const pinned = idMatcher(flags.pinned)
|
|
180
|
+
for (const s of sessions) {
|
|
181
|
+
if (archived(s.sessionId)) s.archived = true
|
|
182
|
+
if (pinned(s.sessionId)) s.pinned = true
|
|
183
|
+
}
|
|
184
|
+
} catch (error) {
|
|
185
|
+
log.debug('markOfficialFlags 跳过(读取失败,保持无标志):' + String(error))
|
|
122
186
|
}
|
|
123
187
|
}
|
|
124
188
|
|
|
@@ -239,6 +303,28 @@ export async function restoreSession(home: string, archive: string, workspaceKey
|
|
|
239
303
|
}
|
|
240
304
|
}
|
|
241
305
|
|
|
306
|
+
/**
|
|
307
|
+
* 恢复后对齐官方归档标志(best-effort):官方 archive 是 workspaceRegistry 的
|
|
308
|
+
* archivedSessionIds 持久标志(只藏 UI/拦模型步骤,不搬目录;dsh-workspace 源码注明
|
|
309
|
+
* unarchiveSession 对未归档与未知 id 均为无写入 no-op、对失踪会话也容忍)。
|
|
310
|
+
* 插件把目录搬回后若该会话曾被官方归档,官方侧栏会继续隐藏它、0.1.7 的
|
|
311
|
+
* archived-session-gate 会继续拦它的模型步骤——这里探测 host 的 workspaceRegistry
|
|
312
|
+
* 服务做一次幂等清除;服务缺失、id 编码差异未命中或调用抛错一律静默跳过
|
|
313
|
+
* (保持现状,不加猜的行为)。有效期:仅恢复成功后触发一次,不轮询不重试。
|
|
314
|
+
* @author ddj 2026年09月22号
|
|
315
|
+
* @param ctx DSH 上下文
|
|
316
|
+
* @param sessionId 恢复的会话 id(目录段名与原始 id 同形时可命中官方标志)
|
|
317
|
+
*/
|
|
318
|
+
export async function unarchiveOfficial(ctx: Ctx, sessionId: string): Promise<void> {
|
|
319
|
+
try {
|
|
320
|
+
const registry = ctx.get('workspaceRegistry')
|
|
321
|
+
if (!registry || typeof registry.unarchiveSession !== 'function') return
|
|
322
|
+
await registry.unarchiveSession(sessionId)
|
|
323
|
+
} catch (error) {
|
|
324
|
+
log.debug('unarchiveOfficial 跳过(服务缺失或调用失败,保持现状):' + String(error))
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
242
328
|
/** 清除归档区早于 N 天的会话(破坏性,仅限归档区)。 */
|
|
243
329
|
export async function purgeArchive(archive: string, olderThanDays: number): Promise<{ removed: number; reclaimedBytes: number }> {
|
|
244
330
|
const cutoff = Date.now() - olderThanDays * 24 * 60 * 60 * 1000
|
package/src/routes.ts
CHANGED
|
@@ -80,7 +80,7 @@ export function registerRoutes(
|
|
|
80
80
|
ctx.effect(() => guarded({
|
|
81
81
|
kind: 'exact',
|
|
82
82
|
path: RPC_PATH,
|
|
83
|
-
handler: async (req: unknown, res: { statusCode?: number; setHeader: (k: string, v: string) => void; end: (b?: string) => void }) => {
|
|
83
|
+
handler: async (req: unknown, res: { statusCode?: number; setHeader: (k: string, v: string) => void; end: (b?: string | Uint8Array) => void }) => {
|
|
84
84
|
try {
|
|
85
85
|
const chunks: Uint8Array[] = []
|
|
86
86
|
for await (const chunk of req as AsyncIterable<Uint8Array>) chunks.push(chunk)
|
|
@@ -92,6 +92,18 @@ export function registerRoutes(
|
|
|
92
92
|
}
|
|
93
93
|
const method = (typeof input.method === 'string' ? input.method : '') as RpcMethod
|
|
94
94
|
const result = await handleRpc(method, (input.args ?? {}) as never)
|
|
95
|
+
// 二进制预览信封(edrv.readBinary):octet-stream + 元数据头直出原始字节,
|
|
96
|
+
// 省 base64 ~33% 体积;错误与其余方法仍走下方 JSON 分支(client 侧据此识别并回退)
|
|
97
|
+
const bin = (result as { ok?: boolean; binary?: { bytes?: Uint8Array; mime?: string; size?: number; version?: string } } | null)?.binary
|
|
98
|
+
if (result && (result as { ok?: boolean }).ok === true && bin?.bytes instanceof Uint8Array) {
|
|
99
|
+
res.statusCode = 200
|
|
100
|
+
res.setHeader('content-type', 'application/octet-stream')
|
|
101
|
+
res.setHeader('x-edrv-mime', String(bin.mime ?? ''))
|
|
102
|
+
res.setHeader('x-edrv-version', String(bin.version ?? ''))
|
|
103
|
+
res.setHeader('x-edrv-size', String(bin.size ?? bin.bytes.byteLength))
|
|
104
|
+
res.end(Buffer.from(bin.bytes))
|
|
105
|
+
return
|
|
106
|
+
}
|
|
95
107
|
res.statusCode = 200
|
|
96
108
|
res.setHeader('content-type', 'application/json')
|
|
97
109
|
res.end(JSON.stringify(result))
|
package/src/rpc.ts
CHANGED
|
@@ -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'
|
|
@@ -47,7 +47,7 @@ import { invalidateIndex, listDirCached } from './treeIndex.js'
|
|
|
47
47
|
import { revealInExplorer } from './reveal.js'
|
|
48
48
|
import { dshHome, debugLogFile, pluginLogRoot } from './paths.js'
|
|
49
49
|
import { clearDebugLog, debugRecord, isDebugLogName, listDebugLogs, readDebugLog } from './debugLog.js'
|
|
50
|
-
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'
|
|
51
51
|
import { patchHasPerfConfig, patchInsertPerfConfig, patchRemovePerfConfig, perfConfigBlock } from './perfPatch.js'
|
|
52
52
|
import type { FileVersions } from './fileVersions.js'
|
|
53
53
|
|
|
@@ -107,6 +107,57 @@ function snippetTargetOf(path: string): string | null {
|
|
|
107
107
|
return isSnippetFilePath(path) ? path.replace(/\\/g, '/') : null
|
|
108
108
|
}
|
|
109
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
|
+
|
|
110
161
|
/**
|
|
111
162
|
* 本地文件系统版本令牌(mtime+size):与 ctx.fs 的不透明版本串用途相同,
|
|
112
163
|
* 仅供全局片段文件(走 node:fs 直读、不经 ctx.fs)在 edrv.read 时回带。
|
|
@@ -241,8 +292,7 @@ async function latestPatchBackup(patchPath: string): Promise<string | undefined>
|
|
|
241
292
|
/** 读取设置中的深链基址(缺省/非法回退默认 3080)。 */
|
|
242
293
|
async function integrationBaseUrlOf(ctx: Ctx): Promise<string> {
|
|
243
294
|
const settings = ctx.get('settings')
|
|
244
|
-
const
|
|
245
|
-
const value = descriptor?.value as { integrationBaseUrl?: unknown } | undefined
|
|
295
|
+
const value = sectionOf(settings, FILE_OPEN_SETTINGS_NS)?.value as { integrationBaseUrl?: unknown } | undefined
|
|
246
296
|
const raw = typeof value?.integrationBaseUrl === 'string' ? value.integrationBaseUrl.trim() : ''
|
|
247
297
|
return raw || INTEGRATION_BASE_DEFAULT
|
|
248
298
|
}
|
|
@@ -433,26 +483,36 @@ export function buildHandlers(
|
|
|
433
483
|
}
|
|
434
484
|
const sc = await requireSession(ctx, args.sessionId)
|
|
435
485
|
if ('err' in sc) return { ok: false, error: sc.err }
|
|
436
|
-
const
|
|
437
|
-
if (
|
|
486
|
+
const prep = await readTargetOf(ctx, sc, args.path)
|
|
487
|
+
if ('err' in prep) return targetErrOf(prep)
|
|
438
488
|
try {
|
|
439
|
-
const target = await resolveTarget(ctx, sc.session, args.path)
|
|
440
|
-
debugRecord(ctx, sc.cwd, '[DEBUG path.resolve] input=' + String(args.path ?? '') + ' resolved=' + fs.processPath(target), 'debug')
|
|
441
|
-
const info = await fs.stat(target)
|
|
442
|
-
if (!info || info.type !== 'file') {
|
|
443
|
-
// 带上解析后的真实路径:跳转失败时可直接看出是路径解析错还是目标本身不存在
|
|
444
|
-
return { ok: false, error: '文件不存在', resolvedPath: fs.processPath(target) }
|
|
445
|
-
}
|
|
446
|
-
if ((info.size ?? 0) > BINARY_READ_CAP) return { ok: false, error: '文件过大(>32MB),不支持整文件预览' }
|
|
447
489
|
if (args.encoding === 'base64') {
|
|
448
|
-
// 图片/PDF 等二进制预览:readBytes
|
|
449
|
-
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)
|
|
450
492
|
const content = Buffer.from(bytes).toString('base64')
|
|
451
|
-
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 ?? '') },
|
|
452
515
|
}
|
|
453
|
-
if ((info.size ?? 0) > READ_CAP) return { ok: false, error: '文件过大(>8MB),不支持整文件预览' }
|
|
454
|
-
const content = await fs.readText(target)
|
|
455
|
-
return { ok: true, content, size: content.length, version: String(info.version ?? '') }
|
|
456
516
|
} catch (error) {
|
|
457
517
|
return { ok: false, error: '读取失败:' + String(error) }
|
|
458
518
|
}
|
|
@@ -847,22 +907,20 @@ export function buildHandlers(
|
|
|
847
907
|
},
|
|
848
908
|
'vscode.fileOpenSettingsGet': async () => {
|
|
849
909
|
const settings = ctx.get('settings')
|
|
850
|
-
const
|
|
851
|
-
const value =
|
|
852
|
-
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 }
|
|
853
913
|
},
|
|
854
914
|
'vscode.fileOpenSettingsUpdate': async (args) => {
|
|
855
915
|
const settings = ctx.get('settings')
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
} catch (error) { return { ok: false, error: String(error) }
|
|
865
|
-
}
|
|
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 }
|
|
866
924
|
},
|
|
867
925
|
'compat': async () => ({ ok: true, report: await buildReport(ctx) }),
|
|
868
926
|
'vscode.devFormGet': async () => ({ ok: true, devForm: readDevForm() }),
|
|
@@ -953,6 +1011,7 @@ export function buildHandlers(
|
|
|
953
1011
|
const inventory = await scanSessionInventory(home, sessionsArchiveRoot(home))
|
|
954
1012
|
const active = activeSessionIds(ctx)
|
|
955
1013
|
markActiveSessions(inventory.sessions, active)
|
|
1014
|
+
markOfficialFlags(inventory.sessions, ctx)
|
|
956
1015
|
return { ok: true, ...inventory, activeIds: [...active] }
|
|
957
1016
|
} catch (error) {
|
|
958
1017
|
return { ok: false, error: '盘点失败:' + String(error) }
|
|
@@ -990,7 +1049,10 @@ export function buildHandlers(
|
|
|
990
1049
|
'edrv.perf.restore': async (args) => {
|
|
991
1050
|
try {
|
|
992
1051
|
const result = await restoreSession(dshHome(), sessionsArchiveRoot(dshHome()), args.workspaceKey, args.sessionId)
|
|
993
|
-
|
|
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 }
|
|
994
1056
|
} catch (error) {
|
|
995
1057
|
return { ok: false, error: '恢复失败:' + String(error) }
|
|
996
1058
|
}
|