dsh-vscode-mode 0.18.0 → 0.19.0

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))
@@ -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
+ }
@@ -9,8 +9,10 @@
9
9
  */
10
10
  import React from 'react'
11
11
  import { createPortal } from 'react-dom'
12
+ import { createRoot } from 'react-dom/client'
12
13
  import { EditorPane, EditorSplit, PassiveEditor, GroupPathbar } from './EditorPane.js'
13
14
  import { FloatingEditor } from './FloatingEditor.js'
15
+ import { LargeTextView } from './LargeTextView.js'
14
16
  import { FloatingWindow } from './FloatingWindow.js'
15
17
  import { createLayout, parseLayout, syncTabs, ownerOf, focusGroup, moveTab, returnGroup, GROUP_LIMIT, MAIN_GROUP } from '../state/editorLayout.js'
16
18
  import { createTabDrag, hitTarget } from '../tabDrag.js'
@@ -318,6 +320,11 @@ export function EditorView(props) {
318
320
  const aiStatusTimer = React.useRef(null)
319
321
  const [error, setError] = React.useState(null)
320
322
  const [loadError, setLoadError] = React.useState(null)
323
+ const [largeCandidate, setLargeCandidate] = React.useState(null)
324
+ const [pagedFile, setPagedFile] = React.useState(null)
325
+ const [pageChanged, setPageChanged] = React.useState(null)
326
+ const pageIdentity = JSON.stringify([scope, sessionId, active])
327
+ const paging = pagedFile === pageIdentity
321
328
  const [loadStage, setLoadStage] = React.useState({ progress: 0, message: '准备加载编辑器…' })
322
329
  const [openInput, setOpenInput] = React.useState(false)
323
330
  const [pathDraft, setPathDraft] = React.useState('')
@@ -1013,6 +1020,9 @@ export function EditorView(props) {
1013
1020
  const seq = ++loadSeqRef.current
1014
1021
  setReceiptLoad({ path, cwd, sessionId: sid, sequence: seq, status: 'loading' })
1015
1022
  setLoadError(null)
1023
+ setLargeCandidate(null)
1024
+ setPagedFile(null)
1025
+ setPageChanged(null)
1016
1026
  /**
1017
1027
  * 记下磁盘版本基线;markRead=true 表示本次真的从磁盘读了内容
1018
1028
  * (已读版本台账随之失效,下一轮轮询按新版本重新比对)。
@@ -1064,6 +1074,7 @@ export function EditorView(props) {
1064
1074
  setLoadStage((prev) => ({ progress: Math.max(84, prev.progress), message: '文件已读取,准备创建编辑器…' }))
1065
1075
  setStatus('已加载')
1066
1076
  } else {
1077
+ if (res?.code === 'file-too-large') setLargeCandidate(JSON.stringify([scope, sid, path]))
1067
1078
  const base = res?.error ? String(res.error) : '读取失败'
1068
1079
  dapTrace('read-failed', { path, error: base, resolvedPath: res?.resolvedPath, cwd })
1069
1080
  // 带出 host 解析后的真实路径:跳转失败时一眼看出是路径解析错还是目标不存在
@@ -1222,7 +1233,7 @@ export function EditorView(props) {
1222
1233
  * @author ddj 2026年09月15号
1223
1234
  * @param change 轮询判定出的外部变更
1224
1235
  */
1225
- const onDiskChange = (change) => {
1236
+ function onDiskChange(change) {
1226
1237
  if (!change || !change.path) return
1227
1238
  // 文件树同步:目录缓存按该路径失效并强制重列(外部新增/删除/改名都能看到)
1228
1239
  const eventId = emitFileChanged(change.path, { scope, kind: change.kind === 'deleted' ? 'structure' : 'text' })
@@ -1242,6 +1253,14 @@ export function EditorView(props) {
1242
1253
  }).catch(() => {})
1243
1254
  return
1244
1255
  }
1256
+ if (change.kind === 'modified' && (paging || pageChanged === pageIdentity) && change.path === activeRef.current) {
1257
+ // Invalidates the old page cursor immediately; retain an identity-scoped notice until explicit reread.
1258
+ loadContent(change.path, sessionId, true)
1259
+ setPageChanged(pageIdentity)
1260
+ setStatus('文件已变化,请重新读取')
1261
+ emitRefresh({ scope, paths: [change.path], kind: 'text', eventId })
1262
+ return
1263
+ }
1245
1264
  if (change.kind === 'modified') {
1246
1265
  // 干净缓冲:强制从磁盘重读(跳过 model 复用),差异标记随之重算
1247
1266
  loadContent(change.path, sessionId, true)
@@ -1264,7 +1283,10 @@ export function EditorView(props) {
1264
1283
  * @param path 文件路径
1265
1284
  * @returns 比对结果;模型不可用/读失败 → null(按「需要重载」处理)
1266
1285
  */
1267
- const readDiskCompare = (path) => {
1286
+ function readDiskCompare(path) {
1287
+ // A read-only page has no full Monaco buffer to compare. Acknowledge the observed
1288
+ // version as changed so polling advances its baseline instead of invalidating every reread.
1289
+ if (path === activeRef.current && (paging || pageChanged === pageIdentity)) return Promise.resolve({ equal: false })
1268
1290
  const model = modelsRef.current?.get(path)
1269
1291
  if (!model || typeof model.getValue !== 'function') return Promise.resolve(null)
1270
1292
  return rpc('edrv.read', { sessionId, path }).then((res) => {
@@ -3764,7 +3786,8 @@ export function EditorView(props) {
3764
3786
  }
3765
3787
  }, [])
3766
3788
 
3767
- const openPath = () => {
3789
+ /** @private @author ddj 2026年10月22号 @description Probe a typed path; let oversized files enter the existing read-only paging flow. @returns Probe completion. */
3790
+ function openPath() {
3768
3791
  const p = (pathDraft || '').trim()
3769
3792
  if (!p) return
3770
3793
  // 图片/PDF 文件跳过文本读探测(二进制会被 host 拒绝),直接进 tab 由专用预览接管
@@ -3774,8 +3797,9 @@ export function EditorView(props) {
3774
3797
  return
3775
3798
  }
3776
3799
  setStatus('打开中…')
3777
- rpc('edrv.read', { sessionId, path: p }).then((res) => {
3778
- if (res && res.ok) {
3800
+ return rpc('edrv.read', { sessionId, path: p }).then((res) => {
3801
+ // Only the explicit size-limit response may bypass the probe; other failures must remain visible.
3802
+ if (res && (res.ok || res.code === 'file-too-large')) {
3779
3803
  openFile(p, false)
3780
3804
  setOpenInput(false); setPathDraft(''); setStatus('已打开'); setError(null)
3781
3805
  } else { setStatus('打开失败'); setError(res?.error ? String(res.error) : '打开失败') }
@@ -3907,9 +3931,19 @@ export function EditorView(props) {
3907
3931
  host.addEventListener('contextmenu', onCtx, true)
3908
3932
  return () => { unsubscribeBp?.(); host.removeEventListener('contextmenu', onCtx) }
3909
3933
  }
3934
+ /** @private @author ddj 2026年10月22号 @param args Read-only page request. @returns Current Host capability result. */
3935
+ function loadTextPage(args) { return rpc('edrv.textPage', args) }
3936
+ /** @private @author ddj 2026年10月22号 @description Never replace a dirty or saving document with partial text. */
3937
+ function openTextPages() {
3938
+ if (largeCandidate !== pageIdentity || dirtyRef.current[active] || savingRef.current.has(active)) return
3939
+ flushSave()
3940
+ if (dirtyRef.current[active] || savingRef.current.has(active)) return
3941
+ setPageChanged(null)
3942
+ setPagedFile(pageIdentity)
3943
+ }
3910
3944
  /** @private @author ddj 2026年10月16号 @param host Passive body. @param path Canonical file. @param groupId Owning group id(find 快照按组隔离). @returns View-only cleanup, models remain workbench-owned. */
3911
3945
  function mountPassive(host, path, groupId) {
3912
- let disposed = false, keeper = null, subscription = null, observer = null, unbindGutter = null
3946
+ let disposed = false, keeper = null, subscription = null, observer = null, unbindGutter = null, pageRoot = null
3913
3947
  const focus = () => { const owner = ownerOf(benchRef.current, path); if (owner) selectGroup(owner.id, path) }
3914
3948
  host.addEventListener('pointerdown', focus)
3915
3949
  if (isPdfPath(path) && pdfNodes.current.has(path)) {
@@ -3923,7 +3957,27 @@ export function EditorView(props) {
3923
3957
  void request.then((result) => {
3924
3958
  if (disposed) return
3925
3959
  host.textContent = ''
3926
- if (!result?.ok) { host.textContent = result?.error || '文件加载失败'; return }
3960
+ if (!result?.ok) {
3961
+ host.textContent = result?.error || '文件加载失败'
3962
+ if (result?.code === 'file-too-large' && !binary) {
3963
+ const button = document.createElement('button')
3964
+ button.textContent = '只读分段查看'
3965
+ /** @author ddj 2026年10月22号 @param event Pointer event. @description Do not focus and unmount the passive group before click. */
3966
+ button.onpointerdown = (event) => event.stopPropagation()
3967
+ /** @author ddj 2026年10月22号 @description Keep partial text out of passive models, autosave and editing commands. */
3968
+ button.onclick = () => {
3969
+ if (disposed || dirtyRef.current[path] || savingRef.current.has(path)) return
3970
+ flushSave()
3971
+ if (dirtyRef.current[path] || savingRef.current.has(path)) return
3972
+ host.textContent = ''
3973
+ host.removeEventListener('pointerdown', focus)
3974
+ pageRoot = createRoot(host)
3975
+ pageRoot.render(React.createElement(LargeTextView, { sessionId, path, loadPage: loadTextPage }))
3976
+ }
3977
+ host.appendChild(button)
3978
+ }
3979
+ return
3980
+ }
3927
3981
  if (binary) {
3928
3982
  const image = document.createElement(isPdfPath(path) ? 'div' : 'img')
3929
3983
  if (isPdfPath(path)) image.textContent = 'PDF · 点击此编辑组继续查看和编辑'
@@ -3948,7 +4002,7 @@ export function EditorView(props) {
3948
4002
  observer = new ResizeObserver(() => editor.layout())
3949
4003
  observer.observe(host)
3950
4004
  }).catch((failure) => { if (!disposed) host.textContent = '文件加载失败:' + String(failure) })
3951
- return () => { disposed = true; host.removeEventListener('pointerdown', focus); unbindGutter?.(); observer?.disconnect(); subscription?.dispose(); keeper?.dispose(); paneEditors.current.delete(path) }
4005
+ return () => { disposed = true; pageRoot?.unmount(); host.removeEventListener('pointerdown', focus); unbindGutter?.(); observer?.disconnect(); subscription?.dispose(); keeper?.dispose(); paneEditors.current.delete(path) }
3952
4006
  }
3953
4007
  const passiveOps = React.useRef(mountPassive)
3954
4008
  passiveOps.current = mountPassive
@@ -3976,7 +4030,7 @@ export function EditorView(props) {
3976
4030
  return React.createElement(EditorPane, { key: id, group, focused, dirty: dirtyMap, suppressActive: hideDiskActive, tabsRef: chrome ? tabsHostRef : undefined, extras: chrome ? planTabExtras : undefined, tools: chrome ? rowTools : undefined, onSelect: selectGroup, onClose: closeTab, onMenu: paneMenu, onDrag: dragTab, consumeClick: consumeDrag },
3977
4031
  focused ? React.createElement('div', { className: 'edrv-full-slot', ref: mountFull }) : React.createElement(React.Fragment, null,
3978
4032
  React.createElement(GroupPathbar, { path: group.active, onFocus: () => selectGroup(id) }),
3979
- React.createElement(PassiveEditor, { key: String(scope) + ':' + group.active, path: group.active, groupId: id, mount: passiveMount })))
4033
+ React.createElement(PassiveEditor, { key: JSON.stringify([scope, sessionId, group.active]), path: group.active, groupId: id, mount: passiveMount })))
3980
4034
  }
3981
4035
  /** @private @author ddj 2026年10月14号 @param node Layout subtree. @returns Recursive resizable panes. */
3982
4036
  function renderTree(node) {
@@ -4176,6 +4230,12 @@ export function EditorView(props) {
4176
4230
  : React.createElement('pre', { className: 'edrv-mdview-pre' }, text)
4177
4231
  body = React.createElement('div', { className: 'edrv-mdview', ref: planHostRef, 'data-plan-address': activePlan.address },
4178
4232
  React.createElement('div', { className: 'edrv-mdview-stage' }, React.createElement('div', { className: 'edrv-mdview-doc' }, render)))
4233
+ } else if (pageChanged === pageIdentity && largeCandidate === pageIdentity) {
4234
+ body = React.createElement('div', { className: 'edrv-empty', role: 'status' },
4235
+ React.createElement('div', null, '文件已变化,请重新读取'),
4236
+ React.createElement('button', { className: 'edrv-pill edrv-pill-ghost', onClick: openTextPages }, '重新读取首段'))
4237
+ } else if (paging) {
4238
+ body = React.createElement(LargeTextView, { key: pageIdentity, sessionId, path: active, loadPage: loadTextPage })
4179
4239
  } else if (!monaco && !monacoErr) {
4180
4240
  body = loadingBody(loadStage.message, loadStage.progress)
4181
4241
  } else if (monacoErr) {
@@ -4200,13 +4260,13 @@ export function EditorView(props) {
4200
4260
  loadContent(active, sessionId)
4201
4261
  } }, '重试'))
4202
4262
  } else if (content === null && loadError) {
4203
- body = loadingBody('文件加载失败:' + loadError, 0, () => {
4263
+ body = React.createElement(React.Fragment, null, loadingBody('文件加载失败:' + loadError, 0, () => {
4204
4264
  setLoadError(null)
4205
4265
  setContent(null)
4206
4266
  setContentPath(null)
4207
4267
  setLoadStage({ progress: 10, message: '重新读取文件…' })
4208
4268
  loadContent(active, sessionId)
4209
- })
4269
+ }), largeCandidate === pageIdentity && React.createElement('button', { className: 'edrv-pill edrv-pill-ghost', onClick: openTextPages }, '只读分段查看'))
4210
4270
  } else if (isImageActive && imageSrc) {
4211
4271
  body = imagePanel()
4212
4272
  } else if (isImageActive) {
@@ -0,0 +1,94 @@
1
+ import React from 'react'
2
+ import * as Primitives from '@deepseek-ai/dsh-client-ui-primitives'
3
+ import { createTextPager } from '../textPages.js'
4
+ import type { TextPageLoader, TextPagerState } from '../textPages.js'
5
+
6
+ export interface LargeTextViewProps {
7
+ sessionId: string
8
+ path: string
9
+ loadPage: TextPageLoader
10
+ }
11
+ const Button = (Primitives as unknown as { Button: React.ComponentType<{
12
+ children?: React.ReactNode; disabled?: boolean; onClick?: () => void
13
+ }> }).Button
14
+
15
+ /**
16
+ * Read-only paged text surface: never creates a Monaco model or exposes a save action.
17
+ * @public @author ddj 2026年10月22号
18
+ * @param props Exact session/file identity and authorized page loader.
19
+ * @returns Bounded selectable text with explicit continuation and status.
20
+ */
21
+ export function LargeTextView(props: LargeTextViewProps): React.ReactElement {
22
+ const [snapshot, setSnapshot] = React.useState<TextPagerState | null>(null)
23
+ const [identity, setIdentity] = React.useState('')
24
+ const pagerRef = React.useRef<ReturnType<typeof createTextPager> | null>(null)
25
+ const loaderRef = React.useRef(props.loadPage)
26
+ const mountedRef = React.useRef(false)
27
+ const firstPending = React.useRef(false)
28
+ loaderRef.current = props.loadPage
29
+ const currentIdentity = JSON.stringify([props.sessionId, props.path])
30
+
31
+ /** @private @author ddj 2026年10月22号 @param args Request identity. @returns Current injected loader response. */
32
+ function loadPage(args: Parameters<TextPageLoader>[0]): ReturnType<TextPageLoader> {
33
+ return loaderRef.current(args)
34
+ }
35
+ /** @private @author ddj 2026年10月22号 @param state Latest pager snapshot. @returns Publish only while mounted. */
36
+ function onSnapshot(state: TextPagerState): void {
37
+ if (!mountedRef.current) return
38
+ setSnapshot(state)
39
+ if (!state.loading && firstPending.current) queueMicrotask(loadFirst)
40
+ }
41
+
42
+ /** @private @author ddj 2026年10月22号 @description Start only the new identity's deferred first page, never automatic continuation. */
43
+ function loadFirst(): void {
44
+ const pager = pagerRef.current
45
+ if (!mountedRef.current || !firstPending.current || !pager || pager.getState().loading
46
+ || (typeof document !== 'undefined' && document.hidden)) return
47
+ firstPending.current = false
48
+ void pager.loadNext()
49
+ }
50
+
51
+ /** @private @author ddj 2026年10月22号 @returns Bind identity without replacing the single transport lock. */
52
+ function mountPager(): () => void {
53
+ mountedRef.current = true
54
+ if (!pagerRef.current) pagerRef.current = createTextPager(loadPage, onSnapshot)
55
+ const pager = pagerRef.current
56
+ setIdentity(currentIdentity)
57
+ firstPending.current = true
58
+ pager.reset({ sessionId: props.sessionId, path: props.path })
59
+ loadFirst()
60
+ if (typeof document !== 'undefined') document.addEventListener('visibilitychange', loadFirst)
61
+ /** @private @author ddj 2026年10月22号 @returns Release retained text and ignore pending responses. */
62
+ function unmountPager(): void {
63
+ mountedRef.current = false
64
+ firstPending.current = false
65
+ if (typeof document !== 'undefined') document.removeEventListener('visibilitychange', loadFirst)
66
+ pager.invalidate()
67
+ }
68
+ return unmountPager
69
+ }
70
+ React.useEffect(mountPager, [props.sessionId, props.path])
71
+
72
+ /** @private @author ddj 2026年10月22号 @returns Load exactly one additional page on user intent. */
73
+ function loadNext(): void {
74
+ if (typeof document !== 'undefined' && document.hidden) return
75
+ if (firstPending.current) { loadFirst(); return }
76
+ void pagerRef.current?.loadNext()
77
+ }
78
+
79
+ const state = identity === currentIdentity ? snapshot : null
80
+ let status = '正在加载只读文本…'
81
+ if (state?.error) status = state.error
82
+ else if (state?.capped) status = '已达预览上限(8 MiB / 50,000 行),请使用外部编辑器查看完整文件'
83
+ else if (state?.eof) status = '文件末尾 · 只读'
84
+ else if (state) status = `已读取 ${state.offset.toLocaleString()} / ${state.size.toLocaleString()} 字节 · 只读`
85
+ if (state?.partialLine) status += ' · 末行尚未完整'
86
+
87
+ return React.createElement('section', { className: 'edrv-large-text', 'aria-label': '大文件只读预览' },
88
+ React.createElement('div', { className: 'edrv-large-text-toolbar' },
89
+ React.createElement('span', { role: 'status' }, status),
90
+ React.createElement(Button, { onClick: loadNext,
91
+ disabled: !state || state.loading || state.eof || state.capped },
92
+ state?.loading ? '加载中…' : (state?.error ? '重试本段' : '加载下一段'))),
93
+ React.createElement('pre', { className: 'edrv-large-text-content', tabIndex: 0 }, state?.text ?? ''))
94
+ }