dsh-vscode-mode 0.20.1 → 0.20.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-vscode-mode",
3
- "version": "0.20.1",
3
+ "version": "0.20.3",
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": [
@@ -2,11 +2,16 @@
2
2
  import { rpc } from './rpc.js'
3
3
  import type { McpSessionStatus } from '../shared/mcp.js'
4
4
 
5
- export interface SessionMcpView { status?: McpSessionStatus; stale: boolean }
5
+ export interface SessionMcpView {
6
+ status?: McpSessionStatus
7
+ stale: boolean
8
+ /** Query lifecycle only; never a connection failure classification. */
9
+ reason?: 'pending' | 'paused' | 'unavailable'
10
+ /** Browser receipt time of the last accepted snapshot, in epoch milliseconds. */
11
+ observedAt?: number
12
+ }
6
13
  type Snapshot = { sessions: McpSessionStatus[]; sampledAt: number }
7
14
  type FetchStatus = (signal: AbortSignal) => Promise<Snapshot>
8
- const unavailable: SessionMcpView = { stale: true }
9
- const inactive: SessionMcpView = { stale: false }
10
15
 
11
16
  /** One poller for all mounted row/hover consumers; never retains or activates sessions. */
12
17
  export class McpSessionState {
@@ -15,14 +20,14 @@ export class McpSessionState {
15
20
  private timer?: ReturnType<typeof setTimeout>
16
21
  private request?: AbortController
17
22
  private epoch = 0
18
- private stale = true
23
+ private fallback: SessionMcpView = { stale: true, reason: 'pending' }
19
24
  private disposed = false
20
25
 
21
26
  /** @public @author ddj 2026年10月20号 @param fetchStatus Authenticated read-only query. @param page Visibility boundary, omitted for server tests. */
22
27
  constructor(private readonly fetchStatus: FetchStatus, private readonly page?: Document) {}
23
28
 
24
29
  /** @public @author ddj 2026年10月20号 @param sessionId Row identity. @returns Stable view until the next observation. */
25
- get(sessionId: string): SessionMcpView { return this.views.get(sessionId) ?? (this.stale ? unavailable : inactive) }
30
+ get(sessionId: string): SessionMcpView { return this.views.get(sessionId) ?? this.fallback }
26
31
 
27
32
  /** @public @author ddj 2026年10月20号 @param sessionId Row identity. @param listener React invalidation. @returns Idempotent unsubscribe; the last consumer stops polling. */
28
33
  subscribe(sessionId: string, listener: () => void): () => void {
@@ -33,7 +38,8 @@ export class McpSessionState {
33
38
  this.listeners.set(sessionId, listeners)
34
39
  if (first) {
35
40
  this.page?.addEventListener('visibilitychange', this.visibility)
36
- void this.poll()
41
+ if (this.page?.hidden) this.markStale('paused')
42
+ else void this.poll()
37
43
  }
38
44
  return () => {
39
45
  listeners.delete(listener)
@@ -54,7 +60,7 @@ export class McpSessionState {
54
60
  clearTimeout(this.timer)
55
61
  this.timer = undefined
56
62
  this.request?.abort()
57
- this.publish(undefined)
63
+ this.markStale('paused')
58
64
  }
59
65
 
60
66
  /** @private @author ddj 2026年10月20号 Cleans listener lifetime as well as timers. */
@@ -66,32 +72,53 @@ export class McpSessionState {
66
72
  /** @public @author ddj 2026年10月20号 Permanently disposes HMR-owned polling. */
67
73
  dispose(): void { this.disposed = true; this.pause(); this.listeners.clear(); this.views.clear() }
68
74
 
69
- /** @private @author ddj 2026年10月20号 @param snapshot Fresh ledger, or undefined to mark all previous observations stale. */
70
- private publish(snapshot?: Snapshot): void {
71
- this.stale = snapshot === undefined
72
- if (snapshot) this.views = new Map(snapshot.sessions.map((status) => [status.sessionId, { status, stale: false }]))
73
- else for (const [id, view] of this.views) this.views.set(id, { ...view, stale: true })
75
+ /** @private @author ddj 2026年10月27号 @param snapshot Fresh ledger. Records browser receipt time without changing the host protocol. */
76
+ private publish(snapshot: Snapshot): void {
77
+ const observedAt = Date.now()
78
+ this.fallback = { stale: false, observedAt }
79
+ this.views = new Map(snapshot.sessions.map((status) => [status.sessionId, { status, stale: false, observedAt }]))
80
+ this.notify()
81
+ }
82
+
83
+ /** @private @author ddj 2026年10月27号 @param reason Query lifecycle, never a claim about connection health. Preserves stable references for unchanged observations. */
84
+ private markStale(reason: NonNullable<SessionMcpView['reason']>): void {
85
+ if (this.fallback.stale && this.fallback.reason === reason) return
86
+ this.fallback = { ...this.fallback, stale: true, reason }
87
+ for (const [id, view] of this.views) this.views.set(id, { ...view, stale: true, reason })
88
+ this.notify()
89
+ }
90
+
91
+ /** @private @author ddj 2026年10月27号 Notifies mounted row and hover consumers after an observation changes. */
92
+ private notify(): void {
74
93
  for (const listeners of this.listeners.values()) for (const listener of listeners) listener()
75
94
  }
76
95
 
77
96
  /** @private @author ddj 2026年10月20号 Queries once, with a 10-second deadline and a 2.5-second gap after settlement. */
78
97
  private async poll(): Promise<void> {
79
98
  if (this.disposed || this.request || !this.listeners.size || this.page?.hidden) return
99
+ clearTimeout(this.timer)
100
+ this.timer = undefined
101
+ if (this.fallback.reason === 'paused') this.markStale('pending')
102
+
80
103
  const request = new AbortController()
81
104
  const epoch = this.epoch
82
105
  this.request = request
83
106
  /** @author ddj 2026年10月20号 Marks overdue responses unusable even if a transport ignores cancellation. */
84
- const expire = (): void => { request.abort(); if (epoch === this.epoch) this.publish(undefined) }
107
+ const expire = (): void => { request.abort(); if (epoch === this.epoch) this.markStale('unavailable') }
85
108
  const deadline = setTimeout(expire, 10000)
86
109
  try {
87
110
  const snapshot = await this.fetchStatus(request.signal)
88
111
  if (epoch === this.epoch && !request.signal.aborted) this.publish(snapshot)
89
112
  } catch {
90
- if (epoch === this.epoch) this.publish(undefined)
113
+ if (epoch === this.epoch) this.markStale('unavailable')
91
114
  } finally {
92
115
  clearTimeout(deadline)
93
116
  this.request = undefined
94
- if (!this.disposed && this.listeners.size && !this.page?.hidden) this.timer = setTimeout(() => { void this.poll() }, 2500)
117
+ // A visibility/subscription change resumes promptly, but only after the old transport settles.
118
+ if (!this.disposed && this.listeners.size && !this.page?.hidden) {
119
+ const delay = epoch === this.epoch ? 2500 : 0
120
+ this.timer = setTimeout(() => { void this.poll() }, delay)
121
+ }
95
122
  }
96
123
  }
97
124
  }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * 官方差异栏派生视图:位置、导航提示、失效说明与 Keep 提示的唯一来源。
3
+ * 计数口径分离:待审是「记录数」,可跳转是「块数」,两者不同源,
4
+ * 全部块失效时必须给出可操作叙事,不能只留一句「无法定位」。
5
+ * 作者 ddj 2026年10月27号
6
+ */
7
+
8
+ /** 官方差异栏输入:全部来自 EditorView dock 快照,不读取磁盘或 RPC。 */
9
+ export interface ReviewBarInput {
10
+ /** 可定位(非 stale)的差异块数 */
11
+ jumps: number
12
+ /** 无法定位的历史差异块数 */
13
+ stale: number
14
+ /** 当前差异块索引(0 基) */
15
+ index: number
16
+ /** 编辑器内容是否已就绪;未就绪时不得断言差异失效 */
17
+ ready: boolean
18
+ /** 当前文件的官方待审记录数 */
19
+ pendingRecords: number
20
+ /** 当前记录是否可 Keep(detail.capability.keep) */
21
+ canKeep: boolean
22
+ }
23
+
24
+ /** 官方差异栏派生文案;调用方只负责渲染,不再自行拼装计数与提示。 */
25
+ export interface ReviewBarView {
26
+ /** 差异位置文案,如 `2 / 6`、`无可跳转`、`载入中` */
27
+ position: string
28
+ /** 上一个差异按钮提示(兼作禁用原因) */
29
+ navPrevTitle: string
30
+ /** 下一个差异按钮提示(兼作禁用原因) */
31
+ navNextTitle: string
32
+ /** 失效说明,空串表示不显示 */
33
+ notice: string
34
+ /** 说明级别:warn 表示需要用户决策 */
35
+ noticeTone: 'info' | 'warn'
36
+ /** Keep 按钮提示 */
37
+ keepHint: string
38
+ /** 待审计数提示 */
39
+ pendingTitle: string
40
+ }
41
+
42
+ /** @private @author ddj 2026年10月27号 @param value 任意计数输入 @returns 非负整数,NaN/负数/非数字归零 */
43
+ function safeCount(value: unknown): number {
44
+ const parsed = Number(value)
45
+ return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : 0
46
+ }
47
+
48
+ /** @private @author ddj 2026年10月27号 @returns 无差异可跳转时的 Keep 提示 */
49
+ function fullStaleKeepHint(): string {
50
+ return '保留该记录的全部改动并结案:这些改动已不在文件中,记录将被标记为已采纳(不改写磁盘)'
51
+ }
52
+
53
+ /**
54
+ * 派生差异栏文案。
55
+ * @public
56
+ * @author ddj 2026年10月27号
57
+ * @param input 差异计数、就绪状态与 Keep 能力
58
+ * @returns 栏内位置、导航提示与失效说明
59
+ */
60
+ export function reviewBarView(input: ReviewBarInput): ReviewBarView {
61
+ const jumps = safeCount(input.jumps)
62
+ const stale = safeCount(input.stale)
63
+ const index = safeCount(input.index)
64
+ const pendingTitle = `${safeCount(input.pendingRecords)} 条官方待审记录` + (input.ready ? `,本文件可跳转 ${jumps} 处` : '')
65
+ const keepHint = jumps === 0 && stale > 0 && input.canKeep ? fullStaleKeepHint() : '保留当前记录的未处理修改'
66
+
67
+ // 载入期所有块天然 stale,此时只能报告进度,不能断言改动已失效。
68
+ if (input.ready !== true) {
69
+ return { position: '载入中', navPrevTitle: '文件载入中', navNextTitle: '文件载入中', notice: '', noticeTone: 'info', keepHint, pendingTitle }
70
+ }
71
+
72
+ if (jumps > 0) {
73
+ const at = Math.min(index + 1, jumps)
74
+ const notice = stale > 0 ? `${stale} 处历史差异无法定位(其余 ${jumps} 处可跳转),展开详情查看原始增删。` : ''
75
+ return { position: `${at} / ${jumps}`, navPrevTitle: '上一个差异', navNextTitle: '下一个差异', notice, noticeTone: 'info', keepHint, pendingTitle }
76
+ }
77
+
78
+ if (stale > 0) {
79
+ const reason = '该记录的改动已不在当前文件中,无可跳转差异'
80
+ const notice = `本记录 ${stale} 处改动均已不在当前文件中(被后续编辑覆盖或已撤销);可点 Keep 结案(只标记已采纳,不改写磁盘),或展开详情核对原始增删。`
81
+ return { position: '无可跳转', navPrevTitle: reason, navNextTitle: reason, notice, noticeTone: 'warn', keepHint, pendingTitle }
82
+ }
83
+
84
+ return { position: '—', navPrevTitle: '当前文件没有可定位差异', navNextTitle: '当前文件没有可定位差异', notice: '', noticeTone: 'info', keepHint, pendingTitle }
85
+ }
@@ -36,3 +36,15 @@ export function sessionMissingDetail(): string {
36
36
  return '该会话可能正被其他 DSH 实例(桌面端 / Web 端)占用,或尚未在当前实例激活。' +
37
37
  '请在持有该会话的一端查看,或在本端打开该会话后重试。'
38
38
  }
39
+
40
+ /**
41
+ * 把任意错误文本归一为可展示文案:会话缺失类替换为终态提示,其余原样返回。
42
+ * 差异栏等狭窄区域共用,避免裸「会话不存在」与业务提示混排。
43
+ * @author ddj 2026年10月27号
44
+ * @param text 任意错误文本(host 业务 error、异常 message 等原始形态)
45
+ * @returns 会话缺失为「标题:详情」,其他错误为原文本,空值返回空串
46
+ */
47
+ export function sessionErrorText(text: unknown): string {
48
+ if (isSessionMissingError(text)) return sessionMissingTitle() + ':' + sessionMissingDetail()
49
+ return String(text ?? '')
50
+ }
@@ -304,6 +304,7 @@
304
304
  [data-edrv-diff-dock-shell] .edrv-reviewbar button:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary, #3b63d9); outline-offset: 2px; }
305
305
  [data-edrv-diff-dock-shell] .edrv-review-notice { padding: 7px 12px; border-top: 1px solid var(--dsw-alias-border-l1, #e5e9ef); color: var(--dsw-alias-label-secondary, #697586); font-size: 11px; overflow-wrap: anywhere; }
306
306
  [data-edrv-diff-dock-shell] .edrv-review-error { color: var(--dsw-alias-state-error-primary, #b64043); }
307
+ [data-edrv-diff-dock-shell] .edrv-review-notice-warn { color: var(--dsw-alias-state-warning-primary, #a86a1f); }
307
308
  [data-edrv-diff-dock-shell] .edrv-review-body { border-top: 1px solid var(--dsw-alias-border-l1, #e5e9ef); max-height: min(240px, 30vh); overflow: auto; padding: 8px 12px; }
308
309
  @container edrv-review (max-width: 420px) {
309
310
  .edrv-reviewbar .edrv-review-heading { gap: 6px; padding-inline: 10px; }
@@ -24,6 +24,7 @@ import { dbg, isDebug, rpc } from '../rpc.js'
24
24
  import { refreshFeed, subscribeErrors, subscribeRecords } from '../state/recordsFeed.js'
25
25
  import { subscribeReview, refreshReview } from '../state/reviewFeed.js'
26
26
  import { runReviewBatch } from '../reviewBatch.js'
27
+ import { ReviewAllConfirm } from './ReviewAllConfirm.js'
27
28
  import { reviewRegions } from '../../shared/reviewRegions.js'
28
29
  import { diffSummary, officialPath } from '../state/diffSummary.js'
29
30
  import { emitFileChanged, emitRefresh } from '../events.js'
@@ -126,7 +127,10 @@ import { buildBpMenu } from '../dap/bpMenu.js'
126
127
  import { clampToolbarPosition, loadToolbarPosition, saveToolbarPosition } from '../dap/toolbarDrag.js'
127
128
  import { editorModelPathOf } from '../dap/modelPath.js'
128
129
  import { dapTrace } from '../dap/trace.js'
129
- import { isSessionMissingError, sessionMissingDetail, sessionMissingTitle } from '../sessionError.js'
130
+ import { isSessionMissingError, sessionErrorText, sessionMissingDetail, sessionMissingTitle } from '../sessionError.js'
131
+
132
+ /** 官方差异读取失败前缀:仅用于清理同源陈旧错误,不吞并保存类错误。@author ddj 2026年10月27号 */
133
+ const REVIEW_LOAD_ERROR = '官方差异读取失败:'
130
134
 
131
135
  /**
132
136
  * 只在 Lua 注解类型槽开启注释自动建议;跨文件/离开类型槽立即恢复默认。
@@ -445,6 +449,7 @@ export function EditorView(props) {
445
449
  const hoverPanelRef = React.useRef(false) // 鼠标是否已进入 Keep/Undo 浮层
446
450
  const batchBusyRef = React.useRef(false) // 批量 Keep All/Undo All 防重入
447
451
  const [reviewBatchBusy, setReviewBatchBusy] = React.useState(false)
452
+ const [reviewAllPrompt, setReviewAllPrompt] = React.useState(null)
448
453
  const reviewLifeRef = React.useRef(null)
449
454
  React.useEffect(() => {
450
455
  const token = { live: true }
@@ -586,7 +591,9 @@ export function EditorView(props) {
586
591
  if (!result.ok) throw new Error(result.error)
587
592
  setOfficialRecords((previous) => JSON.stringify(previous) === JSON.stringify(page.page.items) ? previous : page.page.items)
588
593
  setOfficialDetail((previous) => JSON.stringify(previous) === JSON.stringify(result.detail) ? previous : result.detail)
589
- } catch (cause) { if (!controller.signal.aborted) setError('官方差异读取失败:' + String(cause)) }
594
+ // 读取成功后清除同源陈旧错误,避免会话恢复后仍挂着上一次失败提示。
595
+ setError((previous) => (typeof previous === 'string' && previous.startsWith(REVIEW_LOAD_ERROR) ? null : previous))
596
+ } catch (cause) { if (!controller.signal.aborted) setError(REVIEW_LOAD_ERROR + String(cause)) }
590
597
  }
591
598
  void loadReview()
592
599
  return () => controller.abort()
@@ -3272,9 +3279,7 @@ export function EditorView(props) {
3272
3279
  async function actReviewAll(reject) {
3273
3280
  if (batchBusyRef.current) return
3274
3281
  if (!review.page?.overview) { setError('官方待审列表尚未就绪,未执行批量操作'); return }
3275
- const count = sum.pendingFiles.length
3276
- if (!count) return
3277
- if (!window.confirm(`${reject ? 'Undo All' : 'Keep All'}:处理当前工作区全部 ${count} 个待审差异文件(含其他轮次)?${reject ? '\n撤销会改写磁盘;逐项安全校验,遇到冲突停止,已完成项不会自动恢复。' : '\n只标记待审修改为保留,不改写文件。'}`)) return
3282
+ if (!sum.pendingFiles.length) return
3278
3283
  const token = reviewLifeRef.current
3279
3284
  const undoPath = activeRef.current
3280
3285
  const undoModel = editorRef.current?.getModel?.()
@@ -3301,8 +3306,28 @@ export function EditorView(props) {
3301
3306
  } catch (cause) { if (token?.live) setError(String(cause)) }
3302
3307
  finally { batchBusyRef.current = false; if (token?.live) setReviewBatchBusy(false) }
3303
3308
  }
3304
- const acceptAllFiles = () => actReviewAll(false)
3305
- const undoAllFiles = () => actReviewAll(true)
3309
+ /** @private @author ddj 2026年10月27号 @param reject Whether to undo pending files. @description Open a DSH confirmation without starting any mutations. */
3310
+ function askReviewAll(reject) {
3311
+ if (batchBusyRef.current || !sum.pendingFiles.length) return
3312
+ if (!review.page?.overview) { setError('官方待审列表尚未就绪,未执行批量操作'); return }
3313
+ setReviewAllPrompt({ reject, count: sum.pendingFiles.length, token: reviewLifeRef.current })
3314
+ }
3315
+
3316
+ /** @private @author ddj 2026年10月27号 @description Dismiss the pending confirmation without changing review state. */
3317
+ function cancelReviewAll() {
3318
+ setReviewAllPrompt(null)
3319
+ }
3320
+
3321
+ /** @private @author ddj 2026年10月27号 @description Confirm only within the session lifetime that opened the modal. @returns The guarded batch operation, if still valid. */
3322
+ function confirmReviewAll() {
3323
+ const prompt = reviewAllPrompt
3324
+ setReviewAllPrompt(null)
3325
+ if (!prompt?.token?.live || prompt.token !== reviewLifeRef.current) return
3326
+ return actReviewAll(prompt.reject)
3327
+ }
3328
+
3329
+ const acceptAllFiles = () => askReviewAll(false)
3330
+ const undoAllFiles = () => askReviewAll(true)
3306
3331
  const rollbackFile = () => {
3307
3332
  if (!active) return
3308
3333
  if (!window.confirm('回滚当前文件到修改前状态?(相关差异将归档)')) return
@@ -4462,12 +4487,13 @@ export function EditorView(props) {
4462
4487
  * @returns 含布局及操作回调的差异快照
4463
4488
  */
4464
4489
  const officialFile = review.page?.overview?.coveredPaths.find((path) => sameFile(path, active))
4490
+ const rawReviewError = review.error || error
4465
4491
  const buildDockSnapshot = () => ({
4466
4492
  mode: editorDockMode(active),
4467
4493
  official: officialFile ? { sessionId, page: review.page, path: officialFile,
4468
4494
  pending: sum.files.find((file) => sameFile(file.path, active))?.pending ?? 0,
4469
4495
  detail: officialDetail, records: officialRecords, onSelect: setSelectedReview } : null,
4470
- reviewError: review.error || error,
4496
+ reviewError: rawReviewError ? sessionErrorText(rawReviewError) : undefined,
4471
4497
  layout,
4472
4498
  pendingRegions,
4473
4499
  staleRegions,
@@ -4487,6 +4513,7 @@ export function EditorView(props) {
4487
4513
  onRefresh: reloadFile,
4488
4514
  activePath: active,
4489
4515
  diffIdx: contentReady ? diffIdx : 0,
4516
+ contentReady,
4490
4517
  diffTotal: displayDiffTotal(contentReady, pendingRegions.length, sum.files.find((file) => sameFile(file.path, active))?.pending ?? 0),
4491
4518
  fileIdx,
4492
4519
  fileTotal: sum.pendingFiles.length,
@@ -5015,5 +5042,11 @@ export function EditorView(props) {
5015
5042
  snippetPickerEl,
5016
5043
  dapBpMenuEl,
5017
5044
  dapBpEditEl)
5018
- return rootEl
5045
+ const reviewAllEl = reviewAllPrompt?.token?.live && reviewAllPrompt.token === reviewLifeRef.current
5046
+ ? React.createElement(ReviewAllConfirm, {
5047
+ reject: reviewAllPrompt.reject, count: reviewAllPrompt.count,
5048
+ onCancel: cancelReviewAll, onConfirm: confirmReviewAll,
5049
+ })
5050
+ : null
5051
+ return React.createElement(React.Fragment, null, rootEl, reviewAllEl)
5019
5052
  }
@@ -20,7 +20,7 @@ function useMcpView({ sessionId, store }: McpRowProps): SessionMcpView {
20
20
 
21
21
  /** @public @author ddj 2026年10月20号 @param view Current observation. @returns Accessible row label; steady states deliberately do not occupy the leading slot. */
22
22
  export function mcpRowLabel(view: SessionMcpView): string | undefined {
23
- if (view.stale) return '项目 MCP 状态暂不可用(上次状态已过期)'
23
+ if (view.stale) return undefined
24
24
  if (!view.status || (!busy.has(view.status.phase) && view.status.phase !== 'error')) return undefined
25
25
  return '项目 MCP:' + labels[view.status.phase]
26
26
  }
@@ -30,7 +30,7 @@ export function McpSessionIcon(props: McpRowProps): React.ReactElement | null {
30
30
  const view = useMcpView(props)
31
31
  const label = mcpRowLabel(view)
32
32
  if (!label) return null
33
- const warning = view.stale || view.status?.phase === 'error'
33
+ const warning = view.status?.phase === 'error'
34
34
  return React.createElement('span', {
35
35
  role: 'img', 'aria-label': label, title: label,
36
36
  style: { display: 'inline-flex', width: 16, height: 16, alignItems: 'center', justifyContent: 'center',
@@ -41,6 +41,13 @@ export function McpSessionIcon(props: McpRowProps): React.ReactElement | null {
41
41
  /** @private @author ddj 2026年10月20号 @param start Stage start. @param end Stage finish or local clock. @returns Human-readable elapsed time, without claiming a timeout. */
42
42
  function elapsed(start: number, end: number): string { return Math.max(0, (end - start) / 1000).toFixed(1) + ' 秒' }
43
43
 
44
+ /** @private @author ddj 2026年10月27号 @param view Query observation. @returns Query-only explanation, separate from initialization health. */
45
+ function queryMessage(view: SessionMcpView): string {
46
+ if (view.reason === 'paused') return '状态查询已暂停'
47
+ if (view.reason === 'pending') return '正在查询项目 MCP 状态'
48
+ return '状态查询暂不可用,不代表 MCP 初始化失败'
49
+ }
50
+
44
51
  /** @public @author ddj 2026年10月20号 @param props Official hover sessionId and poller. @returns Project-only diagnostic detail, never transport credentials. */
45
52
  export function McpSessionDetail(props: McpRowProps): React.ReactElement {
46
53
  const view = useMcpView(props)
@@ -53,10 +60,18 @@ export function McpSessionDetail(props: McpRowProps): React.ReactElement {
53
60
  }, [status, view.stale])
54
61
 
55
62
  const children: React.ReactNode[] = [React.createElement('strong', { key: 'title' }, '项目 MCP')]
56
- if (view.stale) children.push(React.createElement('div', { key: 'stale', role: 'status' }, '状态暂不可用;以下如有记录,为上次观测结果'))
57
- if (!status) children.push(React.createElement('div', { key: 'empty' }, view.stale ? '等待状态查询' : '当前无活跃 MCP 实例'))
58
- else {
59
- const end = status.finishedAt ?? now
63
+ if (view.stale) {
64
+ children.push(React.createElement('div', { key: 'stale', role: 'status' }, queryMessage(view)))
65
+ if (status) children.push(React.createElement('div', { key: 'history' }, '以下为上次观测结果,不代表当前状态'))
66
+ if (view.observedAt !== undefined) children.push(React.createElement('div', { key: 'observed' },
67
+ '上次观测时间:' + new Date(view.observedAt).toLocaleString()))
68
+ }
69
+ if (!status && !view.stale) children.push(React.createElement('div', { key: 'empty' }, '当前无活跃 MCP 实例'))
70
+ if (status) {
71
+ // Unobserved time must not look like continued initialization, including per-server timing.
72
+ let observedNow = now
73
+ if (view.stale) observedNow = view.observedAt ?? status.phaseAt
74
+ const end = status.finishedAt ?? observedNow
60
75
  const completed = status.servers.filter((server) => server.phase === 'ready').length
61
76
  const slow = !view.stale && busy.has(status.phase) && now - status.phaseAt > 10000
62
77
  children.push(React.createElement('div', { key: 'phase' }, labels[status.phase], slow ? '(耗时较长,不代表失败)' : ''))
@@ -64,7 +79,7 @@ export function McpSessionDetail(props: McpRowProps): React.ReactElement {
64
79
  if (status.configMs !== undefined) children.push(React.createElement('div', { key: 'config' }, `配置准备 ${elapsed(0, status.configMs)}`))
65
80
  if (status.error) children.push(React.createElement('div', { key: 'error' }, status.error))
66
81
  for (const server of status.servers) children.push(React.createElement('div', { key: 'server:' + server.serverName },
67
- `${server.serverName}:${labels[server.phase]}${server.reused ? ' · 复用已有连接' : ''}${server.phase === 'idle' ? '' : ' · ' + elapsed(server.startedAt, server.finishedAt ?? now)}`,
82
+ `${server.serverName}:${labels[server.phase]}${server.reused ? ' · 复用已有连接' : ''}${server.phase === 'idle' ? '' : ' · ' + elapsed(server.startedAt, server.finishedAt ?? observedNow)}`,
68
83
  server.queuedMs === undefined ? '' : ' · 排队 ' + elapsed(0, server.queuedMs),
69
84
  server.connectMs === undefined ? '' : ' · 初始化 ' + elapsed(0, server.connectMs),
70
85
  server.firstCallMs === undefined ? '' : ' · 首次工具执行 ' + elapsed(0, server.firstCallMs),
@@ -2,6 +2,7 @@ import React from 'react'
2
2
  import { Button, Menu } from '@deepseek-ai/dsh-client-ui-primitives'
3
3
  import type { ReviewDetail, ReviewItem, ReviewPage } from '../../shared/review.js'
4
4
  import { ReviewLedger } from './OfficialReview.js'
5
+ import { reviewBarView } from '../reviewBar.js'
5
6
 
6
7
  export interface OfficialBarProps {
7
8
  official: { sessionId: string; path: string; pending: number; detail?: ReviewDetail; records?: ReviewItem[]; page: ReviewPage; onSelect: (id: string) => void }
@@ -9,6 +10,8 @@ export interface OfficialBarProps {
9
10
  pendingRegions?: unknown[]
10
11
  staleRegions?: unknown[]
11
12
  diffIdx?: number
13
+ /** 编辑器内容是否就绪;未就绪时不得断言差异失效 */
14
+ contentReady?: boolean
12
15
  fileTotal?: number
13
16
  fileIdx?: number
14
17
  allPendingCount?: number
@@ -61,7 +64,8 @@ export function OfficialDiffBar(props: OfficialBarProps): React.ReactElement {
61
64
  const detail = official.detail
62
65
  const records = official.records ?? []
63
66
  const count = pendingRegions.length
64
- const position = count ? `${Math.min(diffIdx + 1, count)} / ${count}` : '—'
67
+ const view = reviewBarView({ jumps: count, stale: staleRegions.length, index: diffIdx, ready: props.contentReady === true,
68
+ pendingRecords: official.pending, canKeep: detail?.capability.keep === true })
65
69
 
66
70
  /** @private @author ddj 2026年10月27号 @returns Close current popup without changing selection. */
67
71
  function closeMenu(): void { setMenu(null) }
@@ -69,9 +73,9 @@ export function OfficialDiffBar(props: OfficialBarProps): React.ReactElement {
69
73
  function selectRecord(id: string): void { official.onSelect(id); closeMenu() }
70
74
  /** @private @author ddj 2026年10月27号 @param tab Existing launcher tab. @returns Open existing overview, never create another review entry. */
71
75
  function openOverview(tab: string): void { props.onOpenLauncher(tab); closeMenu() }
72
- /** @private @author ddj 2026年10月27号 @param mark Navigation icon. @param label Accessible action. @param action Existing callback. @param disabled Whether navigation is unavailable. @returns Compact keyboard-focusable navigation control. */
73
- function navButton(mark: Mark, label: string, action: () => void, disabled: boolean): React.ReactElement {
74
- return <Button size="sm" variant="ghost" className="edrv-review-icon" aria-label={label} title={label} disabled={disabled} onClick={action}>{barIcon(mark)}</Button>
76
+ /** @private @author ddj 2026年10月27号 @param mark Navigation icon. @param label Accessible action. @param action Existing callback. @param disabled Whether navigation is unavailable. @param title Hover reason, defaults to the label. @returns Compact keyboard-focusable navigation control. */
77
+ function navButton(mark: Mark, label: string, action: () => void, disabled: boolean, title?: string): React.ReactElement {
78
+ return <Button size="sm" variant="ghost" className="edrv-review-icon" aria-label={label} title={title || label} disabled={disabled} onClick={action}>{barIcon(mark)}</Button>
75
79
  }
76
80
 
77
81
  const recordTrigger = <Button size="sm" variant="ghost" className="edrv-review-record" aria-label="选择官方差异记录" aria-haspopup="menu" aria-expanded={menu === 'records'} disabled={!records.length} onClick={() => setMenu(menu === 'records' ? null : 'records')}>
@@ -85,7 +89,7 @@ export function OfficialDiffBar(props: OfficialBarProps): React.ReactElement {
85
89
  <div className="edrv-review-heading">
86
90
  <span className="edrv-review-filemark">{barIcon('file')}</span>
87
91
  <span className="edrv-review-filename" title={props.activePath}>{name}</span>
88
- <span className="edrv-review-pending" title={`${official.pending} 条官方待审记录`}>待审 <strong>{official.pending}</strong></span>
92
+ <span className="edrv-review-pending" title={view.pendingTitle}>待审 <strong>{official.pending}</strong></span>
89
93
  <Menu open={menu === 'records'} anchor={recordTrigger} items={records.map((item) => ({ id: item.id, label: `轮次 ${item.turn} / 事件 ${item.seq}` }))} selectedId={detail?.item.id} onSelect={selectRecord} onClose={closeMenu} side="top" align="end" portal compact autoFocus />
90
94
  </div>
91
95
  <div className="edrv-review-tools">
@@ -94,18 +98,18 @@ export function OfficialDiffBar(props: OfficialBarProps): React.ReactElement {
94
98
  {navButton('right', '下一个差异文件', props.onNextFile, !props.fileTotal)}
95
99
  </div>
96
100
  <div className="edrv-review-nav edrv-review-blocknav" role="group" aria-label="差异导航">
97
- {navButton('up', '上一个差异', props.onPrevDiff, !count)}
98
- <span className="edrv-review-position" aria-live="polite">{position}</span>
99
- {navButton('down', '下一个差异', props.onNextDiff, !count)}
101
+ {navButton('up', '上一个差异', props.onPrevDiff, !count, view.navPrevTitle)}
102
+ <span className="edrv-review-position" aria-live="polite">{view.position}</span>
103
+ {navButton('down', '下一个差异', props.onNextDiff, !count, view.navNextTitle)}
100
104
  </div>
101
105
  <div className="edrv-review-actions">
102
106
  <Button size="sm" variant="ghost" className="edrv-review-undo" icon={barIcon('undo')} onClick={props.onUndoFile} disabled={props.reviewBatchBusy || !detail?.capability.undo} title={detail?.capability.reason}>Undo</Button>
103
- <Button size="sm" variant="primary" className="edrv-review-keep" icon={barIcon('check')} onClick={props.onAcceptFile} disabled={props.reviewBatchBusy || !detail?.capability.keep} title="保留当前记录的未处理修改">Keep</Button>
107
+ <Button size="sm" variant="primary" className="edrv-review-keep" icon={barIcon('check')} onClick={props.onAcceptFile} disabled={props.reviewBatchBusy || !detail?.capability.keep} title={detail?.capability.keep ? view.keepHint : detail?.capability.reason}>Keep</Button>
104
108
  <Button size="sm" variant="ghost" className="edrv-review-details" onClick={() => setExpanded(!expanded)} aria-expanded={expanded} aria-label={expanded ? '收起差异' : '展开差异'} title={expanded ? '收起差异' : '展开差异'}>详情{barIcon(expanded ? 'up' : 'down')}</Button>
105
109
  <Menu open={menu === 'more'} anchor={moreTrigger} items={[{ id: 'pending', label: '差异总览' }, { id: 'archive', label: '归档 / 历史' }]} onSelect={openOverview} onClose={closeMenu} side="top" align="end" portal compact autoFocus />
106
110
  </div>
107
111
  </div>
108
- {detail && staleRegions.length > 0 ? <div className="edrv-review-notice" role="status">{staleRegions.length} 处历史差异无法定位,展开详情查看原始增删。</div> : null}
112
+ {detail && view.notice ? <div className={view.noticeTone === 'warn' ? 'edrv-review-notice edrv-review-notice-warn' : 'edrv-review-notice'} role="status">{view.notice}</div> : null}
109
113
  {props.reviewError ? <div className="edrv-review-notice edrv-review-error" role="alert">{props.reviewError}</div> : null}
110
114
  {expanded ? <div className="edrv-review-body"><ReviewLedger key={`${official.sessionId}:${official.path}`} sessionId={official.sessionId} first={official.page} path={official.path} /></div> : null}
111
115
  </div>
@@ -0,0 +1,35 @@
1
+ import React from 'react'
2
+ import { Button, Modal } from '@deepseek-ai/dsh-client-ui-primitives'
3
+
4
+ interface ReviewAllProps {
5
+ reject: boolean
6
+ count: number
7
+ onCancel: () => void
8
+ onConfirm: () => void
9
+ }
10
+
11
+ /**
12
+ * @public
13
+ * @author ddj 2026年10月27号
14
+ * @description Confirm workspace-wide review through the DSH modal; dismissing never applies a decision.
15
+ * @param props Pending file count, action and explicit confirmation callbacks.
16
+ * @returns DSH-themed confirmation with distinct Keep and Undo safety descriptions.
17
+ */
18
+ export function ReviewAllConfirm(props: ReviewAllProps): React.ReactElement {
19
+ const action = props.reject ? 'Undo All' : 'Keep All'
20
+ const warning = props.reject
21
+ ? '撤销会改写磁盘;逐项安全校验,遇到冲突停止,已完成项不会自动恢复。'
22
+ : '只标记待审修改为保留,不改写文件。'
23
+ const footer = React.createElement(React.Fragment, null,
24
+ React.createElement(Button, { variant: 'ghost', onClick: props.onCancel, autoFocus: true }, '取消'),
25
+ React.createElement(Button, { variant: 'primary', onClick: props.onConfirm }, '确认 ' + action))
26
+
27
+ return React.createElement(Modal, {
28
+ open: true,
29
+ title: action + ' 确认',
30
+ closeLabel: '取消',
31
+ onClose: props.onCancel,
32
+ description: `处理当前工作区全部 ${props.count} 个待审差异文件(含其他轮次)?`,
33
+ footer,
34
+ }, React.createElement('p', null, warning))
35
+ }
@@ -14,6 +14,18 @@ declare module '@deepseek-ai/dsh-client-ui-primitives' {
14
14
  icon?: import('react').ReactNode
15
15
  }>
16
16
 
17
+ /** Centered body-portaled dialog; mirrors official Modal.d.ts. @author ddj 2026年10月27号 */
18
+ export const Modal: import('react').ComponentType<{
19
+ open: boolean
20
+ onClose: () => void
21
+ title: string
22
+ description?: string
23
+ children?: import('react').ReactNode
24
+ footer?: import('react').ReactNode
25
+ className?: string
26
+ contentClassName?: string
27
+ } & ({ headless: true; closeLabel?: never } | { headless?: false; closeLabel: string })>
28
+
17
29
  /** Anchored selectable menu; mirrors the subset used from official Menu.d.ts. @author ddj 2026年10月27号 */
18
30
  export const Menu: import('react').ComponentType<{
19
31
  open: boolean