iterate-plugin 3.12.0 → 3.14.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 +8 -8
- package/README.zh-CN.md +5 -4
- package/dist/tools/config.js +109 -9
- package/dist/tools/preflight.js +45 -4
- package/dist/validation-detect.js +193 -0
- package/lib/client.js +267 -7
- package/lib/parse.js +52 -0
- package/package.json +22 -20
- package/src/client/context-menu.ts +289 -0
- package/src/client/i18n.ts +3 -0
- package/src/client/index.ts +145 -8
- package/src/tools/config.ts +113 -10
- package/src/tools/preflight.ts +52 -4
- package/src/validation-detect.ts +207 -0
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/client/context-menu.ts — iterate-plugin right-click menu contributions.
|
|
3
|
+
*
|
|
4
|
+
* dsh 0.2.2-alpha.3 added the *unified context-menu* capability
|
|
5
|
+
* (`@deepseek-ai/dsh-client-ui-context-menu`): any client plugin may register
|
|
6
|
+
* actions that appear on right-click / Shift+F10 / ContextMenu key over a
|
|
7
|
+
* Session, file, directory, URL, image, tab, or workspace, without importing
|
|
8
|
+
* a React component. The captured target stays fixed even when selection
|
|
9
|
+
* changes, and contributions are withdrawn automatically when the plugin
|
|
10
|
+
* unloads.
|
|
11
|
+
*
|
|
12
|
+
* This module turns that new upstream capability into a *user-perceivable*
|
|
13
|
+
* iterate surface: instead of only being reachable through the docked
|
|
14
|
+
* observatory and the empty-dashboard CTA, the iterate entry points now also
|
|
15
|
+
* hang off the native right-click menus — on a Session (open the quality
|
|
16
|
+
* command center, copy a preflight / dry-run / resume / disk-snapshot
|
|
17
|
+
* instruction), on a file (dry-run review scoped to that file), and on a
|
|
18
|
+
* directory (scoped review + preflight).
|
|
19
|
+
*
|
|
20
|
+
* Design (mirrors the rest of the client half):
|
|
21
|
+
* - **Defensive**: the `contextMenus` service is optional and probed at
|
|
22
|
+
* apply-time; a host that predates 0.2.2-alpha.3 simply has no menu
|
|
23
|
+
* entries (the docked panel remains the entry point). Nothing throws.
|
|
24
|
+
* - **Pure builders**: `buildSessionMenuEntries` / `buildFileMenuEntries` /
|
|
25
|
+
* `buildDirectoryMenuEntries` return *localized descriptors* with no DOM
|
|
26
|
+
* or clipboard side effects, so they are unit-testable on their own
|
|
27
|
+
* (normal + empty + boundary). The imperative wiring (`copy` / `openPanel`
|
|
28
|
+
* / `notify`) is injected by `registerIterateContextMenus`.
|
|
29
|
+
* - **Bilingual**: every label carries zh + en and resolves against the
|
|
30
|
+
* language passed in (the caller reads `getLang()` at menu-build time, so
|
|
31
|
+
* a language switch is picked up on the next open).
|
|
32
|
+
*
|
|
33
|
+
* Event bridge: the observatory is a React panel with its own open/tab state.
|
|
34
|
+
* The browser client cannot call harness tools, so the "open command center"
|
|
35
|
+
* menu action dispatches a window CustomEvent (`OBS_OPEN_EVENT`) that the
|
|
36
|
+
* ObservatoryPanel listens for — the panel expands and switches tab without
|
|
37
|
+
* any cross-plugin import. The event carries an optional `sessionId` so a
|
|
38
|
+
* right-click in the session sidebar targets the right session's panel and is
|
|
39
|
+
* ignored by every other mounted panel.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
import { getLang, type Lang } from './i18n.ts'
|
|
43
|
+
|
|
44
|
+
// ─── Structural service types (defensive; mirrors SlotsService) ──────────────
|
|
45
|
+
//
|
|
46
|
+
// The dsh client runtime is not type-imported (no compile-time coupling to a
|
|
47
|
+
// package a pre-0.2.2-alpha.3 host may not have). The surface below is the
|
|
48
|
+
// subset this plugin consumes; `register` is only ever called when a service
|
|
49
|
+
// with the right shape is present.
|
|
50
|
+
|
|
51
|
+
/** A right-click target this plugin contributes actions to. */
|
|
52
|
+
export type IterateMenuTarget =
|
|
53
|
+
| { kind: 'session'; value: { sessionId: string; displayTitle?: string } }
|
|
54
|
+
| { kind: 'file'; value: { sessionId: string; path: string; line?: number } }
|
|
55
|
+
| { kind: 'directory'; value: { sessionId: string; path: string } }
|
|
56
|
+
|
|
57
|
+
/** One menu action (presentation + optional execution). */
|
|
58
|
+
export interface IterateMenuAction {
|
|
59
|
+
id: string
|
|
60
|
+
label: string
|
|
61
|
+
disabled?: boolean
|
|
62
|
+
danger?: boolean
|
|
63
|
+
separatorBefore?: boolean
|
|
64
|
+
/** Localized operation + recovery guidance when `run` rejects. */
|
|
65
|
+
failureLabel?: string
|
|
66
|
+
run?: () => void | Promise<void>
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** A namespaced contribution, ordered within its target family. */
|
|
70
|
+
export interface IterateMenuContribution<K extends string> {
|
|
71
|
+
id: string
|
|
72
|
+
target: K
|
|
73
|
+
order: number
|
|
74
|
+
resolve(target: unknown, signal: AbortSignal): readonly IterateMenuAction[]
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Structural view of the dsh `contextMenus` service this plugin consumes. */
|
|
78
|
+
export interface ContextMenusService {
|
|
79
|
+
register<K extends string>(contribution: IterateMenuContribution<K>): () => void
|
|
80
|
+
notify(
|
|
81
|
+
text: string,
|
|
82
|
+
success?: boolean,
|
|
83
|
+
action?: { label: string; run: () => void | Promise<void>; failureLabel: string },
|
|
84
|
+
): void
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ─── Observatory open event bridge ───────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
/** Window event that asks the (matching) ObservatoryPanel to expand. */
|
|
90
|
+
export const OBS_OPEN_EVENT = 'iterate-plugin:open-observatory'
|
|
91
|
+
|
|
92
|
+
/** Payload carried by {@link OBS_OPEN_EVENT}. */
|
|
93
|
+
export interface ObsOpenDetail {
|
|
94
|
+
/** Optional target session; omitted = open whatever panel is mounted. */
|
|
95
|
+
sessionId?: string
|
|
96
|
+
/** Tab key to switch to (`live` by default). */
|
|
97
|
+
tab?: string
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Dispatch the "open observatory" event. No-op outside a browser (SSR / test
|
|
102
|
+
* environments without `window`). Returns whether the event was dispatched.
|
|
103
|
+
*/
|
|
104
|
+
export function openObservatory(detail?: ObsOpenDetail): boolean {
|
|
105
|
+
if (typeof window === 'undefined' || typeof window.dispatchEvent !== 'function') return false
|
|
106
|
+
try {
|
|
107
|
+
window.dispatchEvent(new CustomEvent<ObsOpenDetail>(OBS_OPEN_EVENT, { detail: detail ?? {} }))
|
|
108
|
+
return true
|
|
109
|
+
} catch {
|
|
110
|
+
return false
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ─── Localized descriptors (pure, testable) ──────────────────────────────────
|
|
115
|
+
|
|
116
|
+
/** The action ids this plugin registers, for tests + duplicate guards. */
|
|
117
|
+
export const SESSION_MENU_IDS = ['iterate.open-panel', 'iterate.preflight', 'iterate.dry-run', 'iterate.resume', 'iterate.pull'] as const
|
|
118
|
+
export const FILE_MENU_IDS = ['iterate.review-file', 'iterate.open-panel'] as const
|
|
119
|
+
export const DIRECTORY_MENU_IDS = ['iterate.review-dir', 'iterate.preflight', 'iterate.open-panel'] as const
|
|
120
|
+
|
|
121
|
+
interface MenuEntry {
|
|
122
|
+
id: string
|
|
123
|
+
label: string
|
|
124
|
+
instruction?: string
|
|
125
|
+
/** Tab key to open when this entry is the "open panel" action. */
|
|
126
|
+
openTab?: string
|
|
127
|
+
danger?: boolean
|
|
128
|
+
disabled?: boolean
|
|
129
|
+
separatorBefore?: boolean
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Bilingual labels; resolved by `pick` at build time. */
|
|
133
|
+
const LABELS: Record<string, { zh: string; en: string }> = {
|
|
134
|
+
'iterate.open-panel': { zh: '打开 iterate 质量指挥中心', en: 'Open iterate command center' },
|
|
135
|
+
'iterate.preflight': { zh: '复制开跑预检指令', en: 'Copy preflight instruction' },
|
|
136
|
+
'iterate.dry-run': { zh: '复制 dry-run 审查指令', en: 'Copy dry-run review instruction' },
|
|
137
|
+
'iterate.resume': { zh: '复制断点恢复指令', en: 'Copy checkpoint-resume instruction' },
|
|
138
|
+
'iterate.pull': { zh: '复制拉取磁盘快照指令', en: 'Copy disk-snapshot pull instruction' },
|
|
139
|
+
'iterate.review-file': { zh: '复制「仅审查此文件」指令', en: 'Copy "review this file only" instruction' },
|
|
140
|
+
'iterate.review-dir': { zh: '复制「仅审查此目录」指令', en: 'Copy "review this directory only" instruction' },
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Pick a localized label; unknown ids degrade to the raw id (never throw). */
|
|
144
|
+
function pick(id: string, lang: Lang): string {
|
|
145
|
+
const entry = LABELS[id]
|
|
146
|
+
if (!entry) return id
|
|
147
|
+
return lang === 'zh' ? entry.zh : entry.en
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Copy-failure guidance (a menu action's `failureLabel`). */
|
|
151
|
+
const COPY_FAILURE: Record<'zh' | 'en', string> = {
|
|
152
|
+
zh: '复制失败:剪贴板被拒绝。请改为在观测台面板里用同一指令的「复制」按钮,或手动选中指令文本复制。',
|
|
153
|
+
en: 'Copy failed: clipboard denied. Use the matching "Copy" button in the observatory panel, or select the instruction text manually.',
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Build the Session target menu entries (pure). */
|
|
157
|
+
export function buildSessionMenuEntries(lang: Lang, opts?: { hasCheckpoint?: boolean }): MenuEntry[] {
|
|
158
|
+
return [
|
|
159
|
+
{ id: 'iterate.open-panel', label: pick('iterate.open-panel', lang), openTab: 'live' },
|
|
160
|
+
{ id: 'iterate.preflight', label: pick('iterate.preflight', lang), separatorBefore: true },
|
|
161
|
+
{ id: 'iterate.dry-run', label: pick('iterate.dry-run', lang) },
|
|
162
|
+
{ id: 'iterate.resume', label: pick('iterate.resume', lang), disabled: opts?.hasCheckpoint === false },
|
|
163
|
+
{ id: 'iterate.pull', label: pick('iterate.pull', lang), separatorBefore: true },
|
|
164
|
+
]
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Build the File target menu entries (pure). */
|
|
168
|
+
export function buildFileMenuEntries(lang: Lang): MenuEntry[] {
|
|
169
|
+
return [
|
|
170
|
+
{ id: 'iterate.review-file', label: pick('iterate.review-file', lang) },
|
|
171
|
+
{ id: 'iterate.open-panel', label: pick('iterate.open-panel', lang) },
|
|
172
|
+
]
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Build the Directory target menu entries (pure). */
|
|
176
|
+
export function buildDirectoryMenuEntries(lang: Lang): MenuEntry[] {
|
|
177
|
+
return [
|
|
178
|
+
{ id: 'iterate.review-dir', label: pick('iterate.review-dir', lang) },
|
|
179
|
+
{ id: 'iterate.preflight', label: pick('iterate.preflight', lang), separatorBefore: true },
|
|
180
|
+
{ id: 'iterate.open-panel', label: pick('iterate.open-panel', lang) },
|
|
181
|
+
]
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ─── Imperative wiring (register + run callbacks) ────────────────────────────
|
|
185
|
+
|
|
186
|
+
/** Injection points the menu wiring needs (kept dependency-light for tests). */
|
|
187
|
+
export interface IterateMenuDeps {
|
|
188
|
+
/** Copy an instruction; resolves to whether the clipboard write succeeded. */
|
|
189
|
+
copy: (text: string) => Promise<boolean>
|
|
190
|
+
/** Open the observatory panel (optionally for a session, on a tab). */
|
|
191
|
+
openPanel: (detail?: ObsOpenDetail) => void
|
|
192
|
+
/** Show the shared toast. */
|
|
193
|
+
notify: (text: string, success?: boolean, action?: { label: string; run: () => void | Promise<void>; failureLabel: string }) => void
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Map a pure entry to a runnable menu action, wiring the injected deps. */
|
|
197
|
+
function toAction(entry: MenuEntry, deps: IterateMenuDeps, lang: Lang): IterateMenuAction {
|
|
198
|
+
const action: IterateMenuAction = {
|
|
199
|
+
id: entry.id,
|
|
200
|
+
label: entry.label,
|
|
201
|
+
danger: entry.danger,
|
|
202
|
+
separatorBefore: entry.separatorBefore,
|
|
203
|
+
}
|
|
204
|
+
if (entry.disabled) action.disabled = true
|
|
205
|
+
if (entry.openTab) {
|
|
206
|
+
action.run = () => {
|
|
207
|
+
deps.openPanel({ tab: entry.openTab })
|
|
208
|
+
deps.notify(lang === 'zh' ? '已打开 iterate 观测台。' : 'Iterate observatory opened.', true)
|
|
209
|
+
}
|
|
210
|
+
return action
|
|
211
|
+
}
|
|
212
|
+
if (entry.instruction) {
|
|
213
|
+
const text = entry.instruction
|
|
214
|
+
const failureLabel = COPY_FAILURE[lang]
|
|
215
|
+
action.failureLabel = failureLabel
|
|
216
|
+
action.run = async () => {
|
|
217
|
+
const ok = await deps.copy(text)
|
|
218
|
+
if (!ok) throw new Error(failureLabel)
|
|
219
|
+
deps.notify(lang === 'zh' ? '指令已复制,粘贴给模型即可。' : 'Instruction copied — paste it to the model.', true)
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return action
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Register this plugin's context-menu contributions on the (already-validated)
|
|
227
|
+
* `contextMenus` service. Returns an unregister function; a host without the
|
|
228
|
+
* service is a no-op that returns `() => {}` (callers guard).
|
|
229
|
+
*/
|
|
230
|
+
export function registerIterateContextMenus(
|
|
231
|
+
service: ContextMenusService,
|
|
232
|
+
deps: IterateMenuDeps,
|
|
233
|
+
instructionByEntry: (id: string) => string,
|
|
234
|
+
): () => void {
|
|
235
|
+
const unregister: Array<() => void> = []
|
|
236
|
+
|
|
237
|
+
const register = <K extends string>(target: K, order: number, build: (lang: Lang, target: unknown) => MenuEntry[]) => {
|
|
238
|
+
try {
|
|
239
|
+
const dispose = service.register({
|
|
240
|
+
id: `iterate-plugin.${target}`,
|
|
241
|
+
target,
|
|
242
|
+
order,
|
|
243
|
+
resolve: (t, _signal) => {
|
|
244
|
+
const lang = getLang()
|
|
245
|
+
return build(lang, t).map((entry) => {
|
|
246
|
+
const full: MenuEntry = { ...entry, instruction: entry.instruction ?? instructionByEntry(entry.id) }
|
|
247
|
+
return toAction(full, deps, lang)
|
|
248
|
+
})
|
|
249
|
+
},
|
|
250
|
+
})
|
|
251
|
+
unregister.push(dispose)
|
|
252
|
+
} catch (err) {
|
|
253
|
+
// A duplicate contribution id or an unsupported target family must not
|
|
254
|
+
// take the plugin down — log and continue with the remaining targets.
|
|
255
|
+
if (typeof console !== 'undefined' && typeof console.error === 'function') {
|
|
256
|
+
console.error('[iterate-ui] context-menu registration failed for target', target, err)
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
register('session', 300, (_lang, t) => {
|
|
262
|
+
const target = t as { value?: { sessionId?: string } } | undefined
|
|
263
|
+
return buildSessionMenuEntries(getLang(), { hasCheckpoint: target?.value?.sessionId ? true : undefined })
|
|
264
|
+
})
|
|
265
|
+
register('file', 300, (_lang, t) => {
|
|
266
|
+
const target = t as { value?: { path?: string; line?: number } } | undefined
|
|
267
|
+
return buildFileMenuEntries(getLang()).map((e) => ({
|
|
268
|
+
...e,
|
|
269
|
+
instruction: e.id === 'iterate.review-file'
|
|
270
|
+
? instructionByEntry(`file:${target?.value?.path ?? ''}${typeof target?.value?.line === 'number' ? `:${target.value.line}` : ''}`)
|
|
271
|
+
: e.instruction,
|
|
272
|
+
}))
|
|
273
|
+
})
|
|
274
|
+
register('directory', 300, (_lang, t) => {
|
|
275
|
+
const target = t as { value?: { path?: string } } | undefined
|
|
276
|
+
return buildDirectoryMenuEntries(getLang()).map((e) => ({
|
|
277
|
+
...e,
|
|
278
|
+
instruction: e.id === 'iterate.review-dir'
|
|
279
|
+
? instructionByEntry(`dir:${target?.value?.path ?? ''}`)
|
|
280
|
+
: e.instruction,
|
|
281
|
+
}))
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
return () => {
|
|
285
|
+
for (const dispose of unregister.splice(0)) {
|
|
286
|
+
try { dispose() } catch { /* noop */ }
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
package/src/client/i18n.ts
CHANGED
|
@@ -460,6 +460,8 @@ export const EN: Record<string, string> = {
|
|
|
460
460
|
复制预检指令: 'Copy preflight instruction',
|
|
461
461
|
验证命令: 'Validation commands',
|
|
462
462
|
'已配置 {a0} 条验证命令': '{a0} validation command(s) configured',
|
|
463
|
+
'未配置 validation.commands(阻断项)—— 检测到 {a0} 项目,建议:{a1}':
|
|
464
|
+
'validation.commands is not configured (blocking) — detected {a0} project; suggested: {a1}',
|
|
463
465
|
'未配置 validation.commands —— 本轮无法证明修复,属阻断项':
|
|
464
466
|
'validation.commands is not configured — the run cannot prove fixes (blocking)',
|
|
465
467
|
'Git 仓库': 'Git repository',
|
|
@@ -481,6 +483,7 @@ export const EN: Record<string, string> = {
|
|
|
481
483
|
'An interrupted checkpoint is waiting (resumed {a0}x) — resume or clear it first',
|
|
482
484
|
无中断断点: 'No interrupted checkpoint',
|
|
483
485
|
配置验证命令: 'Configure validation',
|
|
486
|
+
应用建议: 'Apply suggestion',
|
|
484
487
|
恢复断点: 'Resume checkpoint',
|
|
485
488
|
'阻断 {a0} · 警告 {a1}': 'Blocking {a0} · warnings {a1}',
|
|
486
489
|
'预检失败:{a0}': 'Preflight failed: {a0}',
|
package/src/client/index.ts
CHANGED
|
@@ -77,6 +77,8 @@ import {
|
|
|
77
77
|
scanSessionForDefenseEvents,
|
|
78
78
|
scanSessionForPreflight,
|
|
79
79
|
buildPreflightInstruction,
|
|
80
|
+
buildValidationApplyInstruction,
|
|
81
|
+
buildScopedReviewInstruction,
|
|
80
82
|
filterLiveEntries,
|
|
81
83
|
filterTimelineEntries,
|
|
82
84
|
serializeObservatoryExport,
|
|
@@ -131,6 +133,14 @@ import {
|
|
|
131
133
|
type Lang,
|
|
132
134
|
} from './i18n.ts'
|
|
133
135
|
import { noteSession, readSession, subscribeSession } from './session-cache.ts'
|
|
136
|
+
// v3.14: right-click menu contributions (dsh 0.2.2-alpha.3 unified context menus).
|
|
137
|
+
import {
|
|
138
|
+
openObservatory,
|
|
139
|
+
OBS_OPEN_EVENT,
|
|
140
|
+
registerIterateContextMenus,
|
|
141
|
+
type ContextMenusService,
|
|
142
|
+
type ObsOpenDetail,
|
|
143
|
+
} from './context-menu.ts'
|
|
134
144
|
|
|
135
145
|
// ─── Module contract ─────────────────────────────────────────────────────────
|
|
136
146
|
|
|
@@ -906,6 +916,47 @@ function readTheme(ctx: ClientContext): ThemeService | undefined {
|
|
|
906
916
|
return undefined
|
|
907
917
|
}
|
|
908
918
|
|
|
919
|
+
/**
|
|
920
|
+
* Resolve the dsh 0.2.2-alpha.3 `contextMenus` service. Unlike slots/theme it
|
|
921
|
+
* is NOT in the typed `ClientContext`, so it is probed through `ctx.get` (the
|
|
922
|
+
* cordis service locator) and structurally validated. A host that predates the
|
|
923
|
+
* capability returns `undefined` and the plugin simply has no menu entries.
|
|
924
|
+
*/
|
|
925
|
+
function readContextMenus(ctx: ClientContext): ContextMenusService | undefined {
|
|
926
|
+
if (!ctx || typeof ctx.get !== 'function') return undefined
|
|
927
|
+
const raw = ctx.get('contextMenus', false)
|
|
928
|
+
if (!raw || typeof raw !== 'object') return undefined
|
|
929
|
+
const svc = raw as { register?: unknown; notify?: unknown }
|
|
930
|
+
if (typeof svc.register !== 'function') return undefined
|
|
931
|
+
if (typeof svc.notify !== 'function') return undefined
|
|
932
|
+
return svc as unknown as ContextMenusService
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
/**
|
|
936
|
+
* Extract a session identifier from an opaque host-shaped session object.
|
|
937
|
+
* The slot `props.session` shape is host-defined; this probes the common
|
|
938
|
+
* keys so the observatory open-event can target the right session and be
|
|
939
|
+
* ignored by every other mounted panel. Returns `null` when unknown (the
|
|
940
|
+
* panel then responds to any open event — the single-panel fallback).
|
|
941
|
+
*/
|
|
942
|
+
function sessionIdOfSession(session: unknown): string | null {
|
|
943
|
+
if (!session || typeof session !== 'object') return null
|
|
944
|
+
const s = session as Record<string, unknown>
|
|
945
|
+
const candidates = [
|
|
946
|
+
s.sessionId, s.id, s.header, s.session,
|
|
947
|
+
] as unknown[]
|
|
948
|
+
for (const c of candidates) {
|
|
949
|
+
if (typeof c === 'string' && c) return c
|
|
950
|
+
if (c && typeof c === 'object') {
|
|
951
|
+
const o = c as Record<string, unknown>
|
|
952
|
+
for (const k of ['sessionId', 'id']) {
|
|
953
|
+
if (typeof o[k] === 'string' && o[k]) return o[k] as string
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
return null
|
|
958
|
+
}
|
|
959
|
+
|
|
909
960
|
// ─── Module-level runtime state (shared across slots) ────────────────────────
|
|
910
961
|
|
|
911
962
|
let slotsSvc: SlotsService | undefined = undefined
|
|
@@ -914,6 +965,8 @@ let storage: ReturnType<typeof createStorage> | null = null
|
|
|
914
965
|
let themeDisposer: (() => void) | null = null
|
|
915
966
|
let themeListener: (() => void) | null = null
|
|
916
967
|
let themeEnabled = true
|
|
968
|
+
/** v3.14: right-click menu contribution disposer (re-apply() never stacks). */
|
|
969
|
+
let menuDisposer: (() => void) | null = null
|
|
917
970
|
const roundPulseListeners: Array<(payload: { round: number; converged: boolean }) => void> = []
|
|
918
971
|
|
|
919
972
|
/**
|
|
@@ -2391,6 +2444,28 @@ function ObservatoryPanel(props: SlotProps) {
|
|
|
2391
2444
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
2392
2445
|
}, [open])
|
|
2393
2446
|
|
|
2447
|
+
// v3.14: the right-click "open command center" menu action dispatches a
|
|
2448
|
+
// window event; this panel expands and switches tab when the event targets
|
|
2449
|
+
// it (no sessionId = single-panel fallback, a different sessionId = ignore).
|
|
2450
|
+
const panelSessionId = sessionIdOfSession(session)
|
|
2451
|
+
React.useEffect(() => {
|
|
2452
|
+
if (typeof window === 'undefined' || typeof window.addEventListener !== 'function') return
|
|
2453
|
+
const onOpen = (ev: Event) => {
|
|
2454
|
+
const detail = (ev as CustomEvent<ObsOpenDetail>).detail
|
|
2455
|
+
const detailSession = detail && typeof detail.sessionId === 'string' && detail.sessionId
|
|
2456
|
+
? detail.sessionId
|
|
2457
|
+
: null
|
|
2458
|
+
if (detailSession && panelSessionId && detailSession !== panelSessionId) return
|
|
2459
|
+
setOpen(true)
|
|
2460
|
+
const target = detail && typeof detail.tab === 'string' ? detail.tab : 'live'
|
|
2461
|
+
const valid = OBS_TABS.some((x) => x.key === target)
|
|
2462
|
+
setTab(valid ? target : 'live')
|
|
2463
|
+
}
|
|
2464
|
+
window.addEventListener(OBS_OPEN_EVENT, onOpen)
|
|
2465
|
+
return () => window.removeEventListener(OBS_OPEN_EVENT, onOpen)
|
|
2466
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
2467
|
+
}, [panelSessionId])
|
|
2468
|
+
|
|
2394
2469
|
/** Copy an instruction, flashing "已复制" on the originating button only when the copy actually succeeded. */
|
|
2395
2470
|
const copyInstruction = (key: string, text: string) => {
|
|
2396
2471
|
if (!text) return
|
|
@@ -3464,9 +3539,17 @@ function ObservatoryPanel(props: SlotProps) {
|
|
|
3464
3539
|
label: t('验证命令'),
|
|
3465
3540
|
detail: (d) => {
|
|
3466
3541
|
const n = Number(d.commandCount ?? 0)
|
|
3467
|
-
|
|
3468
|
-
|
|
3469
|
-
|
|
3542
|
+
if (n > 0) return tf('已配置 {a0} 条验证命令', { a0: n })
|
|
3543
|
+
const suggested = Array.isArray(d.suggestedCommands)
|
|
3544
|
+
? d.suggestedCommands.map((c) => String(c)).filter((c) => c.length > 0)
|
|
3545
|
+
: []
|
|
3546
|
+
if (suggested.length > 0) {
|
|
3547
|
+
return tf('未配置 validation.commands(阻断项)—— 检测到 {a0} 项目,建议:{a1}', {
|
|
3548
|
+
a0: String(d.ecosystem || ''),
|
|
3549
|
+
a1: suggested.join(' · '),
|
|
3550
|
+
})
|
|
3551
|
+
}
|
|
3552
|
+
return t('未配置 validation.commands —— 本轮无法证明修复,属阻断项')
|
|
3470
3553
|
},
|
|
3471
3554
|
},
|
|
3472
3555
|
git: {
|
|
@@ -3505,10 +3588,21 @@ function ObservatoryPanel(props: SlotProps) {
|
|
|
3505
3588
|
}
|
|
3506
3589
|
|
|
3507
3590
|
// Repair action per check id; null = guidance only (no tool can fix it).
|
|
3508
|
-
const fixFor = (id: string): { key: string; label: string; instruction: string } | null => {
|
|
3591
|
+
const fixFor = (id: string, detail: Record<string, unknown>): { key: string; label: string; instruction: string } | null => {
|
|
3509
3592
|
switch (id) {
|
|
3510
|
-
case 'validation':
|
|
3593
|
+
case 'validation': {
|
|
3594
|
+
const suggested = Array.isArray(detail.suggestedCommands)
|
|
3595
|
+
? detail.suggestedCommands.map((c) => String(c)).filter((c) => c.length > 0)
|
|
3596
|
+
: []
|
|
3597
|
+
if (suggested.length > 0) {
|
|
3598
|
+
return {
|
|
3599
|
+
key: 'preflight-fix-validation',
|
|
3600
|
+
label: t('应用建议'),
|
|
3601
|
+
instruction: buildValidationApplyInstruction(suggested),
|
|
3602
|
+
}
|
|
3603
|
+
}
|
|
3511
3604
|
return { key: 'preflight-fix-validation', label: t('配置验证命令'), instruction: buildConfigFieldInstruction('validation.commands') }
|
|
3605
|
+
}
|
|
3512
3606
|
case 'stale-gate':
|
|
3513
3607
|
return { key: 'preflight-fix-gate', label: t('清除门禁'), instruction: buildQualityGateClearInstruction() }
|
|
3514
3608
|
case 'interrupted-checkpoint':
|
|
@@ -3523,12 +3617,12 @@ function ObservatoryPanel(props: SlotProps) {
|
|
|
3523
3617
|
const status = String(c.status || 'warn')
|
|
3524
3618
|
const meta = CHECK_META[id]
|
|
3525
3619
|
const label = meta ? meta.label : id || '?'
|
|
3526
|
-
const
|
|
3527
|
-
const fix = status !== 'pass' ? fixFor(id) : null
|
|
3620
|
+
const detailText = meta ? meta.detail(c.detail || {}) : ''
|
|
3621
|
+
const fix = status !== 'pass' ? fixFor(id, c.detail || {}) : null
|
|
3528
3622
|
return React.createElement('div', { key: `pf-${i}`, className: 'iterate-obs-row' },
|
|
3529
3623
|
React.createElement('span', { className: 'iterate-gate', 'data-status': status }, status.toUpperCase()),
|
|
3530
3624
|
React.createElement('b', {}, label),
|
|
3531
|
-
React.createElement('span', { className: 'iterate-obs-msg' },
|
|
3625
|
+
React.createElement('span', { className: 'iterate-obs-msg' }, detailText),
|
|
3532
3626
|
fix
|
|
3533
3627
|
? React.createElement('button', {
|
|
3534
3628
|
className: 'iterate-btn', 'data-copied': copiedKey === fix.key ? '' : undefined,
|
|
@@ -3847,6 +3941,49 @@ export function apply(ctx: ClientContext): void {
|
|
|
3847
3941
|
)
|
|
3848
3942
|
}
|
|
3849
3943
|
|
|
3944
|
+
// 5b. v3.14: right-click menu contributions (dsh 0.2.2-alpha.3 unified
|
|
3945
|
+
// context menus). Optional service: a host that predates the capability has
|
|
3946
|
+
// no menu entries, and the docked observatory stays the entry point.
|
|
3947
|
+
{
|
|
3948
|
+
const menuSvc = readContextMenus(ctx)
|
|
3949
|
+
if (menuSvc) {
|
|
3950
|
+
// Resolve a paste-able instruction for a menu action id. Session-level
|
|
3951
|
+
// ids map to the shared builders (the SAME single source of truth the
|
|
3952
|
+
// observatory copy buttons use); file:/dir: ids are the scoped review.
|
|
3953
|
+
const instructionByEntry = (id: string): string => {
|
|
3954
|
+
if (id === 'iterate.preflight') return buildPreflightInstruction()
|
|
3955
|
+
if (id === 'iterate.dry-run') return START_INSTRUCTIONS.reviewOnly
|
|
3956
|
+
if (id === 'iterate.resume') return buildCheckpointResumeInstruction()
|
|
3957
|
+
if (id === 'iterate.pull') return buildDiskSnapshotInstruction()
|
|
3958
|
+
if (id.startsWith('file:')) {
|
|
3959
|
+
const rest = id.slice('file:'.length)
|
|
3960
|
+
const colon = rest.lastIndexOf(':')
|
|
3961
|
+
const path = colon > 0 ? rest.slice(0, colon) : rest
|
|
3962
|
+
const line = colon > 0 ? Number(rest.slice(colon + 1)) : undefined
|
|
3963
|
+
return buildScopedReviewInstruction({ file: path, ...(Number.isInteger(line) && line! > 0 ? { line: line! } : {}) })
|
|
3964
|
+
}
|
|
3965
|
+
if (id.startsWith('dir:')) return buildScopedReviewInstruction({ dir: id.slice('dir:'.length) })
|
|
3966
|
+
return ''
|
|
3967
|
+
}
|
|
3968
|
+
if (menuDisposer) {
|
|
3969
|
+
try { menuDisposer() } catch { /* noop */ }
|
|
3970
|
+
menuDisposer = null
|
|
3971
|
+
}
|
|
3972
|
+
menuDisposer = registerIterateContextMenus(
|
|
3973
|
+
menuSvc,
|
|
3974
|
+
{
|
|
3975
|
+
copy: (text) => copyText(text),
|
|
3976
|
+
openPanel: (detail) => { openObservatory(detail) },
|
|
3977
|
+
notify: (text, success, action) => { menuSvc.notify(text, success, action) },
|
|
3978
|
+
},
|
|
3979
|
+
instructionByEntry,
|
|
3980
|
+
)
|
|
3981
|
+
if (typeof ctx.effect === 'function') {
|
|
3982
|
+
ctx.effect(() => { if (menuDisposer) { menuDisposer(); menuDisposer = null } })
|
|
3983
|
+
}
|
|
3984
|
+
}
|
|
3985
|
+
}
|
|
3986
|
+
|
|
3850
3987
|
// 6: keep theme toggle state consistent across theme/change events.
|
|
3851
3988
|
// Registered through registerThemeListener so repeated apply() and client
|
|
3852
3989
|
// unmounts never leak a second listener.
|