dsh-vscode-mode 0.7.0 → 0.9.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 +37 -6
- package/assets/icon.svg +7 -0
- package/cordis.patch.yml +16 -0
- package/lib/client.js +2804 -300
- package/lib/client.js.map +1 -1
- package/lib/index.js +1975 -191
- package/lib/index.js.map +1 -1
- package/locale/en.json +4 -0
- package/locale/zh.json +4 -0
- package/package.json +4 -2
- package/src/ai/inline.ts +19 -2
- package/src/ai/svnTriage.ts +150 -0
- package/src/capture.ts +225 -51
- package/src/client/addToConversation.ts +203 -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/pendingNav.ts +141 -0
- package/src/client/settingsContext.ts +6 -4
- package/src/client/sidebar/panels/FileExplorer.ts +2 -15
- package/src/client/sidebar/panels/SvnPanel.ts +682 -72
- package/src/client/sidebar/panels/index.ts +1 -1
- package/src/client/sidebar/panels/svnListBudget.ts +58 -0
- package/src/client/state/records.ts +7 -1
- package/src/client/styles/editor.css +47 -5
- package/src/client/svnStatus.ts +206 -1
- package/src/client/svnStore.ts +6 -1
- package/src/client/ui/AiSettings.ts +47 -14
- package/src/client/ui/DiffBox.ts +1 -1
- package/src/client/ui/DiffLauncher.ts +6 -4
- package/src/client/ui/EditorView.ts +149 -22
- package/src/client/ui/McpSettings.ts +30 -0
- package/src/client/ui/PerfSettings.ts +4 -1
- package/src/client/ui/SvnAiPlanDialog.ts +293 -0
- package/src/client/ui/icons.ts +91 -0
- package/src/compat.ts +26 -5
- package/src/dshVersion.ts +2 -1
- package/src/fileOpenSettings.ts +274 -49
- package/src/index.ts +18 -1
- package/src/lsp/client.ts +24 -0
- package/src/lsp/rpc.ts +57 -0
- package/src/lsp/server.ts +272 -2
- package/src/model.ts +2 -1
- package/src/perf.ts +90 -4
- package/src/remoteWorkspace.ts +177 -0
- package/src/routes.ts +13 -1
- package/src/rpc.ts +115 -39
- package/src/shared/ai.ts +12 -0
- package/src/shared/lsp.ts +79 -0
- package/src/shared/nativeOpen.ts +91 -0
- package/src/shared/rpc.ts +162 -2
- package/src/shared/svn.ts +296 -10
- package/src/shared/types.ts +5 -1
- package/src/svn.ts +235 -13
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/model.ts
CHANGED
|
@@ -32,7 +32,8 @@ export function normalizeRecord(raw: unknown): DiffRecord | null {
|
|
|
32
32
|
}
|
|
33
33
|
})
|
|
34
34
|
: []
|
|
35
|
-
|
|
35
|
+
// 白名单保留已知工具名,未知/缺失回退 'edit'(旧 sidecar 语义不变)
|
|
36
|
+
const toolName: DiffRecord['toolName'] = r.toolName === 'write' || r.toolName === 'remote_ssh_write' ? r.toolName : 'edit'
|
|
36
37
|
const rawDecisions = r.decisions && typeof r.decisions === 'object' ? r.decisions as Record<string, unknown> : {}
|
|
37
38
|
const call = rawDecisions.call === 'accepted' || rawDecisions.call === 'rejected' ? rawDecisions.call : 'pending'
|
|
38
39
|
const rawPerHunk = Array.isArray(rawDecisions.perHunk) ? rawDecisions.perHunk : []
|
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
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-vscode-mode host — dsh-remote-ssh 远程工作区探测与镜像↔远端路径映射。
|
|
3
|
+
* 背景(issue #7):dsh-remote-ssh 的「原生远程工作区」cwd 指向本机镜像目录
|
|
4
|
+
* (如 ~/.dsh/remote-workspaces/<id>,根下含 .remote-ssh.json {profileId, host,
|
|
5
|
+
* user, remotePath}),agent 的 remote_ssh_write 直写远端;差异审查需要把
|
|
6
|
+
* 远端参数路径映射回镜像(捕获/读/决策复用本地链路),并把展示路径翻译成远端形态。
|
|
7
|
+
* 判据与上游 dsh-remote-ssh 的 findMirrorRoot 对齐(host/user/remotePath 必填、
|
|
8
|
+
* 向上 ≤8 层有界);纯函数为主,node:fs 仅用于标记读取(带 60s TTL 缓存)。
|
|
9
|
+
* 作者 ddj 2026年09月23号
|
|
10
|
+
*/
|
|
11
|
+
import { readFileSync } from 'node:fs'
|
|
12
|
+
import { dirname, join } from 'node:path'
|
|
13
|
+
|
|
14
|
+
// --region 常量与类型
|
|
15
|
+
/** dsh-remote-ssh 工作区标记文件名。 */
|
|
16
|
+
const MARKER = '.remote-ssh.json'
|
|
17
|
+
/** 向上探测的最大层数(含 cwd 自身;与上游 findMirrorRoot 的 8 层有界一致)。 */
|
|
18
|
+
const MAX_DEPTH = 8
|
|
19
|
+
/** 探测结果 TTL(edrv.list 为 5s 轮询,避免每轮都做文件 IO)。 */
|
|
20
|
+
const TTL_MS = 60_000
|
|
21
|
+
|
|
22
|
+
/** 远程工作区(镜像根 + 标记内容)。 */
|
|
23
|
+
export interface RemoteWs {
|
|
24
|
+
/** 镜像根(含标记文件的目录,平台绝对路径)。 */
|
|
25
|
+
root: string
|
|
26
|
+
host: string
|
|
27
|
+
user: string
|
|
28
|
+
/** 远端工作区目录(`~/proj` 或绝对路径,原样保留)。 */
|
|
29
|
+
remotePath: string
|
|
30
|
+
profileId?: string
|
|
31
|
+
}
|
|
32
|
+
// --endregion
|
|
33
|
+
|
|
34
|
+
// --region 探测
|
|
35
|
+
/** cwd → { at, ws } 探测缓存(含阴性结果)。 */
|
|
36
|
+
const cache = new Map<string, { at: number; ws: RemoteWs | null }>()
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* 标记内容 → 远程工作区(host/user/remotePath 缺任一字段判非法,与上游一致)。
|
|
40
|
+
* @author ddj 2026年09月23号
|
|
41
|
+
* @param root 标记所在目录
|
|
42
|
+
* @param raw 标记文件文本
|
|
43
|
+
* @returns 合法返回 RemoteWs;非法返回 null
|
|
44
|
+
*/
|
|
45
|
+
function markerWsOf(root: string, raw: string): RemoteWs | null {
|
|
46
|
+
try {
|
|
47
|
+
const info = JSON.parse(raw) as Record<string, unknown>
|
|
48
|
+
if (typeof info.host !== 'string' || !info.host) return null
|
|
49
|
+
if (typeof info.user !== 'string' || !info.user) return null
|
|
50
|
+
if (typeof info.remotePath !== 'string' || !info.remotePath) return null
|
|
51
|
+
const ws: RemoteWs = { root, host: info.host, user: info.user, remotePath: info.remotePath }
|
|
52
|
+
if (typeof info.profileId === 'string' && info.profileId) ws.profileId = info.profileId
|
|
53
|
+
return ws
|
|
54
|
+
} catch {
|
|
55
|
+
return null
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 从 cwd 起向上有界探测标记(不走缓存)。
|
|
61
|
+
* @author ddj 2026年09月23号
|
|
62
|
+
* @param cwd 会话工作区根
|
|
63
|
+
* @returns 命中合法标记返回 RemoteWs;否则 null
|
|
64
|
+
*/
|
|
65
|
+
function walkRemoteWs(cwd: string): RemoteWs | null {
|
|
66
|
+
let dir = cwd
|
|
67
|
+
for (let i = 0; i < MAX_DEPTH; i++) {
|
|
68
|
+
try {
|
|
69
|
+
const ws = markerWsOf(dir, readFileSync(join(dir, MARKER), 'utf8'))
|
|
70
|
+
if (ws) return ws
|
|
71
|
+
} catch {
|
|
72
|
+
// 无标记或不可读/JSON 损坏:继续向上(与上游 findMirrorRoot 行为一致)
|
|
73
|
+
}
|
|
74
|
+
const parent = dirname(dir)
|
|
75
|
+
if (parent === dir) break
|
|
76
|
+
dir = parent
|
|
77
|
+
}
|
|
78
|
+
return null
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* 探测工作区是否为 dsh-remote-ssh 远程工作区(cwd 或向上 ≤8 层找 .remote-ssh.json)。
|
|
83
|
+
* 结果按 cwd 缓存 60s(含阴性);标记变更后可用 clearWsCache 立即重置。
|
|
84
|
+
* @author ddj 2026年09月23号
|
|
85
|
+
* @param cwd 会话工作区根(可空)
|
|
86
|
+
* @returns 远程工作区信息;非远程或标记非法返回 null
|
|
87
|
+
*/
|
|
88
|
+
export function findRemoteWs(cwd: string | null | undefined): RemoteWs | null {
|
|
89
|
+
if (!cwd) return null
|
|
90
|
+
const now = Date.now()
|
|
91
|
+
const hit = cache.get(cwd)
|
|
92
|
+
if (hit && now - hit.at < TTL_MS) return hit.ws
|
|
93
|
+
const ws = walkRemoteWs(cwd)
|
|
94
|
+
cache.set(cwd, { at: now, ws })
|
|
95
|
+
return ws
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* 清空探测缓存(测试与标记变更后重置用)。
|
|
100
|
+
* @author ddj 2026年09月23号
|
|
101
|
+
*/
|
|
102
|
+
export function clearWsCache(): void {
|
|
103
|
+
cache.clear()
|
|
104
|
+
}
|
|
105
|
+
// --endregion
|
|
106
|
+
|
|
107
|
+
// --region 路径映射
|
|
108
|
+
/**
|
|
109
|
+
* 前缀剥离:path 必须严格位于 root 之下;等于 root 或不匹配返回 null。
|
|
110
|
+
* @author ddj 2026年09月23号
|
|
111
|
+
* @param path 待剥离路径(posix 形态)
|
|
112
|
+
* @param root 前缀(posix 形态、无尾斜杠)
|
|
113
|
+
* @returns 相对段;不匹配返回 null
|
|
114
|
+
*/
|
|
115
|
+
function prefixRel(path: string, root: string): string | null {
|
|
116
|
+
if (path === root || !path.startsWith(root + '/')) return null
|
|
117
|
+
return path.slice(root.length + 1)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* 远端参数路径 → 相对远端工作区目录的相对段。
|
|
122
|
+
* 支持:相对路径(工具语义即基于远端工作区目录)、绝对路径且 remotePath 为绝对
|
|
123
|
+
* 形态并为其前缀、`~` 形态且 remotePath 同为 `~` 前缀;Windows 盘符形态按
|
|
124
|
+
* 非远端语义拒绝。含 `..` 逃逸或空相对段返回 null。
|
|
125
|
+
* @author ddj 2026年09月23号
|
|
126
|
+
* @param argPath remote_ssh_write 的 path 参数
|
|
127
|
+
* @param ws 远程工作区
|
|
128
|
+
* @returns posix 相对段;不可映射返回 null
|
|
129
|
+
*/
|
|
130
|
+
function remoteRelOf(argPath: string, ws: RemoteWs): string | null {
|
|
131
|
+
const path = argPath.replace(/\\/g, '/').replace(/^\.\//, '')
|
|
132
|
+
if (!path) return null
|
|
133
|
+
const root = ws.remotePath.replace(/\\/g, '/').replace(/\/+$/, '')
|
|
134
|
+
if (!root) return null
|
|
135
|
+
let rel: string | null
|
|
136
|
+
if (path.startsWith('/')) {
|
|
137
|
+
rel = root.startsWith('/') ? prefixRel(path, root) : null
|
|
138
|
+
} else if (path === '~' || path.startsWith('~/')) {
|
|
139
|
+
rel = root.startsWith('~/') ? prefixRel(path, root) : null
|
|
140
|
+
} else if (/^[A-Za-z]:\//.test(path)) {
|
|
141
|
+
rel = null
|
|
142
|
+
} else {
|
|
143
|
+
rel = path
|
|
144
|
+
}
|
|
145
|
+
if (rel === null || rel.split('/').some((seg) => seg === '..')) return null
|
|
146
|
+
return rel
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* 远端工具参数路径 → 本机镜像绝对路径。
|
|
151
|
+
* @author ddj 2026年09月23号
|
|
152
|
+
* @param argPath remote_ssh_write 的 path 参数
|
|
153
|
+
* @param ws 远程工作区
|
|
154
|
+
* @returns 镜像内绝对路径;镜像外/前缀形态不一致/逃逸返回 null
|
|
155
|
+
*/
|
|
156
|
+
export function mirrorTargetOf(argPath: string, ws: RemoteWs): string | null {
|
|
157
|
+
const rel = remoteRelOf(argPath, ws)
|
|
158
|
+
return rel === null ? null : join(ws.root, rel)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* 镜像内绝对路径 → 远端展示路径(remotePath + 相对段,posix 拼接)。
|
|
163
|
+
* @author ddj 2026年09月23号
|
|
164
|
+
* @param mirrorAbs 镜像内绝对路径(通常是记录 path)
|
|
165
|
+
* @param ws 远程工作区
|
|
166
|
+
* @returns 远端路径;不在镜像根下返回 null(调用方回落原 path)
|
|
167
|
+
*/
|
|
168
|
+
export function remoteDisplayOf(mirrorAbs: string, ws: RemoteWs): string | null {
|
|
169
|
+
const path = mirrorAbs.replace(/\\/g, '/')
|
|
170
|
+
const root = ws.root.replace(/\\/g, '/').replace(/\/+$/, '')
|
|
171
|
+
if (!root) return null
|
|
172
|
+
const rel = prefixRel(path, root)
|
|
173
|
+
if (rel === null || rel.split('/').some((seg) => seg === '..')) return null
|
|
174
|
+
const remoteRoot = ws.remotePath.replace(/\/+$/, '')
|
|
175
|
+
return remoteRoot + '/' + rel
|
|
176
|
+
}
|
|
177
|
+
// --endregion
|
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))
|