dsh-vscode-mode 0.16.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/locale/en.json CHANGED
@@ -1,4 +1,6 @@
1
1
  {
2
- "title": "VSCodeMode",
3
- "description": "VSCode-like coding on DSH: Monaco editor with tabs and QuickOpen, command palette, agent edit review, SVN integration, and LSP smartness."
2
+ "meta": {
3
+ "title": "VSCodeMode",
4
+ "description": "VSCode-like coding on DSH: Monaco editor with tabs and QuickOpen, command palette, agent edit review, SVN integration, and LSP smartness."
5
+ }
4
6
  }
package/locale/zh.json CHANGED
@@ -1,4 +1,6 @@
1
1
  {
2
- "title": "VSCodeMode",
3
- "description": "DSH 上的类 VSCode 编码体验:Monaco 编辑器(页签/QuickOpen/状态栏)、命令栏、Agent 编辑差异审查、SVN 集成与 LSP 智能。"
2
+ "meta": {
3
+ "title": "VSCodeMode",
4
+ "description": "DSH 上的类 VSCode 编码体验:Monaco 编辑器(页签/QuickOpen/状态栏)、命令栏、Agent 编辑差异审查、SVN 集成与 LSP 智能。"
5
+ }
4
6
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-vscode-mode",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
4
4
  "description": "DSH 上的类 VSCode 编码体验:Monaco 编辑器(文件页签/QuickOpen/状态栏,可驻 DSH 0.1.5+ 官方右侧 Sidebar 与对话同屏)+ 命令栏(Ctrl+Shift+P)与可扩展指令系统 + VS Code 兼容代码片段(.code-snippets 全局/项目,IntelliSense 展开)+ Agent 编辑差异审查(整文件差异/采纳/拒绝/归档/回滚)+ SVN 集成(侧栏变更面板/日志弹窗/并排差异)+ 语言服务器(LSP)智能(跳转定义/引用/hover/大纲 + VSIX 扩展市场安装),状态持久化到工作区旁车,界面状态(页签/光标/侧栏/编辑 Tab/导航历史)镜像到 DSH home 跨重启恢复",
5
5
  "icon": "assets/icon.svg",
6
6
  "keywords": [
@@ -29,7 +29,8 @@
29
29
  },
30
30
  "type": "module",
31
31
  "engines": {
32
- "node": "^22.19.0 || >=24.0.0"
32
+ "node": "^22.19.0 || >=24.0.0",
33
+ "dsh": ">=0.1.0-rc.1 <0.3.0-0"
33
34
  },
34
35
  "packageManager": "pnpm@11.3.0",
35
36
  "main": "./lib/index.js",
@@ -90,6 +91,7 @@
90
91
  "exports": {
91
92
  ".": "./lib/index.js",
92
93
  "./client": "./lib/client.js",
94
+ "./locale/*.json": "./locale/*.json",
93
95
  "./package.json": "./package.json"
94
96
  },
95
97
  "dsh": {
package/src/capture.ts CHANGED
@@ -16,7 +16,7 @@ import type { Ctx, Session } from './store.js'
16
16
  import { READ_CAP, archiveCanceled, loadBucket, saveBucket } from './store.js'
17
17
  import { fileMaxBatch, prune } from './model.js'
18
18
  import type { Registry } from './registry.js'
19
- import { cwdOf } from './registry.js'
19
+ import { cwdOfSession } from './registry.js'
20
20
  import { findRemoteWs, mirrorTargetOf } from './remoteWorkspace.js'
21
21
  import { invalidateIndex } from './treeIndex.js'
22
22
  import { log } from './log.js'
@@ -275,7 +275,7 @@ export async function captureToolResult(ctx: Ctx, registry: Registry, exec: any,
275
275
  // remote_ssh_write 只依赖参数与 isError(其 result.value 形状不受本插件约束)
276
276
  if (exec.name !== 'remote_ssh_write' && !result?.value) return
277
277
  try {
278
- const cwd = cwdOf(session)
278
+ const cwd = cwdOfSession(ctx, session)
279
279
  if (!cwd) return
280
280
  const args = ((exec.arguments || {}) as Record<string, unknown>)
281
281
  const record = exec.name === 'remote_ssh_write'
@@ -172,7 +172,8 @@ export interface SlotSpec {
172
172
  name: string
173
173
  id: string
174
174
  order?: number
175
- label?: string
175
+ /** 文案:字符串或 thunk;thunk 在每次读取时重算,语言切换后无需重注册。 */
176
+ label?: string | (() => string)
176
177
  inject?: (sessionId: string) => object
177
178
  }
178
179
 
@@ -20,6 +20,7 @@ import { DiffBadge } from './ui/DiffBadge.js'
20
20
  import { PerfSizeBadge } from './ui/PerfSizeBadge.js'
21
21
  import { ConversationDiffDock } from './ui/ConversationDiffDock.js'
22
22
  import { McpSettings } from './ui/McpSettings.js'
23
+ import { PluginDetailSection } from './ui/PluginDetailSection.js'
23
24
  import { rpc } from './rpc.js'
24
25
  import { loadMonaco } from './monaco/loader.js'
25
26
  import { observeScheme, schemeOfSnapshot } from './monaco/theme.js'
@@ -60,6 +61,7 @@ import { SHORTCUT_PROFILES, defaultKeybindings, normalizeKeybindings } from '../
60
61
  import { sidebarMinApply } from './sidebarMin.js'
61
62
  import { editorLimitApply } from './editorLimit.js'
62
63
  import { log } from './log.js'
64
+ import { installLocale } from './localeClient.js'
63
65
  import { setupLsp, setSession, disposeLsp } from './monaco/lsp/index.js'
64
66
  import { disposeSnippets } from './snippets/provider.js'
65
67
  import { disposeLaunchJson } from './dap/launchSnippetProvider.js'
@@ -123,6 +125,10 @@ function setupCommands(ctx: any): CommandBridge {
123
125
  export async function apply(ctx: any): Promise<void> {
124
126
  const schedule = (fn: () => void, ms: number) => ctx.timeout(fn, ms)
125
127
 
128
+ // UI 壳层文案:注册本插件词典到官方 locale 服务(旧版无该服务时直读中文词典)。
129
+ // slot label / 编辑 Tab 标题一律写成 thunk,让语言切换后无需重注册即可刷新。
130
+ const t = installLocale(ctx)
131
+
126
132
  /**
127
133
  * 无门控上报界面状态镜像事件(绕过客户端 edrv.debug 开关,直接落 host 诊断日志)。
128
134
  * 开关关闭时 dbg 直接 return,「重启后没恢复」这类问题会在客户端侧彻底无痕,故另开一条通路。
@@ -506,7 +512,7 @@ export async function apply(ctx: any): Promise<void> {
506
512
  name: 'conversation.view',
507
513
  id: 'edrv-editor',
508
514
  order: 5,
509
- label: '文件编辑',
515
+ label: () => t('slot.fileEditor'),
510
516
  inject: (sessionId: string) => ({ sessionId }),
511
517
  }, (props: unknown) => React.createElement(EditorView, Object.assign({}, props, { layout: 'tab', sideHint: SIDEBAR_INSTALL_CMD, schedule, addToConversation, sidebarPanels, outlineSources, fileMenuItems, fileOpeners: registry, sessions })))
512
518
  }
@@ -530,9 +536,10 @@ export async function apply(ctx: any): Promise<void> {
530
536
  service: official.service,
531
537
  slots: ctx.slots,
532
538
  renderTab: officialRenderTab,
539
+ title: () => t('slot.fileEditor'),
533
540
  guide: {
534
- title: () => OFFICIAL_TAB_TITLE,
535
- description: () => 'Monaco 文件编辑器:对话与编辑同屏(差异审查/跳转/大纲)',
541
+ title: () => t('guide.title'),
542
+ description: () => t('guide.description'),
536
543
  },
537
544
  })
538
545
  if (outcome === null) {
@@ -632,16 +639,16 @@ export async function apply(ctx: any): Promise<void> {
632
639
  name: 'conversation.input.dock',
633
640
  id: 'edrv-diff-dock',
634
641
  order: 30,
635
- label: '差异',
642
+ label: () => t('slot.diff'),
636
643
  inject: (sessionId: string) => ({ sessionId }),
637
644
  }, (props: unknown) => React.createElement(ConversationDiffDock, Object.assign({}, props)))
638
645
 
639
646
  // header 差异角标:仅当前工作区存在差异时渲染
640
- registerSlotSafely(ctx, { name: 'conversation.session.header.utilities', id: 'edrv-diff-badge', order: 90, label: '差异' },
647
+ registerSlotSafely(ctx, { name: 'conversation.session.header.utilities', id: 'edrv-diff-badge', order: 90, label: () => t('slot.diff') },
641
648
  (props: unknown) => React.createElement(DiffBadge, Object.assign({}, props)))
642
649
 
643
650
  // header 会话体积指示器:当前会话持久化体积,超阈值提示新开会话(性能优化)
644
- registerSlotSafely(ctx, { name: 'conversation.session.header.utilities', id: 'edrv-perf-size', order: 89, label: '会话体积' },
651
+ registerSlotSafely(ctx, { name: 'conversation.session.header.utilities', id: 'edrv-perf-size', order: 89, label: () => t('slot.sessionSize') },
645
652
  (props: unknown) => React.createElement(PerfSizeBadge, Object.assign({}, props, { sessions })))
646
653
 
647
654
  // 设置页中的 VSCodeMode 专属页签,MCP 管理作为该页签的内部子 Tab。
@@ -649,7 +656,7 @@ export async function apply(ctx: any): Promise<void> {
649
656
  name: 'settings.section',
650
657
  id: 'vscode-mode',
651
658
  order: 30,
652
- label: 'VSCodeMode',
659
+ label: () => t('slot.settings'),
653
660
  }, () => React.createElement(SettingsContext.Provider, { value: bridge.scope() }, React.createElement(McpSettings, {
654
661
  openerRegistry: registry,
655
662
  compatSummary,
@@ -658,6 +665,14 @@ export async function apply(ctx: any): Promise<void> {
658
665
  getShortcuts: () => detectShortcuts(ctx),
659
666
  })))
660
667
 
668
+ // 官方插件管理器详情页的只读状态段(DSH 0.2.1 新增扩展位):非本插件页返回 null。
669
+ // 该 slot 在旧版 DSH 不存在,slots.inject 会一直等待(不报错),故无需版本判断。
670
+ registerSlotSafely(ctx, {
671
+ name: 'plugins.detail.section',
672
+ id: 'edrv-plugin-status',
673
+ order: 40,
674
+ }, (props: unknown) => React.createElement(PluginDetailSection, Object.assign({}, props)))
675
+
661
676
  // 卸载收尾(G4,DSH 0.1.6-alpha.2 起支持运行时卸载/重载):
662
677
  // 注销挂在存活的 window.monaco 上的全部 Monaco provider 并复位模块状态。
663
678
  // 不做会导致重载后重复注册(补全/跳转/hover 各翻倍)与陈旧开关残留。
@@ -0,0 +1,97 @@
1
+ /**
2
+ * dsh-vscode-mode client — UI 壳层文案的 locale 接入。
3
+ * 此前 `locale` 写在 inject 里却零调用,界面文案全为硬编码中文;
4
+ * 这里只覆盖壳层(slot label、编辑 Tab 标题、侧栏引导),编辑器内部大段文案不动。
5
+ * 所有对外函数都对 locale 服务做可选探测:旧版 DSH 无该服务时回落中文词典。
6
+ * 作者 ddj 2026年10月19号
7
+ */
8
+
9
+ /** locale 命名空间(与包名一致,避免与其他插件串词条)。 */
10
+ export const LOCALE_NS = 'dsh-vscode-mode'
11
+
12
+ /** 支持的语言;zh 为缺省回退语言。 */
13
+ export const LOCALE_FALLBACK = 'zh'
14
+
15
+ /** 壳层文案词典(键名即 slot label / Tab 标题的稳定标识)。 */
16
+ export const LOCALE_DICTS: Record<string, Record<string, string>> = {
17
+ zh: {
18
+ 'slot.fileEditor': '文件编辑',
19
+ 'slot.diff': '差异',
20
+ 'slot.sessionSize': '会话体积',
21
+ 'slot.settings': 'VSCodeMode',
22
+ 'guide.title': '文件编辑',
23
+ 'guide.description': 'Monaco 文件编辑器:对话与编辑同屏(差异审查/跳转/大纲)',
24
+ },
25
+ en: {
26
+ 'slot.fileEditor': 'File editor',
27
+ 'slot.diff': 'Diff',
28
+ 'slot.sessionSize': 'Session size',
29
+ 'slot.settings': 'VSCodeMode',
30
+ 'guide.title': 'File editor',
31
+ 'guide.description': 'Monaco file editor: chat and editing side by side (diff review, jump, outline)',
32
+ },
33
+ }
34
+
35
+ /** locale 服务的最小结构面(结构性探测,不 import 官方类型)。 */
36
+ export interface LocaleLike {
37
+ register?: (ns: string, dicts: Record<string, Record<string, string>>) => unknown
38
+ bind?: (ns: string) => (key: string) => string
39
+ }
40
+
41
+ /** 翻译函数:键 → 当前语言文案(找不到时回落中文,再回落键本身)。 */
42
+ export type Translate = (key: string) => string
43
+
44
+ /**
45
+ * 取词条:先当前语言、再中文、最后键本身。
46
+ * @author ddj 2026年10月19号
47
+ * @param lang 语言 id
48
+ * @param key 词条键
49
+ * @returns 文案
50
+ */
51
+ function lookup(lang: string, key: string): string {
52
+ return LOCALE_DICTS[lang]?.[key] ?? LOCALE_DICTS[LOCALE_FALLBACK]?.[key] ?? key
53
+ }
54
+
55
+ /**
56
+ * 生成不依赖 locale 服务的直读翻译函数(服务缺失/注册失败时使用)。
57
+ * @author ddj 2026年10月19号
58
+ * @param lang 语言 id
59
+ * @returns 翻译函数
60
+ */
61
+ export function directTranslate(lang: string = LOCALE_FALLBACK): Translate {
62
+ return (key: string) => lookup(lang, key)
63
+ }
64
+
65
+ /**
66
+ * 注册本插件词典并返回翻译函数。
67
+ * 服务缺失、重复注册或 bind 抛错都不影响插件其余部分:一律回落直读词典。
68
+ * @author ddj 2026年10月19号
69
+ * @param ctx DSH 客户端上下文
70
+ * @returns 翻译函数(读取时才解析当前语言,可直接放进 slot label thunk)
71
+ */
72
+ export function installLocale(ctx: { get: (name: string) => unknown }): Translate {
73
+ let service: LocaleLike | undefined
74
+ try {
75
+ service = ctx.get('locale') as LocaleLike | undefined
76
+ } catch {
77
+ // 服务缺失或探测抛错:后续一律按未命中处理。
78
+ }
79
+
80
+ // 注册失败(例如同名 ns 已被占)不应连带废掉 bind,故两段各自 try。
81
+ try {
82
+ if (service && typeof service.register === 'function') service.register(LOCALE_NS, LOCALE_DICTS)
83
+ } catch {
84
+ // 词典未注册成功:仍尝试 bind,最后还有直读词典兜底。
85
+ }
86
+
87
+ try {
88
+ if (service && typeof service.bind === 'function') {
89
+ const bound = service.bind(LOCALE_NS)
90
+ if (typeof bound === 'function') return (key: string) => bound(key)
91
+ }
92
+ } catch {
93
+ // bind 异常按未命中处理。
94
+ }
95
+
96
+ return directTranslate()
97
+ }
@@ -286,8 +286,11 @@ export function installOfficial(options: {
286
286
  slots: SlotsLike
287
287
  renderTab: unknown
288
288
  guide: { title: () => string; description: () => string }
289
+ /** Tab 标题 thunk(缺省用内置中文常量;传入后由调用方按当前语言解析)。 */
290
+ title?: () => string
289
291
  }): (() => void) | null {
290
292
  const { tabs, service, slots, renderTab, guide } = options
293
+ const titleOf = typeof options.title === 'function' ? options.title : () => OFFICIAL_TAB_TITLE
291
294
  const open = (path: string | null, focusDiff: boolean): boolean => {
292
295
  try {
293
296
  // 官方语义:打开即展开侧栏;seat 未挂载(无会话画面)时抛错,降级其他打开器
@@ -305,7 +308,7 @@ export function installOfficial(options: {
305
308
  id: OFFICIAL_TAB_ID,
306
309
  kind: OFFICIAL_TAB_KIND,
307
310
  priority: 'extension',
308
- title: () => OFFICIAL_TAB_TITLE,
311
+ title: () => titleOf(),
309
312
  guide: [{ order: 20, title: guide.title, description: guide.description }],
310
313
  })
311
314
  } catch (error) {
@@ -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
+ }
@@ -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