dsh-vscode-mode 0.10.0 → 0.11.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.
@@ -0,0 +1,80 @@
1
+ /**
2
+ * dsh-vscode-mode host — 补全结果的前缀感知截断(纯函数,node 可测)。
3
+ * 背景:RPC 层按固定上限硬截断补全条目,而本仓库的 xLua 绑定桩
4
+ * (Assets/Scripts/EmmyLuaDefine,形如 CS_IslandSplash_Extensions_Common_ArrayExtension)
5
+ * 有上万个全局,字母序会把项目自身全局(ECommon / EActivity 等)挤到上限之外;
6
+ * 截断发生在 Monaco 前缀过滤之前,因此**之后无论输入什么都取不回**该条目。
7
+ * 改为按当前输入前缀分桶后截断:优先保留真实命中的条目;未超限时零行为变化。
8
+ * 作者 ddj 2026年09月24号
9
+ */
10
+ import type { LspCompletionItem } from '../shared/lsp.js'
11
+
12
+ /** 单次补全返回条目上限(RPC 载荷护栏,与 references 同口径)。 */
13
+ export const COMPLETION_ITEM_CAP = 500
14
+
15
+ /** 截断结果:items 恒不超上限;truncated 标记本轮是否发生了丢弃。 */
16
+ export interface CappedCompletions {
17
+ items: LspCompletionItem[]
18
+ truncated: boolean
19
+ }
20
+
21
+ /**
22
+ * 判断 needle 是否为 haystack 的子序列(两侧均已小写)。
23
+ *
24
+ * 与 Monaco 候选列表的模糊匹配同口径,保证「分桶判为命中」的条目确实是
25
+ * 用户输入能筛出来的那些,避免分桶顺序与前端过滤结果互相矛盾。
26
+ * @author ddj 2026年09月24号
27
+ * @param haystack 候选文本(已小写)
28
+ * @param needle 输入前缀(已小写、非空)
29
+ * @returns 是否为子序列
30
+ */
31
+ function isSubsequence(haystack: string, needle: string): boolean {
32
+ let matched = 0
33
+ for (let i = 0; i < haystack.length && matched < needle.length; i++) {
34
+ if (haystack[i] === needle[matched]) matched++
35
+ }
36
+ return matched === needle.length
37
+ }
38
+
39
+ /**
40
+ * 取补全项的匹配文本(Monaco 口径:filterText 优先,回落 label;统一小写)。
41
+ * @author ddj 2026年09月24号
42
+ * @param item 补全项
43
+ * @returns 小写匹配文本;无可用文本时为空串
44
+ */
45
+ function textOfItem(item: LspCompletionItem): string {
46
+ const raw = typeof item?.filterText === 'string' && item.filterText ? item.filterText : item?.label
47
+ return typeof raw === 'string' ? raw.toLowerCase() : ''
48
+ }
49
+
50
+ /**
51
+ * 按输入前缀分桶后截断补全列表。
52
+ *
53
+ * 分桶优先级:startsWith > includes > 子序列 > 其余;桶内保持服务器原始顺序
54
+ * (服务器已按相关性排序,不重新打分)。无前缀或未超限时与旧行为完全一致。
55
+ * @author ddj 2026年09月24号
56
+ * @param items 服务器归一化后的补全条目
57
+ * @param prefix 当前输入前缀(Monaco getWordUntilPosition 的词)
58
+ * @param cap 条目上限(缺省 COMPLETION_ITEM_CAP;测试可注入小值)
59
+ * @returns 有界条目与截断标记
60
+ */
61
+ export function capCompletions(
62
+ items: LspCompletionItem[],
63
+ prefix?: string,
64
+ cap: number = COMPLETION_ITEM_CAP,
65
+ ): CappedCompletions {
66
+ if (!Array.isArray(items)) return { items: [], truncated: false }
67
+ if (items.length <= cap) return { items, truncated: false }
68
+ const needle = String(prefix ?? '').toLowerCase()
69
+ if (!needle) return { items: items.slice(0, cap), truncated: true }
70
+ const buckets: LspCompletionItem[][] = [[], [], [], []]
71
+ for (const item of items) {
72
+ const text = textOfItem(item)
73
+ if (text.startsWith(needle)) buckets[0].push(item)
74
+ else if (text.includes(needle)) buckets[1].push(item)
75
+ else if (isSubsequence(text, needle)) buckets[2].push(item)
76
+ else buckets[3].push(item)
77
+ }
78
+ const ordered = buckets[0].concat(buckets[1], buckets[2], buckets[3])
79
+ return { items: ordered.slice(0, cap), truncated: true }
80
+ }
@@ -12,11 +12,13 @@ interface Entry {
12
12
  server: LspServer
13
13
  refs: number
14
14
  failures: number
15
+ readyAt: number
15
16
  restartTimer: ReturnType<typeof setTimeout> | null
16
17
  }
