dsh-vscode-mode 0.14.6 → 0.14.8

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,304 @@
1
+ /** Host-only scoped SVN index and single-flight invalidation queue. @author ddj 2026年10月10号 */
2
+ import type { SvnChangeEntry, SvnItemStatus } from './shared/svn.js'
3
+ import { SVN_FOLDER_STATUS_RANK } from './shared/svn.js'
4
+ import { explorerMarkOf, localStatusOf } from './shared/svnExplorer.js'
5
+ import type { SvnExplorerIndex } from './shared/svnExplorer.js'
6
+ import { EXPLORER_CAP } from './svnExplorer.js'
7
+
8
+ export interface ExplorerScan { entries: SvnChangeEntry[]; truncated: boolean; roots?: readonly string[] }
9
+ export type ExplorerScanner = (paths: readonly string[], signal: AbortSignal) => Promise<ExplorerScan>
10
+ export interface ExplorerChange { paths?: readonly string[]; full?: boolean; event?: string }
11
+ export interface ExplorerState {
12
+ revision: number; generation: number; observedGeneration: number; confirmedAt: number; complete: boolean
13
+ refreshing: boolean; scanMs: number; error: string
14
+ }
15
+ type StatusCounts = Map<SvnItemStatus, number>
16
+ const QUIET_MS = 300
17
+ const MAX_WAIT_MS = 1000
18
+ const EVENT_CAP = 512
19
+
20
+ // --region Path sets and normalization
21
+ /** @public @author ddj 2026年10月10号 @param path Validated relative path. @param windows Case-insensitive platform. @returns Canonical lookup key, preserving row spelling separately. */
22
+ export function explorerKey(path: string, windows = process.platform === 'win32'): string {
23
+ const normalized = path.replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+$/, '')
24
+ return windows ? normalized.toLowerCase() : normalized
25
+ }
26
+ /** @private @author ddj 2026年10月10号 @param path Canonical descendant. @param root Canonical subtree. @returns Segment-bound membership including root. */
27
+ function within(path: string, root: string): boolean { return !root || path === root || path.startsWith(root + '/') }
28
+ /** @private @author ddj 2026年10月10号 @param path Canonical entry. @returns Ancestors excluding virtual scope root. */
29
+ function ancestors(path: string): string[] {
30
+ const parents: string[] = []
31
+ let parent = path
32
+ while (parent.includes('/')) { parent = parent.slice(0, parent.lastIndexOf('/')); parents.push(parent) }
33
+ return parents
34
+ }
35
+ /** @public @author ddj 2026年10月10号 @param paths Changed paths. @param windows Platform matching. @returns Minimal parent subtree set; empty root means full scan. */
36
+ export function scanRootsOf(paths: readonly string[], windows = process.platform === 'win32'): string[] {
37
+ const roots: string[] = []
38
+ for (const raw of paths) {
39
+ const path = explorerKey(raw, windows)
40
+ const parent = path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : ''
41
+ if (roots.some((root) => within(parent, root))) continue
42
+ for (let i = roots.length - 1; i >= 0; i--) if (within(roots[i], parent)) roots.splice(i, 1)
43
+ roots.push(parent)
44
+ }
45
+ return roots
46
+ }
47
+ // --endregion
48
+
49
+ // --region Incremental subtree index
50
+ /** @public @author ddj 2026年10月10号 Retains authoritative entries and decrementable ancestor counts; no client-wide payload. */
51
+ export class ExplorerIndex {
52
+ readonly index: SvnExplorerIndex = { entries: new Map(), folders: new Map(), truncated: false }
53
+ private readonly counts = new Map<string, StatusCounts>()
54
+ private readonly children = new Map<string, Set<string>>()
55
+ /** @public @author ddj 2026年10月10号 @param windows Case-insensitive lookup mode. */
56
+ constructor(private readonly windows = process.platform === 'win32') {}
57
+
58
+ /** @private @author ddj 2026年10月10号 @param entry Entry contribution. @param delta Add or remove. Updates strongest status after downgrade. */
59
+ private contribute(entry: SvnChangeEntry, delta: number): void {
60
+ const status = localStatusOf(entry)
61
+ if (status === 'normal' || status === 'ignored' || status === 'external') return
62
+ for (const parent of ancestors(entry.path)) {
63
+ const counts = this.counts.get(parent) ?? new Map<SvnItemStatus, number>()
64
+ const next = (counts.get(status) ?? 0) + delta
65
+ if (next > 0) counts.set(status, next)
66
+ else counts.delete(status)
67
+ if (!counts.size) { this.counts.delete(parent); this.index.folders.delete(parent); continue }
68
+ this.counts.set(parent, counts)
69
+ let strongest: SvnItemStatus = 'normal'
70
+ let count = 0
71
+ for (const [item, n] of counts) {
72
+ count += n
73
+ if ((SVN_FOLDER_STATUS_RANK[item] ?? 0) > (SVN_FOLDER_STATUS_RANK[strongest] ?? 0)) strongest = item
74
+ }
75
+ this.index.folders.set(parent, { status: strongest, count })
76
+ }
77
+ }
78
+
79
+ /** @private @author ddj 2026年10月10号 @param path Canonical entry. Build direct child membership, including implicit ancestors. */
80
+ private link(path: string): void {
81
+ let child = path
82
+ while (child) {
83
+ const parent = child.includes('/') ? child.slice(0, child.lastIndexOf('/')) : ''
84
+ const siblings = this.children.get(parent) ?? new Set<string>()
85
+ siblings.add(child); this.children.set(parent, siblings)
86
+ child = parent
87
+ }
88
+ }
89
+ /** @private @author ddj 2026年10月10号 @param path Removed entry. Prune only empty implicit ancestors; retained siblings remain indexed. */
90
+ private unlink(path: string): void {
91
+ let child = path
92
+ while (child && !this.index.entries.has(child) && !this.children.get(child)?.size) {
93
+ const parent = child.includes('/') ? child.slice(0, child.lastIndexOf('/')) : ''
94
+ this.children.delete(child)
95
+ const siblings = this.children.get(parent)
96
+ siblings?.delete(child)
97
+ if (!siblings?.size) this.children.delete(parent)
98
+ child = parent
99
+ }
100
+ }
101
+ /** @private @author ddj 2026年10月10号 @param roots Subtree membership roots. @returns Retained affected entries only; no whole-index iteration for local replacement. */
102
+ private members(roots: readonly string[]): string[] {
103
+ const result = new Set<string>(), pending = [...roots], visited = new Set<string>()
104
+ while (pending.length) {
105
+ const path = pending.pop()!
106
+ if (visited.has(path)) continue
107
+ visited.add(path)
108
+ if (this.index.entries.has(path)) result.add(path)
109
+ for (const child of this.children.get(path) ?? []) pending.push(child)
110
+ }
111
+ return [...result]
112
+ }
113
+
114
+ /** @public @author ddj 2026年10月10号 @param roots Replaced session-relative subtrees. @param result Complete or bounded scan. @throws Capacity mismatch on partial replacement; caller must full-calibrate. */
115
+ replace(roots: readonly string[], result: ExplorerScan): void {
116
+ const keys = roots.map((root) => explorerKey(root, this.windows))
117
+ const full = keys.includes('')
118
+ const incoming = new Map<string, SvnChangeEntry>()
119
+ for (const entry of result.entries) {
120
+ const path = explorerKey(entry.path, this.windows)
121
+ if (keys.some((root) => within(path, root))) incoming.set(path, { ...entry, path })
122
+ }
123
+ const removed = full ? [] : this.members(keys)
124
+ if (!full && (result.truncated || this.index.entries.size - removed.length + incoming.size > EXPLORER_CAP)) {
125
+ throw new Error('局部 SVN 状态不完整,需要全量校准')
126
+ }
127
+ if (full) this.clear()
128
+ else for (const path of removed) {
129
+ this.contribute(this.index.entries.get(path)!, -1)
130
+ this.index.entries.delete(path)
131
+ this.unlink(path)
132
+ }
133
+ for (const [path, entry] of incoming) { this.index.entries.set(path, entry); this.link(path); this.contribute(entry, 1) }
134
+ if (full) this.index.truncated = result.truncated
135
+ }
136
+
137
+ /** @public @author ddj 2026年10月10号 Release retained entries and aggregation counters. */
138
+ clear(): void { this.index.entries.clear(); this.index.folders.clear(); this.counts.clear(); this.children.clear() }
139
+
140
+ /** @public @author ddj 2026年10月10号 @param path Display row spelling. @param isDir Directory row. @returns Exact/ancestor mark and dirty descendant count. */
141
+ mark(path: string, isDir: boolean) {
142
+ const key = explorerKey(path, this.windows)
143
+ return { mark: explorerMarkOf(this.index, key, isDir), count: isDir ? this.index.folders.get(key)?.count ?? 0 : 0 }
144
+ }
145
+ }
146
+ // --endregion
147
+
148
+ // --region Scoped ownership
149
+ /** @public @author ddj 2026年10月10号 Two retained scope indexes; active work is never evicted to satisfy a new reader. */
150
+ export class ExplorerScopes {
151
+ private readonly scopes = new Map<string, ExplorerQueue>()
152
+ /** @public @author ddj 2026年10月10号 @param key Canonical root/session key. @param scan Scanner bound to this scope. @param now Clock. @param windows Matching. @returns Shared owner; refuses new scopes if all retained owners are busy. */
153
+ get(key: string, scan: ExplorerScanner, now = Date.now, windows = process.platform === 'win32'): ExplorerQueue {
154
+ const existing = this.scopes.get(key)
155
+ if (existing) { this.scopes.delete(key); this.scopes.set(key, existing); return existing }
156
+ if (this.scopes.size >= 2) {
157
+ const idle = [...this.scopes].find(([, queue]) => queue.idle())
158
+ if (!idle) throw new Error('SVN 状态扫描繁忙,请稍后重试')
159
+ idle[1].dispose()
160
+ this.scopes.delete(idle[0])
161
+ }
162
+ const queue = new ExplorerQueue(scan, now, windows)
163
+ this.scopes.set(key, queue)
164
+ return queue
165
+ }
166
+ /** @public @author ddj 2026年10月10号 Release all scoped work and retained state. */
167
+ dispose(): void { for (const queue of this.scopes.values()) queue.dispose(); this.scopes.clear() }
168
+ }
169
+ // --endregion
170
+
171
+ // --region Single-flight dirty queue
172
+ /** @public @author ddj 2026年10月10号 Coalesces generations without swallowing changes arriving during a scan. */
173
+ export class ExplorerQueue {
174
+ readonly data: ExplorerIndex
175
+ private generation = 0
176
+ private observed = 0
177
+ private revision = 0
178
+ private confirmedAt = 0
179
+ private scanMs = 0
180
+ private error = ''
181
+ private fullDirty = false
182
+ private readonly dirty = new Set<string>()
183
+ private active: readonly string[] | null = null
184
+ private timer: ReturnType<typeof setTimeout> | null = null
185
+ private pendingSince: number | null = null
186
+ private lastEventAt = 0
187
+ private controller: AbortController | null = null
188
+ private flight: Promise<void> | null = null
189
+ private stopped = false
190
+ private readonly events = new Set<string>()
191
+ private staleRoots: readonly string[] | null = null
192
+
193
+ /** @public @author ddj 2026年10月10号 @param scan Managed scoped scan. @param now Injectable clock. @param windows Canonical path matching. */
194
+ constructor(private readonly scan: ExplorerScanner, private readonly now = Date.now, private readonly windows = process.platform === 'win32') {
195
+ this.data = new ExplorerIndex(windows)
196
+ }
197
+
198
+ /** @public @author ddj 2026年10月10号 @param change Path/full invalidation and idempotency token. @returns Whether a new change was registered. */
199
+ invalidate(change: ExplorerChange = { full: true }): boolean {
200
+ if (this.stopped || (change.event && this.events.has(change.event))) return false
201
+ if (change.event) {
202
+ this.events.add(change.event)
203
+ if (this.events.size > EVENT_CAP) this.events.delete(this.events.values().next().value!)
204
+ }
205
+ this.generation++
206
+ this.staleRoots = null
207
+ this.lastEventAt = this.now()
208
+ if (this.pendingSince === null) this.pendingSince = this.lastEventAt
209
+ if (change.full || !change.paths?.length || !this.confirmedAt || this.data.index.truncated) this.fullDirty = true
210
+ for (const path of change.paths ?? []) this.dirty.add(explorerKey(path, this.windows))
211
+ if (this.dirty.size > 256 || scanRootsOf([...this.dirty], this.windows).length > 32) this.fullDirty = true
212
+ if (this.fullDirty) this.dirty.clear()
213
+ this.schedule()
214
+ return true
215
+ }
216
+
217
+ /** @private @author ddj 2026年10月10号 Tail debounce has a hard max wait; active scans retain one merged follow-up batch. */
218
+ private schedule(): void {
219
+ if (this.stopped || this.active || this.pendingSince === null) return
220
+ if (this.timer) clearTimeout(this.timer)
221
+ const delay = Math.max(0, Math.min(this.lastEventAt + QUIET_MS, this.pendingSince + MAX_WAIT_MS) - this.now())
222
+ this.timer = setTimeout(() => { this.timer = null; this.launch() }, delay)
223
+ }
224
+
225
+ /** @private @author ddj 2026年10月10号 Starts one scan, snapshots generation and dirtiness before yielding. */
226
+ private launch(): void {
227
+ if (this.stopped || this.active || this.pendingSince === null) return
228
+ const roots = this.fullDirty ? [''] : scanRootsOf([...this.dirty], this.windows)
229
+ const generation = this.generation
230
+ this.fullDirty = false
231
+ this.dirty.clear()
232
+ this.pendingSince = null
233
+ this.active = roots
234
+ this.staleRoots = null
235
+ this.controller = new AbortController()
236
+ this.flight = this.run(roots, generation, this.controller.signal)
237
+ }
238
+
239
+ /** @private @author ddj 2026年10月10号 @param roots Scan subtrees. @param generation Captured invalidation generation. @param signal Owner disposal. Applies known evidence but never confirms newer dirty paths. */
240
+ private async run(roots: readonly string[], generation: number, signal: AbortSignal): Promise<void> {
241
+ const started = this.now()
242
+ try {
243
+ const result = await this.scan(roots, signal)
244
+ if (this.stopped) return
245
+ this.data.replace(result.roots ?? roots, result)
246
+ this.observed = generation
247
+ this.revision++
248
+ this.error = ''
249
+ if (generation === this.generation && !result.truncated) this.confirmedAt = this.now()
250
+ } catch (failure) {
251
+ if (this.stopped) return
252
+ this.error = String(failure)
253
+ // A failed subtree must not keep green evidence. Retry only on a later read/event, not a spin loop.
254
+ this.fullDirty = true
255
+ } finally {
256
+ this.scanMs = Math.max(0, this.now() - started)
257
+ this.active = null
258
+ this.staleRoots = null
259
+ this.controller = null
260
+ this.flight = null
261
+ this.schedule()
262
+ }
263
+ }
264
+
265
+ /** @public @author ddj 2026年10月10号 @param path Display row. @param isDir Directory membership. @returns Whether evidence intersects active/queued changes or incomplete state. */
266
+ stale(path: string, isDir: boolean): boolean {
267
+ if (!this.confirmedAt || this.fullDirty || this.data.index.truncated) return true
268
+ const key = explorerKey(path, this.windows)
269
+ const roots = this.staleRoots ??= [...(this.active ?? []), ...scanRootsOf([...this.dirty], this.windows)]
270
+ return roots.some((root) => within(key, root) || (isDir && within(root, key)))
271
+ }
272
+
273
+ /** @public @author ddj 2026年10月10号 @returns Lightweight scope metadata; freshness is independent of cache TTL. */
274
+ state(): ExplorerState {
275
+ return { revision: this.revision, generation: this.generation, observedGeneration: this.observed, confirmedAt: this.confirmedAt,
276
+ complete: this.confirmedAt > 0 && !this.data.index.truncated && !this.fullDirty && !this.active && !this.dirty.size,
277
+ refreshing: this.active !== null || this.timer !== null, scanMs: this.scanMs, error: this.error }
278
+ }
279
+
280
+ /** @public @author ddj 2026年10月10号 @returns First-load confirmation; warm callers project immediately with stale flags. */
281
+ async initial(): Promise<void> {
282
+ if (this.stopped) throw new Error('SVN 状态调度已释放')
283
+ if (this.confirmedAt || this.revision || this.error) return
284
+ if (this.pendingSince === null && !this.active) this.invalidate({ full: true })
285
+ if (this.timer) { clearTimeout(this.timer); this.timer = null }
286
+ this.launch()
287
+ await this.flight
288
+ }
289
+
290
+ /** @public @author ddj 2026年10月10号 @returns Whether an owner may evict this scope without killing shared work. */
291
+ idle(): boolean { return !this.active && !this.timer }
292
+
293
+ /** @public @author ddj 2026年10月10号 Abort owned scans and timers; late completions cannot restore state. */
294
+ dispose(): void {
295
+ this.stopped = true
296
+ if (this.timer) clearTimeout(this.timer)
297
+ this.timer = null
298
+ this.controller?.abort(new Error('SVN 状态调度已释放'))
299
+ this.dirty.clear()
300
+ this.events.clear()
301
+ this.data.clear()
302
+ }
303
+ }
304
+ // --endregion
@@ -0,0 +1,135 @@
1
+ /** Bounded SVN XML framing; retain statuses, never the entire verbose document. @author ddj 2026年10月09号 */
2
+ import { StringDecoder } from 'node:string_decoder'
3
+ import type { Readable } from 'node:stream'
4
+ import { attrOf } from './svnXml.js'
5
+ import type { SvnChangeEntry } from './shared/svn.js'
6
+
7
+ export interface StatusPipe {
8
+ stdout?: Readable | null
9
+ done: Promise<{ exitCode?: number | null; code?: number | null }>
10
+ terminate?: () => unknown
11
+ collected?: { stderr?: { readFrom: (offset: number) => { text: string } } }
12
+ }
13
+
14
+ /** @public @author ddj 2026年10月09号 @param handle Managed pipe process. @param parse Status semantics. @param cap Entry bound. @param timeout Deadline. @returns EOF-validated statuses. @throws CLI, timeout or framing failure; never retries with inherit. */
15
+ export async function readStatusPipe(handle: StatusPipe, parse: StatusParser, cap: number, timeout: number, signal?: AbortSignal) {
16
+ const reader = new SvnStatusReader(parse, cap)
17
+ /** @private @author ddj 2026年10月09号 Consume raw chunks through stream EOF, not just direct process exit. */
18
+ async function consume(): Promise<void> {
19
+ if (!handle.stdout) throw new Error('SVN 状态管道不可用,未确认正常状态')
20
+ for await (const chunk of handle.stdout) reader.push(chunk)
21
+ }
22
+ let timer: ReturnType<typeof setTimeout> | undefined
23
+ const expired = new Promise<never>((_, reject) => {
24
+ timer = setTimeout(() => reject(new Error('读取 SVN 图标状态超时')), timeout)
25
+ timer.unref?.()
26
+ })
27
+ let abort: (() => void) | undefined
28
+ const cancelled = new Promise<never>((_, reject) => {
29
+ abort = () => reject(signal?.reason ?? new Error('SVN 状态扫描已取消'))
30
+ if (signal?.aborted) abort()
31
+ else signal?.addEventListener('abort', abort, { once: true })
32
+ })
33
+ try {
34
+ const [outcome] = await Promise.race([Promise.all([handle.done, consume()]), expired, cancelled])
35
+ if ((outcome.exitCode ?? outcome.code) !== 0) throw new Error(handle.collected?.stderr?.readFrom(0).text || 'SVN 状态命令失败')
36
+ return reader.finish()
37
+ } catch (error) {
38
+ // Keep managed teardown observable but do not mask the original failure.
39
+ try { await handle.terminate?.() } catch { /* Preserve CLI/stream failure. */ }
40
+ throw error
41
+ } finally {
42
+ if (timer) clearTimeout(timer)
43
+ if (abort) signal?.removeEventListener('abort', abort)
44
+ }
45
+ }
46
+
47
+ type StatusParser = (xml: string, cap: number) => { entries: SvnChangeEntry[]; truncated: boolean }
48
+ const TAG_CAP = 64 * 1024
49
+ const STREAM_CAP = 512 * 1024 * 1024
50
+
51
+ /** @public @author ddj 2026年10月09号 Validates framing through EOF, including entries beyond the retained cap. */
52
+ export class SvnStatusReader {
53
+ private readonly decoder = new StringDecoder('utf8')
54
+ private buffer = ''
55
+ private bytes = 0
56
+ private readonly stack: string[] = []
57
+ private opened = false
58
+ private closed = false
59
+ private entry = ''
60
+ private changelist = ''
61
+ private readonly entries: SvnChangeEntry[] = []
62
+ private truncated = false
63
+
64
+ /** @public @author ddj 2026年10月09号 @param parse Existing status semantics. @param cap Retained entry bound. */
65
+ constructor(private readonly parse: StatusParser, private readonly cap: number) {}
66
+
67
+ /** @public @author ddj 2026年10月09号 @param chunk Raw pipe chunk. @throws Byte/tag limits or invalid framing. */
68
+ push(chunk: Buffer | string): void {
69
+ this.bytes += Buffer.byteLength(chunk)
70
+ if (this.bytes > STREAM_CAP) throw new Error('SVN 状态流超过 512 MiB 安全上限')
71
+ this.feed(typeof chunk === 'string' ? chunk : this.decoder.write(chunk))
72
+ }
73
+
74
+ /** @private @author ddj 2026年10月09号 @param text Decoded chunk. Process each complete tag once; partial tags stay bounded. */
75
+ private feed(text: string): void {
76
+ this.buffer += text
77
+ let cursor = 0
78
+ while (cursor < this.buffer.length) {
79
+ const start = this.buffer.indexOf('<', cursor)
80
+ const end = start < 0 ? -1 : this.buffer.indexOf('>', start)
81
+ const tail = start < 0 ? this.buffer.length : start
82
+ if (!this.stack.length && this.buffer.slice(cursor, tail).trim()) this.invalid()
83
+ if (end < 0) { cursor = tail; break }
84
+ if (end - start > TAG_CAP) this.invalid()
85
+ this.tag(this.buffer.slice(start, end + 1))
86
+ cursor = end + 1
87
+ }
88
+ this.buffer = this.buffer.slice(cursor)
89
+ if (this.buffer.length > TAG_CAP) this.invalid()
90
+ }
91
+
92
+ /** @private @author ddj 2026年10月09号 @param token Complete XML tag. Validate nesting and capture only entry/wc-status attributes. */
93
+ private tag(token: string): void {
94
+ if (token.startsWith('<?xml ') && !this.opened && !this.stack.length) return
95
+ const match = /^<(\/?)([\w-]+)\b([^>]*?)(\/?)>$/.exec(token)
96
+ if (!match || this.closed) this.invalid()
97
+ const [, closing, name, attrs, self] = match!
98
+ if (closing) {
99
+ if (this.stack.pop() !== name) this.invalid()
100
+ if (name === 'entry') this.saveEntry()
101
+ if (name === 'changelist') this.changelist = ''
102
+ if (name === 'status') this.closed = true
103
+ return
104
+ }
105
+ if (!this.stack.length) {
106
+ if (name !== 'status' || this.opened || self) this.invalid()
107
+ this.opened = true
108
+ }
109
+ if (name === 'changelist') this.changelist = attrOf(attrs, 'name')
110
+ if (name === 'entry') this.entry = token.replace(/\/>$/, '>')
111
+ if (name === 'wc-status' && this.entry) this.entry += '<wc-status' + attrs + '/>'
112
+ if (!self) this.stack.push(name)
113
+ else if (name === 'entry') this.saveEntry()
114
+ }
115
+
116
+ /** @private @author ddj 2026年10月09号 Preserve existing property/tree-conflict/path semantics and bounded statuses. */
117
+ private saveEntry(): void {
118
+ const result = this.parse(this.entry + '</entry>', 1)
119
+ this.entry = ''
120
+ for (const entry of result.entries) {
121
+ if (this.entries.length >= this.cap) { this.truncated = true; continue }
122
+ this.entries.push(this.changelist ? { ...entry, changelist: this.changelist } : entry)
123
+ }
124
+ }
125
+
126
+ /** @private @author ddj 2026年10月09号 @throws Invalid/missing/truncated XML; never produces a clean snapshot. */
127
+ private invalid(): never { throw new Error('SVN 状态输出为空或不完整,未确认正常状态') }
128
+
129
+ /** @public @author ddj 2026年10月09号 @returns Confirmed snapshot only after complete stream EOF. @throws Invalid framing. */
130
+ finish(): { entries: SvnChangeEntry[]; truncated: boolean } {
131
+ this.feed(this.decoder.end())
132
+ if (!this.opened || !this.closed || this.stack.length || this.buffer.trim()) this.invalid()
133
+ return { entries: this.entries, truncated: this.truncated }
134
+ }
135
+ }
@@ -15,8 +15,13 @@ namespace Dsh.EditorIntegration
15
15
  {
16
16
  private const string EditorName = "DSH 文件编辑";
17
17
  private const string VirtualPath = "dsh-editor://vscode-mode";
18
- private const string DshVersion = "0.3.0";
18
+ private const string DshVersion = "0.3.1";
19
19
  internal const string VersionText = DshVersion;
20
+ private const string TargetAuto = "auto";
21
+ private const string TargetWeb = "web";
22
+ private const string TargetDesktop = "desktop";
23
+ private static readonly string[] TargetLabels = { "自动", "DSH Web", "DSH 桌面端" };
24
+ private static readonly string[] TargetValues = { TargetAuto, TargetWeb, TargetDesktop };
20
25
 
21
26
  /// <summary>Register this editor without changing the user's selection. @author ddj 2026-09-28</summary>
22
27
  static DshCodeEditor()
@@ -57,7 +62,13 @@ namespace Dsh.EditorIntegration
57
62
  string selected = EditorPrefs.GetString(key, string.Empty);
58
63
  string value = EditorGUILayout.TextField("DSH 桥接配置", selected);
59
64
  if (value != selected) EditorPrefs.SetString(key, value.Trim());
60
- EditorGUILayout.HelpBox("留空时自动读取本项目 UserSettings/dsh-editor.ini;没有安装提示时使用 DSH_HOME 中的旧 shell 配置。填写绝对路径可覆盖自动选择。请求须收到 ACK 才确认成功。", MessageType.Info);
65
+ string targetKey = TargetKey();
66
+ string current = EditorPrefs.GetString(targetKey, TargetAuto);
67
+ int picked = System.Array.IndexOf(TargetValues, current);
68
+ if (picked < 0) picked = 0;
69
+ int chosen = EditorGUILayout.Popup("打开目标", picked, TargetLabels);
70
+ if (TargetValues[chosen] != current) EditorPrefs.SetString(targetKey, TargetValues[chosen]);
71
+ EditorGUILayout.HelpBox("留空时自动读取本项目 UserSettings/dsh-editor.ini;没有安装提示时使用 DSH_HOME 中的旧 shell 配置。填写绝对路径可覆盖自动选择。打开目标选择 DSH 桌面端或 DSH Web 时优先使用对应 profile 的独立 bridge。请求须收到 ACK 才确认成功。", MessageType.Info);
61
72
  if (GUILayout.Button("测试:在 DSH 中打开当前项目")) BeginOpen(ProjectRoot(), 0, 0);
62
73
  }
