dsh-vscode-mode 0.18.0 → 0.19.1

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,106 @@
1
+ /** File explorer fixed-height row model. @author ddj 2026年10月22号 */
2
+ import type { TreeEntry } from '../../../shared/rpc.js'
3
+ import { updateRange } from '../../ui/svnUpdateModel.js'
4
+
5
+ /** Fixed geometry shared with file and loading row CSS. */
6
+ export const TREE_ROW_HEIGHT = 24
7
+
8
+ export interface TreeRow {
9
+ key: string
10
+ depth: number
11
+ e?: TreeEntry
12
+ isDir?: boolean
13
+ isOpen?: boolean
14
+ text?: string
15
+ }
16
+
17
+ export interface TreeViewport {
18
+ top: number
19
+ height: number
20
+ }
21
+
22
+ /**
23
+ * @public @author ddj 2026年10月22号
24
+ * @description Preserve the first visible path when rows above it expand or collapse.
25
+ * @param previous Previous complete model.
26
+ * @param rows New complete model.
27
+ * @param top Previous scroll offset.
28
+ * @returns Anchored offset, or original offset for subsequent clamping if path disappeared.
29
+ */
30
+ export function treeAnchorTop(previous: TreeRow[], rows: TreeRow[], top: number): number {
31
+ const index = Math.floor(Math.max(0, top) / TREE_ROW_HEIGHT)
32
+ const anchor = previous[index]?.key
33
+ if (!anchor) return top
34
+ const nextIndex = rows.findIndex((row) => row.key === anchor)
35
+ if (nextIndex < 0) return top
36
+ return nextIndex * TREE_ROW_HEIGHT + top % TREE_ROW_HEIGHT
37
+ }
38
+
39
+ /**
40
+ * @public @author ddj 2026年10月22号
41
+ * @description Flatten all expanded entries in their supplied order, including metadata files.
42
+ * @param entriesOf Directory snapshot lookup; null means unavailable.
43
+ * @param expanded Current directory expansion flags.
44
+ * @param loading Current loading flags.
45
+ * @returns Complete row model, independent of viewport mounting.
46
+ */
47
+ export function flattenTree(entriesOf: (rel: string) => TreeEntry[] | null,
48
+ expanded: Record<string, boolean>, loading: Record<string, boolean>): TreeRow[] {
49
+ const rows: TreeRow[] = []
50
+ const pending: { rel: string; depth: number; entry?: TreeEntry }[] = [{ rel: '', depth: 0 }]
51
+ while (pending.length) {
52
+ const current = pending.pop()!
53
+ const entry = current.entry
54
+ if (entry) {
55
+ const isDir = entry.type === 'directory'
56
+ const isOpen = expanded[entry.path] === true
57
+ rows.push({ key: entry.path, e: entry, depth: current.depth, isDir, isOpen })
58
+ if (isDir && isOpen) pending.push({ rel: entry.path, depth: current.depth + 1 })
59
+ continue
60
+ }
61
+
62
+ const entries = entriesOf(current.rel)
63
+ if (!entries) {
64
+ rows.push({ key: current.rel + ':loading', depth: current.depth,
65
+ text: loading[current.rel] ? '加载中…' : '无法加载' })
66
+ continue
67
+ }
68
+ // Stack reverses insertion order; push backwards to preserve host sorting.
69
+ for (let index = entries.length - 1; index >= 0; index--) {
70
+ pending.push({ rel: current.rel, depth: current.depth, entry: entries[index] })
71
+ }
72
+ }
73
+ return rows
74
+ }
75
+
76
+ /**
77
+ * @public @author ddj 2026年10月22号
78
+ * @description Clamp scroll state before reusing the existing fixed-height range calculation.
79
+ * @param count Complete row count.
80
+ * @param viewport Latest scroll position and measured viewport height.
81
+ * @returns Clamped scroll offset and overscanned exclusive row range.
82
+ */
83
+ export function treeWindow(count: number, viewport: TreeViewport) {
84
+ const height = Math.max(0, viewport.height)
85
+ const top = Math.min(Math.max(0, viewport.top), Math.max(0, count * TREE_ROW_HEIGHT - height))
86
+ return { top, ...updateRange(count, top, height, TREE_ROW_HEIGHT) }
87
+ }
88
+
89
+ /**
90
+ * @public @author ddj 2026年10月22号
91
+ * @description Locate an unmounted row using model indices, without querying row DOM.
92
+ * @param rows Complete flattened model.
93
+ * @param path Requested relative file path.
94
+ * @param viewport Current viewport geometry.
95
+ * @returns New scroll offset, or null while ancestors/viewport are unavailable.
96
+ */
97
+ export function treeRevealTop(rows: TreeRow[], path: string, viewport: TreeViewport): number | null {
98
+ if (viewport.height <= 0) return null
99
+ const index = rows.findIndex((row) => row.e?.path === path)
100
+ if (index < 0) return null
101
+ const current = treeWindow(rows.length, viewport).top
102
+ const rowTop = index * TREE_ROW_HEIGHT
103
+ if (rowTop >= current && rowTop + TREE_ROW_HEIGHT <= current + viewport.height) return current
104
+ return treeWindow(rows.length, { top: rowTop - (viewport.height - TREE_ROW_HEIGHT) / 2,
105
+ height: viewport.height }).top
106
+ }
@@ -27,6 +27,8 @@ interface RevealOptions {
27
27
  activePath: string | null
28
28
  treeRef: React.RefObject<HTMLElement>
29
29
  prepare: (path: string) => void
30
+ /** Optional virtual-list locator; success does not depend on mounted row DOM. */
31
+ locate?: (path: string) => boolean
30
32
  }
