dsh-vscode-mode 0.16.0 → 0.18.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.
Files changed (45) hide show
  1. package/README.md +15 -9
  2. package/lib/client.js +954 -133
  3. package/lib/client.js.map +1 -1
  4. package/lib/index.js +5026 -4387
  5. package/lib/index.js.map +1 -1
  6. package/locale/en.json +4 -2
  7. package/locale/zh.json +4 -2
  8. package/package.json +4 -2
  9. package/skills/dsh-vscodemode-mcp/SKILL.md +24 -7
  10. package/src/capture.ts +2 -2
  11. package/src/client/compat.ts +2 -1
  12. package/src/client/index.ts +37 -7
  13. package/src/client/localeClient.ts +97 -0
  14. package/src/client/mcpSessionState.ts +108 -0
  15. package/src/client/monaco/lsp/providers.ts +32 -3
  16. package/src/client/officialSidebar.ts +4 -1
  17. package/src/client/state/modelWarmer.ts +198 -0
  18. package/src/client/state/recordsFeed.ts +241 -0
  19. package/src/client/styles/editor.css +5 -1
  20. package/src/client/ui/ConversationDiffDock.ts +10 -19
  21. package/src/client/ui/DiffBadge.ts +12 -19
  22. package/src/client/ui/EditorPane.ts +16 -4
  23. package/src/client/ui/EditorView.ts +114 -51
  24. package/src/client/ui/McpSessionStatus.ts +75 -0
  25. package/src/client/ui/McpSettings.ts +47 -13
  26. package/src/client/ui/PerfSizeBadge.ts +7 -4
  27. package/src/client/ui/PluginDetailSection.ts +82 -0
  28. package/src/dshVersion.ts +4 -2
  29. package/src/fileVersions.ts +2 -2
  30. package/src/lsp/rpc.ts +2 -2
  31. package/src/mcp.ts +1 -0
  32. package/src/mcpBridge.ts +163 -0
  33. package/src/mcpDiscover.ts +38 -0
  34. package/src/mcpPool.ts +245 -0
  35. package/src/mcpProject.ts +15 -12
  36. package/src/mcpRuntime.ts +301 -255
  37. package/src/registry.ts +15 -2
  38. package/src/rpc.ts +15 -3
  39. package/src/rules.ts +9 -4
  40. package/src/search/fallback.ts +2 -2
  41. package/src/search/ripgrep.ts +2 -2
  42. package/src/shared/mcp.ts +47 -0
  43. package/src/shared/rpc.ts +5 -1
  44. package/src/store.ts +26 -1
  45. package/src/svn.ts +2 -2