63
74
 
@@ -134,7 +145,8 @@ namespace Dsh.EditorIntegration
134
145
  private static void BeginOpen(string path, int line, int column)
135
146
  {
136
147
  string config = EditorPrefs.GetString(ConfigKey(), string.Empty);
137
- var worker = new DshOpenWorker(config, path, line, column, ProjectRoot(), LegacyConfig());
148
+ string target = EditorPrefs.GetString(TargetKey(), TargetAuto);
149
+ var worker = new DshOpenWorker(config, path, line, column, ProjectRoot(), LegacyConfig(), target);
138
150
  var thread = new Thread(worker.Run) { IsBackground = true };
139
151
  thread.Start();
140
152
  }
@@ -143,6 +155,10 @@ namespace Dsh.EditorIntegration
143
155
  /// <returns>Project-local editor preference key.</returns>
144
156
  private static string ConfigKey() { return "DshEditor.Config." + ProjectRoot(); }
145
157
 
158
+ /// <summary>Derive a project-specific open-target preference key. @author ddj 2026-10-12</summary>
159
+ /// <returns>Project-local open-target preference key.</returns>
160
+ private static string TargetKey() { return "DshEditor.Target." + ProjectRoot(); }
161
+
146
162
  /// <summary>Read the project's installed profile hint on the worker. @author ddj 2026-09-28</summary>
