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.
@@ -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'
@@ -47,7 +49,7 @@ import {
47
49
  import { useFileWatch } from './useFileWatch.js'
48
50
  import { createPdfPanel } from '../pdf/pdfPanel.js'
49
51
  import { SvnDiffPanel } from './SvnDiffPanel.js'
50
- import { applyOfficial, registerThemes, themeNameOf } from '../monaco/theme.js'
52
+ import { applyOfficial, editorFont, registerThemes, themeNameOf } from '../monaco/theme.js'
51
53
  import { createDiffRenderer } from '../monaco/diffRender.js'
52
54
  import { ST, callIdAttr, noopHunk, summarize } from '../state/records.js'
53
55
  import { diffRegions } from '../state/regions.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) => {
@@ -2370,9 +2392,7 @@ export function EditorView(props) {
2370
2392
  language: 'plaintext',
2371
2393
  theme: themeNameOf(),
2372
2394
  fontFamily: 'var(--ds-font-family-code, ui-monospace, monospace)',
2373
- // 16px/21px:与 md 预览渲染态同字号,切分页/切组时观感不再跳变(ddj 2026-10-16)
2374
- fontSize: 16,
2375
- lineHeight: 21,
2395
+ ...editorFont(),
2376
2396
  minimap: { enabled: !side, scale: 1 },
2377
2397
  glyphMargin: true,
2378
2398
  lineDecorationsWidth: 16,
@@ -3764,7 +3784,8 @@ export function EditorView(props) {
3764
3784
  }
3765
3785
  }, [])
3766
3786
 