31
33
  interface RevealTarget {
32
34
  scope: string
@@ -60,12 +62,13 @@ export function useExplorerReveal(options: RevealOptions) {
60
62
  setTarget(next)
61
63
  if (next) current.prepare(next.path)
62
64
  }
63
- /** @private @author ddj 2026年10月14号 Attempts the latest target after row commit or container resize. Consumes only a successful, current request. */
65
+ /** @private @author ddj 2026年10月22号 Attempts virtual data-index location or the original mounted-row fallback after commit/resize; consumes only a successful current request. */
64
66
  function tryReveal(): void {
65
67
  const next = pending.current
66
68
  const current = latest.current
67
69
  if (!next || next.scope !== current.scope || next.active !== current.activePath) return
68
- if (!revealTreeRow(current.treeRef.current, next.path)) return
70
+ const located = current.locate ? current.locate(next.path) : revealTreeRow(current.treeRef.current, next.path)
71
+ if (!located) return
69
72
  pending.current = null
70
73
  setRevealPath(next.path)
71
74
  clearHighlight()
@@ -28,8 +28,11 @@ function panelRegister(entries: Map<string, SidebarPanelDef>, notify: () => void
28
28
  export function createSidebarPanelRegistry(): SidebarPanelRegistry {
29
29
  const entries = new Map<string, SidebarPanelDef>()
30
30
  const listeners = new Set<() => void>()
31
+ /** @private @author ddj 2026年10月22号 @description Isolate subscribers and snapshot notification membership against reentrant changes. */
31
32
  const notify = (): void => {
32
- for (const listener of listeners) listener()
33
+ for (const listener of [...listeners]) {
34
+ try { listener() } catch { /* One extension must not block other subscribers or registration teardown. */ }
35
+ }
33
36
  }
34
37
  const list = (): readonly SidebarPanelDef[] =>
35
38
  [...entries.values()].sort((a, b) => (a.order ?? 100) - (b.order ?? 100))
@@ -97,6 +97,8 @@ export interface UiStateMirrorDeps {
97
97
  export interface UiStateMirror {
98
98
  /** 包装 localStorage 写口(写口不可用时返回 false)。 */
99
99
  install(): boolean
100
+ /** Release storage hooks and pending hydration work. */
101
+ dispose(): void
100
102
  /** 取回 host 镜像并回填本地缺失键,返回回填条数。 */
101
103
  hydrate(): Promise<number>
102
104
  /** 立即上送待推送批次,返回最终丢弃的键数(0 = 全部落盘;供卸载与测试驱动)。 */
@@ -194,6 +196,8 @@ export function createUiStateMirror(deps: UiStateMirrorDeps): UiStateMirror {
194
196
  let suppressed = 0
195
197
  let done = false
196
198
  let installed = false
199
+ let disposed = false
200
+ let restoreStorage: (() => void) | undefined
197
201
  let lastError = ''
198
202
  /** 水合尝试次数(失败重试;超过 `HYDRATE_RETRY_MAX` 放弃并解禁)。 */
199
203
  let attempts = 0
@@ -203,14 +207,19 @@ export function createUiStateMirror(deps: UiStateMirrorDeps): UiStateMirror {
203
207
 
204
208
  /** 记入待推送批次并安排去抖上送(水合期间或非镜像键 → 跳过)。 */
205
209
  function queue(key: string, value: string | null): void {
206
- if (suppressed > 0 || !isMirroredKey(key)) return
210
+ if (disposed || suppressed > 0 || !isMirroredKey(key)) return
207
211
  const bucket = isGlobalKey(key) ? pending.globals : pending.keys
208
212
  bucket[key] = value
209
213
  if (timer !== null) cancel(timer)
210
- timer = schedule(() => {
214
+ try {
215
+ timer = schedule(() => {
216
+ timer = null
217
+ if (!disposed) void flush()
218
+ }, FLUSH_DEBOUNCE_MS)
219
+ } catch (error) {
211
220
  timer = null
212
- void flush()
213
- }, FLUSH_DEBOUNCE_MS)
221
+ log('界面状态调度失败: ' + String(error))
222
+ }
214
223
  }
215
224
 
216
225
  /** 上送一批补丁(失败重试一次)。 */
@@ -273,7 +282,7 @@ export function createUiStateMirror(deps: UiStateMirrorDeps): UiStateMirror {
273
282
 
274
283
  /** @private @author ddj 2026年10月12号 @returns Durable refresh snapshot. @throws When state is unreadable, unsettled or rejected by Host. @sideEffects Resends current local state after waiting for inflight writes. */
275
284
  async function flushStrict(): Promise<void> {
276
- if (!settleDone || !installed) throw new Error('界面状态镜像尚未就绪')
285
+ if (disposed || !settleDone || !installed) throw new Error('界面状态镜像尚未就绪')
277
286
  if (inflight) await inflight
278
287
  // Read directly: the lenient key scanner intentionally hides storage errors.
279
288
  for (let index = 0; index < storage.length; index++) {
@@ -338,7 +347,7 @@ export function createUiStateMirror(deps: UiStateMirrorDeps): UiStateMirror {
338
347
  * 未结束前界面不得写存档,否则空初值会覆盖 host 上的真实状态。
339
348
  */
340
349
  function settle(): void {
341
- if (settleDone) return
350
+ if (disposed || settleDone) return
342
351
  settleDone = true
343
352
  emitReady()
344
353
  report('水合阶段结束(就绪;' + (done ? '已回填' : '未回填,退回纯 localStorage') + ')')
@@ -349,9 +358,11 @@ export function createUiStateMirror(deps: UiStateMirrorDeps): UiStateMirror {
349
358
  * @returns 本次回填键数(失败/重试中为 0)
350
359
  */
351
360
  async function hydrate(): Promise<number> {
361
+ if (disposed) return 0
352
362
  try {
353
363
  report('水合开始(origin=' + originOf() + ',第 ' + (attempts + 1) + ' 次)')
354
364
  const snapshot = await deps.fetch(deps.sessionIdOf?.())
365
+ if (disposed) return 0
355
366
  // 先搬运本 origin 既有状态(只含换 origin 前就存在的键),再回填缺失键:
356
367
  // 顺序反过来会把刚回填的键再回送一遍(值与 host 完全相同,纯属多余流量)。
357
368
  const seeded = seedLocal()
@@ -362,19 +373,24 @@ export function createUiStateMirror(deps: UiStateMirrorDeps): UiStateMirror {
362
373
  if (seeded > 0) {
363
374
  const lost = await flush()
364
375
  report('首次搬运输送完成:丢弃 ' + lost + ' 键')
365
- if (lost === 0) markSeeded()
376
+ if (!disposed && lost === 0) markSeeded()
366
377
  }
367
378
  settle()
368
379
  return filled
369
380
  } catch (error) {
381
+ if (disposed) return 0
370
382
  attempts++
371
383
  if (attempts < HYDRATE_RETRY_MAX) {
372
384
  report('水合失败(第 ' + attempts + ' 次,' + HYDRATE_RETRY_MS + 'ms 后重试): ' + String(error), 'warn')
373
- retryTimer = schedule(() => {
374
- retryTimer = null
375
- void hydrate()
376
- }, HYDRATE_RETRY_MS)
377
- return 0
385
+ try {
386
+ retryTimer = schedule(() => {
387
+ retryTimer = null
388
+ void hydrate()
389
+ }, HYDRATE_RETRY_MS)
390
+ return 0
391
+ } catch (scheduleError) {
392
+ report('水合重试调度失败: ' + String(scheduleError), 'warn')
393
+ }
378
394
  }
379
395
  report('水合放弃(重试 ' + attempts + ' 次仍失败): ' + String(error), 'warn')
380
396
  settle()
@@ -382,26 +398,58 @@ export function createUiStateMirror(deps: UiStateMirrorDeps): UiStateMirror {
382
398
  }
383
399
  }
384
400
 
401
+ /** @private @author ddj 2026年10月23号 Release timers and only this instance's storage hooks. */
402
+ function dispose(): void {
403
+ if (disposed) return
404
+ disposed = true
405
+ if (timer !== null) cancel(timer)
406
+ if (retryTimer !== null) cancel(retryTimer)
407
+ timer = null
408
+ retryTimer = null
409
+ restoreStorage?.()
410
+ installed = false
411
+ }
412
+
385
413
  return {
414
+ dispose,
386
415
  install(): boolean {
416
+ if (disposed) return false
387
417
  if (installed) return true
388
418
  try {
389
419
  const proto = Object.getPrototypeOf(storage) as Partial<UiStorage> | null
390
- const rawSet = proto?.setItem
391
- const rawRemove = proto?.removeItem
420
+ const rawSet = storage.setItem ?? proto?.setItem
421
+ const rawRemove = storage.removeItem ?? proto?.removeItem
392
422
  if (typeof rawSet !== 'function' || typeof rawRemove !== 'function') return false
393
- storage.setItem = function (key: string, value: string): void {
423
+ const setDescriptor = Object.getOwnPropertyDescriptor(storage, 'setItem')
424
+ const removeDescriptor = Object.getOwnPropertyDescriptor(storage, 'removeItem')
425
+ /** @private @author ddj 2026年10月23号 @param key Storage key. @param value Stored text. @sideEffects Queues a mirror update. */
426
+ function wrappedSet(key: string, value: string): void {
394
427
  rawSet.call(storage, key, value)
395
428
  queue(String(key), String(value))
396
429
  }
397
- storage.removeItem = function (key: string): void {
430
+ /** @private @author ddj 2026年10月23号 @param key Storage key. @sideEffects Queues a mirror deletion. */
431
+ function wrappedRemove(key: string): void {
398
432
  rawRemove.call(storage, key)
399
433
  queue(String(key), null)
400
434
  }
435
+ restoreStorage = () => {
436
+ if (storage.setItem === wrappedSet) {
437
+ if (setDescriptor) Object.defineProperty(storage, 'setItem', setDescriptor)
438
+ else delete (storage as Partial<UiStorage>).setItem
439
+ }
440
+ if (storage.removeItem === wrappedRemove) {
441
+ if (removeDescriptor) Object.defineProperty(storage, 'removeItem', removeDescriptor)
442
+ else delete (storage as Partial<UiStorage>).removeItem
443
+ }
444
+ }
445
+ storage.setItem = wrappedSet
446
+ storage.removeItem = wrappedRemove
401
447
  installed = true
402
448
  report('镜像已包装 localStorage 写口')
403
449
  return true
404
450
  } catch (error) {
451
+ restoreStorage?.()
452
+ installed = false
405
453
  report('界面状态镜像包装失败(退回纯 localStorage): ' + String(error), 'warn')
406
454
  return false
407
455
  }
@@ -500,7 +548,7 @@ async function fetchViaRpc(sessionId?: string): Promise<UiStateSnapshot> {
500
548
  * @returns 是否安装成功(存储不可用 → false,功能退回纯 localStorage)
501
549
  */
502
550
  export function installUiStatePersist(
503
- ctx: { get: (name: string) => unknown },
551
+ ctx: { get: (name: string) => unknown; effect: (setup: () => () => void, label?: string) => unknown },
504
552
  schedule: (fn: () => void, ms: number) => unknown,
505
553
  report?: (text: string, level?: string) => void,
506
554
  ): boolean {
@@ -518,6 +566,9 @@ export function installUiStatePersist(
518
566
  fetch: fetchViaRpc,
519
567
  sessionIdOf,
520
568
  schedule,
569
+ cancel: (handle) => {
570
+ if (typeof handle === 'function') handle()
571
+ },
521
572
  report: (text: string, level?: string) => reportUiStateEvent(text, level),
522
573
  log: (text: string) => {
523
574
  try { dbg(sessionIdOf(), 'ui-state: ' + text, 'warn') } catch (error) { /* 日志失败忽略 */ }
@@ -528,18 +579,28 @@ export function installUiStatePersist(
528
579
  return false
529
580
  }
530
581
  singleton = mirror
531
- try {
532
- if (typeof window !== 'undefined') {
533
- window.addEventListener('pagehide', () => { void mirror.flush() })
582
+ const pageHost = typeof window === 'undefined' ? undefined : window
583
+ /** @private @author ddj 2026年10月23号 Persist pending changes when the page is hidden. */
584
+ function onPageHide(): void { void mirror.flush() }
585
+ ctx.effect(() => {
586
+ pageHost?.addEventListener('pagehide', onPageHide)
587
+ return () => {
588
+ pageHost?.removeEventListener('pagehide', onPageHide)
589
+ mirror.dispose()
590
+ if (singleton !== mirror) return
591
+ singleton = null
592
+ hydrating = null
593
+ uiReady = false
594
+ reporter = null
534
595
  }
535
- } catch (error) { /* 无 window 环境忽略 */ }
596
+ }, 'vscode-mode: ui state lifecycle')
536
597
  hydrating = mirror.hydrate().then((filled) => {
537
- if (filled > 0 || mirror.settled()) uiReady = true
598
+ if (singleton === mirror && (filled > 0 || mirror.settled())) uiReady = true
538
599
  return filled
539
600
  })
540
601
  void hydrating.finally(() => {
541
602
  // 首轮结束后:已结束水合阶段才允许后续 hydrateUiState() 重新发起(重试由镜像内部承担)
542
- if (mirror.settled()) {
603
+ if (singleton === mirror && mirror.settled()) {
543
604
  uiReady = true
544
605
  hydrating = null
545
606
  }
@@ -305,6 +305,14 @@
305
305
  [data-edrv-view] .edrv-side-btn { display: inline-flex; align-items: center; justify-content: center; width: 22px; height: 22px; border: none; border-radius: 6px; background: transparent; color: var(--dsw-alias-label-secondary, #52606d); font-size: 12px; cursor: pointer; flex-shrink: 0; }
306
306
  [data-edrv-view] .edrv-side-btn:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(15,157,88,.08)); }
307
307
  [data-edrv-view] .edrv-tree { flex: 1; min-height: 0; overflow: auto; padding: 4px 6px 12px; font-size: 12px; }
308
+ /* Virtual rows and placeholders share the same geometry as treeWindow.ts. @author ddj 2026年10月22号 */
309
+ [data-edrv-view] .edrv-tree.edrv-tree-virtual { padding-top: 0; padding-bottom: 0; }
310
+ [data-edrv-view] .edrv-tree-virtual .edrv-tree-row,
311
+ [data-edrv-view] .edrv-tree-virtual .edrv-tree-loading { height: 24px; min-height: 24px; box-sizing: border-box; }
312
+ [data-edrv-view] .edrv-tree-virtual .edrv-tree-loading { line-height: 24px; }
313
+ [data-edrv-view] .edrv-large-text { flex: 1; min-width: 0; min-height: 0; display: flex; flex-direction: column; }
314
+ [data-edrv-view] .edrv-large-text-toolbar { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
315
+ [data-edrv-view] .edrv-large-text-content { margin: 0; flex: 1; min-height: 0; overflow: auto; white-space: pre; font-family: var(--ds-font-family-code, ui-monospace, monospace); user-select: text; }
308
316
  [data-edrv-view] .edrv-tree-row { display: flex; align-items: center; gap: 4px; height: 22px; padding-right: 6px; border-radius: 5px; color: var(--dsw-alias-label-primary, #1f2933); cursor: pointer; white-space: nowrap; }
309
317
  [data-edrv-view] .edrv-tree-row:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(15,157,88,.08)); }
310
318
  [data-edrv-view] .edrv-tree-row.edrv-tree-active { background: var(--dsw-alias-interactive-bg-hover, rgba(15,157,88,.14)); }
@@ -0,0 +1,135 @@
1
+ import { TEXT_PAGE_BYTES, TEXT_VIEW_BYTES, TEXT_VIEW_LINES } from '../shared/textPages.js'
2
+ import type { TextPageArgs, TextPageResult } from '../shared/textPages.js'
3
+
4
+ export interface TextPagerState {
5
+ text: string
6
+ loading: boolean
7
+ eof: boolean
8
+ capped: boolean
9
+ error: string | null
10
+ offset: number
11
+ size: number
12
+ lines: number
13
+ partialLine: boolean
14
+ }
15
+ export type TextPageLoader = (args: TextPageArgs) => Promise<TextPageResult>
16
+
17
+ /**
18
+ * Own one preview identity and one in-flight request; no polling or automatic continuation.
19
+ * @public @author ddj 2026年10月22号
20
+ * @param load Authorized RPC loader. @param changed Snapshot observer.
21
+ * @returns Pager lifecycle and explicit load controls.
22
+ */
23
+ export function createTextPager(load: TextPageLoader, changed: (state: TextPagerState) => void) {
24
+ let generation = 0
25
+ let disposed = false
26
+ let target: TextPageArgs | null = null
27
+ let cursor: string | undefined
28
+ let version: string | undefined
29
+ let inFlight = false
30
+ let retainedBytes = 0
31
+ let state = emptyState()
32
+
33
+ /** @private @author ddj 2026年10月22号 @returns Publish immutable snapshot. */
34
+ function publish(): void { changed({ ...state }) }
35
+
36
+ /** @public @author ddj 2026年10月22号 @param args New file/session identity. @returns Reset state; old responses are ignored. */
37
+ function reset(args: TextPageArgs): void {
38
+ generation++
39
+ target = { sessionId: args.sessionId, path: args.path }
40
+ cursor = undefined
41
+ version = undefined
42
+ retainedBytes = 0
43
+ state = emptyState()
44
+ state.loading = inFlight
45
+ if (!disposed) publish()
46
+ }
47
+
48
+ /** @public @author ddj 2026年10月22号 @returns Explicitly load one page, or no-op when busy/finished. */
49
+ async function loadNext(): Promise<void> {
50
+ if (disposed || !target || inFlight || state.eof || state.capped) return
51
+ state.error = null
52
+ const requestGeneration = generation
53
+ const request = { ...target, cursor }
54
+ inFlight = true
55
+ state.loading = true
56
+ publish()
57
+
58
+ try {
59
+ const page = await load(request)
60
+ if (disposed || requestGeneration !== generation) return
61
+ if (!page.ok) { state.error = page.error; return }
62
+ if (!validPage(page, state.offset, version)) { state.error = 'Invalid or stale text page'; return }
63
+ appendPage(page)
64
+ } catch (error) {
65
+ if (!disposed && requestGeneration === generation) state.error = String(error)
66
+ } finally {
67
+ inFlight = false
68
+ if (!disposed) { state.loading = false; publish() }
69
+ }
70
+ }
71
+
72
+ /** @private @author ddj 2026年10月22号 @param page Verified page. @returns Append only within byte/line limits. */
73
+ function appendPage(page: Extract<TextPageResult, { ok: true }>): void {
74
+ let text = page.text
75
+ const encoded = new TextEncoder().encode(text)
76
+ const remaining = TEXT_VIEW_BYTES - retainedBytes
77
+ if (encoded.length > remaining) {
78
+ // A UTF-8 suffix may straddle the cap; never retain a replacement character.
79
+ let end = remaining
80
+ while (end > 0 && (encoded[end]! & 0xc0) === 0x80) end--
81
+ text = new TextDecoder('utf-8', { fatal: true }).decode(encoded.subarray(0, end))
82
+ state.capped = true
83
+ }
84
+ let lines = state.lines
85
+ for (let index = 0; index < text.length; index++) {
86
+ if (text[index] !== '\n' && (text[index] !== '\r' || text[index + 1] === '\n')) continue
87
+ if (lines >= TEXT_VIEW_LINES) {
88
+ const end = text[index] === '\n' && text[index - 1] === '\r' ? index - 1 : index
89
+ text = text.slice(0, end); state.capped = true; break
90
+ }
91
+ lines++
92
+ }
93
+ retainedBytes += new TextEncoder().encode(text).length
94
+ state.text += text
95
+ state.lines = lines
96
+ state.offset = page.offset
97
+ state.size = page.size
98
+ state.eof = page.eof
99
+ state.partialLine = page.partialLine || state.capped
100
+ state.capped ||= page.offset >= TEXT_VIEW_BYTES || retainedBytes >= TEXT_VIEW_BYTES || lines >= TEXT_VIEW_LINES
101
+ version = page.version
102
+ cursor = page.nextCursor ?? undefined
103
+ }
104
+
105
+ /** @public @author ddj 2026年10月22号 @returns Invalidate identity while retaining the transport lock. */
106
+ function invalidate(): void { generation++; target = null; state = emptyState() }
107
+
108
+ /** @public @author ddj 2026年10月22号 @returns Invalidate pending responses and release preview content. */
109
+ function dispose(): void { disposed = true; invalidate() }
110
+
111
+ return { reset, loadNext, invalidate, dispose, getState: () => ({ ...state }) }
112
+ }
113
+
114
+ /** @private @author ddj 2026年10月22号 @returns Empty bounded preview state. */
115
+ function emptyState(): TextPagerState {
116
+ return { text: '', loading: false, eof: false, capped: false, error: null, offset: 0, size: 0, lines: 1, partialLine: false }
117
+ }
118
+
119
+ /**
120
+ * Validate the RPC envelope before retaining any content.
121
+ * @private @author ddj 2026年10月22号
122
+ * @param page Server page. @param offset Previous byte position. @param version Bound version.
123
+ * @returns Whether bounds and continuation invariants hold.
124
+ */
125
+ function validPage(page: Extract<TextPageResult, { ok: true }>, offset: number, version?: string): boolean {
126
+ return typeof page.text === 'string' && typeof page.version === 'string'
127
+ && (version === undefined || version === page.version)
128
+ && Number.isSafeInteger(page.offset) && Number.isSafeInteger(page.size)
129
+ && page.size >= 0 && page.offset >= offset && page.offset <= page.size
130
+ && (page.offset > offset || page.eof) && page.offset - offset <= TEXT_PAGE_BYTES
131
+ && new TextEncoder().encode(page.text).length <= TEXT_PAGE_BYTES + 1
132
+ && page.encoding === 'utf-8' && typeof page.partialLine === 'boolean'
133
+ && typeof page.eof === 'boolean' && (page.eof ? page.nextCursor === null : typeof page.nextCursor === 'string' && page.nextCursor.length > 0)
134
+ && (page.eof === (page.offset === page.size))
135
+ }