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.
- package/README.md +8 -4
- package/lib/client.debug.js +934 -0
- package/lib/client.debug.js.map +1 -0
- package/lib/client.js +990 -1705
- package/lib/client.js.map +1 -1
- package/lib/client.rules.js +793 -0
- package/lib/client.rules.js.map +1 -0
- package/lib/index.js +236 -42
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/client/externalOpen.ts +7 -1
- package/src/client/index.ts +9 -0
- package/src/client/lazyPanel.ts +68 -0
- package/src/client/monaco/loader.ts +2 -0
- package/src/client/monaco/theme.ts +11 -0
- package/src/client/openReceipt.ts +4 -1
- package/src/client/requestId.ts +20 -0
- package/src/client/sidebar/PanelBoundary.ts +57 -0
- package/src/client/sidebar/SidebarView.ts +58 -21
- package/src/client/sidebar/panels/FileExplorer.ts +80 -27
- package/src/client/sidebar/panels/SvnPanel.ts +24 -8
- package/src/client/sidebar/panels/index.ts +3 -4
- package/src/client/sidebar/panels/treeWindow.ts +106 -0
- package/src/client/sidebar/panels/useExplorerReveal.ts +5 -2
- package/src/client/sidebar/registry.ts +4 -1
- package/src/client/state/uiStatePersist.ts +84 -23
- package/src/client/styles/editor.css +8 -0
- package/src/client/textPages.ts +135 -0
- package/src/client/ui/EditorView.ts +74 -16
- package/src/client/ui/LargeTextView.ts +94 -0
- package/src/rpc.ts +29 -7
- package/src/shared/rpc.ts +3 -1
- package/src/shared/snippets.ts +1 -1
- package/src/shared/textPages.ts +28 -0
- package/src/textPages.ts +149 -0
- package/src/treeIndex.ts +62 -37
|
@@ -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月
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
213
|
-
}
|
|
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
|
-
|
|
374
|
-
retryTimer =
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
532
|
-
|
|
533
|
-
|
|
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
|
-
}
|
|
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
|
+
}
|