@@ -0,0 +1,198 @@
1
+ /**
2
+ * dsh-vscode-mode client — Monaco model 预热调度层。
3
+ *
4
+ * 背景:Monaco 的 GotoDefinitionAtPositionEditorContribution 在 Ctrl+hover 渲染定义链接
5
+ * 预览时,对目标的 edrv:// uri 调 textModelResolverService.createModelReference 后
6
+ * **只挂 then、没有 catch**;目标文件若从未在本会话打开过,该 uri 没有已注册 model,
7
+ * createModelReference 直接 reject("Model not found"),成为未捕获的 promise 异常。
8
+ * 本构建的 vendored Monaco 也没有 registerTextModelContentProvider 可供惰性喂内容,
9
+ * 因此修法是在 provideDefinition 返回前把目标 model 建出来(预热)。
10
+ *
11
+ * 本模块只负责「让某路径的 model 存在」这件事的调度:安装/卸载实现、并发单飞、
12
+ * 失败冷却与等待上限。真正建 model 的实现由 EditorView 注入(它才持有 monaco 与文档缓存)。
13
+ * 纯逻辑,不依赖 monaco,可单测。
14
+ * 作者 ddj 2026年10月20号
15
+ */
16
+
17
+ // --region 常量与状态
18
+
19
+ /** 单次预热调用的等待上限:超过即放行 hover(预热仍在后台继续)。 */
20
+ export const WARM_TIMEOUT_MS = 400
21
+
22
+ /** 预热失败后该路径的冷却窗口,避免每次 hover 都重试同一失败目标。 */
23
+ export const WARM_COOLDOWN_MS = 10_000
24
+
25
+ /** 冷却表条目上限(超出按插入序丢最旧),防长会话里无限增长。 */
26
+ export const WARM_COOLDOWN_CAP = 64
27
+
28
+ /** 预热实现:给定工作区相对路径,成功让 model 存在则返回 true。 */
29
+ export type WarmImpl = (path: string) => Promise<boolean>
30
+
31
+ /** 当前安装的预热实现(EditorView 挂载时注入,未挂载则为 null)。 */
32
+ let impl: WarmImpl | null = null
33
+ /** path → 进行中的预热(单飞:连续 hover 同一目标只读一次盘)。 */
34
+ const inFlight = new Map<string, Promise<boolean>>()
35
+ /** path → 冷却截止时间戳(毫秒)。 */
36
+ const cooled = new Map<string, number>()
37
+ // --endregion
38
+
39
+ // --region 状态维护
40
+
41
+ /**
42
+ * 清空进行中/冷却状态(安装或卸载实现时复位,避免跨实现串味)。
43
+ * @author ddj 2026年10月20号
44
+ */
45
+ function resetWarmerState(): void {
46
+ inFlight.clear()
47
+ cooled.clear()
48
+ }
49
+
50
+ /**
51
+ * 该路径是否处于失败冷却窗口内(过期的顺手清掉)。
52
+ * @author ddj 2026年10月20号
53
+ * @param path 工作区相对路径
54
+ * @returns 仍在冷却中返回 true
55
+ */
56
+ function isCooling(path: string): boolean {
57
+ const until = cooled.get(path)
58
+ if (until === undefined) return false
59
+ if (until > Date.now()) return true
60
+
61
+ cooled.delete(path)
62
+ return false
63
+ }
64
+
65
+ /**
66
+ * 记录一次失败冷却,并把冷却表裁剪到上限以内。
67
+ * @author ddj 2026年10月20号
68
+ * @param path 工作区相对路径
69
+ */
70
+ function coolDown(path: string): void {
71
+ cooled.delete(path)
72
+ cooled.set(path, Date.now() + WARM_COOLDOWN_MS)
73
+
74
+ while (cooled.size > WARM_COOLDOWN_CAP) {
75
+ const oldest = cooled.keys().next()
76
+ if (oldest.done) break
77
+
78
+ cooled.delete(oldest.value)
79
+ }
80
+ }
81
+
82
+ /**
83
+ * 结算一次预热:清 inFlight(仅当仍是自己那条)、按结果维护冷却。
84
+ * @author ddj 2026年10月20号
85
+ * @param path 工作区相对路径
86
+ * @param run 该次预热的 promise(用于识别是否已被更新的一次替换)
87
+ * @param ok 实现返回的成功与否
88
+ */
89
+ function settleWarm(path: string, run: Promise<boolean>, ok: boolean): void {
90
+ if (inFlight.get(path) === run) inFlight.delete(path)
91
+ if (ok) {
92
+ cooled.delete(path)
93
+ return
94
+ }
95
+
96
+ coolDown(path)
97
+ }
98
+ // --endregion
99
+
100
+ // --region 底层预热
101
+
102
+ /**
103
+ * 启动一次底层预热:登记 inFlight、把实现异常折叠成 false(绝不向调用方抛)。
104
+ * @author ddj 2026年10月20号
105
+ * @param path 工作区相对路径
106
+ * @param fn 预热实现
107
+ * @returns 永不 reject 的布尔 promise(已登记到 inFlight)
108
+ */
109
+ function startWarm(path: string, fn: WarmImpl): Promise<boolean> {
110
+ const run = Promise.resolve()
111
+ .then(() => fn(path))
112
+ .then(
113
+ (ok) => ok === true,
114
+ () => false,
115
+ )
116
+
117
+ inFlight.set(path, run)
118
+ run.then((ok) => settleWarm(path, run, ok))
119
+ return run
120
+ }
121
+
122
+ /**
123
+ * 给底层预热挂一个等待上限:超时只是本次调用放行,底层继续跑(仍可能建成 model
124
+ * 惠及下一次 hover),因此超时既不取消 promise,也不提前摘掉 inFlight。
125
+ * @author ddj 2026年10月20号
126
+ * @param path 工作区相对路径
127
+ * @param run 底层预热 promise(永不 reject)
128
+ * @returns 最多 WARM_TIMEOUT_MS 内结算的布尔结果
129
+ */
130
+ function withTimeout(path: string, run: Promise<boolean>): Promise<boolean> {
131
+ return new Promise<boolean>((resolve) => {
132
+ let settled = false
133
+ let timer: ReturnType<typeof setTimeout> | null = null
134
+
135
+ const finish = (ok: boolean): void => {
136
+ if (settled) return
137
+
138
+ settled = true
139
+ if (timer) clearTimeout(timer)
140
+ resolve(ok)
141
+ }
142
+
143
+ timer = setTimeout(() => {
144
+ coolDown(path)
145
+ finish(false)
146
+ }, WARM_TIMEOUT_MS)
147
+
148
+ run.then(finish, () => finish(false))
149
+ })
150
+ }
151
+ // --endregion
152
+
153
+ // --region 安装与卸载
154
+
155
+ /**
156
+ * 安装预热实现(EditorView 挂载时调用;传 null 视同卸载)。
157
+ * @author ddj 2026年10月20号
158
+ * @param fn 预热实现,或 null 表示卸载
159
+ */
160
+ export function setModelWarmer(fn: WarmImpl | null): void {
161
+ impl = typeof fn === 'function' ? fn : null
162
+ if (!impl) resetWarmerState()
163
+ }
164
+
165
+ /**
166
+ * 卸载预热实现:仅当传入的仍是当前实现才清(防卸载竞态误清新挂载的实现)。
167
+ * @author ddj 2026年10月20号
168
+ * @param fn 当初安装的实现
169
+ */
170
+ export function clearModelWarmer(fn: WarmImpl): void {
171
+ if (impl !== fn) return
172
+
173
+ impl = null
174
+ resetWarmerState()
175
+ }
176
+ // --endregion
177
+
178
+ // --region 预热调度
179
+
180
+ /**
181
+ * 确保目标路径的 Monaco model 存在(供 hover 定义预览的 createModelReference 命中)。
182
+ *
183
+ * 降级语义:未安装实现(编辑器页签未挂载)、路径为空、处于失败冷却、实现失败或超时,
184
+ * 一律返回 false 且不抛异常——此时行为等价于修复前(预览不显示,但不再产生未捕获异常)。
185
+ * @author ddj 2026年10月20号
186
+ * @param path 工作区相对路径(或绝对路径,由实现决定如何解析)
187
+ * @returns 预热成功返回 true;被跳过/失败/超时返回 false
188
+ */
189
+ export function warmModelFor(path: string): Promise<boolean> {
190
+ if (!path || !impl) return Promise.resolve(false)
191
+
192
+ const running = inFlight.get(path)
193
+ if (running) return withTimeout(path, running)
194
+ if (isCooling(path)) return Promise.resolve(false)
195
+
196
+ return withTimeout(path, startWarm(path, impl))
197
+ }
198
+ // --endregion
@@ -0,0 +1,241 @@
1
+ /**
2
+ * dsh-vscode-mode client — 会话级差异记录共享订阅。
3
+ * 原先 DiffBadge / ConversationDiffDock / EditorView 各自 5s 轮询同一个
4
+ * `edrv.list`,同一会话一轮要打 3 次 RPC。本模块按会话收敛为单一定时器:
5
+ * 订阅者共享一次拉取,`edrv:refresh` 事件与主动刷新也走同一条通道。
6
+ * 作者 ddj 2026年10月19号
7
+ */
8
+ import type { RecordView } from '../../shared/types.js'
9
+ import { rpc } from '../rpc.js'
10
+
11
+ /** 兜底轮询周期(毫秒):仅作事件丢失时的兜底,正常由 edrv:refresh 驱动。 */
12
+ export const FEED_INTERVAL_MS = 5000
13
+
14
+ /** 记录变化回调。 */
15
+ export type RecordsListener = (records: RecordView[]) => void
16
+
17
+ /** 周期心跳回调(给只关心「该刷新了」的订阅者复用同一定时器)。 */
18
+ export type TickListener = () => void
19
+
20
+ /** 拉取失败回调。 */
21
+ export type ErrorListener = (message: string) => void
22
+
23
+ /** 单会话的共享订阅状态。 */
24
+ interface FeedEntry {
25
+ records: RecordView[] | null
26
+ listeners: Set<RecordsListener>
27
+ tickers: Set<TickListener>
28
+ errorListeners: Set<ErrorListener>
29
+ timer: ReturnType<typeof setInterval> | null
30
+ onRefresh: (() => void) | null
31
+ seq: number
32
+ inflight: boolean
33
+ }
34
+
35
+ const feeds = new Map<string, FeedEntry>()
36
+
37
+ /** 取全局 window(Node 测试环境无 window)。 */
38
+ function win(): (Window & typeof globalThis) | undefined {
39
+ return typeof window === 'undefined' ? undefined : window
40
+ }
41
+
42
+ /**
43
+ * 取或建会话订阅槽位。
44
+ * @author ddj 2026年10月19号
45
+ * @param sessionId 会话 id
46
+ * @returns 订阅槽位
47
+ */
48
+ function entryOf(sessionId: string): FeedEntry {
49
+ let entry = feeds.get(sessionId)
50
+ if (!entry) {
51
+ entry = { records: null, listeners: new Set(), tickers: new Set(), errorListeners: new Set(), timer: null, onRefresh: null, seq: 0, inflight: false }
52
+ feeds.set(sessionId, entry)
53
+ }
54
+ return entry
55
+ }
56
+
57
+ /**
58
+ * 把最新记录分发给订阅者(快照相等时也分发,交由消费方自行比对)。
59
+ * @author ddj 2026年10月19号
60
+ * @param entry 订阅槽位
61
+ */
62
+ function notifyRecords(entry: FeedEntry): void {
63
+ const snapshot = entry.records
64
+ if (!snapshot) return
65
+ for (const listener of Array.from(entry.listeners)) {
66
+ try {
67
+ listener(snapshot)
68
+ } catch {
69
+ // 单个订阅者异常不得影响其余订阅者。
70
+ }
71
+ }
72
+ }
73
+
74
+ /**
75
+ * 拉取一次该会话的差异记录。
76
+ * @author ddj 2026年10月19号
77
+ * @param sessionId 会话 id
78
+ * @param skipStale 是否跳过过期判定(与 edrv.list 参数同名同义)
79
+ * @returns 拉取完成 Promise
80
+ */
81
+ export function refreshFeed(sessionId: string, skipStale = false): Promise<void> {
82
+ const entry = entryOf(sessionId)
83
+ const current = ++entry.seq
84
+ entry.inflight = true
85
+ const payload: { sessionId: string; skipStale?: boolean } = { sessionId }
86
+ if (skipStale) payload.skipStale = true
87
+
88
+ return rpc('edrv.list', payload)
89
+ .then((res) => {
90
+ if (current !== entry.seq) return
91
+ if (!res || !res.ok || !Array.isArray(res.records)) return
92
+ entry.records = res.records as RecordView[]
93
+ notifyRecords(entry)
94
+ })
95
+ .catch((e) => {
96
+ // 拉取失败保留上一次快照,等下一轮心跳重试;同时把错误交给关心者(EditorView 报错条)。
97
+ if (current !== entry.seq) return
98
+ const message = String(e)
99
+ for (const listener of Array.from(entry.errorListeners)) {
100
+ try {
101
+ listener(message)
102
+ } catch {
103
+ // 单个错误订阅者异常不得影响其余订阅者。
104
+ }
105
+ }
106
+ })
107
+ .finally(() => {
108
+ if (current === entry.seq) entry.inflight = false
109
+ })
110
+ }
111
+
112
+ /**
113
+ * 确保该会话的定时器与 refresh 事件监听已就绪(首个订阅者到来时启动)。
114
+ * @author ddj 2026年10月19号
115
+ * @param sessionId 会话 id
116
+ * @param entry 订阅槽位
117
+ */
118
+ function ensureTimer(sessionId: string, entry: FeedEntry): void {
119
+ if (entry.timer === null) {
120
+ entry.timer = setInterval(() => {
121
+ const tickers = Array.from(entry.tickers)
122
+ for (const tick of tickers) {
123
+ try {
124
+ tick()
125
+ } catch {
126
+ // 心跳订阅者异常不得中断定时器。
127
+ }
128
+ }
129
+ // 只有存在记录/错误订阅者时才拉取记录;纯心跳订阅者(如体积徽章)不额外制造 RPC。
130
+ if (entry.listeners.size > 0 || entry.errorListeners.size > 0) void refreshFeed(sessionId)
131
+ }, FEED_INTERVAL_MS)
132
+ }
133
+ if (entry.onRefresh === null) {
134
+ const handler = () => {
135
+ void refreshFeed(sessionId)
136
+ }
137
+ const target = win()
138
+ if (target) target.addEventListener('edrv:refresh', handler)
139
+ entry.onRefresh = handler
140
+ }
141
+ }
142
+
143
+ /**
144
+ * 会话无任何订阅者时释放定时器、事件监听与缓存快照。
145
+ * @author ddj 2026年10月19号
146
+ * @param sessionId 会话 id
147
+ * @param entry 订阅槽位
148
+ */
149
+ function releaseIfIdle(sessionId: string, entry: FeedEntry): void {
150
+ if (entry.listeners.size || entry.tickers.size || entry.errorListeners.size) return
151
+ if (entry.timer !== null) clearInterval(entry.timer)
152
+ if (entry.onRefresh !== null) {
153
+ const target = win()
154
+ if (target) target.removeEventListener('edrv:refresh', entry.onRefresh)
155
+ }
156
+ feeds.delete(sessionId)
157
+ }
158
+
159
+ /**
160
+ * 订阅会话的差异记录。
161
+ * 首个订阅者触发一次立即拉取;同会话多订阅者共享一次 RPC。
162
+ * @author ddj 2026年10月19号
163
+ * @param sessionId 会话 id
164
+ * @param listener 记录变化回调
165
+ * @returns 取消订阅函数
166
+ */
167
+ export function subscribeRecords(sessionId: string, listener: RecordsListener): () => void {
168
+ const entry = entryOf(sessionId)
169
+ entry.listeners.add(listener)
170
+ ensureTimer(sessionId, entry)
171
+
172
+ if (entry.records) listener(entry.records)
173
+ else if (!entry.inflight) void refreshFeed(sessionId)
174
+
175
+ return () => {
176
+ entry.listeners.delete(listener)
177
+ releaseIfIdle(sessionId, entry)
178
+ }
179
+ }
180
+
181
+ /**
182
+ * 订阅会话的周期心跳(复用同一定时器,不额外制造定时器)。
183
+ * @author ddj 2026年10月19号
184
+ * @param sessionId 会话 id
185
+ * @param listener 心跳回调
186
+ * @returns 取消订阅函数
187
+ */
188
+ export function subscribeTick(sessionId: string, listener: TickListener): () => void {
189
+ const entry = entryOf(sessionId)
190
+ entry.tickers.add(listener)
191
+ ensureTimer(sessionId, entry)
192
+
193
+ return () => {
194
+ entry.tickers.delete(listener)
195
+ releaseIfIdle(sessionId, entry)
196
+ }
197
+ }
198
+
199
+ /**
200
+ * 订阅会话记录拉取失败(错误提示用;同样复用该会话的共享通道)。
201
+ * @author ddj 2026年10月19号
202
+ * @param sessionId 会话 id
203
+ * @param listener 失败回调,参数为已字符串化的错误
204
+ * @returns 取消订阅函数
205
+ */
206
+ export function subscribeErrors(sessionId: string, listener: ErrorListener): () => void {
207
+ const entry = entryOf(sessionId)
208
+ entry.errorListeners.add(listener)
209
+ ensureTimer(sessionId, entry)
210
+
211
+ return () => {
212
+ entry.errorListeners.delete(listener)
213
+ releaseIfIdle(sessionId, entry)
214
+ }
215
+ }
216
+
217
+ /**
218
+ * 读取会话的最近一次记录快照。
219
+ * @author ddj 2026年10月19号
220
+ * @param sessionId 会话 id
221
+ * @returns 记录数组或 null(尚未拉到过)
222
+ */
223
+ export function readRecords(sessionId: string): RecordView[] | null {
224
+ return feeds.get(sessionId)?.records ?? null
225
+ }
226
+
227
+ /**
228
+ * 主动丢弃会话的共享通道(会话销毁或测试复位用)。
229
+ * @author ddj 2026年10月19号
230
+ * @param sessionId 会话 id
231
+ */
232
+ export function disposeFeed(sessionId: string): void {
233
+ const entry = feeds.get(sessionId)
234
+ if (!entry) return
235
+ if (entry.timer !== null) clearInterval(entry.timer)
236
+ if (entry.onRefresh !== null) {
237
+ const target = win()
238
+ if (target) target.removeEventListener('edrv:refresh', entry.onRefresh)
239
+ }
240
+ feeds.delete(sessionId)
241
+ }
@@ -16,6 +16,11 @@
16
16
  /* 固定页签:📌 标记 + 免关闭按钮(固定页签不渲染 ×,仅可经右键菜单取消固定) */
