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.
- package/README.md +6 -4
- package/lib/client.debug.js +934 -0
- package/lib/client.debug.js.map +1 -0
- package/lib/client.js +848 -1676
- 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/index.ts +9 -0
- package/src/client/lazyPanel.ts +68 -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/styles/editor.css +8 -0
- package/src/client/textPages.ts +135 -0
- package/src/client/ui/EditorView.ts +71 -11
- package/src/client/ui/LargeTextView.ts +94 -0
- package/src/rpc.ts +29 -7
- package/src/shared/rpc.ts +3 -1
- package/src/shared/textPages.ts +28 -0
- package/src/textPages.ts +149 -0
- package/src/treeIndex.ts +62 -37
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/** File explorer fixed-height row model. @author ddj 2026年10月22号 */
|
|
2
|
+
import type { TreeEntry } from '../../../shared/rpc.js'
|
|
3
|
+
import { updateRange } from '../../ui/svnUpdateModel.js'
|
|
4
|
+
|
|
5
|
+
/** Fixed geometry shared with file and loading row CSS. */
|
|
6
|
+
export const TREE_ROW_HEIGHT = 24
|
|
7
|
+
|
|
8
|
+
export interface TreeRow {
|
|
9
|
+
key: string
|
|
10
|
+
depth: number
|
|
11
|
+
e?: TreeEntry
|
|
12
|
+
isDir?: boolean
|
|
13
|
+
isOpen?: boolean
|
|
14
|
+
text?: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface TreeViewport {
|
|
18
|
+
top: number
|
|
19
|
+
height: number
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @public @author ddj 2026年10月22号
|
|
24
|
+
* @description Preserve the first visible path when rows above it expand or collapse.
|
|
25
|
+
* @param previous Previous complete model.
|
|
26
|
+
* @param rows New complete model.
|
|
27
|
+
* @param top Previous scroll offset.
|
|
28
|
+
* @returns Anchored offset, or original offset for subsequent clamping if path disappeared.
|
|
29
|
+
*/
|
|
30
|
+
export function treeAnchorTop(previous: TreeRow[], rows: TreeRow[], top: number): number {
|
|
31
|
+
const index = Math.floor(Math.max(0, top) / TREE_ROW_HEIGHT)
|
|
32
|
+
const anchor = previous[index]?.key
|
|
33
|
+
if (!anchor) return top
|
|
34
|
+
const nextIndex = rows.findIndex((row) => row.key === anchor)
|
|
35
|
+
if (nextIndex < 0) return top
|
|
36
|
+
return nextIndex * TREE_ROW_HEIGHT + top % TREE_ROW_HEIGHT
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @public @author ddj 2026年10月22号
|
|
41
|
+
* @description Flatten all expanded entries in their supplied order, including metadata files.
|
|
42
|
+
* @param entriesOf Directory snapshot lookup; null means unavailable.
|
|
43
|
+
* @param expanded Current directory expansion flags.
|
|
44
|
+
* @param loading Current loading flags.
|
|
45
|
+
* @returns Complete row model, independent of viewport mounting.
|
|
46
|
+
*/
|
|
47
|
+
export function flattenTree(entriesOf: (rel: string) => TreeEntry[] | null,
|
|
48
|
+
expanded: Record<string, boolean>, loading: Record<string, boolean>): TreeRow[] {
|
|
49
|
+
const rows: TreeRow[] = []
|
|
50
|
+
const pending: { rel: string; depth: number; entry?: TreeEntry }[] = [{ rel: '', depth: 0 }]
|
|
51
|
+
while (pending.length) {
|
|
52
|
+
const current = pending.pop()!
|
|
53
|
+
const entry = current.entry
|
|
54
|
+
if (entry) {
|
|
55
|
+
const isDir = entry.type === 'directory'
|
|
56
|
+
const isOpen = expanded[entry.path] === true
|
|
57
|
+
rows.push({ key: entry.path, e: entry, depth: current.depth, isDir, isOpen })
|
|
58
|
+
if (isDir && isOpen) pending.push({ rel: entry.path, depth: current.depth + 1 })
|
|
59
|
+
continue
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const entries = entriesOf(current.rel)
|
|
63
|
+
if (!entries) {
|
|
64
|
+
rows.push({ key: current.rel + ':loading', depth: current.depth,
|
|
65
|
+
text: loading[current.rel] ? '加载中…' : '无法加载' })
|
|
66
|
+
continue
|
|
67
|
+
}
|
|
68
|
+
// Stack reverses insertion order; push backwards to preserve host sorting.
|
|
69
|
+
for (let index = entries.length - 1; index >= 0; index--) {
|
|
70
|
+
pending.push({ rel: current.rel, depth: current.depth, entry: entries[index] })
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return rows
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* @public @author ddj 2026年10月22号
|
|
78
|
+
* @description Clamp scroll state before reusing the existing fixed-height range calculation.
|
|
79
|
+
* @param count Complete row count.
|
|
80
|
+
* @param viewport Latest scroll position and measured viewport height.
|
|
81
|
+
* @returns Clamped scroll offset and overscanned exclusive row range.
|
|
82
|
+
*/
|
|
83
|
+
export function treeWindow(count: number, viewport: TreeViewport) {
|
|
84
|
+
const height = Math.max(0, viewport.height)
|
|
85
|
+
const top = Math.min(Math.max(0, viewport.top), Math.max(0, count * TREE_ROW_HEIGHT - height))
|
|
86
|
+
return { top, ...updateRange(count, top, height, TREE_ROW_HEIGHT) }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* @public @author ddj 2026年10月22号
|
|
91
|
+
* @description Locate an unmounted row using model indices, without querying row DOM.
|
|
92
|
+
* @param rows Complete flattened model.
|
|
93
|
+
* @param path Requested relative file path.
|
|
94
|
+
* @param viewport Current viewport geometry.
|
|
95
|
+
* @returns New scroll offset, or null while ancestors/viewport are unavailable.
|
|
96
|
+
*/
|
|
97
|
+
export function treeRevealTop(rows: TreeRow[], path: string, viewport: TreeViewport): number | null {
|
|
98
|
+
if (viewport.height <= 0) return null
|
|
99
|
+
const index = rows.findIndex((row) => row.e?.path === path)
|
|
100
|
+
if (index < 0) return null
|
|
101
|
+
const current = treeWindow(rows.length, viewport).top
|
|
102
|
+
const rowTop = index * TREE_ROW_HEIGHT
|
|
103
|
+
if (rowTop >= current && rowTop + TREE_ROW_HEIGHT <= current + viewport.height) return current
|
|
104
|
+
return treeWindow(rows.length, { top: rowTop - (viewport.height - TREE_ROW_HEIGHT) / 2,
|
|
105
|
+
height: viewport.height }).top
|
|
106
|
+
}
|
|
@@ -27,6 +27,8 @@ interface RevealOptions {
|
|
|
27
27
|
activePath: string | null
|
|
28
28
|
treeRef: React.RefObject<HTMLElement>
|
|
29
29
|
prepare: (path: string) => void
|
|
30
|
+
/** Optional virtual-list locator; success does not depend on mounted row DOM. */
|
|
31
|
+
locate?: (path: string) => boolean
|
|
30
32
|
}
|
|
31
33
|
interface RevealTarget {
|
|
32
34
|
scope: string
|
|
@@ -60,12 +62,13 @@ export function useExplorerReveal(options: RevealOptions) {
|
|
|
60
62
|
setTarget(next)
|
|
61
63
|
if (next) current.prepare(next.path)
|
|
62
64
|
}
|
|
63
|
-
/** @private @author ddj 2026年10月
|
|
65
|
+
/** @private @author ddj 2026年10月22号 Attempts virtual data-index location or the original mounted-row fallback after commit/resize; consumes only a successful current request. */
|
|
64
66
|
function tryReveal(): void {
|
|
65
67
|
const next = pending.current
|
|
66
68
|
const current = latest.current
|
|
67
69
|
if (!next || next.scope !== current.scope || next.active !== current.activePath) return
|
|
68
|
-
|
|
70
|
+
const located = current.locate ? current.locate(next.path) : revealTreeRow(current.treeRef.current, next.path)
|
|
71
|
+
if (!located) return
|
|
69
72
|
pending.current = null
|
|
70
73
|
setRevealPath(next.path)
|
|
71
74
|
clearHighlight()
|
|
@@ -28,8 +28,11 @@ function panelRegister(entries: Map<string, SidebarPanelDef>, notify: () => void
|
|
|
28
28
|
export function createSidebarPanelRegistry(): SidebarPanelRegistry {
|
|
29
29
|
const entries = new Map<string, SidebarPanelDef>()
|
|
30
30
|
const listeners = new Set<() => void>()
|
|
31
|
+
/** @private @author ddj 2026年10月22号 @description Isolate subscribers and snapshot notification membership against reentrant changes. */
|
|
31
32
|
const notify = (): void => {
|
|
32
|
-
for (const listener of listeners)
|
|
33
|
+
for (const listener of [...listeners]) {
|
|
34
|
+
try { listener() } catch { /* One extension must not block other subscribers or registration teardown. */ }
|
|
35
|
+
}
|
|
33
36
|
}
|
|
34
37
|
const list = (): readonly SidebarPanelDef[] =>
|
|
35
38
|
[...entries.values()].sort((a, b) => (a.order ?? 100) - (b.order ?? 100))
|
|
@@ -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
|
-
|
|
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) => {
|
|
@@ -3764,7 +3786,8 @@ export function EditorView(props) {
|
|
|
3764
3786
|
}
|
|
3765
3787
|
}, [])
|
|
3766
3788
|
|
|
3767
|
-
|
|
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
|
-
|
|
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) {
|
|
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:
|
|
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
|
+
}
|