17
18
 
18
19
  const BACKOFF_MS = [1000, 5000, 30000]
19
20
  const MAX_FAILURES = 3
21
+ const STABLE_MS = 60000
20
22
 
21
23
  export interface LspManager {
22
24
  /** 获取(或惰性创建)某工作区+语言的服务器;refs+1。 */
@@ -51,6 +53,19 @@ export function createLspManager(logger?: (line: string) => void): LspManager {
51
53
 
52
54
  const log = (line: string): void => logger?.('[lsp-manager] ' + line)
53
55
 
56
+ /**
57
+ * 重试耗尽后保留退出根因,并让 UI 不再误报“未启动”。
58
+ * @author ddj 2026年09月24号
59
+ * @param entry 服务器条目
60
+ * @returns 当前可见状态
61
+ */
62
+ const statusOf = (entry: Entry): LspServerStatus => {
63
+ const status = entry.server.status()
64
+ return entry.failures >= MAX_FAILURES
65
+ ? { ...status, phase: 'unavailable', reason: (status.reason ?? '服务器已退出') + '(已停止自动重试)' }
66
+ : status
67
+ }
68
+
54
69
  /** 同步摘除注册表项并后台回收(refs 归零/工作区清理用;statusAll 立即可见归零)。 */
55
70
  const dropEntry = (entry: Entry): void => {
56
71
  if (entry.restartTimer) {
@@ -62,20 +77,22 @@ export function createLspManager(logger?: (line: string) => void): LspManager {
62
77
  }
63
78
 
64
79
  const scheduleRestart = (entry: Entry): void => {
65
- if (entry.restartTimer) return
80
+ if (entry.restartTimer || entry.refs === 0 || entries.get(entry.key) !== entry) return
66
81
  if (entry.failures >= MAX_FAILURES) {
67
82
  log('unavailable after ' + entry.failures + ' failures: ' + entry.key)
83
+ manager.onStatusChange?.(statusOf(entry))
68
84
  return
69
85
  }
70
- const delay = BACKOFF_MS[Math.min(entry.failures, BACKOFF_MS.length - 1)]
86
+ const delay = BACKOFF_MS[Math.min(Math.max(entry.failures - 1, 0), BACKOFF_MS.length - 1)]
71
87
  entry.restartTimer = setTimeout(() => {
72
88
  entry.restartTimer = null
73
- if (!entries.has(entry.key)) return
89
+ if (entries.get(entry.key) !== entry || entry.refs === 0) return
74
90
  void (async () => {
75
91
  const ok = await entry.server.start()
76
- if (!ok) entry.failures++
77
- else entry.failures = 0
78
- if (!ok && entries.has(entry.key)) scheduleRestart(entry)
92
+ if (!ok && entries.get(entry.key) === entry && !entry.restartTimer) {
93
+ entry.failures++
94
+ scheduleRestart(entry)
95
+ }
79
96
  })()
80
97
  }, delay)
81
98
  }
@@ -87,13 +104,28 @@ export function createLspManager(logger?: (line: string) => void): LspManager {
87
104
  if (!entry) {
88
105
  const spec = resolveSpec(languageId)
89
106
  const server = createLspServer(spec, root, languageId, log)
90
- server.onStateChange = (status) => manager.onStatusChange?.(status)
91
- entry = { key, server, refs: 0, failures: 0, restartTimer: null }
107
+ entry = { key, server, refs: 0, failures: 0, readyAt: 0, restartTimer: null }
92
108
  entries.set(key, entry)
109
+ server.onStateChange = (status) => {
110
+ const live = entries.get(key)
111
+ if (live?.server === server) {
112
+ if (status.phase === 'ready' && live.readyAt === 0) live.readyAt = Date.now()
113
+ if (status.phase === 'idle' && status.reason?.startsWith('服务器已退出')) {
114
+ if (live.readyAt && Date.now() - live.readyAt >= STABLE_MS) live.failures = 0
115
+ live.failures++
116
+ live.readyAt = 0
117
+ scheduleRestart(live)
118
+ }
119
+ }
120
+ manager.onStatusChange?.(live?.server === server ? statusOf(live) : status)
121
+ }
93
122
  // 惰性启动:若 spec ready 才启动,否则保持 idle(供 status 展示 reason)
94
123
  if (spec.ready) {
95
124
  void server.start().then((ok) => {
96
- if (!ok && entries.has(key)) entry!.failures++
125
+ if (!ok && entries.get(key) === entry && server.phase === 'idle' && !entry!.restartTimer) {
126
+ entry!.failures++
127
+ scheduleRestart(entry!)
128
+ }
97
129
  })
98
130
  }
99
131
  }
@@ -126,7 +158,7 @@ export function createLspManager(logger?: (line: string) => void): LspManager {
126
158
  },
127
159
 
128
160
  statusAll(): LspServerStatus[] {
129
- return [...entries.values()].map((entry) => entry.server.status())
161
+ return [...entries.values()].map(statusOf)
130
162
  },
131
163
 
132
164
  peek(root: string, languageId: string): LspServer | undefined {
package/src/lsp/rpc.ts CHANGED
@@ -10,6 +10,7 @@ import { searchRoot } from '../search/ripgrep.js'
10
10
  import { sessionOf, cwdOf } from '../registry.js'
11
11
  import { resolveProviderSpec, langOfPath, configFromPlugin, mergeConfig, sanitizeLang, LSP_LANGUAGES, type LspConfig, type LspLangConfig } from './config.js'
12
12
  import { clearProviderCache, candidatesFor } from './providers.js'
13
+ import { capCompletions } from './completionCap.js'
13
14
  import { onRuntimeProvisioned, envInstallStates } from './dotnetProvision.js'
14
15
  import { envRequirementsFor, installRequirement } from './envRequirements.js'
15
16
  import type { LspManager } from './manager.js'
@@ -451,11 +452,13 @@ export function createLspRpc(deps: LspRpcDeps): { handlers: Partial<RpcHandlerMa
451
452
  args.context,
452
453
  )
453
454
  if (!completions) return { ok: true, completions: undefined }
454
- const truncated = completions.items.length > 500
455
- return {
456
- ok: true,
457
- completions: truncated ? { ...completions, items: completions.items.slice(0, 500), truncated: true } : completions,
458
- }
455
+ // 前缀感知截断:超限时按当前输入前缀分桶,保证正在打的符号不被挤出上限
456
+ // (本仓库上万个 CS_* 绑定桩会把 ECommon 等项目全局顶到 500 条之外)。
457
+ const capped = capCompletions(completions.items, args.prefix)
458
+ const payload = { ...completions, items: capped.items }
459
+ if (capped.truncated) payload.truncated = true
460
+ else delete payload.truncated
461
+ return { ok: true, completions: payload }
459
462
  } catch (error) {
460
463
  return { ok: false, error: 'LSP 补全查询失败:' + String(error) }
461
464
  }
package/src/lsp/server.ts CHANGED
@@ -57,6 +57,19 @@ interface OpenDoc {
57
57
  }
58
58
 
59
59
  const RESTARTABLE = true
60
+ const MAX_DOC_VERSION = 2147483647
61
+
62
+ /**
63
+ * 使用合法、递增的 Int32 文档版本;旧客户端的毫秒时间戳不能直发给 Roslyn。
64
+ * @author ddj 2026年09月24号
65
+ * @param incoming 客户端传来的版本
66
+ * @param previous 当前服务器记录的版本
67
+ * @returns 可发送给语言服务器的版本
68
+ */
69
+ export function lspDocVersion(incoming: number, previous?: number): number {
70
+ const next = Math.min(MAX_DOC_VERSION, (previous ?? 0) + 1)
71
+ return Number.isInteger(incoming) && incoming >= next && incoming <= MAX_DOC_VERSION ? incoming : next
72
+ }
60
73
 
61
74
  /**
62
75
  * 创建语言服务器会话。
@@ -237,21 +250,22 @@ export function createLspServer(spec: LspProviderSpec, root: string, languageId:
237
250
  sync(path: string, text: string, version: number): void {
238
251
  const existing = docs.get(path)
239
252
  const uri = existing?.uri ?? docUriOf(path)
253
+ const safeVersion = lspDocVersion(version, existing?.version)
240
254
  if (existing) {
241
- existing.version = version
255
+ existing.version = safeVersion
242
256
  existing.text = text
243
257
  if (phase === 'ready' || phase === 'indexing') {
244
258
  client?.notify('textDocument/didChange', {
245
- textDocument: { uri, version },
259
+ textDocument: { uri, version: safeVersion },
246
260
  contentChanges: [{ text }],
247
261
  })
248
262
  }
249
263
  return
250
264
  }
251
- docs.set(path, { path, uri, version, text })
265
+ docs.set(path, { path, uri, version: safeVersion, text })
252
266
  if (phase === 'ready' || phase === 'indexing') {
253
267
  client?.notify('textDocument/didOpen', {
254
- textDocument: { uri, languageId, version, text },
268
+ textDocument: { uri, languageId, version: safeVersion, text },
255
269
  })
256
270
  }
257
271
  },
package/src/rpc.ts CHANGED
@@ -51,6 +51,7 @@ import { clearDebugLog, debugRecord, isDebugLogName, listDebugLogs, readDebugLog
51
51
  import { markActiveSessions, markOfficialFlags, moveOutSessions, planMoveOut, purgeArchive, restoreSession, scanSessionInventory, sessionsArchiveRoot, sessionSizeOf, sidecarSummaryOf, unarchiveOfficial } from './perf.js'
52
52
  import { patchHasPerfConfig, patchInsertPerfConfig, patchRemovePerfConfig, perfConfigBlock } from './perfPatch.js'
53
53
  import type { FileVersions } from './fileVersions.js'
54
+ import { settingsDocPath } from './settingsDocPath.js'
54
55
 
55
56
  /** cwd → 上次 stale 自动清理时间:全量轮询(EditorView/DiffBadge/Dock 各自 5s)节流,避免每轮都读文件算指纹。 */
56
57
  const staleCheckedAt = new Map<string, number>()
@@ -109,6 +110,23 @@ function snippetTargetOf(path: string): string | null {
109
110
  return isSnippetFilePath(path) ? path.replace(/\\/g, '/') : null
110
111
  }
111
112
 
113
+
114
+ /**
115
+ * 返回与当前 settings provider 文档路径精确匹配的授权目标。
116
+ * @author ddj 2026年10月02号
117
+ * @param ctx DSH host 上下文
118
+ * @param path 编辑器请求路径
119
+ * @returns provider 当前配置文档的绝对路径;未匹配返回 null
120
+ */
121
+ function settingsTargetOf(ctx: Ctx, path: string): string | null {
122
+ try {
123
+ const settings = ctx.get('settings') as { documentPath?: unknown } | undefined
124
+ return settingsDocPath(settings?.documentPath, path)
125
+ } catch {
126
+ return null
127
+ }
128
+ }
129
+
112
130
  /** readTargetOf 成功形态:解析后的目标路径、stat 信息与 fs 服务句柄。 */
113
131
  interface ReadTargetOk {
114
132
  target: string
@@ -473,6 +491,18 @@ export function buildHandlers(
473
491
  return { ok: true, results }
474
492
  },
475
493
  'edrv.read': async (args) => {
494
+ const settingsTarget = settingsTargetOf(ctx, args.path)
495
+ if (settingsTarget && args.encoding !== 'base64') {
496
+ try {
497
+ const info = await stat(settingsTarget)
498
+ if (!info.isFile()) return { ok: false, error: '设置配置文件不存在' }
499
+ if (info.size > READ_CAP) return { ok: false, error: '设置配置文件过大(>8MB)' }
500
+ const content = await readFile(settingsTarget, 'utf8')
501
+ return { ok: true, content, size: content.length, version: fileVersionOf(info) }
502
+ } catch (error) {
503
+ return { ok: false, error: '读取设置配置文件失败:' + String(error), resolvedPath: settingsTarget }
504
+ }
505
+ }
476
506
  // 全局片段文件(~/.dsh/snippets/*.code-snippets)在工作区之外:直读,不走会话 cwd 解析
477
507
  const snippetTarget = snippetTargetOf(args.path)
478
508
  if (snippetTarget && args.encoding !== 'base64') {
@@ -522,9 +552,26 @@ export function buildHandlers(
522
552
  }
523
553
  },
524
554
  'edrv.versions': async (args) => {
525
- // 外部改动检测:批量返回已打开文件的磁盘版本(观察器同时失效变化的目录树)
555
+ // 工作区外的 settings 文档按同一精确授权路径直接观察,其余路径保持工作区版本服务策略。
556
+ const items = []
557
+ const regularPaths: string[] = []
558
+ for (const path of args.paths) {
559
+ const target = settingsTargetOf(ctx, path)
560
+ if (!target) {
561
+ regularPaths.push(path)
562
+ continue
563
+ }
564
+ try {
565
+ const info = await stat(target)
566
+ items.push({ path, version: fileVersionOf(info), size: info.size, type: 'file' as const })
567
+ } catch (error) {
568
+ items.push({ path, version: '', type: 'missing' as const, error: String(error) })
569
+ }
570
+ }
571
+ if (regularPaths.length === 0) return { ok: true, items }
526
572
  if (!fileVersions) return { ok: false, error: '文件版本观察器未装配' }
527
- return fileVersions.versions(args)
573
+ const regular = await fileVersions.versions({ ...args, paths: regularPaths })
574
+ return { ...regular, items: [...items, ...regular.items] }
528
575
  },
529
576
  'edrv.original': async (args) => {
530
577
  // 重建"本批次修改前"内容:DiffEditor 原始侧。仅反解 pending 块;
@@ -553,6 +600,20 @@ export function buildHandlers(
553
600
  }
554
601
  },
555
602
  'edrv.save': async (args) => {
603
+ const settingsTarget = settingsTargetOf(ctx, args.path)
604
+ if (settingsTarget) {
605
+ try {
606
+ const info = await stat(settingsTarget)
607
+ const currentRev = fileVersionOf(info)
608
+ if (args.rev && args.rev !== currentRev) return { ok: false, conflict: true, error: '设置配置文件已被外部修改,请先重新加载' }
609
+ if (settingsTargetOf(ctx, args.path) !== settingsTarget) return { ok: false, error: '设置配置文件路径已变化,拒绝保存' }
610
+ await writeFile(settingsTarget, args.content, 'utf8')
611
+ const saved = await stat(settingsTarget).catch(() => null)
612
+ return { ok: true, rev: fileVersionOf(saved) || currentRev }
613
+ } catch (error) {
614
+ return { ok: false, error: '保存设置配置文件失败:' + String(error) }
615
+ }
616
+ }
556
617
  // 全局片段文件:直写(工作区外),且不进差异审查(片段变更不是 agent 编辑产物)
557
618
  const snippetTarget = snippetTargetOf(args.path)
558
619
  if (snippetTarget) {
@@ -925,6 +986,17 @@ export function buildHandlers(
925
986
  const value = section?.value as { fileOpenTool?: unknown } | undefined
926
987
  return { ok: true, fileOpenTool: normalizeFileOpenTool(value?.fileOpenTool ?? FILE_OPEN_DEFAULT), integrationBaseUrl: await integrationBaseUrlOf(ctx), revision: section?.revision }
927
988
  },
989
+ 'vscode.settingsDocumentPath': async () => {
990
+ try {
991
+ const settings = ctx.get('settings') as { prepareDocument?: () => Promise<string> } | undefined
992
+ if (typeof settings?.prepareDocument !== 'function') return { ok: false, error: '设置配置文件不可用' }
993
+ const path = await settings.prepareDocument()
994
+ if (typeof path !== 'string' || !path.trim()) return { ok: false, error: '设置配置文件路径为空' }
995
+ return { ok: true, path }
996
+ } catch (error) {
997
+ return { ok: false, error: '准备设置配置文件失败:' + String(error) }
998
+ }
999
+ },
928
1000
  'vscode.fileOpenSettingsUpdate': async (args) => {
929
1001
  const settings = ctx.get('settings')
930
1002
  const patch: Record<string, unknown> = { fileOpenTool: normalizeFileOpenTool(args.fileOpenTool) }
@@ -0,0 +1,30 @@
1
+ import { posix, win32 } from 'node:path'
2
+
3
+ /**
4
+ * Resolve the settings document only when the request names its exact configured path.
5
+ *
6
+ * Both sides are resolved with the path rules of `platform` (win32 → `C:`-style paths and
7
+ * case-insensitive comparison; otherwise POSIX rules and case-sensitive comparison), so the
8
+ * function keeps its meaning when the host platform is passed explicitly instead of implied.
9
+ * @author ddj 2026年10月02号
10
+ * @param configuredPath Path exposed by the active settings provider
11
+ * @param requestedPath Path requested by the editor RPC
12
+ * @param platform Platform whose path rules resolve both sides and drive case comparison
13
+ * @returns The configured absolute path when authorized; otherwise null
14
+ */
15
+ export function settingsDocPath(
16
+ configuredPath: unknown,
17
+ requestedPath: string,
18
+ platform = process.platform,
19
+ ): string | null {
20
+ if (typeof configuredPath !== 'string' || !configuredPath.trim() || !requestedPath) return null
21
+ try {
22
+ const api = platform === 'win32' ? win32 : posix
23
+ const configured = api.resolve(configuredPath)
24
+ const requested = api.resolve(requestedPath)
25
+ const fold = (value: string): string => platform === 'win32' ? value.toLowerCase() : value
26
+ return fold(configured) === fold(requested) ? configured : null
27
+ } catch {
28
+ return null
29
+ }
30
+ }
package/src/shared/rpc.ts CHANGED
@@ -19,7 +19,7 @@ import type { RuleInfo, RuleProject, RuleRefInput, RuleSaveInput } from './rules
19
19
  import type { SnippetEntry, SnippetInfo, SnippetProject, SnippetRefInput, SnippetSaveInput } from './snippets.js'
20
20
  import type { LspCompletionItem, LspCompletionList, LspEnvInstallState, LspExtInfo, LspExtUpdate, LspHover, LspLocation, LspMarketItem, LspPosition, LspSemanticTokens, LspServerStatus, LspSignatureHelp, LspSymbol } from './lsp.js'
21
21
  import type { AiConfigPatch, AiConfigView, AiDirectoryView, AiInlineRequest, AiInlineResult } from './ai.js'
22
- import type { SvnAction, SvnAiPlan, SvnChangeEntry, SvnConflictArtifact, SvnDiffRevResult, SvnIgnoreItem, SvnLogEntry, SvnRemoteOutdatedEntry, SvnStatusPayload, SvnSumEntry, SvnUpdateResult } from './svn.js'
22
+ import type { SvnAction, SvnAiPlan, SvnChangeEntry, SvnConflictArtifact, SvnDiffRevResult, SvnIgnoreItem, SvnLogEntry, SvnRemoteOutdatedEntry, SvnStatusPayload, SvnSumEntry, SvnUpdatePoll, SvnUpdateResult, SvnUpdateState } from './svn.js'
23
23
  import type { DapAction, DapAdapterInfo, DapBreakpointAck, DapBreakpointInput, DapConfigSnippet, DapConfigSource, DapDebugConfig, DapFrameView, DapPhase, DapPollResult, DapProcessInfo, DapScopeView, DapVariableView } from './dap.js'
24
24
  import { DAP_ACTIONS } from './dap.js'
25
25
  import type { LoggerLevel } from './logger.js'
@@ -371,6 +371,7 @@ export interface RpcRequestMap {
371
371
  'mcp.projectToggle': { workspacePath: string; serverName: string; enabled: boolean }
372
372
  'mcp.projectRefresh': { workspacePath: string; serverName: string }
373
373
  'vscode.fileOpenSettingsGet': {}
374
+ 'vscode.settingsDocumentPath': {}
374
375
  'vscode.fileOpenSettingsUpdate': { fileOpenTool: string; integrationBaseUrl?: string; expectedRevision?: number }
375
376
  'edrv.integration.status': {}
376
377
  'edrv.integration.register': {}
@@ -396,7 +397,7 @@ export interface RpcRequestMap {
396
397
  'edrv.lsp.workspaceSymbol': { sessionId?: string; query: string }
397
398
  'edrv.lsp.hover': { sessionId?: string; path: string; position: LspPosition }
398
399
  'edrv.lsp.semanticTokens': { sessionId?: string; path: string }
399
- 'edrv.lsp.completion': { sessionId?: string; path: string; position: LspPosition; context?: { triggerKind?: number; triggerCharacter?: string } }
400
+ 'edrv.lsp.completion': { sessionId?: string; path: string; position: LspPosition; context?: { triggerKind?: number; triggerCharacter?: string }; prefix?: string }
400
401
  'edrv.lsp.resolveCompletion': { sessionId?: string; path: string; item: LspCompletionItem }
401
402
  'edrv.lsp.signatureHelp': { sessionId?: string; path: string; position: LspPosition }
402
403
  'edrv.lsp.redetect': { languageId: string }
@@ -447,6 +448,10 @@ export interface RpcRequestMap {
447
448
  'svn.wcRev': { sessionId?: string; path?: string }
448
449
  'svn.cleanup': { sessionId?: string; removeUnversioned?: boolean; removeIgnored?: boolean }
449
450
  'svn.update': { sessionId?: string; path?: string }
451
+ 'svn.updateStart': { sessionId?: string; path?: string }
452
+ 'svn.updatePoll': { sessionId?: string; jobId: string; since: number }
453
+ 'svn.updateCancel': { sessionId?: string; jobId: string }
454
+ 'svn.updateActive': { sessionId?: string }
450
455
  'svn.tortoise': { sessionId?: string; action: SvnAction; path?: string }
451
456
  'svn.patchText': { sessionId?: string; path: string; whitespace?: 'none' | 'b' | 'w'; ignoreEol?: boolean; unified?: number }
452
457
  'svn.diffSum': { sessionId?: string; path?: string; revA: number; revB: number }
@@ -519,6 +524,7 @@ export interface RpcOkMap {
519
524
  'mcp.projectToggle': { project: MpcProject }
520
525
  'mcp.projectRefresh': { project: MpcProject }
521
526
  'vscode.fileOpenSettingsGet': { fileOpenTool: string; integrationBaseUrl: string; revision?: number }
527
+ 'vscode.settingsDocumentPath': { path: string }
522
528
  'vscode.fileOpenSettingsUpdate': { fileOpenTool: string; integrationBaseUrl: string; revision?: number }
523
529
  'edrv.integration.status': ShellIntegrationStatus
524
530
  'edrv.integration.register': ShellIntegrationStatus
@@ -595,6 +601,10 @@ export interface RpcOkMap {
595
601
  'svn.wcRev': { revision: number | null }
596
602
  'svn.cleanup': { summary: string; output: string }
597
603
  'svn.update': SvnUpdateResult
604
+ 'svn.updateStart': SvnUpdateState
605
+ 'svn.updatePoll': SvnUpdatePoll
606
+ 'svn.updateCancel': SvnUpdateState
607
+ 'svn.updateActive': { job: SvnUpdateState | null }
598
608
  'svn.tortoise': { launched: string }
599
609
  'svn.patchText': { text: string; truncated: boolean; binary: boolean }
600
610
  'svn.diffSum': { entries: SvnSumEntry[]; truncated: boolean }
package/src/shared/svn.ts CHANGED
@@ -99,6 +99,44 @@ export interface SvnUpdateResult {
99
99
  output: string
100
100
  }
101
101
 
102
+ /** 原生更新窗口的真实执行阶段。 */
103
+ export type SvnUpdatePhase = 'starting' | 'running' | 'cancelling' | 'completed' | 'failed' | 'cancelled'
104
+
105
+ /** 流式更新行;relPath 仅在目标位于当前工作区内时可操作。 */
106
+ export interface SvnUpdateRow {
107
+ seq: number
108
+ action: SvnUpdateAction | null
109
+ path: string
110
+ relPath: string | null
111
+ label: string
112
+ raw: string
113
+ }
114
+
115
+ /** 分类数量按 svn 输出中的条目行计,不猜文件总数或网络字节。 */
116
+ export type SvnUpdateCounts = Record<SvnUpdateAction, number>
117
+
118
+ /** 更新任务快照(窗口第一次连接和每次增量 poll 共用)。 */
119
+ export interface SvnUpdateState {
120
+ jobId: string
121
+ target: string
122
+ phase: SvnUpdatePhase
123
+ counts: SvnUpdateCounts
124
+ revision: number | null
125
+ elapsedMs: number
126
+ error: string
127
+ rawTail: string
128
+ truncated: boolean
129
+ nextSeq: number
130
+ }
131
+
132
+ /** 增量读取结果:rows 只含 since 之后的一页,offset 表示目前可重放的首条序号。 */
133
+ export interface SvnUpdatePoll extends SvnUpdateState {
134
+ rows: SvnUpdateRow[]
135
+ offset: number
136
+ /** 当前后台总序号;nextSeq 仅是本次前端已经拿到的最后一条。 */
137
+ totalSeq: number
138
+ }
139
+
102
140
  /** revert/add 等批量动作结果(计数 + 摘要 + 原文)。 */
103
141
  export interface SvnActionResult {
104
142
  /** 处理条目数(宽松计数)。 */