147
163
  /// <param name="root">Project root captured on the main thread.</param><param name="fallback">Legacy shell INI.</param>
148
164
  /// <returns>The hinted absolute INI, or legacy path only when the hint is absent.</returns>
@@ -171,10 +187,48 @@ namespace Dsh.EditorIntegration
171
187
  /// <summary>Honor custom DSH_HOME for legacy installations. @author ddj 2026-09-28</summary>
172
188
  /// <returns>Legacy installed shell INI path, without filesystem IO.</returns>
173
189
  private static string LegacyConfig()
190
+ {
191
+ return Path.Combine(Path.Combine(Path.Combine(Home(), "dsh-vscode-mode"), "shell"), "dsh-open.ini");
192
+ }
193
+
194
+ /// <summary>Resolve the user DSH home honoring DSH_HOME. @author ddj 2026-10-12</summary>
195
+ /// <returns>Absolute user DSH home directory.</returns>
196
+ internal static string Home()
174
197
  {
175
198
  string home = Environment.GetEnvironmentVariable("DSH_HOME");
176
199
  if (string.IsNullOrEmpty(home)) home = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".dsh");
177
- return Path.Combine(Path.Combine(Path.Combine(home, "dsh-vscode-mode"), "shell"), "dsh-open.ini");
200
+ return home;
201
+ }
202
+
203
+ /// <summary>Find the first profile bridge whose mode matches a requested target. @author ddj 2026-10-12</summary>
204
+ /// <param name="mode">Target mode (web or desktop) stored in the bridge INI.</param>
205
+ /// <returns>Absolute matching bridge INI path, or null when no profile provides it.</returns>
206
+ internal static string FindBridge(string mode)
207
+ {
208
+ string profiles = Path.Combine(Home(), "profiles");
209
+ if (!Directory.Exists(profiles)) return null;
210
+ foreach (string profile in Directory.GetDirectories(profiles))
211
+ {
212
+ string ini = Path.Combine(Path.Combine(Path.Combine(profile, "dsh-vscode-mode"), "bridge"), "dsh-open.ini");
213
+ if (!File.Exists(ini) || ModeOf(ini) != mode) continue;
214
+ return Path.GetFullPath(ini);
215
+ }
216
+ return null;
217
+ }
218
+
219
+ /// <summary>Read the mode value of a bridge INI without guessing a profile. @author ddj 2026-10-12</summary>
220
+ /// <param name="ini">Absolute bridge INI path.</param><returns>mode value, or empty when absent.</returns>
221
+ private static string ModeOf(string ini)
222
+ {
223
+ foreach (string text in File.ReadAllLines(ini))
224
+ {
225
+ int equal = text.IndexOf('=');
226
+ if (equal < 0) continue;
227
+ string key = text.Substring(0, equal).Trim();
228
+ if (!string.Equals(key, "mode", StringComparison.OrdinalIgnoreCase)) continue;
229
+ return text.Substring(equal + 1).Trim();
230
+ }
231
+ return string.Empty;
178
232
  }
