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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|
|
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)',
|
|
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:
|
|
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.
|
|
191
|
-
|
|
192
|
-
|
|
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> = (
|
package/src/shared/snippets.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/textPages.ts
ADDED
|
@@ -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
|
+
}
|