3767
- const openPath = () => {
3787
+ /** @private @author ddj 2026年10月22号 @description Probe a typed path; let oversized files enter the existing read-only paging flow. @returns Probe completion. */
3788
+ function openPath() {
3768
3789
  const p = (pathDraft || '').trim()
3769
3790
  if (!p) return
3770
3791
  // 图片/PDF 文件跳过文本读探测(二进制会被 host 拒绝),直接进 tab 由专用预览接管
@@ -3774,8 +3795,9 @@ export function EditorView(props) {
3774
3795
  return
3775
3796
  }
3776
3797
  setStatus('打开中…')
3777
- rpc('edrv.read', { sessionId, path: p }).then((res) => {
3778
- if (res && res.ok) {
3798
+ return rpc('edrv.read', { sessionId, path: p }).then((res) => {
3799
+ // Only the explicit size-limit response may bypass the probe; other failures must remain visible.
3800
+ if (res && (res.ok || res.code === 'file-too-large')) {
3779
3801
  openFile(p, false)
3780
3802
  setOpenInput(false); setPathDraft(''); setStatus('已打开'); setError(null)
3781
3803
  } else { setStatus('打开失败'); setError(res?.error ? String(res.error) : '打开失败') }
@@ -3907,9 +3929,19 @@ export function EditorView(props) {
3907
3929
  host.addEventListener('contextmenu', onCtx, true)
3908
3930
  return () => { unsubscribeBp?.(); host.removeEventListener('contextmenu', onCtx) }
3909
3931
  }
3932
+ /** @private @author ddj 2026年10月22号 @param args Read-only page request. @returns Current Host capability result. */
3933
+ function loadTextPage(args) { return rpc('edrv.textPage', args) }
3934
+ /** @private @author ddj 2026年10月22号 @description Never replace a dirty or saving document with partial text. */
3935
+ function openTextPages() {
3936
+ if (largeCandidate !== pageIdentity || dirtyRef.current[active] || savingRef.current.has(active)) return
3937
+ flushSave()
3938
+ if (dirtyRef.current[active] || savingRef.current.has(active)) return
3939
+ setPageChanged(null)
3940
+ setPagedFile(pageIdentity)
3941
+ }
3910
3942
  /** @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
3943
  function mountPassive(host, path, groupId) {
3912
- let disposed = false, keeper = null, subscription = null, observer = null, unbindGutter = null
3944
+ let disposed = false, keeper = null, subscription = null, observer = null, unbindGutter = null, pageRoot = null
3913
3945
  const focus = () => { const owner = ownerOf(benchRef.current, path); if (owner) selectGroup(owner.id, path) }
3914
3946
  host.addEventListener('pointerdown', focus)
3915
3947
  if (isPdfPath(path) && pdfNodes.current.has(path)) {
@@ -3923,7 +3955,27 @@ export function EditorView(props) {
3923
3955
  void request.then((result) => {
3924
3956
  if (disposed) return
3925
3957
  host.textContent = ''
3926
- if (!result?.ok) { host.textContent = result?.error || '文件加载失败'; return }
3958
+ if (!result?.ok) {
3959
+ host.textContent = result?.error || '文件加载失败'
3960
+ if (result?.code === 'file-too-large' && !binary) {
3961
+ const button = document.createElement('button')
3962
+ button.textContent = '只读分段查看'
3963
+ /** @author ddj 2026年10月22号 @param event Pointer event. @description Do not focus and unmount the passive group before click. */
3964
+ button.onpointerdown = (event) => event.stopPropagation()
3965
+ /** @author ddj 2026年10月22号 @description Keep partial text out of passive models, autosave and editing commands. */
3966
+ button.onclick = () => {
3967
+ if (disposed || dirtyRef.current[path] || savingRef.current.has(path)) return
3968
+ flushSave()
3969
+ if (dirtyRef.current[path] || savingRef.current.has(path)) return
3970
+ host.textContent = ''
3971
+ host.removeEventListener('pointerdown', focus)
3972
+ pageRoot = createRoot(host)
3973
+ pageRoot.render(React.createElement(LargeTextView, { sessionId, path, loadPage: loadTextPage }))
3974
+ }
3975
+ host.appendChild(button)
3976
+ }
3977
+ return
3978
+ }
3927
3979
  if (binary) {
3928
3980
  const image = document.createElement(isPdfPath(path) ? 'div' : 'img')
3929
3981
  if (isPdfPath(path)) image.textContent = 'PDF · 点击此编辑组继续查看和编辑'
@@ -3936,7 +3988,7 @@ export function EditorView(props) {
3936
3988
  if (result.version) { revRef.current[path] = result.version; recordBaseline(scope, path, result.version) }
3937
3989
  const editor = window.monaco.editor.create(host, { model, automaticLayout: true, minimap: { enabled: false }, scrollBeyondLastLine: false, glyphMargin: true,
3938
3990
  // 与焦点编辑器(ensureEditor)同观感参数:字号/行高/顶部留白(side 形态 6,tab 形态 8)/装饰宽度,切分页行号位置不跳变(ddj 2026-10-16)
3939
- fontFamily: 'var(--ds-font-family-code, ui-monospace, monospace)', fontSize: 16, lineHeight: 21,
3991
+ fontFamily: 'var(--ds-font-family-code, ui-monospace, monospace)', ...editorFont(),
3940
3992
  lineDecorationsWidth: 16, padding: { top: layoutRef.current === 'side' ? 6 : 8 } })
3941
3993
  const unbindGutter = bindBpGutter(editor, host, path)
3942
3994
  keeper = createViewKeeper(editor)
@@ -3948,7 +4000,7 @@ export function EditorView(props) {
3948
4000
  observer = new ResizeObserver(() => editor.layout())
3949
4001
  observer.observe(host)
3950
4002
  }).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) }
4003
+ return () => { disposed = true; pageRoot?.unmount(); host.removeEventListener('pointerdown', focus); unbindGutter?.(); observer?.disconnect(); subscription?.dispose(); keeper?.dispose(); paneEditors.current.delete(path) }
3952
4004
  }
3953
4005
  const passiveOps = React.useRef(mountPassive)
3954
4006
  passiveOps.current = mountPassive
@@ -3976,7 +4028,7 @@ export function EditorView(props) {
3976
4028
  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
4029
  focused ? React.createElement('div', { className: 'edrv-full-slot', ref: mountFull }) : React.createElement(React.Fragment, null,
3978
4030
  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 })))
4031
+ React.createElement(PassiveEditor, { key: JSON.stringify([scope, sessionId, group.active]), path: group.active, groupId: id, mount: passiveMount })))
3980
4032
  }
