dsh-vscode-mode 0.4.6 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +38 -2
  2. package/lib/client.js +9316 -4038
  3. package/lib/client.js.map +1 -1
  4. package/lib/index.js +1852 -68
  5. package/lib/index.js.map +1 -1
  6. package/package.json +8 -8
  7. package/src/client/ai/inlineProvider.ts +42 -1
  8. package/src/client/index.ts +50 -45
  9. package/src/client/monaco/lsp/index.ts +33 -3
  10. package/src/client/monaco/lsp/lspClient.ts +3 -1
  11. package/src/client/monaco/lsp/providers.ts +60 -6
  12. package/src/client/navHighlight.ts +96 -0
  13. package/src/client/officialSidebar.ts +67 -3
  14. package/src/client/paths.ts +2 -0
  15. package/src/client/rpc.ts +32 -12
  16. package/src/client/sessionScope.ts +183 -0
  17. package/src/client/sidebar/SidebarView.ts +6 -2
  18. package/src/client/sidebar/menuItems.ts +93 -2
  19. package/src/client/sidebar/panels/FileExplorer.ts +18 -1
  20. package/src/client/sidebar/panels/SvnPanel.ts +303 -0
  21. package/src/client/sidebar/panels/index.ts +44 -3
  22. package/src/client/sidebar/types.ts +20 -0
  23. package/src/client/snippets/provider.ts +26 -4
  24. package/src/client/styles/editor.css +177 -0
  25. package/src/client/svnActions.ts +153 -0
  26. package/src/client/svnLog.ts +356 -0
  27. package/src/client/svnStatus.ts +363 -0
  28. package/src/client/svnStore.ts +150 -0
  29. package/src/client/tabActions.ts +30 -5
  30. package/src/client/tabMenu.ts +51 -0
  31. package/src/client/ui/AiSettings.ts +8 -0
  32. package/src/client/ui/EditorView.ts +632 -35
  33. package/src/client/ui/LogDialog.ts +333 -0
  34. package/src/client/ui/McpSettings.ts +98 -0
  35. package/src/client/ui/ModalShell.ts +79 -0
  36. package/src/client/ui/SideBySideDiff.tsx +139 -0
  37. package/src/client/ui/SnippetsPicker.ts +70 -58
  38. package/src/client/ui/SvnDiffPanel.ts +50 -0
  39. package/src/client/ui/SvnLogDialog.ts +1141 -0
  40. package/src/client/ui/SvnLogStats.ts +243 -0
  41. package/src/client/ui/commandCatalog.ts +72 -0
  42. package/src/compat.ts +1 -1
  43. package/src/debugLog.ts +81 -12
  44. package/src/dshVersion.ts +8 -1
  45. package/src/fileOpenSettings.ts +40 -3
  46. package/src/index.ts +5 -1
  47. package/src/rpc.ts +41 -4
  48. package/src/shared/ai.ts +25 -0
  49. package/src/shared/rpc.ts +53 -1
  50. package/src/shared/svn.ts +377 -0
  51. package/src/shared/svnActions.ts +296 -0
  52. package/src/svn.ts +870 -0
  53. package/src/svnLog.ts +315 -0
  54. package/src/svnText.ts +175 -0
  55. package/src/svnXml.ts +110 -0
package/src/rpc.ts CHANGED
@@ -44,8 +44,8 @@ import { findProfileDir, readDevForm, setDevForm } from './devForm.js'
44
44
  import { normalizeRel } from './tree.js'
45
45
  import { invalidateIndex, listDirCached } from './treeIndex.js'
46
46
  import { revealInExplorer } from './reveal.js'
47
- import { dshHome } from './paths.js'
48
- import { debugRecord } from './debugLog.js'
47
+ import { dshHome, debugLogFile, pluginLogRoot } from './paths.js'
48
+ import { clearDebugLog, debugRecord, isDebugLogName, listDebugLogs, readDebugLog } from './debugLog.js'
49
49
  import { markActiveSessions, moveOutSessions, planMoveOut, purgeArchive, restoreSession, scanSessionInventory, sessionsArchiveRoot, sessionSizeOf, sidecarSummaryOf } from './perf.js'
50
50
  import { patchHasPerfConfig, patchInsertPerfConfig, patchRemovePerfConfig, perfConfigBlock } from './perfPatch.js'
51
51
  import type { FileVersions } from './fileVersions.js'
