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.
- package/README.md +15 -9
- package/lib/client.js +954 -133
- package/lib/client.js.map +1 -1
- package/lib/index.js +5026 -4387
- package/lib/index.js.map +1 -1
- package/locale/en.json +4 -2
- package/locale/zh.json +4 -2
- package/package.json +4 -2
- package/skills/dsh-vscodemode-mcp/SKILL.md +24 -7
- package/src/capture.ts +2 -2
- package/src/client/compat.ts +2 -1
- package/src/client/index.ts +37 -7
- package/src/client/localeClient.ts +97 -0
- package/src/client/mcpSessionState.ts +108 -0
- package/src/client/monaco/lsp/providers.ts +32 -3
- package/src/client/officialSidebar.ts +4 -1
- package/src/client/state/modelWarmer.ts +198 -0
- package/src/client/state/recordsFeed.ts +241 -0
- package/src/client/styles/editor.css +5 -1
- package/src/client/ui/ConversationDiffDock.ts +10 -19
- package/src/client/ui/DiffBadge.ts +12 -19
- package/src/client/ui/EditorPane.ts +16 -4
- package/src/client/ui/EditorView.ts +114 -51
- package/src/client/ui/McpSessionStatus.ts +75 -0
- package/src/client/ui/McpSettings.ts +47 -13
- package/src/client/ui/PerfSizeBadge.ts +7 -4
- package/src/client/ui/PluginDetailSection.ts +82 -0
- package/src/dshVersion.ts +4 -2
- package/src/fileVersions.ts +2 -2
- package/src/lsp/rpc.ts +2 -2
- package/src/mcp.ts +1 -0
- package/src/mcpBridge.ts +163 -0
- package/src/mcpDiscover.ts +38 -0
- package/src/mcpPool.ts +245 -0
- package/src/mcpProject.ts +15 -12
- package/src/mcpRuntime.ts +301 -255
- package/src/registry.ts +15 -2
- package/src/rpc.ts +15 -3
- package/src/rules.ts +9 -4
- package/src/search/fallback.ts +2 -2
- package/src/search/ripgrep.ts +2 -2
- package/src/shared/mcp.ts +47 -0
- package/src/shared/rpc.ts +5 -1
- package/src/store.ts +26 -1
- 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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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':
|
|
39
|
-
className: 'edrv-tab' + (
|
|
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
|
|
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
|
|