3981
4033
  /** @private @author ddj 2026年10月14号 @param node Layout subtree. @returns Recursive resizable panes. */
3982
4034
  function renderTree(node) {
@@ -4176,6 +4228,12 @@ export function EditorView(props) {
4176
4228
  : React.createElement('pre', { className: 'edrv-mdview-pre' }, text)
4177
4229
  body = React.createElement('div', { className: 'edrv-mdview', ref: planHostRef, 'data-plan-address': activePlan.address },
4178
4230
  React.createElement('div', { className: 'edrv-mdview-stage' }, React.createElement('div', { className: 'edrv-mdview-doc' }, render)))
4231
+ } else if (pageChanged === pageIdentity && largeCandidate === pageIdentity) {
4232
+ body = React.createElement('div', { className: 'edrv-empty', role: 'status' },
4233
+ React.createElement('div', null, '文件已变化,请重新读取'),
4234
+ React.createElement('button', { className: 'edrv-pill edrv-pill-ghost', onClick: openTextPages }, '重新读取首段'))
4235
+ } else if (paging) {
4236
+ body = React.createElement(LargeTextView, { key: pageIdentity, sessionId, path: active, loadPage: loadTextPage })
4179
4237
  } else if (!monaco && !monacoErr) {
4180
4238
  body = loadingBody(loadStage.message, loadStage.progress)
4181
4239
  } else if (monacoErr) {
@@ -4200,13 +4258,13 @@ export function EditorView(props) {
4200
4258
  loadContent(active, sessionId)
4201
4259
  } }, '重试'))
4202
4260
  } else if (content === null && loadError) {
4203
- body = loadingBody('文件加载失败:' + loadError, 0, () => {
4261
+ body = React.createElement(React.Fragment, null, loadingBody('文件加载失败:' + loadError, 0, () => {
4204
4262
  setLoadError(null)
4205
4263
  setContent(null)
4206
4264
  setContentPath(null)
4207
4265
  setLoadStage({ progress: 10, message: '重新读取文件…' })
4208
4266
  loadContent(active, sessionId)
4209
- })
4267
+ }), largeCandidate === pageIdentity && React.createElement('button', { className: 'edrv-pill edrv-pill-ghost', onClick: openTextPages }, '只读分段查看'))
4210
4268
  } else if (isImageActive && imageSrc) {
4211
4269
  body = imagePanel()
4212
4270
  } 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