179
233
 
180
234
  /// <summary>Resolve asset paths from the Unity project root. @author ddj 2026-09-28</summary>
@@ -16,6 +16,7 @@ namespace Dsh.EditorIntegration
16
16
  private readonly string targetPath;
17
17
  private readonly string projectRoot;
18
18
  private readonly string legacyConfig;
19
+ private readonly string target;
19
20
  private readonly int line;
20
21
  private readonly int column;
21
22
 
@@ -23,12 +24,14 @@ namespace Dsh.EditorIntegration
23
24
  /// <param name="config">Selected INI path.</param><param name="path">Absolute asset or project.</param>
24
25
  /// <param name="row">One-based line.</param><param name="col">One-based column.</param>
25
26
  /// <param name="root">Project root.</param><param name="fallback">Legacy shell INI path.</param>
26
- internal DshOpenWorker(string config, string path, int row, int col, string root, string fallback)
27
+ /// <param name="mode">Requested target (auto/web/desktop); empty means auto. @author ddj 2026-10-12</param>
28
+ internal DshOpenWorker(string config, string path, int row, int col, string root, string fallback, string mode)
27
29
  {
28
30
  configPath = config;
29
31
  targetPath = path;
30
32
  projectRoot = root;
31
33
  legacyConfig = fallback;
34
+ target = string.IsNullOrEmpty(mode) ? "auto" : mode;
32
35
  line = row;
33
36
  column = col;
34
37
  }