17
17
  [data-edrv-view] .edrv-tab .edrv-tab-pin { flex-shrink: 0; font-size: 10px; line-height: 1; opacity: .85; }
18
18
  [data-edrv-view] .edrv-tab.edrv-tab-pinned { background: var(--dsw-alias-bg-layer-2, rgba(255,255,255,.04)); }
19
+ /* 页签行 = 可横滚的页签区 + 行尾固定工具列(计划预览页签与磁盘页签同处这一行的滚动区)@author ddj 2026年10月20号 */
20
+ [data-edrv-view] .edrv-tabs-row { display: flex; align-items: center; min-width: 0; flex: 0 0 auto; background: var(--dsw-alias-bg-layer-1, transparent); }
21
+ /* 特异性需盖过下面 .edrv-group .edrv-tabs 的 flex: 0 0 auto,页签区才能吃掉行走宽、把工具列压到行尾 */
22
+ [data-edrv-view] .edrv-group .edrv-tabs-row > .edrv-tabs { flex: 1 1 auto; min-width: 0; background: transparent; }
23
+ [data-edrv-view] .edrv-tabs-tools { display: flex; align-items: center; gap: 2px; flex: 0 0 auto; padding: 0 6px 0 2px; }
19
24
  [data-edrv-view] .edrv-tab-add { display: inline-flex; align-items: center; justify-content: center; width: 24px; height: 24px; margin-left: 2px; border: none; border-radius: 6px; background: transparent; color: var(--dsw-alias-label-secondary, #aaa); font-size: 14px; cursor: pointer; flex-shrink: 0; }
20
25
  [data-edrv-view] .edrv-tab-add:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(255,255,255,.06)); }
