dsh-vscode-mode 0.14.4 → 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.
- package/README.md +13 -4
- package/lib/client.js +957 -220
- package/lib/client.js.map +1 -1
- package/package.json +1 -1
- package/src/client/index.ts +2 -0
- package/src/client/monaco/diffRender.ts +234 -174
- package/src/client/monaco/theme.ts +26 -15
- package/src/client/officialSidebar.ts +6 -1
- package/src/client/openReceipt.ts +13 -3
- package/src/client/planBridge.ts +178 -0
- package/src/client/planState.ts +129 -0
- package/src/client/receiptState.ts +1 -0
- package/src/client/state/regions.ts +83 -3
- package/src/client/styles/editor.css +8 -7
- package/src/client/ui/ClaimRouter.ts +1 -1
- package/src/client/ui/EditorView.ts +107 -36
- package/src/client/ui/OfficialSideTab.ts +2 -2
|
@@ -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:
|
|
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
|
-
|
|
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
|
-
|
|
65
|
-
[data-edrv-view] .edrv-mn-
|
|
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
|
|
68
|
-
[data-edrv-view] .edrv-del-row { white-space: pre;
|
|
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-
|
|
71
|
-
[data-edrv-view] .edrv-
|
|
72
|
-
[data-edrv-view] .edrv-
|
|
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; }
|
|
@@ -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
|
}
|