@@ -105,7 +108,15 @@ namespace Dsh.EditorIntegration
105
108
  /// <returns>Selected bridge INI path; missing or malformed hints are handled by the caller.</returns>
106
109
  internal string SelectConfig()
107
110
  {
108
- return string.IsNullOrEmpty(configPath) ? DshCodeEditor.DefaultConfig(projectRoot, legacyConfig) : configPath;
111
+ if (!string.IsNullOrEmpty(configPath)) return configPath;
112
+ if (target == "web") return DshCodeEditor.FindBridge("web") ?? DshCodeEditor.DefaultConfig(projectRoot, legacyConfig);
113
+ if (target == "desktop")
114
+ {
115
+ string desktop = DshCodeEditor.FindBridge("desktop");
116
+ if (desktop != null) return desktop;
117
+ throw new FileNotFoundException("No DSH desktop bridge configuration found. Open the DSH desktop app once, then retry, or set an explicit bridge configuration in External Tools.");
118
+ }
119
+ return DshCodeEditor.DefaultConfig(projectRoot, legacyConfig);
109
120
  }
110
121
 
111
122
  /// <summary>Build helper arguments without routing files through a URL. @author ddj 2026-09-28</summary>
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "com.dsh.editor",
3
3
  "displayName": "DSH 文件编辑",
4
- "description": "DSH(dsh-vscode-mode)外部脚本编辑器:通过当前用户私有 OPEN REQUEST/ACK 文件队列打开脚本、Console 行列或项目;确认匹配 ACK 后才报告成功,支持官方桌面与所选 Web profile。",
5
- "version": "0.3.0",
4
+ "description": "DSH(dsh-vscode-mode)外部脚本编辑器:通过当前用户私有 OPEN REQUEST/ACK 文件队列打开脚本、Console 行列或项目;确认匹配 ACK 后才报告成功,支持官方桌面与所选 Web profile,可在外部工具面板选择打开目标(自动/DSH Web/DSH 桌面端)。",
5
+ "version": "0.3.1",
6
6
  "unity": "2019.4"
7
7
  }