dsh-vscode-mode 0.14.3 → 0.14.5

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.
@@ -0,0 +1,178 @@
1
+ /** Optional DSH plan integration; no provider or approval UI replacement. @author ddj 2026年10月08号 */
2
+ import React from 'react'
3
+ import { requestOpen } from './openReceipt.js'
4
+ import { createPlanFeed, clearPlanState, isPlanAddress, planDocument, type PlanWindow, type PlanOpen } from './planState.js'
5
+ import { detectOfficial, OFFICIAL_SLOT_NAME, type OfficialSidebar } from './officialSidebar.js'
6
+ import { readSessionScope, subscribeScope } from './sessionScope.js'
7
+ import { log } from './log.js'
8
+
9
+ interface PlanContext {
10
+ get: (name: string) => unknown
11
+ slots: { inject: (name: string, callback: () => unknown) => unknown; register: (spec: object, body: unknown) => unknown }
12
+ }
13
+ interface EventSource { getSnapshot: () => PlanWindow; subscribe: (listener: () => void) => () => void }
14
+ interface Sessions { binding?: (id: string) => { eventSource: EventSource } | undefined }
15
+ interface PlanTab { navigation?: { address?: string; revision?: number; params?: { planReview?: unknown } }; actions?: { close?: () => void } }
16
+ interface PlanProps {
17
+ sessionId?: string
18
+ useTabInfo?: () => { tab?: PlanTab }
19
+ useResource?: (address: string) => { status: string; value?: unknown }
20
+ }
21
+ const CLAIM_ID = 'dsh-vscode-mode/plan'
22
+ const CLAIM_KIND = 'edrvPlan'
23
+ const EMPTY_RESOURCE = { status: 'none' }
24
+
25
+ /** @private @author ddj 2026年10月08号 @param ctx Optional context. @param name Service name. @returns Service or undefined without gating plugin activation. */
26
+ function optional(ctx: PlanContext, name: string): unknown {
27
+ try { return ctx.get(name) } catch { return undefined }
28
+ }
29
+ /** @private @author ddj 2026年10月08号 @param tab Current tab. @returns Native plan fallback options, preserving transient review parameters. */
30
+ function nativeOptions(tab: PlanTab | undefined): { kind: string; params?: unknown } {
31
+ return { kind: 'plan', params: tab?.navigation?.params }
32
+ }
33
+ /** @private @author ddj 2026年10月08号 @param ctx Optional context. @param official Sidebar services. @returns Transfer component with native fallback and cancellable receipts. */
34
+ function planRouter(ctx: PlanContext, official: OfficialSidebar) {
35
+ const transfers = new Map<string, { controller: AbortController; sessionId: string }>()
36
+ /** @private @author ddj 2026年10月08号 @param all Plugin disposal. Cancel scope-stale transfers, not transfers merely hidden by the editor mount. */
37
+ function cancel(all = false): void {
38
+ for (const [key, transfer] of transfers) {
39
+ if (!all && transfer.sessionId === readSessionScope(ctx).sessionId) continue
40
+ transfer.controller.abort()
41
+ transfers.delete(key)
42
+ }
43
+ }
44
+ /** @private @author ddj 2026年10月08号 React to owning-session changes without cancelling on unrelated list updates. */
45
+ function scopeChanged(): void { cancel() }
46
+ const offScope = subscribeScope(ctx, scopeChanged)
47
+ /** @private @author ddj 2026年10月08号 @param props Official slot props. @returns Transfer status; only closes after preview readiness. */
48
+ function PlanRouter(props: PlanProps) {
49
+ const tab = props.useTabInfo?.()?.tab
50
+ const nav = tab?.navigation
51
+ const address = nav?.address ?? ''
52
+ const resource = props.useResource?.(address) ?? EMPTY_RESOURCE
53
+ const review = nav?.params?.planReview
54
+ const plan = planDocument(address, review ?? ('value' in resource ? resource.value : undefined))
55
+ const [failed, setFailed] = React.useState(false)
56
+ const done = React.useRef('')
57
+ const key = address + ':' + nav?.revision
58
+
59
+ /** @private @author ddj 2026年10月08号 Restore the official plan viewer; never re-claim our own fallback. */
60
+ function openNative(): void {
61
+ if (typeof official.service.openResource !== 'function') return
62
+ try {
63
+ official.service.openResource(address, nativeOptions(tab))
64
+ tab?.actions?.close?.()
65
+ } catch (error) { log.warn('计划官方查看器回退失败:' + String(error)) }
66
+ }
67
+ React.useEffect(() => {
68
+ if (!plan || done.current === key || props.sessionId !== readSessionScope(ctx).sessionId) return
69
+ const transferKey = props.sessionId + ':' + key
70
+ if (transfers.has(transferKey)) return
71
+ const controller = new AbortController()
72
+ transfers.set(transferKey, { controller, sessionId: props.sessionId! })
73
+ done.current = key
74
+ setFailed(false)
75
+ let mounted = true
76
+ /** @private @author ddj 2026年10月08号 @param ok Preview readiness. Preserve the resource tab on failure; a successful cold mount may already have hidden this component. */
77
+ function finish(ok: boolean): void {
78
+ transfers.delete(transferKey)
79
+ if (controller.signal.aborted) return
80
+ if (ok) tab?.actions?.close?.()
81
+ else if (mounted) setFailed(true)
82
+ }
83
+ /** @private @author ddj 2026年10月08号 @param error Unexpected routing failure. Keep the official fallback visible. */
84
+ function reject(error: unknown): void { log.warn('计划预览转发失败:' + String(error)); finish(false) }
85
+ void requestOpen(null, undefined, undefined, props.sessionId, { plan, preview: true, signal: controller.signal }).then(finish, reject)
86
+ return () => { mounted = false }
87
+ }, [key, plan?.markdown, props.sessionId])
88
+ const unavailable = failed || (!plan && ['none', 'failed'].includes(resource.status))
89
+ return React.createElement('div', { className: 'edrv-empty' },
90
+ React.createElement('div', null, unavailable ? '计划暂未能在文件编辑器打开' : '正在打开计划预览…'),
91
+ React.createElement('button', { className: 'edrv-pill edrv-pill-ghost', onClick: openNative }, '在官方计划查看器打开'))
92
+ }
93
+ return { body: PlanRouter, dispose: () => { offScope(); cancel(true) } }
94
+ }
95
+ /** @private @author ddj 2026年10月08号 @param ctx Context. @param official Optional sidebar. @returns Atomic claim disposer; failures retain official ownership. */
96
+ function registerPlan(ctx: PlanContext, official: OfficialSidebar): () => void {
97
+ const offType = official.tabs.register({
98
+ id: CLAIM_ID, kind: CLAIM_KIND, priority: 'extension',
99
+ patterns: ['dsh-resource://plan/**', 'dsh-resource://plan-review/**'],
100
+ canOpen: isPlanAddress, title: () => '计划预览',
101
+ })
102
+ const router = planRouter(ctx, official)
103
+ try {
104
+ const offBody = ctx.slots.inject(OFFICIAL_SLOT_NAME, () => ctx.slots.register({ name: OFFICIAL_SLOT_NAME, key: CLAIM_ID }, router.body))
105
+ return () => { router.dispose(); if (typeof offBody === 'function') offBody(); offType() }
106
+ } catch (error) { router.dispose(); offType(); throw error }
107
+ }
108
+ /** @private @author ddj 2026年10月08号 @param ctx Context. @returns Current-session feed subscription, cancelling pending opens on scope change. */
109
+ function watchPlans(ctx: PlanContext): () => void {
110
+ let source: EventSource | undefined
111
+ let owner = ''
112
+ let workspace = ''
113
+ let offFeed: (() => void) | undefined
114
+ let controller = new AbortController()
115
+
116
+ /** @private @author ddj 2026年10月08号 @param id Owning session. @returns Valid borrowed feed, or undefined on older/incompatible services. */
117
+ function sourceOf(id: string): EventSource | undefined {
118
+ try {
119
+ const sessions = optional(ctx, 'sessions') as Sessions | undefined
120
+ if (!id || typeof sessions?.binding !== 'function') return undefined
121
+ const feed = sessions.binding(id)?.eventSource
122
+ return typeof feed?.getSnapshot === 'function' && typeof feed.subscribe === 'function' ? feed : undefined
123
+ } catch { return undefined }
124
+ }
125
+ /** @private @author ddj 2026年10月08号 @param open Live plan target. Opens only in the still-current owning session. */
126
+ function openPlan(open: PlanOpen): void {
127
+ if (readSessionScope(ctx).sessionId !== owner || controller.signal.aborted) return
128
+ void requestOpen(open.path ?? null, undefined, undefined, owner, { preview: true, plan: open.plan, signal: controller.signal })
129
+ .catch(report)
130
+ }
131
+ /** @private @author ddj 2026年10月08号 @param error Open failure. Report without disrupting the Session event publisher. */
132
+ function report(error: unknown): void { log.warn('计划自动预览失败:' + String(error)) }
133
+ /** @private @author ddj 2026年10月08号 Borrow the retained binding, baseline history, and follow append deltas only. */
134
+ function attach(): void {
135
+ const scope = readSessionScope(ctx)
136
+ const id = scope.sessionId ?? ''
137
+ const cwd = scope.cwd ?? ''
138
+ const next = sourceOf(id)
139
+ if (next === source && id === owner && cwd === workspace) return
140
+ offFeed?.()
141
+ controller.abort()
142
+ controller = new AbortController()
143
+ source = next; owner = id; workspace = cwd
144
+ if (!next || !id) return
145
+ const feed = createPlanFeed(id, cwd)
146
+ try { feed.consume(next.getSnapshot()) } catch (error) { source = undefined; report(error); return }
147
+ /** @private @author ddj 2026年10月08号 Process a single synchronous window delta; no history scans. */
148
+ function consume(): void {
149
+ try { for (const open of feed.consume(next!.getSnapshot())) openPlan(open) } catch (error) { report(error) }
150
+ }
151
+ try { offFeed = next.subscribe(consume) } catch (error) { source = undefined; report(error) }
152
+ }
153
+ const offScope = subscribeScope(ctx, attach)
154
+ attach()
155
+ return () => { offScope(); offFeed?.(); controller.abort() }
156
+ }
157
+ /** @public @author ddj 2026年10月08号
158
+ * Install optional plan routing and live generation detection with bounded service-arrival retries.
159
+ * @param ctx Client context. @param schedule Lifecycle-owned timer scheduler. @returns Full cleanup for HMR/unload.
160
+ */
161
+ export function installPlanBridge(ctx: PlanContext, schedule: (fn: () => void, delay: number) => unknown): () => void {
162
+ let disposed = false
163
+ let left = 15
164
+ let offClaim: (() => void) | undefined
165
+ const offWatch = watchPlans(ctx)
166
+ /** @private @author ddj 2026年10月08号 Probe optional sidebar once, then bounded retries; never hold activation pending. */
167
+ function probe(): void {
168
+ if (disposed || offClaim) return
169
+ const official = detectOfficial(ctx)
170
+ if (official) {
171
+ try { offClaim = registerPlan(ctx, official); return } catch (error) { log.warn('计划接入降级:' + String(error)) }
172
+ }
173
+ left -= 1
174
+ if (left > 0) schedule(probe, 2000)
175
+ }
176
+ probe()
177
+ return () => { disposed = true; offWatch(); offClaim?.(); clearPlanState() }
178
+ }
@@ -0,0 +1,129 @@
1
+ /** Plan identities and live-only event reduction. @author ddj 2026年10月08号 */
2
+ export interface PlanDocument { address: string; markdown: string; title: string }
3
+ export interface PlanOpen { path?: string; plan?: PlanDocument }
4
+ export interface PlanEntry { type?: string; event: { type: string; seq?: number; data: unknown } }
5
+ export interface PlanWindow { revision: number; entries: readonly PlanEntry[]; change: { kind: string; entries?: readonly PlanEntry[] } }
6
+ const associated = new Map<string, Set<string>>()
7
+
8
+ /** @private @author ddj 2026年10月08号 @param value JSON candidate. @returns Object or empty record. */
9
+ function record(value: unknown): Record<string, unknown> {
10
+ return value !== null && typeof value === 'object' ? value as Record<string, unknown> : {}
11
+ }
12
+ /** @private @author ddj 2026年10月08号 @param value Tool arguments. @returns Parsed object, never throws. */
13
+ function argsOf(value: unknown): Record<string, unknown> {
14
+ if (typeof value !== 'string') return record(value)
15
+ try { return record(JSON.parse(value)) } catch { return {} }
16
+ }
17
+ /** @private @author ddj 2026年10月08号 @param path Path identity. @returns Slash-normalized, Windows-case-folded identity. */
18
+ function pathKey(path: string): string {
19
+ const value = path.replace(/\\/g, '/').replace(/\/+$/, '')
20
+ return /^(?:[a-z]:\/|\/\/)/i.test(value) ? value.toLowerCase() : value
21
+ }
22
+ /** @private @author ddj 2026年10月08号 @param path File path. @param cwd Workspace. @returns Absolute association identity. */
23
+ function absoluteKey(path: string, cwd = ''): string {
24
+ return pathKey(/^(?:[a-z]:[\\/]|\/)/i.test(path) || !cwd ? path : cwd + '/' + path.replace(/^\.\//, ''))
25
+ }
26
+ /** @public @author ddj 2026年10月08号 @param path File path. @param cwd Workspace. @param sessionId Association owner. @returns Whether a Markdown file belongs to a plan directory or explicit submission. */
27
+ export function isPlanPath(path: string, cwd = '', sessionId = ''): boolean {
28
+ if (!/\.(?:md|markdown)$/i.test(path) || path.replace(/\\/g, '/').split('/').includes('..')) return false
29
+ const key = absoluteKey(path, cwd)
30
+ const root = cwd ? pathKey(cwd) + '/plans/' : 'plans/'
31
+ return key.startsWith(root) || /(?:^|\/)\.dsh\/plans\//.test(key)
32
+ || Boolean(associated.get(sessionId)?.has(key))
33
+ }
34
+ /** @public @author ddj 2026年10月08号 @param address Official resource address. @returns Whether this is an official plan resource, without treating it as a disk path. */
35
+ export function isPlanAddress(address: unknown): address is string {
36
+ if (typeof address !== 'string') return false
37
+ try {
38
+ const segments = address.split('/').slice(3).map(decodeURIComponent)
39
+ if (segments.some((value) => !value || /[\\/]/.test(value))) return false
40
+ if (address.startsWith('dsh-resource://plan-review/')) return segments.length === 2
41
+ if (!address.startsWith('dsh-resource://plan/')) return false
42
+ return segments.length === 2 || (segments.length === 5 && segments[0] === 'subagent'
43
+ && ['one-shot', 'continuable', 'unknown'].includes(segments[3]))
44
+ } catch { return false }
45
+ }
46
+ /** @public @author ddj 2026年10月08号 @param address Official address. @param value Provider or review payload. @returns Valid complete read-only plan, if available. */
47
+ export function planDocument(address: string, value: unknown): PlanDocument | undefined {
48
+ const data = record(value)
49
+ if (!isPlanAddress(address) || typeof data.markdown !== 'string') return undefined
50
+ const title = /^#\s+(\S[^\r\n]*)/.exec(data.markdown.trim())?.[1]
51
+ if (!title) return undefined
52
+ return { address, markdown: data.markdown, title }
53
+ }
54
+ /** @private @author ddj 2026年10月08号 @param sessionId Current session. @param path Explicit plan path. @param cwd Workspace. Stores bounded associations, not file contents. */
55
+ function associate(sessionId: string, path: string, cwd: string): void {
56
+ const paths = associated.get(sessionId) ?? new Set<string>()
57
+ paths.add(absoluteKey(path, cwd))
58
+ if (paths.size > 128) paths.delete(paths.values().next().value!)
59
+ associated.set(sessionId, paths)
60
+ if (associated.size > 32) associated.delete(associated.keys().next().value!)
61
+ }
62
+ /** @public @author ddj 2026年10月08号 Clear associations when the plugin unloads. */
63
+ export function clearPlanState(): void { associated.clear() }
64
+
65
+ /** @public @author ddj 2026年10月08号
66
+ * Reduce only live append deltas; hydration, pagination, reconnect and duplicate sequences never open plans.
67
+ * @param sessionId Feed owner. @param cwd Workspace. @returns Revision-aware reducer.
68
+ */
69
+ export function createPlanFeed(sessionId: string, cwd = '') {
70
+ let revision: number | undefined
71
+ let watermark = -1
72
+ const pending = new Map<string, { name: string; args: Record<string, unknown> }>()
73
+ const seen = new Set<string>()
74
+
75
+ /** @private @author ddj 2026年10月08号 @param entry Durable entry. @param live Whether opening is permitted. @returns New plan open, if any. */
76
+ function reduce(entry: PlanEntry, live: boolean): PlanOpen | undefined {
77
+ const event = entry.event
78
+ const data = record(event.data)
79
+ const result = event.type === 'tool/result' ? record(data.message) : data
80
+ const id = event.type === 'tool/result' ? result.toolCallId : data.subCallId ?? data.callId
81
+ if (typeof id !== 'string') return undefined
82
+ const isCall = event.type === 'tool/call' || event.type === 'tool/ptc-dispatch-start'
83
+ const settled = event.type === 'tool/result' || event.type === 'tool/ptc-dispatch'
84
+ if (!isCall && !settled) return undefined
85
+ const args = argsOf(data.arguments)
86
+ if (isCall) pending.set(id, { name: String(data.name), args })
87
+ const call = event.type === 'tool/result' ? pending.get(id) : { name: String(data.name), args }
88
+ if (settled) pending.delete(id)
89
+ if (!call || !live || seen.has(id) || result.isError === true || data.error) return undefined
90
+ if (call.name === 'exit_plan_mode') {
91
+ if (event.type === 'tool/result') return undefined
92
+ const address = 'dsh-resource://plan/' + encodeURIComponent(sessionId) + '/' + encodeURIComponent(id)
93
+ const plan = planDocument(address, { markdown: call.args.plan })
94
+ if (plan) { remember(id); return { plan } }
95
+ return undefined
96
+ }
97
+ if (!settled || !['write', 'edit'].includes(call.name) || typeof call.args.file_path !== 'string') return undefined
98
+ const path = call.args.file_path
99
+ if (call.name === 'write' && typeof call.args.content === 'string' && /^#\s*计划[::]/.test(call.args.content.trim())) associate(sessionId, path, cwd)
100
+ if (!isPlanPath(path, cwd, sessionId)) return undefined
101
+ remember(id)
102
+ return { path }
103
+ }
104
+ /** @private @author ddj 2026年10月08号 @param id Handled call. Bound retained identities and incomplete calls. */
105
+ function remember(id: string): void {
106
+ seen.add(id)
107
+ if (seen.size > 256) seen.delete(seen.values().next().value!)
108
+ }
109
+ /** @public @author ddj 2026年10月08号 @param snapshot Event window. @returns Live opens, in durable order. */
110
+ function consume(snapshot: PlanWindow): PlanOpen[] {
111
+ if (snapshot.revision === revision) return []
112
+ const baseline = revision === undefined || snapshot.change.kind === 'replace'
113
+ revision = snapshot.revision
114
+ if (baseline) pending.clear()
115
+ const entries = baseline ? snapshot.entries : snapshot.change.kind === 'append' ? snapshot.change.entries ?? [] : []
116
+ const opens: PlanOpen[] = []
117
+ for (const entry of entries) {
118
+ if (entry.type === 'transient') continue
119
+ const seq = entry.event.seq
120
+ const live = !baseline && (seq === undefined || seq > watermark)
121
+ if (seq !== undefined) watermark = Math.max(watermark, seq)
122
+ const open = reduce(entry, live)
123
+ if (open) opens.push(open)
124
+ }
125
+ while (pending.size > 256) pending.delete(pending.keys().next().value!)
126
+ return opens
127
+ }
128
+ return { consume }
129
+ }
@@ -87,6 +87,7 @@ export function receiptReadyFor(request: EditorRequest, view: ReceiptView): bool
87
87
  if (view.kind === 'pdf') return view.pdfLoaded
88
88
  if (!view.contentReady || !view.contentPath || receiptPath(view.contentPath, view.cwd) !== receiptPath(request.path, view.cwd)) return false
89
89
  const positioned = request.line != null || request.column != null
90
+ if (request.preview === true && !positioned && view.kind !== 'markdown') return false
90
91
  if (view.kind === 'markdown') return !positioned
91
92
  return view.modelMatches && (!positioned || view.positionMatches)
92
93
  }
@@ -4,7 +4,7 @@
4
4
  * 作者 ddj 2026-08-20
5
5
  */
6
6
  import type { Hunk, RecordView } from '../../shared/types.js'
7
- import { locateHunks, lineBefore, normalizeForCompare, normalizeHunk, preciseHunk, splitLines } from '../../shared/diff.js'
7
+ import { applyLocations, fingerprint, locateHunks, lineBefore, normalizeForCompare, normalizeHunk, preciseHunk, splitLines } from '../../shared/diff.js'
8
8
  import { ST, noopHunk, statusAt } from './records.js'
9
9
  import type { Status } from './records.js'
10
10
 
@@ -14,6 +14,8 @@ export interface Region {
14
14
  idx: number
15
15
  start?: number
16
16
  end?: number
17
+ /** 原快照中的 1 基行号;历史证据不足时不伪造为当前行号。 */
18
+ oldStart?: number
17
19
  oldLines: string[]
18
20
  newLines: string[]
19
21
  whole?: boolean
@@ -64,6 +66,77 @@ export function trimCommonLines(oldLines: string[], newLines: string[]): { oldLi
64
66
  return { oldLines: oldLines.slice(prefix, oldLines.length - suffix), newLines: newLines.slice(prefix, newLines.length - suffix), shift: prefix }
65
67
  }
66
68
 
69
+ /**
70
+ * 从执行后快照反向验证原文本,计算每块原行号(而非当前文件行号)。
71
+ * @private @author ddj 2026年10月09号
72
+ * @param rec 审查记录
73
+ * @returns 按 hunk 索引的原行号;不完整/指纹冲突时全部留空
74
+ */
75
+ function oldStartsOf(rec: RecordView): Array<number | undefined> {
76
+ if (typeof rec.after !== 'string' || rec.create) return []
77
+ const hunks = rec.hunks.map((_, index) => preciseHunk(rec, index))
78
+ if (hunks.some((hunk) => !hunk || hunk.oldText === null)) return []
79
+ const locations = locateHunks(rec.after, hunks as Hunk[])
80
+ // 没有原指纹时,要求每块有验证过的快照坐标,避免重复 newText 被猜中。
81
+ for (const location of locations) {
82
+ const hunk = location.hunk
83
+ if (!location.matched || location.end > rec.after.length || location.end - location.start !== hunk.newText.length) return []
84
+ if (!rec.baseFingerprint && (hunk.afterStart !== location.start || hunk.afterEnd !== location.end)) return []
85
+ }
86
+ const original = applyLocations(rec.after, locations, true)
87
+ if (original.stale.length || (rec.baseFingerprint && fingerprint(original.content) !== rec.baseFingerprint)) return []
88
+ const breaks: number[] = []
89
+ for (let index = rec.after.indexOf('\n'); index >= 0; index = rec.after.indexOf('\n', index + 1)) breaks.push(index)
90
+ const starts: Array<number | undefined> = []
91
+ let shift = 0
92
+ for (const location of locations.slice().sort((a, b) => a.start - b.start)) {
93
+ starts[location.idx] = lineAt(breaks, location.start) + shift + 1
94
+ const hunk = location.hunk
95
+ shift += lineBefore(hunk.oldText!, hunk.oldText!.length) - lineBefore(hunk.newText, hunk.newText.length)
96
+ }
97
+ return starts
98
+ }
99
+
100
+ /**
101
+ * 将快照坐标一次性映射到 LF/BOM 归一化坐标,避免每块扫描文件前缀。
102
+ * @private @author ddj 2026年10月09号
103
+ * @param after 已确认相同的原始快照 @param hunks 块集合
104
+ * @returns 原偏移到展示偏移的映射;O(文本长度 + 块数 log 块数)
105
+ */
106
+ function offsetsOf(after: string | undefined, hunks: Hunk[]): Map<number, number> {
107
+ const offsets = new Map<number, number>()
108
+ if (after === undefined) return offsets
109
+ const points = hunks.flatMap((hunk) => [hunk.afterStart, hunk.afterEnd])
110
+ .filter((point): point is number => Number.isInteger(point) && point! >= 0 && point! <= after.length)
111
+ .sort((a, b) => a - b)
112
+ let cursor = 0
113
+ let removed = 0
114
+ for (const point of points) {
115
+ while (cursor < point) {
116
+ if ((cursor === 0 && after[cursor] === '\uFEFF') || (after[cursor] === '\r' && after[cursor + 1] === '\n')) removed++
117
+ cursor++
118
+ }
119
+ offsets.set(point, point - removed)
120
+ }
121
+ return offsets
122
+ }
123
+
124
+ /**
125
+ * 保留可验证的归一化快照坐标,让纯删除也能在 CRLF/BOM 文本中定位。
126
+ * @private @author ddj 2026年10月09号
127
+ * @param hunk 原始块 @param after 已确认与当前文本相同的执行后快照 @param offsets 展示偏移映射
128
+ * @returns 展示用块;快照已变化时不借用删除点
129
+ */
130
+ function viewHunk(hunk: Hunk, after: string | undefined, offsets: Map<number, number>): Hunk {
131
+ const normalized = normalizeHunk(hunk)
132
+ if (after === undefined) return normalized
133
+ const start = hunk.afterStart
134
+ const end = hunk.afterEnd
135
+ if (!Number.isInteger(start) || !Number.isInteger(end) || start! < 0 || end! < start! || end! > after.length) return normalized
136
+ if (after.slice(start, end) !== hunk.newText) return normalized
137
+ return { ...normalized, afterStart: offsets.get(start!), afterEnd: offsets.get(end!) }
138
+ }
139
+
67
140
  /**
68
141
  * 计算文件内各差异区域(行范围 + old/new + 状态),用于行内绿标注与 DiffBox。
69
142
  * 定位统一基于归一化文本(剥 BOM、CRLF→LF):外部工具可能改变行尾/BOM,
@@ -87,10 +160,13 @@ export function diffRegions(records: RecordView[], content: string | null): Regi
87
160
  }
88
161
  continue
89
162
  }
163
+ const oldStarts = oldStartsOf(rec)
164
+ const snapshot = typeof rec.after === 'string' && normalizeForCompare(rec.after) === normalized ? rec.after : undefined
165
+ const offsets = offsetsOf(snapshot, rec.hunks)
90
166
  const entries: Array<{ idx: number; hunk: Hunk }> = []
91
167
  for (let i = 0; i < rec.hunks.length; i++) {
92
168
  const hunk = preciseHunk(rec, i)
93
- if (hunk && !noopHunk(rec, hunk)) entries.push({ idx: i, hunk: normalizeHunk(hunk) })
169
+ if (hunk && !noopHunk(rec, hunk)) entries.push({ idx: i, hunk: viewHunk(hunk, snapshot, offsets) })
94
170
  }
95
171
  const locations = locateHunks(normalized, entries.map((entry) => entry.hunk))
96
172
  for (let i = 0; i < entries.length; i++) {
@@ -105,8 +181,12 @@ export function diffRegions(records: RecordView[], content: string | null): Regi
105
181
  const oldLines = entry.hunk.oldText === null ? [] : entry.hunk.oldText.split('\n')
106
182
  const newLines = entry.hunk.newText.split('\n')
107
183
  const trimmed = trimCommonLines(oldLines, newLines)
184
+ // 保留尾换行的公共空行裁剪,但空字符串自身不制造增删占位行。
185
+ if (entry.hunk.oldText === '') trimmed.oldLines = []
186
+ if (entry.hunk.newText === '') trimmed.newLines = []
108
187
  const regionStart = start + trimmed.shift
109
- regions.push({ callId: rec.callId, idx: entry.idx, start: regionStart, end: regionStart + trimmed.newLines.length, oldLines: trimmed.oldLines, newLines: trimmed.newLines, status, create: false, rec, superseded: rec.superseded === true })
188
+ const oldStart = oldStarts[entry.idx] === undefined ? undefined : oldStarts[entry.idx]! + trimmed.shift
189
+ regions.push({ callId: rec.callId, idx: entry.idx, start: regionStart, end: regionStart + trimmed.newLines.length, oldStart, oldLines: trimmed.oldLines, newLines: trimmed.newLines, status, create: false, rec, superseded: rec.superseded === true })
110
190
  }
111
191
  }
112
192
  regions.sort((a, b) => (a.start ?? Infinity) - (b.start ?? Infinity))
@@ -61,15 +61,16 @@
61
61
  [data-edrv-view] .edrv-chip-btn:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(255,255,255,.06)); }
62
62
  [data-edrv-view] .edrv-chip-btn:disabled { opacity: .35; cursor: default; }
63
63
  [data-edrv-view] .edrv-chip-btn:disabled:hover { background: transparent; }
64
- [data-edrv-view] .edrv-mn-add-line { background: var(--dsw-alias-interactive-bg-hover, rgba(15,157,88,.10)); }
65
- [data-edrv-view] .edrv-mn-gutter-add { color: var(--dsw-alias-state-success-primary, #0f9d58) !important; font-weight: 700; display: flex !important; align-items: center; justify-content: center; }
64
+ /* 行内和原生 Monaco diff 共用主题色盘;不再借用普通 hover 的灰底。 */
65
+ [data-edrv-view] .edrv-mn-add-line { background: var(--edrv-diff-add, #e6f4e8); }
66
+ [data-edrv-view] .edrv-mn-gutter-add { background: var(--edrv-diff-add, #e6f4e8); color: var(--dsw-alias-state-success-primary, #0f9d58) !important; display: flex !important; align-items: center; justify-content: center; }
66
67
  [data-edrv-view] .edrv-mn-gutter-add::before { content: "+"; }
67
- [data-edrv-view] .edrv-del-zone { background: var(--dsw-alias-interactive-bg-hover-danger, rgba(217,83,79,.14)); border-top: 1px solid var(--dsw-alias-state-error-primary, rgba(217,83,79,.35)); border-bottom: 1px solid var(--dsw-alias-state-error-primary, rgba(217,83,79,.35)); box-shadow: inset 0 0 0 1px var(--dsw-alias-interactive-bg-hover-danger, rgba(217,83,79,.12)); }
68
- [data-edrv-view] .edrv-del-row { white-space: pre; font-family: var(--ds-font-family-code, ui-monospace, monospace); font-size: 12.5px; line-height: 20px; color: var(--dsw-alias-state-error-primary, #c0392b); }
68
+ [data-edrv-view] .edrv-del-zone, [data-edrv-view] .edrv-del-margin { background: var(--edrv-diff-del, #fbe7e3); color: var(--edrv-diff-fg, #1e1e1e); }
69
+ [data-edrv-view] .edrv-del-row { white-space: pre; height: var(--edrv-line-height, 20px); line-height: var(--edrv-line-height, 20px); }
69
70
  [data-edrv-view] .edrv-del-text { display: inline-block; white-space: pre; }
70
- [data-edrv-view] .edrv-minus-overlay { position: absolute; left: 0; top: 0; width: 100%; height: 100%; max-width: 100%; overflow: visible; pointer-events: none; z-index: 6; }
71
- [data-edrv-view] .edrv-minus-item { position: absolute; box-sizing: border-box; max-width: 100%; overflow: hidden; }
72
- [data-edrv-view] .edrv-minus-item::before { content: ''; position: absolute; left: 4px; top: 50%; width: 8px; height: 2px; transform: translateY(-50%); border-radius: 2px; background: var(--dsw-alias-state-error-primary, #d9534f); }
71
+ [data-edrv-view] .edrv-del-margin-row { position: relative; height: var(--edrv-line-height, 20px); line-height: var(--edrv-line-height, 20px); }
72
+ [data-edrv-view] .edrv-del-number { position: absolute; left: var(--edrv-number-left); width: var(--edrv-number-width); color: var(--edrv-diff-number, #6c6c6c); text-align: right; }
73
+ [data-edrv-view] .edrv-del-marker { position: absolute; left: var(--edrv-marker-left); width: var(--edrv-marker-width); color: var(--dsw-alias-state-error-primary, #d9534f); text-align: center; }
73
74
  [data-edrv-view] .edrv-monaco-host { flex: 1 1 auto; min-width: 0; min-height: 0; position: relative; overflow: hidden; }
74
75
  [data-edrv-view] .edrv-monaco-host > div { position: absolute; inset: 0; min-width: 0; min-height: 0; }
75
76
  [data-edrv-view] .edrv-imgview { flex: 1 1 auto; min-width: 0; min-height: 0; display: flex; flex-direction: column; overflow: hidden; }
@@ -49,16 +49,19 @@
49
49
  .vsm-mcp-card-head{display:flex;justify-content:space-between;align-items:center;gap:12px}
50
50
  .vsm-mcp-title{display:flex;align-items:center;gap:9px;font-weight:600;font-size:14px;color:var(--dsw-alias-label-primary,#e6e6e6);min-width:0}
51
51
  .vsm-mcp-source{font-size:11px;font-weight:400;padding:2px 7px;border-radius:4px;background:var(--dsw-alias-bg-mask-2,#ffffff22);color:var(--dsw-alias-label-tertiary,#9aa5b1);flex-shrink:0}
52
- .vsm-mcp-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0;background:var(--dsw-alias-state-error-primary,#e05252)}
52
+ .vsm-mcp-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0;background:var(--dsw-alias-label-tertiary,#9aa5b1)}
53
53
  .vsm-mcp-dot.connected{background:var(--dsw-alias-state-success-primary,#38bd75)}
54
54
  .vsm-mcp-dot.connecting{background:var(--dsw-alias-state-warn-primary,#e5b93f)}
55
- .vsm-mcp-dot.disabled{background:var(--dsw-alias-label-tertiary,#9aa5b1)}
55
+ .vsm-mcp-dot.error{background:var(--dsw-alias-state-error-primary,#e05252)}
56
+ .vsm-mcp-dot.disabled,.vsm-mcp-dot.neutral,.vsm-mcp-dot.configured,.vsm-mcp-dot.unverified{background:var(--dsw-alias-label-tertiary,#9aa5b1)}
56
57
  .vsm-mcp-actions{display:flex;align-items:center;gap:4px;flex-shrink:0}
57
58
  .vsm-mcp-actions button{border:0;background:none;color:var(--dsw-alias-label-tertiary,#9aa5b1);cursor:pointer;font-size:15px;width:28px;height:28px;border-radius:6px;line-height:1}
58
59
  .vsm-mcp-actions button:hover{color:var(--dsw-alias-label-primary,#fff);background:var(--dsw-alias-interactive-bg-hover,#ffffff14)}
59
60
  .vsm-mcp-actions button.vsm-danger:hover{color:var(--dsw-alias-state-error-primary,#f07878);background:var(--dsw-alias-interactive-bg-hover-danger,#ff000014)}
60
61
  .vsm-switch{font-size:14px !important}
61
- .vsm-switch.on{color:var(--dsw-alias-state-success-primary,#38bd75)}
62
+ /* 配置启用使用品牌色,不冒充在线状态;在线证据仅由 connected 圆点表达。 */
63
+ .vsm-switch.on{color:var(--dsw-alias-brand-primary,#6d55ff)}
64
+ .vsm-mcp-actions button:disabled,.vsm-mcp-dialog-actions button:disabled{opacity:.55;cursor:default}
62
65
  .vsm-mcp-meta{font-size:12px;color:var(--dsw-alias-label-tertiary,#9aa5b1);margin:9px 0}
63
66
  .vsm-mcp-error{color:var(--dsw-alias-state-error-primary,#f07878);font-size:12px;margin:8px 0}
64
67
  .vsm-mcp-banner{padding:9px 12px;border:1px solid var(--dsw-alias-state-error-secondary,#d9534f55);border-radius:6px;background:var(--dsw-alias-state-error-secondary,#d9534f1a);margin-bottom:10px}
@@ -78,7 +78,7 @@ export function createClaimRouter(spec) {
78
78
  const line = resolveNavLine(navigation?.params)
79
79
  let left = FORWARD_ATTEMPTS
80
80
  const attempt = () => {
81
- if (forwardToEditor(service, parsed.path, line)) {
81
+ if (forwardToEditor(service, parsed.path, line, navigation?.params?.preview)) {
82
82
  closeTab(tab)
83
83
  return
84
84
  }