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/client/rpc.ts CHANGED
@@ -1,21 +1,40 @@
1
1
  /**
2
2
  * dsh-vscode-mode client — 类型化 RPC 包装 + 诊断日志。
3
3
  * 迁移自原 src/client/index.ts 的 rpc/dbg,语义不改。
4
- * 作者 ddj 2026-08-20
4
+ * 诊断开关运行时化:内存缓存 + localStorage 持久(日志弹窗开关即改即生效,免刷新)。
5
+ * 作者 ddj 2026-08-20 / 2026-09-17
5
6
  */
6
7
  import { RPC_PATH } from '../shared/rpc.js'
7
8
  import type { RpcMethod, RpcRequestMap, RpcResult } from '../shared/rpc.js'
9
+ import type { LoggerLevel } from '../shared/logger.js'
8
10
 
9
- /**
10
- * 诊断日志开关(默认关):diff 渲染热路径每条日志 = 1 次 RPC 往返 +
11
- * host 端 debug 文件全量读改写(上限 512KB),高频渲染下开销可观。
12
- * 排查问题时在浏览器控制台执行 localStorage.setItem('edrv.debug','1') 后刷新开启。
13
- * @author ddj 2026年08月26号
14
- */
15
- const DEBUG = (() => {
11
+ /** 诊断开关内存缓存(localStorage 'edrv.debug' 持久;热路径读内存,零 localStorage 开销)。 */
12
+ let debugEnabled = (() => {
16
13
  try { return localStorage.getItem('edrv.debug') === '1' } catch (e) { return false }
17
14
  })()
18
15
 
16
+ /**
17
+ * 诊断日志开关当前值。
18
+ * @author ddj 2026年09月17号
19
+ * @returns 是否开启
20
+ */
21
+ export function isDebug(): boolean {
22
+ return debugEnabled
23
+ }
24
+
25
+ /**
26
+ * 设置诊断日志开关(立即生效;localStorage 持久,写失败仅内存生效不抛错)。
27
+ * @author ddj 2026年09月17号
28
+ * @param on 是否开启
29
+ */
30
+ export function setDebugEnabled(on: boolean): void {
31
+ debugEnabled = on
32
+ try {
33
+ if (on) localStorage.setItem('edrv.debug', '1')
34
+ else localStorage.removeItem('edrv.debug')
35
+ } catch (e) { /* 无 localStorage 环境:仅内存生效 */ }
36
+ }
37
+
19
38
  /** 同源 RPC 调用(host /edrv/rpc 精确路由)。 */
20
39
  export function rpc<M extends RpcMethod>(method: M, args: RpcRequestMap[M]): Promise<RpcResult<M>> {
21
40
  return fetch(RPC_PATH, {
@@ -27,13 +46,14 @@ export function rpc<M extends RpcMethod>(method: M, args: RpcRequestMap[M]): Pro
27
46
 
28
47
  /**
29
48
  * 诊断日志:开关关闭时直接返回(零 RPC 零落盘);开启时走 host edrv.debug 落盘。
30
- * @author ddj 2026年08月26号
49
+ * @author ddj 2026年08月26号 / 2026年09月17号
31
50
  * @param sessionId 会话 id
32
51
  * @param text 日志文本
52
+ * @param level 日志级别(缺省 debug;落盘行与 host 终端回显均带此级别)
33
53
  */
34
- export function dbg(sessionId: string | undefined, text: string): void {
35
- if (!DEBUG) return
54
+ export function dbg(sessionId: string | undefined, text: string, level: LoggerLevel = 'debug'): void {
55
+ if (!debugEnabled) return
36
56
  try {
37
- rpc('edrv.debug', { sessionId, text }).catch(() => {})
57
+ rpc('edrv.debug', { sessionId, text, level }).catch(() => {})
38
58
  } catch (e) { /* 日志失败忽略 */ }
39
59
  }
@@ -0,0 +1,183 @@
1
+ /**
2
+ * dsh-vscode-mode client — 会话作用域解析(跨 DSH 版本适配)。
3
+ *
4
+ * 背景:DSH 0.1.6-alpha.2 随「客户端 Session 多实例共存」移除了 `sessions.list`
5
+ * 快照的 `current` / `currentAddress` 字段(alpha.1 及更早仍发布 `current`)。
6
+ * 官方替代面是 `ctx.uiSession.current`(`HostObservable<StandardSourceBinding>`,
7
+ * 其 `key` 即当前会话 id;绑定行由官方 ui-session 依 retainInfo.mainView 推出)。
8
+ *
9
+ * 本模块把「当前会话」收敛为单一三级取值链,并附可订阅的变更通知:
10
+ * 1. `uiSession.current.key`(0.1.6-alpha.2+ 权威)
11
+ * 2. `sessions.list.current`(rc 线 / 0.1.6-alpha.1 及更早)
12
+ * 3. `sessions.list.byId` 中 `retainedBy.mainView > 0` 的首行(新版兜底,与官方同判据)
13
+ * 4. 槽位注入的 sessionId(最后兜底)
14
+ *
15
+ * 约束:`uiSession` 不进 `inject`(旧版 DSH 无此服务,进 inject 会让插件停等),
16
+ * 一律 `ctx.get` 可选探测;任一来源缺失/异常都降级,不抛错、不阻断其他两级。
17
+ * 取值纯逻辑(pickSessionScope 等)可在 node 环境单测。
18
+ * 作者 ddj 2026年09月18号
19
+ */
20
+
21
+ /** 解析出的会话作用域:sessionId 可能缺失;cwd 仅在 list 快照可读时给出。 */
22
+ export interface SessionScope {
23
+ sessionId?: string
24
+ cwd?: string
25
+ }
26
+
27
+ /** 取值链的候选来源(三者都可缺失,缺失即跳过该级)。 */
28
+ export interface ScopeSources {
29
+ /** `ctx.uiSession.current` 快照(0.1.6-alpha.2+ 权威来源;binding.key = sessionId)。 */
30
+ uiSession?: unknown
31
+ /** `ctx.sessions.list` 快照(旧版含 `current`;新版只有 `ids`/`byId`)。 */
32
+ list?: unknown
33
+ /** 槽位注入的 sessionId(前几级都拿不到时的最后兜底)。 */
34
+ fallbackSessionId?: unknown
35
+ }
36
+
37
+ /** 对象窄化(非对象返回 undefined,供后续安全取字段)。 */
38
+ function asRecord(value: unknown): Record<string, unknown> | undefined {
39
+ return value && typeof value === 'object' ? (value as Record<string, unknown>) : undefined
40
+ }
41
+
42
+ /** 非空字符串取值(其他类型一律视为缺失)。 */
43
+ function strOf(value: unknown): string | undefined {
44
+ return typeof value === 'string' && value ? value : undefined
45
+ }
46
+
47
+ /**
48
+ * 从 `uiSession.current` 快照读出 sessionId。
49
+ * 兼容两种形态:绑定源本身(`{ key }`)与其包裹形态(`{ value: { key } }`)——
50
+ * 官方 `createBindingSource` 的 `getSnapshot()` 返回绑定值本身,但跨版本包裹层存在差异,
51
+ * 故两者都尝试。
52
+ * @author ddj 2026年09月18号
53
+ * @param snapshot `uiSession.current` 的 `getSnapshot()` 结果(任意形状)
54
+ * @returns sessionId,或 undefined
55
+ */
56
+ export function uiSessionIdOf(snapshot: unknown): string | undefined {
57
+ const binding = asRecord(snapshot)
58
+ if (!binding) return undefined
59
+ return strOf(binding.key) ?? strOf(asRecord(binding.value)?.key)
60
+ }
61
+
62
+ /**
63
+ * 从 `sessions.list` 快照读出 sessionId。
64
+ * 先取旧版 `current`;缺失时回退 `byId` 中 `retainedBy.mainView > 0` 的首行
65
+ * (与官方 ui-session 的 publishMain 同判据,故新版行为与官方视图一致)。
66
+ * @author ddj 2026年09月18号
67
+ * @param list `sessions.list` 的 `getSnapshot()` 结果(任意形状)
68
+ * @returns sessionId,或 undefined
69
+ */
70
+ export function listSessionId(list: unknown): string | undefined {
71
+ const snap = asRecord(list)
72
+ if (!snap) return undefined
73
+ const current = strOf(snap.current)
74
+ if (current) return current
75
+ const byId = asRecord(snap.byId)
76
+ if (!byId) return undefined
77
+ for (const [id, row] of Object.entries(byId)) {
78
+ const mainView = asRecord(asRecord(row)?.retainedBy)?.mainView
79
+ if (typeof mainView === 'number' && mainView > 0) return id
80
+ }
81
+ return undefined
82
+ }
83
+
84
+ /**
85
+ * 三级取值链(纯函数):决定当前会话 id 与其 cwd。
86
+ * 优先级 uiSession > list.current > list.byId.retainedBy > fallback。
87
+ * @author ddj 2026年09月18号
88
+ * @param sources 候选来源集合
89
+ * @returns 会话作用域(字段缺失即省略该键,不写 undefined 占位)
90
+ */
91
+ export function pickSessionScope(sources: ScopeSources): SessionScope {
92
+ const sessionId = uiSessionIdOf(sources.uiSession)
93
+ ?? listSessionId(sources.list)
94
+ ?? strOf(sources.fallbackSessionId)
95
+ if (!sessionId) return {}
96
+ const byId = asRecord(asRecord(sources.list)?.byId)
97
+ const cwd = strOf(asRecord(byId?.[sessionId])?.cwd)
98
+ return cwd ? { sessionId, cwd } : { sessionId }
99
+ }
100
+
101
+ /** 读 `ctx.get(name)`(缺失/异常返回 undefined,不抛错)。 */
102
+ function safeGet(ctx: { get: (name: string) => unknown }, name: string): unknown {
103
+ try {
104
+ return ctx.get(name)
105
+ } catch {
106
+ return undefined
107
+ }
108
+ }
109
+
110
+ /** 取 `uiSession.current` 快照(服务或订阅面缺失时返回 undefined)。 */
111
+ function uiCurrentSnapshot(ctx: { get: (name: string) => unknown }): unknown {
112
+ const uiSession = asRecord(safeGet(ctx, 'uiSession'))
113
+ const current = asRecord(uiSession?.current)
114
+ const getSnapshot = current?.getSnapshot
115
+ if (typeof getSnapshot !== 'function') return undefined
116
+ try {
117
+ return (getSnapshot as () => unknown).call(current)
118
+ } catch {
119
+ return undefined
120
+ }
121
+ }
122
+
123
+ /** 取 `sessions.list` 快照(服务缺失时返回 undefined)。 */
124
+ function listSnapshot(ctx: { get: (name: string) => unknown }): unknown {
125
+ const sessions = asRecord(safeGet(ctx, 'sessions'))
126
+ const list = asRecord(sessions?.list)
127
+ const getSnapshot = list?.getSnapshot
128
+ if (typeof getSnapshot !== 'function') return undefined
129
+ try {
130
+ return (getSnapshot as () => unknown).call(list)
131
+ } catch {
132
+ return undefined
133
+ }
134
+ }
135
+
136
+ /**
137
+ * 读当前会话作用域(服务面取值 + 三级链)。
138
+ * @author ddj 2026年09月18号
139
+ * @param ctx 客户端根上下文(只需 `get`)
140
+ * @param fallbackSessionId 槽位注入的 sessionId(可选)
141
+ * @returns 会话作用域
142
+ */
143
+ export function readSessionScope(ctx: { get: (name: string) => unknown }, fallbackSessionId?: unknown): SessionScope {
144
+ return pickSessionScope({
145
+ uiSession: uiCurrentSnapshot(ctx),
146
+ list: listSnapshot(ctx),
147
+ fallbackSessionId,
148
+ })
149
+ }
150
+
151
+ /**
152
+ * 订阅会话切换(同时挂 `uiSession.current` 与新/旧 `sessions.list`)。
153
+ * 旧版无 `uiSession`,新版 `list` 不再变(切会话只动 uiSession),故两条都要挂;
154
+ * 回调可能因两条源先后触发而重复,调用方按上一次 sessionId 自行去重。
155
+ * @author ddj 2026年09月18号
156
+ * @param ctx 客户端根上下文(只需 `get`)
157
+ * @param listener 变更回调
158
+ * @returns 取消订阅函数(幂等)
159
+ */
160
+ export function subscribeScope(ctx: { get: (name: string) => unknown }, listener: () => void): () => void {
161
+ const disposers: Array<() => void> = []
162
+ const attach = (owner: unknown): void => {
163
+ const subscribe = asRecord(owner)?.subscribe
164
+ if (typeof subscribe !== 'function') return
165
+ try {
166
+ const disposer = (subscribe as (fn: () => void) => unknown).call(owner, listener)
167
+ if (typeof disposer === 'function') disposers.push(disposer as () => void)
168
+ } catch {
169
+ /* 订阅面异常:放弃该源,不影响其他源 */
170
+ }
171
+ }
172
+ attach(asRecord(asRecord(safeGet(ctx, 'uiSession'))?.current))
173
+ attach(asRecord(asRecord(safeGet(ctx, 'sessions'))?.list))
174
+ return () => {
175
+ for (const disposer of disposers.splice(0)) {
176
+ try {
177
+ disposer()
178
+ } catch {
179
+ /* 卸载异常不影响其余清理 */
180
+ }
181
+ }
182
+ }
183
+ }
@@ -5,7 +5,8 @@
5
5
  * 右侧详情面板区可拖拽调宽/隐藏:visible=false 时仅保留图标列(宽 = 图标列宽),
6
6
  * 点击任一图标重新展开面板(onShow),底部按钮在 ◀ 收起 / ▶ 展开间切换。
7
7
  * 拖拽调宽下限 = minWidth prop(通用设置 sidebarMinWidth,默认 300),低于下限直接收起面板区。
8
- * 作者 ddj 2026-08-26 / 2026-09-04
8
+ * 面板可按 visible(ctx) 声明可用性(如「SVN 变更」仅在受 SVN 管理时出现,非 SVN 工作区零变化)。
9
+ * 作者 ddj 2026-08-26 / 2026-09-04 / 2026-09-16
9
10
  */
10
11
  import React from 'react'
11
12
  import type { SidebarCtx } from './types.js'
@@ -47,8 +48,11 @@ function railIconEl(icon) {
47
48
  * @param props.minWidth 最小宽度(通用设置 sidebarMinWidth;拖拽低于它触发隐藏)
48
49
  */
49
50
  export function SidebarView(props) {
50
- const panels = props.registry?.list?.() ?? []
51
51
  const ctx = props.ctx
52
+ // 按 visible(ctx) 过滤活动栏图标与面板(能力相关面板:SVN 变更仅在受 SVN 管理时出现)
53
+ const panels = (props.registry?.list?.() ?? []).filter((panel) => (
54
+ typeof panel.visible !== 'function' || panel.visible(ctx) === true
55
+ ))
52
56
  const activePanel = props.activePanel
53
57
  const onActive = props.onActive
54
58
  const visible = props.visible !== false
@@ -1,13 +1,103 @@
1
1
  /**
2
2
  * dsh-vscode-mode client — 文件管理右键菜单内置项。
3
3
  * 内置项:「在文件浏览器中打开」(文件→OS Explorer 定位选中、目录→打开目录);
4
- * 「添加引用到对话」(文件/文件夹引用注入当前会话对话输入框)。
4
+ * 「添加引用到对话」(文件/文件夹引用注入当前会话对话输入框);
5
+ * 「SVN 组」——动作清单与显隐规则来自 shared/svnActions.ts(三入口共用一份,不再各写一遍)。
5
6
  * 反馈统一走 ctx.notify(由 EditorView 提供,落到编辑区路径栏状态)。
6
- * 作者 ddj 2026-08-27 / 2026-09-03
7
+ * 作者 ddj 2026-08-27 / 2026-09-03 / 2026-09-16
7
8
  */
9
+ import { isSvnDiffable } from '../../shared/svn.js'
10
+ import type { SvnChangeEntry } from '../../shared/svn.js'
11
+ import { svnActionOn, svnActionsFor } from '../../shared/svnActions.js'
12
+ import type { SvnActionContext, SvnTargetType } from '../../shared/svnActions.js'
13
+ import { runSvnAction } from '../svnActions.js'
14
+ import type { SvnActionRunCtx } from '../svnActions.js'
8
15
  import { revealInExplorer } from '../fileReveal.js'
9
16
  import { statusOfAdd } from '../addToConversation.js'
10
17
  import type { TreeMenuItem } from './contextMenu.js'
18
+ import type { SidebarCtx } from './types.js'
19
+ import type { TreeMenuTarget } from './contextMenu.js'
20
+
21
+ /** 目标路径对应的变更条目(不在清单里返回 undefined)。 */
22
+ function svnEntryOf(target: TreeMenuTarget, ctx: SidebarCtx): SvnChangeEntry | undefined {
23
+ if (!target.path) return undefined
24
+ const map = ctx.svnChangeMap
25
+ return map ? map[target.path] : undefined
26
+ }
27
+
28
+ /**
29
+ * 把树菜单目标投影为动作求值上下文(能力 + 目标类型 + 状态门禁)。
30
+ * 显隐判定统一交给 shared 的 svnActionOn,避免与页签菜单/命令栏漂移。
31
+ * @author ddj 2026年09月16号
32
+ * @param target 右键目标
33
+ * @param ctx 面板上下文
34
+ * @returns 求值上下文;无 SVN 状态返回 null
35
+ */
36
+ function svnContextOf(target: TreeMenuTarget, ctx: SidebarCtx): SvnActionContext | null {
37
+ const svn = ctx.svn
38
+ if (!svn) return null
39
+ const entry = svnEntryOf(target, ctx)
40
+ const isRoot = target.path === ''
41
+ const targetType: SvnTargetType = isRoot ? 'root' : (target.type === 'directory' ? 'directory' : 'file')
42
+ return {
43
+ managed: svn.managed,
44
+ svnCli: svn.svnCli,
45
+ tortoise: svn.tortoise,
46
+ // 自研能力集合:驱动「自研替换时间线」(隐藏已被自研覆盖的 Tortoise 项)
47
+ svnFeatures: svn.svnFeatures,
48
+ target: targetType,
49
+ // 不在变更清单里视为受版本控制(干净文件);未版本控制条目显式为 false
50
+ versioned: entry ? entry.versioned : true,
51
+ status: entry?.status,
52
+ diffable: isSvnDiffable(target.path, entry?.status),
53
+ }
54
+ }
55
+
56
+ /**
57
+ * 树菜单动作执行上下文(RPC 与反馈经面板 ctx 注入)。
58
+ * @author ddj 2026年09月16号
59
+ * @param target 右键目标
60
+ * @param ctx 面板上下文
61
+ * @returns 执行上下文
62
+ */
63
+ function treeRunCtx(target: TreeMenuTarget, ctx: SidebarCtx): SvnActionRunCtx {
64
+ return {
65
+ sessionId: ctx.sessionId,
66
+ scope: ctx.scope,
67
+ path: target.path,
68
+ notify: (message) => ctx.notify?.(message),
69
+ openSvnDiff: (p) => ctx.openSvnDiff?.(p),
70
+ openSvnLog: ctx.openSvnLog,
71
+ refreshChanges: ctx.refreshSvnChanges,
72
+ // 确认对话框可注入(测试/宿主可替换;缺省走 window.confirm)
73
+ confirm: ctx.confirm,
74
+ }
75
+ }
76
+
77
+ /**
78
+ * 构造 SVN 组菜单项(动作清单来自 shared/svnActions.ts)。
79
+ *
80
+ * 收敛收益:此前每个动作在这里手写一项、页签菜单再手写一遍、命令栏第三遍,
81
+ * 显隐规则三处漂移(P2 的「加入/还原」状态矩阵就重复过两份)。现在只做映射。
82
+ * @author ddj 2026年09月16号
83
+ * @returns SVN 组条目
84
+ */
85
+ function svnMenuItems(): TreeMenuItem[] {
86
+ return svnActionsFor('tree').map((action) => ({
87
+ id: 'svn-' + action.id,
88
+ label: action.label,
89
+ order: action.order,
90
+ danger: action.danger === true,
91
+ separator: action.separator === true,
92
+ visible: (target: TreeMenuTarget, ctx: SidebarCtx) => {
93
+ const context = svnContextOf(target, ctx)
94
+ return context !== null && svnActionOn(action, context)
95
+ },
96
+ run: (target: TreeMenuTarget, ctx: SidebarCtx) => {
97
+ runSvnAction(action, treeRunCtx(target, ctx))
98
+ },
99
+ }))
100
+ }
11
101
 
12
102
  /**
13
103
  * 构造内置右键菜单项列表(后续内置项直接追加)。
@@ -45,5 +135,6 @@ export function createDefaultFileMenuItems(): TreeMenuItem[] {
45
135
  })
46
136
  },
47
137
  },
138
+ ...svnMenuItems(),
48
139
  ]
49
140
  }
@@ -6,7 +6,8 @@
6
6
  * 保留旧条目强制后台重列(force);每次用户发起加载后预取 ≤4 个子目录(排除重型
7
7
  * 目录、缓存新鲜跳过、不级联);10s 轻量跟随已展开目录(命中索引,RPC 近零成本)。
8
8
  * 差异角标/右键菜单/展开状态持久化行为不变;行图标用官方原语(文件夹图标 + 文件类型图标)。
9
- * 作者 ddj 2026-08-26 / 2026-08-27 / 2026-08-31 / 2026-09-10
9
+ * SVN 状态徽标(P2):行尾方形字母,数据来自 ctx.svnChangeMap(客户端合成,不改 listDir 契约)。
10
+ * 作者 ddj 2026-08-26 / 2026-08-27 / 2026-08-31 / 2026-09-10 / 2026-09-16
10
11
  */
11
12
  import React from 'react'
12
13
  import { FileTypeIcon, IconFolderClose16, IconFolderOpen16, IconRefreshOutline16, classifyFileType } from '@deepseek-ai/dsh-client-ui-primitives'
@@ -17,6 +18,8 @@ import { explorerLoad, explorerSave } from '../../state/explorerCache.js'
17
18
  import { entriesCacheGet, entriesCacheIsFresh, entriesCachePut } from '../../state/explorerEntriesCache.js'
18
19
  import { workspaceScopeOf } from '../../state/scopeStore.js'
19
20
  import { ancestorDirsOf } from '../../tabActions.js'
21
+ import { refreshSvnChanges, refreshSvnStatus } from '../../svnStatus.js'
22
+ import { SVN_STATUS_TONE, svnBadgeOf, svnBadgeTitle } from '../../../shared/svn.js'
20
23
  import type { SidebarCtx } from '../types.js'
21
24
 
22
25
  const DIR_CAP = 4000
@@ -105,6 +108,7 @@ export function FileExplorer(props) {
105
108
  const openFile = ctx?.openFile
106
109
  const activePath = ctx?.activePath ?? null
107
110
  const pendingByPath = ctx?.pendingByPath ?? {}
111
+ const svnChangeMap = ctx?.svnChangeMap ?? {}
108
112
  const [root, setRoot] = React.useState(null)
109
113
  const [dirs, setDirs] = React.useState({}) // rel → 最新条目(本会话内存态)
110
114
  const [expanded, setExpanded] = React.useState({})
@@ -264,6 +268,10 @@ export function FileExplorer(props) {
264
268
  setExpanded({})
265
269
  }
266
270
  void loadDir('', { force: true, prefetch: true })
271
+ // SVN 状态一并强制重测(检出 .svn / 卸载 svn 后可恢复菜单显隐)
272
+ refreshSvnStatus(sessionId, scope)
273
+ // 变更清单同样重查(目录重列后徽标要跟上;host 侧 TTL 内会被 force 绕过)
274
+ refreshSvnChanges(sessionId, scope)
267
275
  }
268
276
  refreshRef.current = refresh
269
277
  reloadDirRef.current = loadDir
@@ -387,6 +395,9 @@ export function FileExplorer(props) {
387
395
  const active = !isDir && e.path === activePath
388
396
  const contextTarget = Boolean(menu && menuEntries.length && menu.target.path === e.path)
389
397
  const dim = e.type === 'other'
398
+ // SVN 状态徽标:客户端由变更清单合成的查表(未加载/干净/未版本控制被过滤时不出徽标)
399
+ const svnEntry = svnChangeMap[e.path]
400
+ const svnLetter = svnEntry ? svnBadgeOf(svnEntry) : ''
390
401
  return React.createElement('div', {
391
402
  key: e.path,
392
403
  className: 'edrv-tree-row'
@@ -409,6 +420,12 @@ export function FileExplorer(props) {
409
420
  rowIconEl(e, isDir, isOpen),
410
421
  React.createElement('span', { className: 'edrv-tree-name' + (dim ? ' edrv-tree-dim' : '') },
411
422
  e.name),
423
+ (svnLetter
424
+ ? React.createElement('span', {
425
+ className: 'edrv-tree-svn edrv-svn-ch-' + (SVN_STATUS_TONE[svnEntry.status] ?? 'plain'),
426
+ title: 'SVN ' + svnBadgeTitle(svnEntry),
427
+ }, svnLetter)
428
+ : null),
412
429
  (pending > 0
413
430
  ? React.createElement('span', { className: 'edrv-tree-badge' }, String(pending))
414
431
  : null))