dsh-vscode-mode 0.20.1 → 0.20.2

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.2",
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
  }
@@ -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'
@@ -445,6 +446,7 @@ export function EditorView(props) {
445
446
  const hoverPanelRef = React.useRef(false) // 鼠标是否已进入 Keep/Undo 浮层
446
447
  const batchBusyRef = React.useRef(false) // 批量 Keep All/Undo All 防重入
447
448
  const [reviewBatchBusy, setReviewBatchBusy] = React.useState(false)
449
+ const [reviewAllPrompt, setReviewAllPrompt] = React.useState(null)
448
450
  const reviewLifeRef = React.useRef(null)
449
451
  React.useEffect(() => {
450
452
  const token = { live: true }
@@ -3272,9 +3274,7 @@ export function EditorView(props) {
3272
3274
  async function actReviewAll(reject) {
3273
3275
  if (batchBusyRef.current) return
3274
3276
  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
3277
+ if (!sum.pendingFiles.length) return
3278
3278
  const token = reviewLifeRef.current
3279
3279
  const undoPath = activeRef.current
3280
3280
  const undoModel = editorRef.current?.getModel?.()
@@ -3301,8 +3301,28 @@ export function EditorView(props) {
3301
3301
  } catch (cause) { if (token?.live) setError(String(cause)) }
3302
3302
  finally { batchBusyRef.current = false; if (token?.live) setReviewBatchBusy(false) }
3303
3303
  }
3304
- const acceptAllFiles = () => actReviewAll(false)
3305
- const undoAllFiles = () => actReviewAll(true)
3304
+ /** @private @author ddj 2026年10月27号 @param reject Whether to undo pending files. @description Open a DSH confirmation without starting any mutations. */
3305
+ function askReviewAll(reject) {
3306
+ if (batchBusyRef.current || !sum.pendingFiles.length) return
3307
+ if (!review.page?.overview) { setError('官方待审列表尚未就绪,未执行批量操作'); return }
3308
+ setReviewAllPrompt({ reject, count: sum.pendingFiles.length, token: reviewLifeRef.current })
3309
+ }
3310
+
3311
+ /** @private @author ddj 2026年10月27号 @description Dismiss the pending confirmation without changing review state. */
3312
+ function cancelReviewAll() {
3313
+ setReviewAllPrompt(null)
3314
+ }
3315
+
3316
+ /** @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. */
3317
+ function confirmReviewAll() {
3318
+ const prompt = reviewAllPrompt
3319
+ setReviewAllPrompt(null)
3320
+ if (!prompt?.token?.live || prompt.token !== reviewLifeRef.current) return
3321
+ return actReviewAll(prompt.reject)
3322
+ }
3323
+
3324
+ const acceptAllFiles = () => askReviewAll(false)
3325
+ const undoAllFiles = () => askReviewAll(true)
3306
3326
  const rollbackFile = () => {
3307
3327
  if (!active) return
3308
3328
  if (!window.confirm('回滚当前文件到修改前状态?(相关差异将归档)')) return
@@ -5015,5 +5035,11 @@ export function EditorView(props) {
5015
5035
  snippetPickerEl,
5016
5036
  dapBpMenuEl,
5017
5037
  dapBpEditEl)
5018
- return rootEl
5038
+ const reviewAllEl = reviewAllPrompt?.token?.live && reviewAllPrompt.token === reviewLifeRef.current
5039
+ ? React.createElement(ReviewAllConfirm, {
5040
+ reject: reviewAllPrompt.reject, count: reviewAllPrompt.count,
5041
+ onCancel: cancelReviewAll, onConfirm: confirmReviewAll,
5042
+ })
5043
+ : null
5044
+ return React.createElement(React.Fragment, null, rootEl, reviewAllEl)
5019
5045
  }
@@ -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),
@@ -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