@@ -255,12 +255,15 @@ export function buildHandlers(
255
255
  lspHandlers?: Partial<RpcHandlerMap>,
256
256
  aiHandlers?: Partial<RpcHandlerMap>,
257
257
  fileVersions?: FileVersions,
258
+ svnHandlers?: Partial<RpcHandlerMap>,
258
259
  ): RpcHandlerMap {
259
260
  return {
260
261
  // edrv.lsp.* 由 createLspRpc 一次性提供(tracker 跨请求保留),这里并入。
261
262
  ...((lspHandlers ?? {}) as RpcHandlerMap),
262
263
  // edrv.ai.* 由 createAiRpc 提供(AI 内联补全/模型目录/配置读写),这里并入。
263
264
  ...((aiHandlers ?? {}) as RpcHandlerMap),
265
+ // svn.* 由 createSvnRpc 提供(SVN 检测/更新/Tortoise 发射),这里并入。
266
+ ...((svnHandlers ?? {}) as RpcHandlerMap),
264
267
  'edrv.list': async (args) => {
265
268
  const sc = await requireSession(ctx, args.sessionId)
266
269
  if ('err' in sc) return { ok: false, error: sc.err }
@@ -505,9 +508,42 @@ export function buildHandlers(
505
508
  // (console 不一定落盘,文件可靠)。只出现在调试开关开启时(client dbg 默认关)。
506
509
  const sc = await requireSession(ctx, args.sessionId)
507
510
  if ('err' in sc) return { ok: false, error: sc.err }
508
- debugRecord(ctx, sc.cwd, String(args.text ?? ''))
511
+ debugRecord(ctx, sc.cwd, String(args.text ?? ''), args.level)
509
512
  return { ok: true }
510
513
  },
514
+ 'edrv.dlog.list': async (args) => {
515
+ // 诊断日志清单:日志根下全部 debug.*.log + 当前会话对应文件名(可能尚不存在)
516
+ const sc = await requireSession(ctx, args.sessionId)
517
+ if ('err' in sc) return { ok: false, error: sc.err }
518
+ const files = await listDebugLogs()
519
+ const current = basename(debugLogFile(sc.cwd))
520
+ return { ok: true, root: pluginLogRoot(), files, current }
521
+ },
522
+ 'edrv.dlog.read': async (args) => {
523
+ // 尾部读取:file 缺省读当前会话那份;maxBytes 夹取 [1KB, 512KB]
524
+ const sc = await requireSession(ctx, args.sessionId)
525
+ if ('err' in sc) return { ok: false, error: sc.err }
526
+ const name = isDebugLogName(args.file) ? args.file : basename(debugLogFile(sc.cwd))
527
+ const maxBytes = Math.min(Math.max(args.maxBytes ?? 256 * 1024, 1024), 512 * 1024)
528
+ const read = await readDebugLog(name, maxBytes)
529
+ if (!read) return { ok: false, error: '日志不存在:' + name + '(开启诊断日志并触发一次操作后生成)' }
530
+ return { ok: true, ...read }
531
+ },
532
+ 'edrv.dlog.clear': async (args) => {
533
+ // 清空:file 缺省清当前会话那份;白名单外直接拒绝
534
+ const sc = await requireSession(ctx, args.sessionId)
535
+ if ('err' in sc) return { ok: false, error: sc.err }
536
+ const name = isDebugLogName(args.file) ? args.file : basename(debugLogFile(sc.cwd))
537
+ const cleared = await clearDebugLog(name)
538
+ if (!cleared) return { ok: false, error: '清空失败:日志不存在或名字非法' }
539
+ return { ok: true, name }
540
+ },
541
+ 'edrv.dlog.reveal': async () => {
542
+ // 打开日志根目录(OS 文件管理器;目录形态 → 直接打开)
543
+ const revealed = await revealInExplorer(ctx, pluginLogRoot(), true)
544
+ if (!revealed.ok) return { ok: false, error: revealed.error }
545
+ return { ok: true, opened: true }
546
+ },
511
547
  'edrv.searchFiles': async (args) => {
512
548
  const sc = await requireSession(ctx, args.sessionId)
513
549
  if ('err' in sc) return { ok: false, error: sc.err }
@@ -914,8 +950,9 @@ export async function handleRpc<M extends RpcMethod>(
914
950
  lspHandlers?: Partial<RpcHandlerMap>,
915
951
  aiHandlers?: Partial<RpcHandlerMap>,
916
952
  fileVersions?: FileVersions,
953
+ svnHandlers?: Partial<RpcHandlerMap>,
917
954
  ): Promise<RpcResult<M>> {
918
- const handlers = buildHandlers(ctx, registry, searcher, contentSearcher, lspHandlers, aiHandlers, fileVersions)
955
+ const handlers = buildHandlers(ctx, registry, searcher, contentSearcher, lspHandlers, aiHandlers, fileVersions, svnHandlers)
919
956
  const handler = handlers[method]
920
957
  if (!handler) return { ok: false, error: '未知方法: ' + String(method) } as RpcResult<M>
921
958
  return handler(args)
package/src/shared/ai.ts CHANGED
@@ -77,6 +77,31 @@ export interface AiConfigPatch {
77
77
  effort?: string
78
78
  }
79
79
 
80
+ /**
81
+ * 已从官方默认模型列表下架的模型标识(依据 DSH 0.1.6-alpha.2 release notes:
82
+ * 「默认模型列表移除 V4 Flash 和 V4 Flash Vision Exp」)。
83
+ *
84
+ * 用途:用户此前保存过这些模型时,配置仍指向它们,而新版模型目录不再列出 →
85
+ * 需给出可读降级提示(而非静默失败)。按模型名小写子串匹配,
86
+ * 因为 provider 前缀与具体版本后缀(如 -exp)在不同部署下写法不一。
87
+ */
88
+ export const DELISTED_MODEL_MARKERS: readonly string[] = ['v4-flash']
89
+
90
+ /**
91
+ * 配置指向的模型是否已被官方下架(G7)。
92
+ *
93
+ * 语义:仅当确实指定了 model(非空 = 非自动路由)且命中下架标识时返回 true。
94
+ * 空 model 表示「自动取首个可用模型」,不构成问题,故不提示。
95
+ * @author ddj 2026年09月18号
96
+ * @param model 配置中的模型标识(可空)
97
+ * @returns 是否已下架
98
+ */
99
+ export function isDelistedModel(model: unknown): boolean {
100
+ const text = typeof model === 'string' ? model.trim().toLowerCase() : ''
101
+ if (!text) return false
102
+ return DELISTED_MODEL_MARKERS.some((marker) => text.includes(marker))
103
+ }
104
+
80
105
  /**
81
106
  * 按 AI_PREFIX_MAX/AI_SUFFIX_MAX 裁剪前后缀(client 侧发请求前调用)。
82
107
  * @author ddj
package/src/shared/rpc.ts CHANGED
@@ -19,6 +19,8 @@ import type { RuleInfo, RuleProject, RuleRefInput, RuleSaveInput } from './rules
19
19
  import type { SnippetEntry, SnippetInfo, SnippetProject, SnippetRefInput, SnippetSaveInput } from './snippets.js'
20
20
  import type { LspEnvInstallState, LspExtInfo, LspExtUpdate, LspHover, LspLocation, LspMarketItem, LspPosition, LspSemanticTokens, LspServerStatus, LspSymbol } from './lsp.js'
21
21
  import type { AiConfigPatch, AiConfigView, AiDirectoryView, AiInlineRequest, AiInlineResult } from './ai.js'
22
+ import type { SvnAction, SvnChangeEntry, SvnDiffRevResult, SvnLogEntry, SvnStatusPayload, SvnUpdateResult } from './svn.js'
23
+ import type { LoggerLevel } from './logger.js'
22
24
 
23
25
  /** webServer 精确路由。 */
24
26
  export const RPC_PATH = '/edrv/rpc'
@@ -173,6 +175,22 @@ export interface DecideResult {
173
175
  stale?: boolean
174
176
  }
175
177
 
178
+ /** 一份诊断日志文件的清单项(edrv.dlog.list;name 即请求回传用的 file 参数)。 */
179
+ export interface DebugLogFileInfo {
180
+ name: string
181
+ bytes: number
182
+ mtimeMs: number
183
+ }
184
+
185
+ /** 诊断日志读取结果(edrv.dlog.read;tail 语义,truncated = 已按 maxBytes 截头保尾)。 */
186
+ export interface DebugLogRead {
187
+ name: string
188
+ path: string
189
+ content: string
190
+ size: number
191
+ truncated: boolean
192
+ }
193
+
176
194
  /** 每个方法的请求参数(sessionId 为公共可选字段)。 */
177
195
  export interface RpcRequestMap {
178
196
  'edrv.list': { sessionId?: string; callIds?: string[]; skipStale?: boolean }
@@ -187,7 +205,11 @@ export interface RpcRequestMap {
187
205
  'edrv.archiveList': { sessionId?: string }
188
206
  'edrv.archiveRead': { sessionId?: string; path?: string }
189
207
  'edrv.rollback': { sessionId?: string; path: string; batch?: number }
190
- 'edrv.debug': { sessionId?: string; text: string }
208
+ 'edrv.debug': { sessionId?: string; text: string; level?: LoggerLevel }
209
+ 'edrv.dlog.list': { sessionId?: string }
210
+ 'edrv.dlog.read': { sessionId?: string; file?: string; maxBytes?: number }
211
+ 'edrv.dlog.clear': { sessionId?: string; file?: string }
212
+ 'edrv.dlog.reveal': { sessionId?: string }
191
213
  'edrv.searchFiles': { sessionId?: string; query: string }
192
214
  'edrv.searchContent': { sessionId?: string; query: string; matchCase?: boolean; wholeWord?: boolean; regex?: boolean; maxResults?: number; include?: string[]; exclude?: string[] }
193
215
  'edrv.listDir': { sessionId?: string; path: string; force?: boolean }
@@ -263,6 +285,19 @@ export interface RpcRequestMap {
263
285
  'snippets.save': SnippetSaveInput
264
286
  'snippets.remove': SnippetRefInput
265
287
  'snippets.entries': { sessionId?: string }
288
+ 'svn.status': { sessionId?: string; force?: boolean }
289
+ 'svn.changes': { sessionId?: string; force?: boolean }
290
+ 'svn.diffBase': { sessionId?: string; path: string }
291
+ 'svn.revert': { sessionId?: string; paths: string[] }
292
+ 'svn.add': { sessionId?: string; paths: string[] }
293
+ 'svn.log': { sessionId?: string; path?: string; limit?: number; stopOnCopy?: boolean; startRev?: number; endRev?: number; showMerged?: boolean }
294
+ 'svn.diffRev': { sessionId?: string; path: string; revision: number }
295
+ 'svn.diffPair': { sessionId?: string; path: string; revA: number; revB: number }
296
+ 'svn.diffWorking': { sessionId?: string; path: string; revision: number }
297
+ 'svn.wcRev': { sessionId?: string; path?: string }
298
+ 'svn.cleanup': { sessionId?: string; removeUnversioned?: boolean; removeIgnored?: boolean }
299
+ 'svn.update': { sessionId?: string; path?: string }
300
+ 'svn.tortoise': { sessionId?: string; action: SvnAction; path?: string }
266
301
  }
267
302
 
268
303
  export type RpcMethod = keyof RpcRequestMap
@@ -282,6 +317,10 @@ export interface RpcOkMap {
282
317
  'edrv.archiveRead': { batches: ArchiveBatch[] }
283
318
  'edrv.rollback': { path: string; batch: number | null }
284
319
  'edrv.debug': object
320
+ 'edrv.dlog.list': { root: string; files: DebugLogFileInfo[]; current: string | null }
321
+ 'edrv.dlog.read': DebugLogRead
322
+ 'edrv.dlog.clear': { name: string }
323
+ 'edrv.dlog.reveal': { opened: boolean }
285
324
  'edrv.searchFiles': { files: string[]; truncated: boolean }
286
325
  'edrv.searchContent': { matches: SearchContentMatch[]; truncated: boolean; warning?: string }
287
326
  'edrv.listDir': { root: string; path: string; entries: TreeEntry[] }
@@ -357,6 +396,19 @@ export interface RpcOkMap {
357
396
  'snippets.save': { file: SnippetInfo }
358
397
  'snippets.remove': object
359
398
  'snippets.entries': { entries: SnippetEntry[] }
399
+ 'svn.status': SvnStatusPayload
400
+ 'svn.changes': { wcRoot: string; entries: SvnChangeEntry[]; truncated: boolean }
401
+ 'svn.diffBase': { base: string | null; working: string; reason?: string; error?: string }
402
+ 'svn.revert': { count: number; summary: string; output: string }
403
+ 'svn.add': { count: number; summary: string; output: string }
404
+ 'svn.log': { entries: SvnLogEntry[]; truncated: boolean; target: string; limit?: number }
405
+ 'svn.diffRev': SvnDiffRevResult
406
+ 'svn.diffPair': SvnDiffRevResult
407
+ 'svn.diffWorking': SvnDiffRevResult
408
+ 'svn.wcRev': { revision: number | null }
409
+ 'svn.cleanup': { summary: string; output: string }
410
+ 'svn.update': SvnUpdateResult
411
+ 'svn.tortoise': { launched: string }
360
412
  }
361
413
 
362
414
  /** 统一响应:{ok:true, ...payload} 或 {ok:false, error}。 */
@@ -0,0 +1,377 @@
1
+ /**
2
+ * dsh-vscode-mode shared — SVN 能力双面契约(类型/动作表/TortoiseProc argv/变更纯函数)。
3
+ * 底座:svn CLI(跨平台);TortoiseProc 对话框为 Windows 过渡增强(自研 UI 就绪后按能力标志隐藏)。
4
+ * P2 增补:工作副本变更条目类型与显隐/徽标纯函数(`svn status --xml` 解析结果的消费口径)。
5
+ * Purity rule: no node/react imports(对齐 rpc.ts/types.ts)。
6
+ * 作者 ddj 2026年09月16号
7
+ */
8
+
9
+ /** TortoiseSVN 过渡动作(P1 菜单/命令面;后续阶段由自研能力逐项替换)。 */
10
+ export type SvnAction = 'update' | 'commit' | 'log' | 'diff' | 'blame' | 'revert'
11
+
12
+ /** svn CLI 缺省可执行名(未配置 svnPath 时从 PATH 解析)。 */
13
+ export const SVN_CLI_DEFAULT = 'svn'
14
+
15
+ /** TortoiseSVN 缺省安装目录(Windows;对齐 SvnHelper 的 TortoiseSVN.path 默认值)。 */
16
+ export const TORTOISE_DIR_DEFAULT = 'C:\\Program Files\\TortoiseSVN'
17
+
18
+ /** TortoiseSVN 主程序名。 */
19
+ export const TORTOISE_EXE = 'TortoiseProc.exe'
20
+
21
+ /** SvnAction → TortoiseProc /command: 参数(全部同名,显式映射防拼写漂移)。 */
22
+ export function tortoiseCommandOf(action: SvnAction): string {
23
+ const table: Record<SvnAction, string> = {
24
+ update: 'update',
25
+ commit: 'commit',
26
+ log: 'log',
27
+ diff: 'diff',
28
+ blame: 'blame',
29
+ revert: 'revert',
30
+ }
31
+ return table[action]
32
+ }
33
+
34
+ /**
35
+ * TortoiseProc 启动 argv(纯函数)。
36
+ * Windows CRT 引号规则:node 对含空格参数整体加引号,子进程 CommandLineToArgvW
37
+ * 还原为单参数,TortoiseSVN 解析器取 `/path:` 后全串为路径——无 shell 注入面。
38
+ * @author ddj 2026年09月16号
39
+ * @param exe TortoiseProc.exe 绝对路径
40
+ * @param action Tortoise 动作
41
+ * @param absPath 目标绝对路径(文件/目录)
42
+ * @returns argv 数组(无 shell 插值)
43
+ */
44
+ export function tortoiseLaunchArgv(exe: string, action: SvnAction, absPath: string): string[] {
45
+ return [exe, '/command:' + tortoiseCommandOf(action), '/path:' + absPath]
46
+ }
47
+
48
+ /** 自研 SVN 能力标志(用于「自研替换 Tortoise 项」的显隐推进)。 */
49
+ export type SvnFeature = 'diff' | 'revert' | 'log' | 'commit' | 'blame' | 'update'
50
+
51
+ /** SVN 能力状态(svn.status 载荷;client 显隐判定唯一数据源)。 */
52
+ export interface SvnStatusPayload {
53
+ /** 工作区是否位于 SVN 工作副本内(cwd 向上找 .svn)。 */
54
+ managed: boolean
55
+ /** 工作副本根目录绝对路径(未受管理为 null)。 */
56
+ wcRoot: string | null
57
+ /** svn CLI 是否可用(`svn --version --quiet` 退出码 0)。 */
58
+ svnCli: boolean
59
+ /** TortoiseProc.exe 是否可用(仅 Windows 可能为 true)。 */
60
+ tortoise: boolean
61
+ /**
62
+ * 已由**自研界面**提供等价能力的功能集合(能力标志)。
63
+ *
64
+ * 推进口径见 `plans/svn-integration/01-infra-visibility.md` 的「自研替换时间线」:
65
+ * 自研能力上线后,对应 TortoiseProc 项自动隐藏(避免同名项并列、误点到官方弹窗)。
66
+ * 缺省 `[]`(旧 payload/未探测时)→ 不隐藏任何 Tortoise 项,保持降级可用。
67
+ */
68
+ svnFeatures?: readonly SvnFeature[]
69
+ /** 实际使用的 svn CLI(探测依据,诊断用)。 */
70
+ svnPath: string
71
+ /** 实际解析到的 TortoiseProc.exe(诊断用;不可用为空串)。 */
72
+ tortoiseExe: string
73
+ }
74
+
75
+ /** update 结果行的动作(对齐 `svn help update` 的字母表)。 */
76
+ export type SvnUpdateAction = 'A' | 'U' | 'D' | 'G' | 'C' | 'E' | 'R'
77
+
78
+ /** 一条 update 条目(path 为 svn 输出的原始形态)。 */
79
+ export interface SvnUpdateEntry {
80
+ /** 动作字母。 */
81
+ action: SvnUpdateAction
82
+ /** 目标路径(svn 原样输出)。 */
83
+ path: string
84
+ /** 中文说明(面板展示;未知字母回落为字母本身)。 */
85
+ label: string
86
+ }
87
+
88
+ /** svn.update 结果(条目级 + 宽松解析;output 始终带原文兜底)。 */
89
+ export interface SvnUpdateResult {
90
+ /** 一行摘要(状态栏展示)。 */
91
+ summary: string
92
+ /** 解析到的目标修订版号(解析失败缺省)。 */
93
+ revision?: number
94
+ /** 冲突文件清单(宽松解析,上限截断)。 */
95
+ conflicts: string[]
96
+ /** 条目级明细(update 无 --xml,由文本行解析;解析不出时为空数组)。 */
97
+ entries: SvnUpdateEntry[]
98
+ /** 原始输出(stdout+stderr 合并,诊断与兜底展示)。 */
99
+ output: string
100
+ }
101
+
102
+ /** revert/add 等批量动作结果(计数 + 摘要 + 原文)。 */
103
+ export interface SvnActionResult {
104
+ /** 处理条目数(宽松计数)。 */
105
+ count: number
106
+ /** 一行摘要(含失败原因兜底)。 */
107
+ summary: string
108
+ /** 原始输出。 */
109
+ output: string
110
+ }
111
+
112
+ // --region P2 工作副本变更(svn status --xml 消费口径)
113
+
114
+ /**
115
+ * 工作副本条目状态(svn status `item` 属性全集)。
116
+ * 取值范围与 `svn help status` 的首列字母表一一对应;'normal'/'external' 仅在返回
117
+ * 自身条目时出现(根条目、external 目录),不属于「变更」。
118
+ */
119
+ export type SvnItemStatus =
120
+ | 'normal' | 'added' | 'deleted' | 'replaced' | 'conflicted'
121
+ | 'missing' | 'unversioned' | 'obstructed' | 'ignored' | 'modified'
122
+ | 'external' | 'incomplete'
123
+
124
+ // --region P3 日志与更新结果
125
+
126
+ /** 日志条目里单个被改动的路径(对齐 `svn log -v` 的 `<path>` 节点)。 */
127
+ export interface SvnLogPath {
128
+ /** 仓库绝对路径(`/trunk/sub/x.txt`;host 会额外提供映射后的工作区相对路径)。 */
129
+ path: string
130
+ /** 映射到工作区内的相对路径;仓库前缀之外(如分支/外部)为 null。 */
131
+ relPath: string | null
132
+ /** 动作字母:A 新增 / D 删除 / R 替换 / M 修改。 */
133
+ action: 'A' | 'D' | 'R' | 'M'
134
+ /** 条目类型(file / dir)。 */
135
+ kind?: string
136
+ /** 复制来源路径(`svn copy` 记录,仓库绝对路径)。 */
137
+ copyFrom?: string
138
+ /** 复制来源版本。 */
139
+ copyFromRev?: number
140
+ /** 属性改动(`prop-mods`)。 */
141
+ propMods?: boolean
142
+ /** 内容改动(`text-mods`)。 */
143
+ textMods?: boolean
144
+ }
145
+
146
+ /** 一条提交日志(对齐 `svn log --xml -v` 的 `<logentry>`)。 */
147
+ export interface SvnLogEntry {
148
+ /** 修订版号。 */
149
+ revision: number
150
+ /** 提交者(可能为空)。 */
151
+ author: string
152
+ /** 提交时间(ISO8601 原文)。 */
153
+ date: string
154
+ /** 提交信息(保留换行)。 */
155
+ message: string
156
+ /** 该版本的变更路径(`-v` 才有)。 */
157
+ paths: SvnLogPath[]
158
+ /**
159
+ * 被合并进本版本的修订(P1-5,仅 `-g`/`--use-merge-history` 才有)。
160
+ *
161
+ * 实测(§6 M1,svn 1.14.5):`-g` 时被合并的修订以**嵌套 `<logentry>`** 形式出现在父条目
162
+ * `</msg>` 之后,同一父条目下可并列多个;嵌套条目的 `<paths>` 是**源分支路径**,
163
+ * `mapRepoPath` 会判为仓库外(relPath=null)——UI 按「仓库外」展示,不得误映射。
164
+ * 缺省 undefined = 未勾选 `-g` 或该修订无合并历史。
165
+ */
166
+ merged?: SvnLogEntry[]
167
+ /**
168
+ * 是否为反向合并(`reverse-merge="true"`;仅嵌套条目可能带,顶层从不带)。
169
+ * 实测只采到 `reverse-merge="false"` 样本,故按「可能存在 true」处理。
170
+ */
171
+ reverseMerge?: boolean
172
+ }
173
+
174
+ /** 日志动作 → 中文说明。 */
175
+ export const SVN_LOG_ACTION_LABEL: Record<string, string> = {
176
+ A: '新增',
177
+ D: '删除',
178
+ R: '替换',
179
+ M: '修改',
180
+ }
181
+
182
+ /** 版本差异结果(左右两侧内容;左侧为 null 表示该版本尚无此文件)。 */
183
+ export interface SvnDiffRevResult {
184
+ /** 左(revision-1)侧内容;新增文件/版本 1 时为 null。 */
185
+ left: string | null
186
+ /** 右(revision)侧内容。 */
187
+ right: string | null
188
+ /** left 缺失原因('not-exist' = 该版本无此路径)。 */
189
+ reason?: string
190
+ /** 失败提示(诊断用)。 */
191
+ error?: string
192
+ }
193
+
194
+ // --endregion
195
+
196
+ /** 一条工作副本变更(path 相对工作副本根、`/` 分隔;host 已归一)。 */
197
+ export interface SvnChangeEntry {
198
+ /** 工作区/工作副本相对路径(`/` 分隔,无前导 `./`)。 */
199
+ path: string
200
+ /** 条目状态。 */
201
+ status: SvnItemStatus
202
+ /** 属性(svn:*) 修改状态:'modified'/'conflicted' 时非空(展示用)。 */
203
+ props?: 'none' | 'modified' | 'conflicted'
204
+ /** 所属 changelist 名(未分组为空)。 */
205
+ changelist?: string
206
+ /** 是否受版本控制(unversioned/ignored 为 false)。 */
207
+ versioned: boolean
208
+ /** 工作副本修订版号(`-1` = 新增未提交,展示时忽略)。 */
209
+ revision?: number
210
+ }
211
+
212
+ /** 条目状态 → 展示字母(对齐 `svn status` 首列,VSCode 扩展同口径)。 */
213
+ export const SVN_STATUS_LETTER: Record<SvnItemStatus, string> = {
214
+ normal: ' ',
215
+ added: 'A',
216
+ deleted: 'D',
217
+ replaced: 'R',
218
+ conflicted: 'C',
219
+ missing: '!',
220
+ unversioned: '?',
221
+ obstructed: '~',
222
+ ignored: 'I',
223
+ modified: 'M',
224
+ external: 'X',
225
+ incomplete: '!',
226
+ }
227
+
228
+ /** 条目状态 → 中文说明(徽标 tooltip / 面板行 tooltip)。 */
229
+ export const SVN_STATUS_LABEL: Record<SvnItemStatus, string> = {
230
+ normal: '未修改',
231
+ added: '已添加(待提交)',
232
+ deleted: '已删除(待提交)',
233
+ replaced: '已替换',
234
+ conflicted: '冲突',
235
+ missing: '缺失(已被外部删除)',
236
+ unversioned: '未纳入版本控制',
237
+ obstructed: '类型冲突(被同名的其他类型占用)',
238
+ ignored: '已被忽略',
239
+ modified: '已修改',
240
+ external: '外部定义',
241
+ incomplete: '不完整(需 cleanup)',
242
+ }
243
+
244
+ /**
245
+ * 条目状态 → 颜色分级(对齐 TortoiseSVN「Check for Modifications」配色口径:
246
+ * 本地修改=蓝、新增=紫、删除/缺失=暗红、冲突=亮红、未版本控制/忽略=中性)。
247
+ */
248
+ export type SvnStatusTone = 'modified' | 'added' | 'deleted' | 'conflict' | 'plain'
249
+
250
+ /** 条目状态 → 颜色分级。 */
251
+ export const SVN_STATUS_TONE: Record<SvnItemStatus, SvnStatusTone> = {
252
+ normal: 'plain',
253
+ added: 'added',
254
+ deleted: 'deleted',
255
+ replaced: 'added',
256
+ conflicted: 'conflict',
257
+ missing: 'deleted',
258
+ unversioned: 'plain',
259
+ obstructed: 'conflict',
260
+ ignored: 'plain',
261
+ modified: 'modified',
262
+ external: 'plain',
263
+ incomplete: 'deleted',
264
+ }
265
+
266
+ /**
267
+ * 是否属于「工作副本变更」(`normal`/`external` 不算;`ignored` 由开关决定是否收录)。
268
+ * @author ddj 2026年09月16号
269
+ * @param entry 变更条目
270
+ * @returns 是否计入变更清单
271
+ */
272
+ export function isSvnChange(entry: SvnChangeEntry): boolean {
273
+ if (entry.status === 'normal' || entry.status === 'external') return false
274
+ if (entry.status === 'ignored') return false
275
+ return true
276
+ }
277
+
278
+ /** 面板「显示」开关(未版本控制/忽略项默认关掉忽略项、保留未版本控制)。 */
279
+ export interface SvnChangeFilter {
280
+ /** 是否显示未纳入版本控制的条目(默认 true)。 */
281
+ unversioned?: boolean
282
+ /** 是否显示已被忽略的条目(默认 false)。 */
283
+ ignored?: boolean
284
+ }
285
+
286
+ /**
287
+ * 按开关过滤变更清单(过滤规则唯一出口,面板/徽标共用,避免两处漂移)。
288
+ * @author ddj 2026年09月16号
289
+ * @param entries 变更清单(host 已按 `--no-ignore` 取全集)
290
+ * @param filter 显示开关
291
+ * @returns 过滤后的清单(保持原顺序)
292
+ */
293
+ export function svnVisibleChanges(
294
+ entries: readonly SvnChangeEntry[],
295
+ filter: SvnChangeFilter = {},
296
+ ): SvnChangeEntry[] {
297
+ const showUnversioned = filter.unversioned !== false
298
+ const showIgnored = filter.ignored === true
299
+ return entries.filter((entry) => {
300
+ if (entry.status === 'ignored') return showIgnored
301
+ if (entry.status === 'unversioned') return showUnversioned
302
+ return isSvnChange(entry)
303
+ })
304
+ }
305
+
306
+ /**
307
+ * 条目 → 文件树行尾徽标字母(无可展示徽标返回空串)。
308
+ * 与 svnVisibleChanges 同口径:未版本控制/忽略项由开关决定,故此处直接按状态给出字母,
309
+ * 由调用方决定是否已过滤。
310
+ * @author ddj 2026年09月16号
311
+ * @param entry 变更条目
312
+ * @returns 单字母徽标;`normal`/`external` 返回空串
313
+ */
314
+ export function svnBadgeOf(entry: SvnChangeEntry): string {
315
+ if (entry.status === 'normal' || entry.status === 'external') return ''
316
+ return SVN_STATUS_LETTER[entry.status]
317
+ }
318
+
319
+ /**
320
+ * 条目 → 徽标/行 tooltip 文案(`M 已修改` 形式)。
321
+ * @author ddj 2026年09月16号
322
+ * @param entry 变更条目
323
+ * @returns 展示文案;无徽标时返回空串
324
+ */
325
+ export function svnBadgeTitle(entry: SvnChangeEntry): string {
326
+ const letter = svnBadgeOf(entry)
327
+ if (!letter) return ''
328
+ return letter + ' ' + SVN_STATUS_LABEL[entry.status]
329
+ }
330
+
331
+ // --endregion
332
+
333
+ // --region P2 动作文案与判定
334
+
335
+ /** 与基线比较(`svn cat -r BASE` 差异视图)。 */
336
+ export const SVN_DIFF_BASE_LABEL = '与基线比较'
337
+ /** 加入版本控制(`svn add`)。 */
338
+ export const SVN_ADD_LABEL = '加入版本控制'
339
+ /** 还原(`svn revert -R`;CLI 全平台,区别于 TortoiseSVN 还原)。 */
340
+ export const SVN_REVERT_LABEL = 'SVN 还原'
341
+ /** 刷新变更(强制重查 `svn status --xml`)。 */
342
+ export const SVN_REFRESH_LABEL = '刷新变更'
343
+
344
+ /** 基线差异可打开的路径判据(需为可读文本文件;二进制/图片/PDF 不支持)。 */
345
+ const SVN_BINARY_EXT = new Set([
346
+ 'png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'ico', 'avif', 'pdf', 'zip', 'gz', '7z', 'rar',
347
+ 'exe', 'dll', 'so', 'dylib', 'class', 'jar', 'woff', 'woff2', 'ttf', 'otf', 'eot', 'mp3', 'mp4',
348
+ ])
349
+
350
+ /**
351
+ * 是否可打开基线差异(受版本控制 + 非二进制扩展名)。
352
+ * `added`/`unversioned` 无 BASE 内容,但 `added` 仍允许调用(host 会返回 no-pristine 原因)。
353
+ * @author ddj 2026年09月16号
354
+ * @param path 目标路径
355
+ * @param status 该条目的状态(缺省表示「不在变更清单里」,按受版本控制的已修改处理)
356
+ * @returns 是否可打开基线差异
357
+ */
358
+ export function isSvnDiffable(path: string, status?: SvnItemStatus): boolean {
359
+ if (status === 'unversioned' || status === 'ignored') return false
360
+ const base = String(path ?? '').split(/[\\/]/).pop() ?? ''
361
+ const dot = base.lastIndexOf('.')
362
+ if (dot <= 0) return true
363
+ return !SVN_BINARY_EXT.has(base.slice(dot + 1).toLowerCase())
364
+ }
365
+
366
+ /** 菜单/页签共用的动作文案(避免 client 侧两处漂移)。 */
367
+ export const SVN_UPDATE_LABEL = 'SVN 更新'
368
+
369
+ /** Tortoise 菜单文案表(按动作)。 */
370
+ export const SVN_TORTOISE_LABELS: Record<SvnAction, string> = {
371
+ update: 'TortoiseSVN 更新',
372
+ commit: 'TortoiseSVN 提交',
373
+ log: 'TortoiseSVN 日志',
374
+ diff: 'TortoiseSVN 差异',
375
+ blame: 'TortoiseSVN 追溯',
376
+ revert: 'TortoiseSVN 还原',
377
+ }