+ }
package/src/rpc.ts CHANGED
@@ -56,6 +56,10 @@ import { markActiveSessions, markOfficialFlags, moveOutSessions, planMoveOut, pu
56
56
  import { patchHasPerfConfig, patchInsertPerfConfig, patchRemovePerfConfig, perfConfigBlock } from './perfPatch.js'
57
57
  import type { FileVersions } from './fileVersions.js'
58
58
  import { settingsDocPath } from './settingsDocPath.js'
59
+ import { createTextPages } from './textPages.js'
60
+
61
+ /** Process-local cursor signer; reads must stay on the Host filesystem capability. */
62
+ const readTextPage = createTextPages()
59
63
 
60
64
  /** cwd → 上次 stale 自动清理时间:全量轮询(EditorView/DiffBadge/Dock 各自 5s)节流,避免每轮都读文件算指纹。 */
61
65
  const staleCheckedAt = new Map<string, number>()
@@ -162,7 +166,7 @@ async function readTargetOf(
162
166
  ctx: Ctx,
163
167
  sc: { session: Session; cwd: string },
164
168
  path: string,
165
- ): Promise<ReadTargetOk | { err: string; resolvedPath?: string }> {
169
+ ): Promise<ReadTargetOk | { err: string; resolvedPath?: string; code?: 'file-too-large' }> {
166
170
  const fs = ctx.get('fs')
167
171
  if (!fs) return { err: '缺少 fs' }
168
172
  try {
@@ -173,7 +177,7 @@ async function readTargetOf(
173
177
  // 带上解析后的真实路径:跳转失败时可直接看出是路径解析错还是目标本身不存在
174
178
  return { err: '文件不存在', resolvedPath: fs.processPath(target) }
175
179
  }
176
- if ((info.size ?? 0) > BINARY_READ_CAP) return { err: '文件过大(>32MB),不支持整文件预览' }
180
+ if ((info.size ?? 0) > BINARY_READ_CAP) return { err: '文件过大(>32MB),不支持整文件预览', code: 'file-too-large' }
177
181
  return { target, fs, info }
178
182
  } catch (error) {
179
183
  return { err: '读取失败:' + String(error) }
@@ -186,10 +190,10 @@ async function readTargetOf(
186
190
  * @param prep readTargetOf 的失败分支
187
191
  * @returns { ok:false } 形态载荷
188
192
  */
189
- function targetErrOf(prep: { err: string; resolvedPath?: string }): { ok: false; error: string; resolvedPath?: string } {
190
- return prep.resolvedPath !== undefined
191
- ? { ok: false, error: prep.err, resolvedPath: prep.resolvedPath }
192
- : { ok: false, error: prep.err }
193
+ function targetErrOf(prep: { err: string; resolvedPath?: string; code?: 'file-too-large' }): { ok: false; error: string; resolvedPath?: string; code?: 'file-too-large' } {
194
+ return { ok: false, error: prep.err,
195
+ ...(prep.resolvedPath !== undefined ? { resolvedPath: prep.resolvedPath } : {}),
196
+ ...(prep.code ? { code: prep.code } : {}) }
193
197
  }
194
198
 
195
199
  /**
@@ -699,6 +703,24 @@ export function buildHandlers(
699
703
  const results = await applyDecisions(ctx, sc.session, sc.cwd, bucket, Array.isArray(args.items) ? args.items : [])
700
704
  return { ok: true, results }
701
705
  },
706
+ /** @author ddj 2026年10月22号 @param args Read-only request. @returns Bounded official range read, or unsupported on older providers; never bypasses Host IO. */
707
+ 'edrv.textPage': async (args) => {
708
+ const sc = await requireSession(ctx, args.sessionId)
709
+ if ('err' in sc) return { ok: false, error: sc.err, reason: 'read-failed' }
710
+ try {
711
+ const target = await resolveTarget(ctx, sc.session, args.path)
712
+ const fs = ctx.get('fs')
713
+ if (typeof fs?.readByteRange !== 'function' || typeof fs.stat !== 'function') return readTextPage(undefined, args)
714
+ // Keys are opaque identities, never decoded into physical paths. Both legacy string and official FsTarget are supported.
715
+ const targetKey = typeof target === 'string' ? target : (target as { targetKey?: unknown }).targetKey
716
+ if (typeof targetKey !== 'string') return readTextPage(undefined, args)
717
+ /** @author ddj 2026年10月22号 @returns Provider metadata used for before/after freshness checks. */
718
+ function statPage() { return fs.stat(target) }
719
+ /** @author ddj 2026年10月22号 @param offset Verified cursor position. @param length Bounded byte count. @returns Bytes through the official provider only. */
720
+ function readRange(offset: number, length: number) { return fs.readByteRange(target, { offset, length }) }
721
+ return readTextPage({ identity: JSON.stringify([sc.cwd, targetKey]), stat: statPage, readRange }, args)
722
+ } catch (error) { return { ok: false, error: '读取失败:' + String(error), reason: 'read-failed' } }
723
+ },
702
724
  'edrv.read': async (args) => {
703
725
  const settingsTarget = settingsTargetOf(ctx, args.path)
704
726
  if (settingsTarget && args.encoding !== 'base64') {
@@ -735,7 +757,7 @@ export function buildHandlers(
735
757
  const content = Buffer.from(bytes).toString('base64')
736
758
  return { ok: true, content, size: bytes.byteLength, encoding: 'base64', mime: binaryMimeOf(args.path), version: String(prep.info.version ?? '') }
737
759
  }
738
- if ((prep.info.size ?? 0) > READ_CAP) return { ok: false, error: '文件过大(>8MB),不支持整文件预览' }
760
+ if ((prep.info.size ?? 0) > READ_CAP) return { ok: false, error: '文件过大(>8MB),不支持整文件预览', code: 'file-too-large' }
739
761
  const content = await prep.fs.readText(prep.target)
740
762
  return { ok: true, content, size: content.length, version: String(prep.info.version ?? '') }
741
763
  } catch (error) {
package/src/shared/rpc.ts CHANGED
@@ -372,6 +372,7 @@ export interface RpcRequestMap {
372
372
  'edrv.accept': { sessionId?: string; callId: string; scope?: RpcScope; hunkIndex?: number }
373
373
  'edrv.reject': { sessionId?: string; callId: string; scope?: RpcScope; hunkIndex?: number }
374
374
  'edrv.decideBatch': { sessionId?: string; items: DecideItem[] }
375
+ 'edrv.textPage': import('./textPages.js').TextPageArgs
375
376
  'edrv.read': { sessionId?: string; path: string; encoding?: 'base64' }
376
377
  'edrv.readBinary': { sessionId?: string; path: string }
377
378
  'edrv.versions': { sessionId?: string; paths: string[] }
@@ -540,6 +541,7 @@ export interface RpcOkMap {
540
541
  'edrv.accept': { record: RecordView }
541
542
  'edrv.reject': { record: RecordView }
542
543
  'edrv.decideBatch': { results: DecideResult[] }
544
+ 'edrv.textPage': Extract<import('./textPages.js').TextPageResult, { ok: true }>
543
545
  'edrv.read': { content: string; size: number; encoding?: 'base64'; mime?: string; version?: string }
544
546
  'edrv.readBinary': { binary: BinaryReadPayload }
545
547
  'edrv.versions': { items: FileVersionItem[] }
@@ -698,7 +700,7 @@ export interface RpcOkMap {
698
700
  /** 统一响应:{ok:true, ...payload} 或 {ok:false, error}。 */
699
701
  export type RpcResult<M extends RpcMethod> =
700
702
  | ({ ok: true } & RpcOkMap[M])
701
- | { ok: false; error: string }
703
+ | { ok: false; error: string; code?: 'file-too-large'; reason?: Extract<import('./textPages.js').TextPageResult, { ok: false }>['reason'] }
702
704
 
703
705
  /** 每个方法的 handler 签名(host 分发表与 client 类型检查共用)。 */
704
706
  export type RpcHandler<M extends RpcMethod> = (
@@ -128,7 +128,7 @@ export const SNIPPET_LANGUAGES: readonly string[] = [
128
128
  'c', 'cpp', 'csharp', 'css', 'dart', 'dockerfile', 'go', 'html', 'ini', 'java',
129
129
  'javascript', 'json', 'jsonc', 'kotlin', 'less', 'lua', 'markdown', 'mdx', 'php',
130
130
  'plaintext', 'powershell', 'python', 'ruby', 'rust', 'scss', 'shell', 'sql',
131
- 'swift', 'typescript', 'xml', 'yaml',
131
+ 'swift', 'systemverilog', 'tcl', 'typescript', 'verilog', 'xml', 'yaml',
132
132
  ]
133
133
 
134
134
  /** 语言 id → 展示名(仅收录写法与 id 明显不同的;未收录的原样返回)。 */
@@ -0,0 +1,28 @@
1
+ /** Bounded, read-only text paging protocol. @author ddj 2026年10月22号 */
2
+ export const TEXT_PAGE_BYTES = 256 * 1024
3
+ export const TEXT_VIEW_BYTES = 8 * 1024 * 1024
4
+ export const TEXT_VIEW_LINES = 50_000
5
+
6
+ export interface TextPageArgs {
7
+ sessionId?: string
8
+ path: string
9
+ cursor?: string
10
+ }
11
+
12
+ export type TextPageResult = {
13
+ ok: true
14
+ text: string
15
+ version: string
16
+ /** Absolute byte offset following this page, including the initial BOM. */
17
+ offset: number
18
+ size: number
19
+ eof: boolean
20
+ nextCursor: string | null
21
+ encoding: 'utf-8'
22
+ /** True when this non-final page ends in the middle of a logical line. */
23
+ partialLine: boolean
24
+ } | {
25
+ ok: false
26
+ reason: 'unsupported' | 'invalid-cursor' | 'stale' | 'not-text' | 'read-failed'
27
+ error: string
28
+ }
@@ -0,0 +1,149 @@
1
+ import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto'
2
+ import { TEXT_PAGE_BYTES } from './shared/textPages.js'
3
+ import type { TextPageArgs, TextPageResult } from './shared/textPages.js'
4
+
5
+ /** Only an authorized Host adapter may supply this capability; never use raw fs as fallback. */
6
+ export interface TextPageSource {
7
+ /** Authorized workspace + canonical target identity; re-authorize every request before supplying this source. */
8
+ identity: string
9
+ stat(): Promise<{ version: string; size: number; type: string } | undefined>
10
+ readRange(offset: number, length: number): Promise<Uint8Array>
11
+ }
12
+
13
+ interface PageCursor {
14
+ identity: string
15
+ sessionId: string
16
+ path: string
17
+ version: string
18
+ offset: number
19
+ pendingCR: boolean
20
+ }
21
+
22
+ /**
23
+ * Create a Host-owned pager with process-local authenticated cursors.
24
+ * @public @author ddj 2026年10月22号
25
+ * @returns A bounded reader requiring an independently authorized range capability.
26
+ */
27
+ export function createTextPages() {
28
+ const secret = randomBytes(32)
29
+
30
+ /** @private @author ddj 2026年10月22号 @param payload Authenticated state. @returns Signed cursor. */
31
+ function signCursor(payload: PageCursor): string {
32
+ const body = Buffer.from(JSON.stringify(payload)).toString('base64url')
33
+ return body + '.' + createHmac('sha256', secret).update(body).digest('base64url')
34
+ }
35
+
36
+ /** @private @author ddj 2026年10月22号 @param token Untrusted cursor. @returns Verified state or null. */
37
+ function parseCursor(token: string): PageCursor | null {
38
+ if (typeof token !== 'string' || token.length > 8192) return null
39
+ const parts = token.split('.')
40
+ if (parts.length !== 2 || !/^[A-Za-z0-9_-]+$/.test(parts[0]!)) return null
41
+ const signature = Buffer.from(parts[1]!, 'base64url')
42
+ const expected = createHmac('sha256', secret).update(parts[0]!).digest()
43
+ if (signature.length !== expected.length || !timingSafeEqual(signature, expected)) return null
44
+
45
+ try {
46
+ const payload = JSON.parse(Buffer.from(parts[0]!, 'base64url').toString('utf8')) as PageCursor
47
+ if (typeof payload.identity !== 'string' || typeof payload.version !== 'string'
48
+ || typeof payload.sessionId !== 'string' || typeof payload.path !== 'string'
49
+ || typeof payload.pendingCR !== 'boolean' || !Number.isSafeInteger(payload.offset)
50
+ || payload.offset <= 0) return null
51
+ return payload
52
+ } catch { return null }
53
+ }
54
+
55
+ /**
56
+ * Read at most 256 KiB, validating freshness before and after IO.
57
+ * @public @author ddj 2026年10月22号
58
+ * @param source Authorized range capability; absent means unsupported.
59
+ * @param args Request identity and optional signed cursor.
60
+ * @returns Strict UTF-8 text or an explicit fail-closed result; never partial success on mutation.
61
+ */
62
+ async function readTextPage(source: TextPageSource | undefined, args: TextPageArgs): Promise<TextPageResult> {
63
+ if (!source) return failure('unsupported', 'Host does not expose authorized range reads')
64
+ const cursor = args.cursor === undefined ? undefined : parseCursor(args.cursor)
65
+ if (cursor === null || (cursor && (cursor.identity !== source.identity
66
+ || cursor.sessionId !== (args.sessionId ?? '') || cursor.path !== args.path))) {
67
+ return failure('invalid-cursor', 'Cursor does not belong to this file and session')
68
+ }
69
+
70
+ try {
71
+ const before = await source.stat()
72
+ if (!before || before.type !== 'file' || typeof before.version !== 'string'
73
+ || !Number.isSafeInteger(before.size) || before.size < 0) {
74
+ return failure('unsupported', 'Regular file size and opaque version are required')
75
+ }
76
+ if (cursor && (cursor.version !== before.version || cursor.offset >= before.size)) {
77
+ return failure('stale', 'File changed; restart text preview')
78
+ }
79
+ const offset = cursor?.offset ?? 0
80
+ const length = Math.min(TEXT_PAGE_BYTES, before.size - offset)
81
+ const bytes = await source.readRange(offset, length)
82
+ const after = await source.stat()
83
+ if (!after || after.version !== before.version || after.size !== before.size || after.type !== 'file') {
84
+ return failure('stale', 'File changed during text read')
85
+ }
86
+ if (!(bytes instanceof Uint8Array) || bytes.byteLength !== length) {
87
+ return failure('read-failed', 'Range reader returned an incomplete or oversized page')
88
+ }
89
+ return decodePage(bytes, before, offset, cursor?.pendingCR ?? false, args, source.identity, signCursor)
90
+ } catch (error) {
91
+ return failure('read-failed', String(error))
92
+ }
93
+ }
94
+
95
+ return readTextPage
96
+ }
97
+
98
+ /** @private @author ddj 2026年10月22号 @param reason Failure discriminator. @param error User-visible error. @returns Failure result. */
99
+ function failure(reason: Extract<TextPageResult, { ok: false }>['reason'], error: string): TextPageResult {
100
+ return { ok: false, reason, error }
101
+ }
102
+
103
+ /**
104
+ * Keep an incomplete final UTF-8 sequence for a bounded re-read on the next page.
105
+ * @private @author ddj 2026年10月22号
106
+ * @param bytes Raw page. @returns Complete prefix length; invalid sequences remain for fatal decoding.
107
+ */
108
+ function utf8Prefix(bytes: Uint8Array): number {
109
+ let start = bytes.length - 1
110
+ while (start >= 0 && (bytes[start]! & 0xc0) === 0x80) start--
111
+ if (start < 0) return bytes.length
112
+ const lead = bytes[start]!
113
+ let width = 1
114
+ if (lead >= 0xc2 && lead <= 0xdf) width = 2
115
+ else if (lead >= 0xe0 && lead <= 0xef) width = 3
116
+ else if (lead >= 0xf0 && lead <= 0xf4) width = 4
117
+ return bytes.length - start < width ? start : bytes.length
118
+ }
119
+
120
+ /**
121
+ * Decode one page preserving original line endings without buffering a long line.
122
+ * @private @author ddj 2026年10月22号
123
+ * @param bytes Raw page. @param info File snapshot. @param offset Current offset.
124
+ * @param pendingCR Previous trailing CR. @param args Request identity. @param identity Authorized target identity.
125
+ * @param signCursor Host cursor signer. @returns Page or strict decoding failure.
126
+ */
127
+ function decodePage(
128
+ bytes: Uint8Array, info: { size: number; version: string }, offset: number, pendingCR: boolean,
129
+ args: TextPageArgs, identity: string, signCursor: (cursor: PageCursor) => string,
130
+ ): TextPageResult {
131
+ const finalRange = offset + bytes.length === info.size
132
+ const consumed = finalRange ? bytes.length : utf8Prefix(bytes)
133
+ let text: string
134
+ try {
135
+ text = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes.subarray(0, consumed))
136
+ } catch { return failure('not-text', 'File is not valid UTF-8') }
137
+ if (text.includes('\0')) return failure('not-text', 'Binary text preview is unsupported')
138
+ if (offset === 0 && text.startsWith('\uFEFF')) text = text.slice(1)
139
+ if (pendingCR) text = '\r' + text
140
+
141
+ const nextOffset = offset + consumed
142
+ const eof = nextOffset === info.size
143
+ const trailingCR = !eof && text.endsWith('\r')
144
+ if (trailingCR) text = text.slice(0, -1)
145
+ const nextCursor = eof ? null : signCursor({ identity, sessionId: args.sessionId ?? '', path: args.path,
146
+ version: info.version, offset: nextOffset, pendingCR: trailingCR })
147
+ return { ok: true, text, version: info.version, offset: nextOffset, size: info.size, eof, nextCursor,
148
+ encoding: 'utf-8', partialLine: !eof && !/[\r\n]$/.test(text) }
149
+ }