21
26
  [data-edrv-view] .edrv-pathbar { display: flex; align-items: center; gap: 8px; height: 26px; padding: 0 10px; background: var(--dsw-alias-bg-layer-1, #ffffff); border-bottom: 1px solid var(--dsw-alias-border-l1, #e0e6e8); font-size: 12px; flex-shrink: 0; overflow: hidden; }
@@ -988,7 +993,6 @@
988
993
  [data-edrv-view] .edrv-group { border: 0; }
989
994
  [data-edrv-view] .edrv-group .edrv-tabs { flex: 0 0 auto; touch-action: none; }
990
995
  [data-edrv-view] .edrv-group-tab-name { overflow: hidden; text-overflow: ellipsis; max-width: 180px; }
991
- [data-edrv-view] .edrv-workbench-tools { flex: 0 0 auto; }
992
996
  [data-edrv-view] .edrv-split { display: flex; flex: 1 1 auto; min-width: 0; min-height: 0; overflow: hidden; }
993
997
  [data-edrv-view] .edrv-split-vertical { flex-direction: column; }
994
998
  [data-edrv-view] .edrv-split-child { display: flex; min-width: 0; min-height: 0; overflow: hidden; }
@@ -2,12 +2,13 @@
2
2
  /**
3
3
  * dsh-vscode-mode client — 会话级差异 dock。
4
4
  * conversation.input.dock 是唯一实例:普通对话显示摘要,文件编辑页显示完整操作条。
5
+ * 轮询已收敛到 state/recordsFeed.ts 的会话级共享订阅,本组件不再自建定时器。
5
6
  * 作者 ddj 2026-08-26
6
7
  */
7
8
  import React from 'react'
8
- import { rpc } from '../rpc.js'
9
9
  import { openDiffView } from '../events.js'
10
10
  import { summarize } from '../state/records.js'
11
+ import { subscribeRecords } from '../state/recordsFeed.js'
11
12
  import { nextDiffPath } from '../diffDock.js'
12
13
  import { dockInLayout, readDiffDock, subscribeDiffDock } from '../diffDockStore.js'
13
14
  import { DiffBox } from './DiffBox.js'
@@ -25,27 +26,17 @@ export function ConversationDiffDock(props) {
25
26
  const sessionId = props?.sessionId
26
27
  const [summary, setSummary] = React.useState(null)
27
28
  const [, setStoreRevision] = React.useState(0)
28
- const seq = React.useRef(0)
29
-
30
- const load = React.useCallback(() => {
31
- if (!sessionId) return
32
- const current = ++seq.current
33
- rpc('edrv.list', { sessionId }).then((res) => {
34
- if (current !== seq.current || !res || !res.ok || !Array.isArray(res.records)) return
35
- setSummary(summarize(res.records))
36
- }).catch(() => {})
37
- }, [sessionId])
38
29
 
39
30
  React.useEffect(() => {
40
- load()
41
- const timer = setInterval(load, 5000)
42
- const onRefresh = () => load()
43
- window.addEventListener('edrv:refresh', onRefresh)
44
- return () => {
45
- clearInterval(timer)
46
- window.removeEventListener('edrv:refresh', onRefresh)
31
+ if (!sessionId) {
32
+ setSummary(null)
33
+ return undefined
47
34
  }
48
- }, [load])
35
+
36
+ return subscribeRecords(sessionId, (records) => {
37
+ setSummary(summarize(records))
38
+ })
39
+ }, [sessionId])
49
40
 
50
41
  React.useEffect(() => {
51
42
  if (!sessionId) return undefined
@@ -1,39 +1,32 @@
1
1
  // @ts-nocheck
2
2
  /**
3
3
  * dsh-vscode-mode client — DiffBadge:header 差异角标(仅工作区有差异时显示)。
4
- * 迁移自原 src/client/index.ts 的 DiffBadge,语义不改。
4
+ * 轮询已收敛到 state/recordsFeed.ts 的会话级共享订阅,本组件不再自建定时器。
5
5
  * 作者 ddj 2026-08-20
6
6
  */
7
7
  import React from 'react'
8
8
  import { appUrl } from '../../shared/appUrl.js'
9
- import { rpc } from '../rpc.js'
10
9
  import { openEditorView, emitShowLauncher } from '../events.js'
11
10
  import { summarize } from '../state/records.js'
11
+ import { subscribeRecords } from '../state/recordsFeed.js'
12
12
 
13
13
  /**
14
- * 会话 header 差异角标:5s 轮询 edrv.list,有差异时显示数字徽章,点击打开编辑区并拉起 DiffLauncher。
14
+ * 会话 header 差异角标:订阅会话差异记录,有差异时显示数字徽章,点击打开编辑区并拉起 DiffLauncher。
15
15
  */
16
16
  export function DiffBadge(props) {
17
17
  const sessionId = props?.sessionId
18
18
  const [summary, setSummary] = React.useState(null)
19
- const seq = React.useRef(0)
20
-
21
- const load = React.useCallback(() => {
22
- if (!sessionId) return
23
- const s = ++seq.current
24
- rpc('edrv.list', { sessionId }).then((res) => {
25
- if (s !== seq.current || !res || !res.ok || !Array.isArray(res.records)) return
26
- setSummary(summarize(res.records))
27
- }).catch(() => {})
28
- }, [sessionId])
29
19
 
30
20
  React.useEffect(() => {
31
- load()
32
- const t = setInterval(load, 5000)
33
- const onRefresh = () => load()
34
- window.addEventListener('edrv:refresh', onRefresh)
35
- return () => { clearInterval(t); window.removeEventListener('edrv:refresh', onRefresh) }
36
- }, [load])
21
+ if (!sessionId) {
22
+ setSummary(null)
23
+ return undefined
24
+ }
25
+
26
+ return subscribeRecords(sessionId, (records) => {
27
+ setSummary(summarize(records))
28
+ })
29
+ }, [sessionId])
37
30
 
38
31
  const n = summary?.totalFiles ?? 0
39
32
  if (!n) return null
@@ -14,14 +14,24 @@ export interface PaneProps {
14
14
  onDrag(path: string, event: React.PointerEvent): void
15
15
  consumeClick(): boolean
16
16
  children?: React.ReactNode
17
+ /** @public @author ddj 2026年10月20号 Virtual preview tabs of this group's own strip: plans and files share one row. */
17
18
  extras?: React.ReactNode
19
+ /** @public @author ddj 2026年10月20号 Row controls pinned after the strip of the same row (never inside the scroller). */
20
+ tools?: React.ReactNode
21
+ /** @public @author ddj 2026年10月20号 Strip ref owned by the workbench (scroll-into-view and horizontal wheel). */
22
+ tabsRef?: React.Ref<HTMLDivElement>
23
+ /** @public @author ddj 2026年10月20号 Read-only plan preview owns this pane body, so no disk tab paints as active. */
24
+ suppressActive?: boolean
18
25
  }
19
26
 
20
27
  /** @public @author ddj 2026年10月14号 @param props Group callbacks and file view. @returns Accessible tab strip and drop target. */
21
28
  export function EditorPane(props: PaneProps): React.ReactElement {
22
29
  const { group } = props
23
- /** @private @author ddj 2026年10月14号 @param tab File record. @returns Draggable tab with isolated close control. */
30
+ // 计划预览接管本组正文时,磁盘页签只保留可点击性,不再画选中态,避免同一可见区域出现两条下划线。
31
+ const suppressActive = props.suppressActive === true
32
+ /** @private @author ddj 2026年10月20号 @param tab File record. @returns Draggable tab with isolated close control. */
24
33
  function renderTab(tab: TabLike): React.ReactElement {
34
+ const selected = !suppressActive && tab.path === group.active
25
35
  /** @private @author ddj 2026年10月14号 @param event Primary press. Start trusted file drag. */
26
36
  function press(event: React.PointerEvent): void { props.onDrag(tab.path, event) }
27
37
  /** @private @author ddj 2026年10月14号 Select unless release completed a drag. */
@@ -35,8 +45,8 @@ export function EditorPane(props: PaneProps): React.ReactElement {
35
45
  /** @private @author ddj 2026年10月14号 @param event Keyboard activation. */
36
46
  function key(event: React.KeyboardEvent): void { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); props.onSelect(group.id, tab.path) } }
37
47
  return React.createElement('div', {
38
- key: tab.path, 'data-edrv-tab-path': tab.path, role: 'tab', tabIndex: 0, 'aria-selected': tab.path === group.active,
39
- className: 'edrv-tab' + (tab.path === group.active ? ' edrv-tab-active' : '') + (tab.pinned ? ' edrv-tab-pinned' : ''),
48
+ key: tab.path, 'data-edrv-tab-path': tab.path, role: 'tab', tabIndex: 0, 'aria-selected': selected,
49
+ className: 'edrv-tab' + (selected ? ' edrv-tab-active' : '') + (tab.pinned ? ' edrv-tab-pinned' : ''),
40
50
  title: tab.path + (tab.pinned ? '(已固定)' : ''), onPointerDown: press, onClick: select, onContextMenu: menu, onKeyDown: key,
41
51
  }, tab.pinned ? React.createElement('span', { className: 'edrv-tab-pin' }, '📌') : null,
42
52
  React.createElement('span', { className: 'edrv-group-tab-name' }, tab.path.split(/[\\/]/).pop()),
@@ -48,7 +58,9 @@ export function EditorPane(props: PaneProps): React.ReactElement {
48
58
  if (!group.active && !props.focused && event.button === 0) props.onSelect(group.id)
49
59
  }
50
60
  return React.createElement('section', { className: 'edrv-group' + (props.focused ? ' edrv-group-focused' : ''), 'data-edrv-group': group.id, onPointerDown: focusEmpty },
51
- React.createElement('div', { className: 'edrv-tabs', role: 'tablist', 'data-edrv-group-tabs': group.id }, group.tabs.map(renderTab), props.extras),
61
+ React.createElement('div', { className: 'edrv-tabs-row' },
62
+ React.createElement('div', { className: 'edrv-tabs', role: 'tablist', 'data-edrv-group-tabs': group.id, ref: props.tabsRef }, group.tabs.map(renderTab), props.extras),
63
+ props.tools),
52
64
  React.createElement('div', { className: 'edrv-group-body', role: 'tabpanel' }, props.children))
53
65
  }
54
66