dsh-vscode-mode 0.17.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 +8 -4
- package/lib/client.debug.js +934 -0
- package/lib/client.debug.js.map +1 -0
- package/lib/client.js +1425 -1724
- 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 +1078 -286
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/skills/dsh-vscodemode-mcp/SKILL.md +24 -7
- package/src/client/index.ts +24 -0
- package/src/client/lazyPanel.ts +68 -0
- package/src/client/mcpSessionState.ts +108 -0
- package/src/client/monaco/lsp/providers.ts +32 -3
- 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/modelWarmer.ts +198 -0
- package/src/client/styles/editor.css +13 -1
- package/src/client/textPages.ts +135 -0
- package/src/client/ui/EditorPane.ts +16 -4
- package/src/client/ui/EditorView.ts +147 -29
- package/src/client/ui/LargeTextView.ts +94 -0
- package/src/client/ui/McpSessionStatus.ts +75 -0
- package/src/client/ui/McpSettings.ts +47 -13
- package/src/mcp.ts +1 -0
- package/src/mcpBridge.ts +163 -0
- package/src/mcpDiscover.ts +38 -0
- package/src/mcpPool.ts +245 -0
- package/src/mcpProject.ts +15 -12
- package/src/mcpRuntime.ts +299 -254
- package/src/rpc.ts +42 -8
- package/src/shared/mcp.ts +47 -0
- package/src/shared/rpc.ts +8 -2
- package/src/shared/textPages.ts +28 -0
- package/src/textPages.ts +149 -0
- package/src/treeIndex.ts +62 -37
|
@@ -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
|
+
}
|
|
@@ -14,14 +14,24 @@ export interface PaneProps {
|
|
|
14
14
|
onDrag(path: string, event: React.PointerEvent): void
|
|
15
15
|
consumeClick(): boolean
|
|
16
16
|
children?: React.ReactNode
|
|
17
|
+
/** @public @author ddj 2026年10月20号 Virtual preview tabs of this group's own strip: plans and files share one row. */
|
|
17
18
|
extras?: React.ReactNode
|
|
19
|
+
/** @public @author ddj 2026年10月20号 Row controls pinned after the strip of the same row (never inside the scroller). */
|
|
20
|
+
tools?: React.ReactNode
|
|
21
|
+
/** @public @author ddj 2026年10月20号 Strip ref owned by the workbench (scroll-into-view and horizontal wheel). */
|
|
22
|
+
tabsRef?: React.Ref<HTMLDivElement>
|
|
23
|
+
/** @public @author ddj 2026年10月20号 Read-only plan preview owns this pane body, so no disk tab paints as active. */
|
|
24
|
+
suppressActive?: boolean
|
|
18
25
|
}
|
|
19
26
|
|
|
20
27
|
/** @public @author ddj 2026年10月14号 @param props Group callbacks and file view. @returns Accessible tab strip and drop target. */
|
|
21
28
|
export function EditorPane(props: PaneProps): React.ReactElement {
|
|
22
29
|
const { group } = props
|
|
23
|
-
|
|
30
|
+
// 计划预览接管本组正文时,磁盘页签只保留可点击性,不再画选中态,避免同一可见区域出现两条下划线。
|
|
31
|
+
const suppressActive = props.suppressActive === true
|
|
32
|
+
/** @private @author ddj 2026年10月20号 @param tab File record. @returns Draggable tab with isolated close control. */
|
|
24
33
|
function renderTab(tab: TabLike): React.ReactElement {
|
|
34
|
+
const selected = !suppressActive && tab.path === group.active
|
|
25
35
|
/** @private @author ddj 2026年10月14号 @param event Primary press. Start trusted file drag. */
|
|
26
36
|
function press(event: React.PointerEvent): void { props.onDrag(tab.path, event) }
|
|
27
37
|
/** @private @author ddj 2026年10月14号 Select unless release completed a drag. */
|
|
@@ -35,8 +45,8 @@ export function EditorPane(props: PaneProps): React.ReactElement {
|
|
|
35
45
|
/** @private @author ddj 2026年10月14号 @param event Keyboard activation. */
|
|
36
46
|
function key(event: React.KeyboardEvent): void { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); props.onSelect(group.id, tab.path) } }
|
|
37
47
|
return React.createElement('div', {
|
|
38
|
-
key: tab.path, 'data-edrv-tab-path': tab.path, role: 'tab', tabIndex: 0, 'aria-selected':
|
|
39
|
-
className: 'edrv-tab' + (
|
|
48
|
+
key: tab.path, 'data-edrv-tab-path': tab.path, role: 'tab', tabIndex: 0, 'aria-selected': selected,
|
|
49
|
+
className: 'edrv-tab' + (selected ? ' edrv-tab-active' : '') + (tab.pinned ? ' edrv-tab-pinned' : ''),
|
|
40
50
|
title: tab.path + (tab.pinned ? '(已固定)' : ''), onPointerDown: press, onClick: select, onContextMenu: menu, onKeyDown: key,
|
|
41
51
|
}, tab.pinned ? React.createElement('span', { className: 'edrv-tab-pin' }, '📌') : null,
|
|
42
52
|
React.createElement('span', { className: 'edrv-group-tab-name' }, tab.path.split(/[\\/]/).pop()),
|
|
@@ -48,7 +58,9 @@ export function EditorPane(props: PaneProps): React.ReactElement {
|
|
|
48
58
|
if (!group.active && !props.focused && event.button === 0) props.onSelect(group.id)
|
|
49
59
|
}
|
|
50
60
|
return React.createElement('section', { className: 'edrv-group' + (props.focused ? ' edrv-group-focused' : ''), 'data-edrv-group': group.id, onPointerDown: focusEmpty },
|
|
51
|
-
React.createElement('div', { className: 'edrv-tabs
|
|
61
|
+
React.createElement('div', { className: 'edrv-tabs-row' },
|
|
62
|
+
React.createElement('div', { className: 'edrv-tabs', role: 'tablist', 'data-edrv-group-tabs': group.id, ref: props.tabsRef }, group.tabs.map(renderTab), props.extras),
|
|
63
|
+
props.tools),
|
|
52
64
|
React.createElement('div', { className: 'edrv-group-body', role: 'tabpanel' }, props.children))
|
|
53
65
|
}
|
|
54
66
|
|
|
@@ -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'
|
|
@@ -68,6 +70,7 @@ import { mdPreviewLoad, mdPreviewSave } from '../state/mdPreviewCache.js'
|
|
|
68
70
|
import { onUiStateReady, reportUiStateEvent, uiStateSettled } from '../state/uiStatePersist.js'
|
|
69
71
|
import { migrateScopedKeys, workspaceScopeOf } from '../state/scopeStore.js'
|
|
70
72
|
import { modelsForScope, rememberModel, retainModels } from '../state/modelCache.js'
|
|
73
|
+
import { clearModelWarmer, setModelWarmer } from '../state/modelWarmer.js'
|
|
71
74
|
import { navFlashRangeOf } from '../navHighlight.js'
|
|
72
75
|
import { chordOf, useKeybindingsVersion } from '../keybindings.js'
|
|
73
76
|
import { getSidebarMinWidth } from '../sidebarMin.js'
|
|
@@ -260,6 +263,8 @@ export function EditorView(props) {
|
|
|
260
263
|
const planActiveRef = React.useRef(null)
|
|
261
264
|
planActiveRef.current = planActive
|
|
262
265
|
const planHostRef = React.useRef(null)
|
|
266
|
+
// 进入只读计划预览前的磁盘选中页签,关闭该计划页签时恢复,避免跳到第一个页签。
|
|
267
|
+
const planReturnRef = React.useRef(null)
|
|
263
268
|
React.useEffect(() => { setPlanTabs([]); setPlanActive(null) }, [sessionId, cwd])
|
|
264
269
|
/** @private @author ddj 2026年10月08号 @param path Disk tab selection. Leave read-only plan mode before any disk navigation. */
|
|
265
270
|
function setActive(path) {
|
|
@@ -271,6 +276,10 @@ export function EditorView(props) {
|
|
|
271
276
|
const [layoutScope, setLayoutScope] = React.useState(null)
|
|
272
277
|
const benchRef = React.useRef(workbench)
|
|
273
278
|
benchRef.current = workbench
|
|
279
|
+
// 页签行归属的分组:焦点落在浮窗时行仍留在页面主组上(浮窗自带标题栏),与 renderGroup 同一判据。
|
|
280
|
+
// 行节点会随焦点在分组间迁移,绑定滚轮/滚入视野的 effect 必须跟着这个归属重跑。
|
|
281
|
+
const rowOnFloat = workbench.floats.some((item) => item.group === workbench.focused)
|
|
282
|
+
const rowOwner = rowOnFloat ? MAIN_GROUP : workbench.focused
|
|
274
283
|
const [dragPreview, setDragPreview] = React.useState(null)
|
|
275
284
|
const dragRef = React.useRef(null)
|
|
276
285
|
const benchOps = React.useRef(null)
|
|
@@ -311,6 +320,11 @@ export function EditorView(props) {
|
|
|
311
320
|
const aiStatusTimer = React.useRef(null)
|
|
312
321
|
const [error, setError] = React.useState(null)
|
|
313
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
|
|
314
328
|
const [loadStage, setLoadStage] = React.useState({ progress: 0, message: '准备加载编辑器…' })
|
|
315
329
|
const [openInput, setOpenInput] = React.useState(false)
|
|
316
330
|
const [pathDraft, setPathDraft] = React.useState('')
|
|
@@ -1006,6 +1020,9 @@ export function EditorView(props) {
|
|
|
1006
1020
|
const seq = ++loadSeqRef.current
|
|
1007
1021
|
setReceiptLoad({ path, cwd, sessionId: sid, sequence: seq, status: 'loading' })
|
|
1008
1022
|
setLoadError(null)
|
|
1023
|
+
setLargeCandidate(null)
|
|
1024
|
+
setPagedFile(null)
|
|
1025
|
+
setPageChanged(null)
|
|
1009
1026
|
/**
|
|
1010
1027
|
* 记下磁盘版本基线;markRead=true 表示本次真的从磁盘读了内容
|
|
1011
1028
|
* (已读版本台账随之失效,下一轮轮询按新版本重新比对)。
|
|
@@ -1057,6 +1074,7 @@ export function EditorView(props) {
|
|
|
1057
1074
|
setLoadStage((prev) => ({ progress: Math.max(84, prev.progress), message: '文件已读取,准备创建编辑器…' }))
|
|
1058
1075
|
setStatus('已加载')
|
|
1059
1076
|
} else {
|
|
1077
|
+
if (res?.code === 'file-too-large') setLargeCandidate(JSON.stringify([scope, sid, path]))
|
|
1060
1078
|
const base = res?.error ? String(res.error) : '读取失败'
|
|
1061
1079
|
dapTrace('read-failed', { path, error: base, resolvedPath: res?.resolvedPath, cwd })
|
|
1062
1080
|
// 带出 host 解析后的真实路径:跳转失败时一眼看出是路径解析错还是目标不存在
|
|
@@ -1215,7 +1233,7 @@ export function EditorView(props) {
|
|
|
1215
1233
|
* @author ddj 2026年09月15号
|
|
1216
1234
|
* @param change 轮询判定出的外部变更
|
|
1217
1235
|
*/
|
|
1218
|
-
|
|
1236
|
+
function onDiskChange(change) {
|
|
1219
1237
|
if (!change || !change.path) return
|
|
1220
1238
|
// 文件树同步:目录缓存按该路径失效并强制重列(外部新增/删除/改名都能看到)
|
|
1221
1239
|
const eventId = emitFileChanged(change.path, { scope, kind: change.kind === 'deleted' ? 'structure' : 'text' })
|
|
@@ -1235,6 +1253,14 @@ export function EditorView(props) {
|
|
|
1235
1253
|
}).catch(() => {})
|
|
1236
1254
|
return
|
|
1237
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
|
+
}
|
|
1238
1264
|
if (change.kind === 'modified') {
|
|
1239
1265
|
// 干净缓冲:强制从磁盘重读(跳过 model 复用),差异标记随之重算
|
|
1240
1266
|
loadContent(change.path, sessionId, true)
|
|
@@ -1257,7 +1283,10 @@ export function EditorView(props) {
|
|
|
1257
1283
|
* @param path 文件路径
|
|
1258
1284
|
* @returns 比对结果;模型不可用/读失败 → null(按「需要重载」处理)
|
|
1259
1285
|
*/
|
|
1260
|
-
|
|
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 })
|
|
1261
1290
|
const model = modelsRef.current?.get(path)
|
|
1262
1291
|
if (!model || typeof model.getValue !== 'function') return Promise.resolve(null)
|
|
1263
1292
|
return rpc('edrv.read', { sessionId, path }).then((res) => {
|
|
@@ -1853,11 +1882,11 @@ export function EditorView(props) {
|
|
|
1853
1882
|
const right = left + el.offsetWidth
|
|
1854
1883
|
if (left < host.scrollLeft) host.scrollLeft = left
|
|
1855
1884
|
else if (right > host.scrollLeft + host.clientWidth) host.scrollLeft = right - host.clientWidth
|
|
1856
|
-
}, [active, tabs.length])
|
|
1885
|
+
}, [active, planActive, planTabs.length, tabs.length, rowOwner])
|
|
1857
1886
|
|
|
1858
1887
|
// 页签栏滚轮横向滚动(需求 3):页签栏溢出时,鼠标滚轮(含触控板纵向手势)横滚页签栏,
|
|
1859
1888
|
// 不溢出时不接管(保持页面垂直滚动);Shift+滚轮交由浏览器原生横滚。详见 horizontalWheel.ts。
|
|
1860
|
-
React.useEffect(() => attachHorizontalWheel(tabsHostRef.current), [])
|
|
1889
|
+
React.useEffect(() => attachHorizontalWheel(tabsHostRef.current), [rowOwner])
|
|
1861
1890
|
|
|
1862
1891
|
// 鼠标侧键后退/前进:Logitech 官方默认「后退/前进」= XButton 鼠标事件(button 3/4),不产生键盘事件。
|
|
1863
1892
|
// pointerdown 触发导航(preventDefault 后兼容 mousedown 可能不再触发,避免双触发);
|
|
@@ -2065,6 +2094,42 @@ export function EditorView(props) {
|
|
|
2065
2094
|
return model
|
|
2066
2095
|
}
|
|
2067
2096
|
|
|
2097
|
+
const warmRef = React.useRef(null)
|
|
2098
|
+
/**
|
|
2099
|
+
* 让目标文件的 Monaco model 存在(预热入口,供 Ctrl+hover 定义链接预览)。
|
|
2100
|
+
*
|
|
2101
|
+
* 为什么需要:Monaco 在渲染定义链接的源码预览时对目标 uri 调 `createModelReference`
|
|
2102
|
+
* 后只挂 then、没有 catch;本插件的定义目标是自造 `edrv://` uri,目标文件若从未打开过
|
|
2103
|
+
* 就没有已注册 model,会 reject("Model not found") 成为未捕获异常(见 state/modelWarmer.ts)。
|
|
2104
|
+
* 已缓存的路径直接返回成功:模型已在 Monaco 注册表里,无需再读盘。
|
|
2105
|
+
* 预热出的 model 走 modelsRef 常规 LRU(40 条、未附着超限被 dispose);正式打开该文件时
|
|
2106
|
+
* 由 loadContent 的 cached-model 分支 + checkCached 后台校验接管,不会显示陈旧内容。
|
|
2107
|
+
* @private
|
|
2108
|
+
* @author ddj 2026年10月20号
|
|
2109
|
+
* @param path 工作区相对路径
|
|
2110
|
+
* @returns 模型是否已存在/成功建出(失败一律 false,绝不抛)
|
|
2111
|
+
*/
|
|
2112
|
+
warmRef.current = (path) => {
|
|
2113
|
+
if (!path) return Promise.resolve(false)
|
|
2114
|
+
if (modelsRef.current.get(path)) return Promise.resolve(true)
|
|
2115
|
+
// 图片/PDF 不建文本 model(各自走预览控件),预热对它们没有意义
|
|
2116
|
+
if (isImagePath(path) || isPdfPath(path)) return Promise.resolve(false)
|
|
2117
|
+
const sid = sessionId
|
|
2118
|
+
if (!sid) return Promise.resolve(false)
|
|
2119
|
+
|
|
2120
|
+
return rpc('edrv.read', { sessionId: sid, path }).then((res) => {
|
|
2121
|
+
if (!res?.ok || typeof res.content !== 'string') return false
|
|
2122
|
+
getModel(path, res.content)
|
|
2123
|
+
return true
|
|
2124
|
+
}).catch(() => false)
|
|
2125
|
+
}
|
|
2126
|
+
// 预热实现随挂载安装、卸载移除:编辑器未挂载时 warmModelFor 直接返回 false(退化为修复前行为)
|
|
2127
|
+
React.useEffect(() => {
|
|
2128
|
+
const warmImpl = (path) => warmRef.current?.(path) ?? Promise.resolve(false)
|
|
2129
|
+
setModelWarmer(warmImpl)
|
|
2130
|
+
return () => clearModelWarmer(warmImpl)
|
|
2131
|
+
}, [])
|
|
2132
|
+
|
|
2068
2133
|
/**
|
|
2069
2134
|
* 提交待执行的防抖保存(**真正执行保存**,不是取消)。
|
|
2070
2135
|
* 语义与缺陷背景见 saveDebounce.ts:`schedule` 的返回值是只 clearTimeout 的 disposer,
|
|
@@ -3595,6 +3660,7 @@ export function EditorView(props) {
|
|
|
3595
3660
|
if (!document) return
|
|
3596
3661
|
flushSave()
|
|
3597
3662
|
saveViewState(active)
|
|
3663
|
+
if (!planActiveRef.current) planReturnRef.current = active
|
|
3598
3664
|
setDiskActive(null)
|
|
3599
3665
|
setPlanTabs((prev) => [...prev.filter((item) => item.address !== document.address), document].slice(-32))
|
|
3600
3666
|
setPlanActive(document.address)
|
|
@@ -3605,7 +3671,10 @@ export function EditorView(props) {
|
|
|
3605
3671
|
function closePlan(address, event) {
|
|
3606
3672
|
event?.stopPropagation()
|
|
3607
3673
|
setPlanTabs((prev) => prev.filter((item) => item.address !== address))
|
|
3608
|
-
if (planActiveRef.current
|
|
3674
|
+
if (planActiveRef.current !== address) return
|
|
3675
|
+
const previous = planReturnRef.current
|
|
3676
|
+
planReturnRef.current = null
|
|
3677
|
+
setActive(tabsRef.current.some((tab) => tab.path === previous) ? previous : (tabsRef.current[0]?.path ?? null))
|
|
3609
3678
|
}
|
|
3610
3679
|
/** @private @author ddj 2026年10月08号 @param request Claimed disk or virtual open. Flushes saves without replacing dirty models. */
|
|
3611
3680
|
function openReceiptFile(request) {
|
|
@@ -3717,7 +3786,8 @@ export function EditorView(props) {
|
|
|
3717
3786
|
}
|
|
3718
3787
|
}, [])
|
|
3719
3788
|
|
|
3720
|
-
|
|
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() {
|
|
3721
3791
|
const p = (pathDraft || '').trim()
|
|
3722
3792
|
if (!p) return
|
|
3723
3793
|
// 图片/PDF 文件跳过文本读探测(二进制会被 host 拒绝),直接进 tab 由专用预览接管
|
|
@@ -3727,8 +3797,9 @@ export function EditorView(props) {
|
|
|
3727
3797
|
return
|
|
3728
3798
|
}
|
|
3729
3799
|
setStatus('打开中…')
|
|
3730
|
-
rpc('edrv.read', { sessionId, path: p }).then((res) => {
|
|
3731
|
-
|
|
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')) {
|
|
3732
3803
|
openFile(p, false)
|
|
3733
3804
|
setOpenInput(false); setPathDraft(''); setStatus('已打开'); setError(null)
|
|
3734
3805
|
} else { setStatus('打开失败'); setError(res?.error ? String(res.error) : '打开失败') }
|
|
@@ -3860,9 +3931,19 @@ export function EditorView(props) {
|
|
|
3860
3931
|
host.addEventListener('contextmenu', onCtx, true)
|
|
3861
3932
|
return () => { unsubscribeBp?.(); host.removeEventListener('contextmenu', onCtx) }
|
|
3862
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
|
+
}
|
|
3863
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. */
|
|
3864
3945
|
function mountPassive(host, path, groupId) {
|
|
3865
|
-
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
|
|
3866
3947
|
const focus = () => { const owner = ownerOf(benchRef.current, path); if (owner) selectGroup(owner.id, path) }
|
|
3867
3948
|
host.addEventListener('pointerdown', focus)
|
|
3868
3949
|
if (isPdfPath(path) && pdfNodes.current.has(path)) {
|
|
@@ -3876,7 +3957,27 @@ export function EditorView(props) {
|
|
|
3876
3957
|
void request.then((result) => {
|
|
3877
3958
|
if (disposed) return
|
|
3878
3959
|
host.textContent = ''
|
|
3879
|
-
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
|
+
}
|
|
3880
3981
|
if (binary) {
|
|
3881
3982
|
const image = document.createElement(isPdfPath(path) ? 'div' : 'img')
|
|
3882
3983
|
if (isPdfPath(path)) image.textContent = 'PDF · 点击此编辑组继续查看和编辑'
|
|
@@ -3901,7 +4002,7 @@ export function EditorView(props) {
|
|
|
3901
4002
|
observer = new ResizeObserver(() => editor.layout())
|
|
3902
4003
|
observer.observe(host)
|
|
3903
4004
|
}).catch((failure) => { if (!disposed) host.textContent = '文件加载失败:' + String(failure) })
|
|
3904
|
-
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) }
|
|
3905
4006
|
}
|
|
3906
4007
|
const passiveOps = React.useRef(mountPassive)
|
|
3907
4008
|
passiveOps.current = mountPassive
|
|
@@ -3916,14 +4017,20 @@ export function EditorView(props) {
|
|
|
3916
4017
|
setWorkbench((current) => ({ ...current, tree: replace(current.tree) }))
|
|
3917
4018
|
}
|
|
3918
4019
|
/** @private @author ddj 2026年10月14号 @param id Leaf id. @returns Tabs and either focused full-feature editor or independent passive view. */
|
|
3919
|
-
function renderGroup(id) {
|
|
4020
|
+
function renderGroup(id, floating) {
|
|
3920
4021
|
const group = workbench.groups.find((item) => item.id === id)
|
|
3921
4022
|
if (!group) return null
|
|
3922
4023
|
const focused = id === workbench.focused
|
|
3923
|
-
|
|
4024
|
+
// 同一行:计划预览页签并入该组自己的页签行(extras),与磁盘页签共用一条滚动区,
|
|
4025
|
+
// 页面工具列固定在行尾(滚动区之外,横滚不带走)。焦点落在浮窗时,这一行仍留在页面分组上,
|
|
4026
|
+
// 避免整条工具栏跟着搬进浮窗;页签缓存 ref 只认页面上的这条行。
|
|
4027
|
+
const chrome = !floating && id === rowOwner
|
|
4028
|
+
// 计划预览占用的正文只会在焦点组里;承载页签行的组同时收起磁盘选中态,保证整屏只有计划页签一条下划线。
|
|
4029
|
+
const hideDiskActive = Boolean(planActive) && (chrome || focused)
|
|
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 },
|
|
3924
4031
|
focused ? React.createElement('div', { className: 'edrv-full-slot', ref: mountFull }) : React.createElement(React.Fragment, null,
|
|
3925
4032
|
React.createElement(GroupPathbar, { path: group.active, onFocus: () => selectGroup(id) }),
|
|
3926
|
-
React.createElement(PassiveEditor, { key:
|
|
4033
|
+
React.createElement(PassiveEditor, { key: JSON.stringify([scope, sessionId, group.active]), path: group.active, groupId: id, mount: passiveMount })))
|
|
3927
4034
|
}
|
|
3928
4035
|
/** @private @author ddj 2026年10月14号 @param node Layout subtree. @returns Recursive resizable panes. */
|
|
3929
4036
|
function renderTree(node) {
|
|
@@ -3932,15 +4039,18 @@ export function EditorView(props) {
|
|
|
3932
4039
|
}
|
|
3933
4040
|
// --endregion
|
|
3934
4041
|
|
|
3935
|
-
|
|
3936
|
-
|
|
3937
|
-
|
|
4042
|
+
// 计划预览页签不再是独立一行的工具条:它作为焦点分组页签行的尾部内容(EditorPane 的 extras)渲染,
|
|
4043
|
+
// 与磁盘页签同处一条滚动区,因此整块工作台只有一行页签。
|
|
4044
|
+
const planTabExtras = planTabs.length
|
|
4045
|
+
? planTabs.map((plan) => React.createElement('div', {
|
|
4046
|
+
key: plan.address, className: 'edrv-tab edrv-tab-plan' + (plan.address === planActive ? ' edrv-tab-active' : ''),
|
|
3938
4047
|
title: plan.title + '(计划预览 · 只读)', onClick: () => openPlanDoc(plan),
|
|
3939
|
-
}, React.createElement('span', {
|
|
3940
|
-
React.createElement('span', { className: 'edrv-tab-x', title: '关闭', onClick: (event) => closePlan(plan.address, event) }, '×')))
|
|
3941
|
-
|
|
3942
|
-
|
|
3943
|
-
|
|
4048
|
+
}, React.createElement('span', { className: 'edrv-group-tab-name' }, plan.title),
|
|
4049
|
+
React.createElement('span', { className: 'edrv-tab-x', title: '关闭', onClick: (event) => closePlan(plan.address, event) }, '×')))
|
|
4050
|
+
: null
|
|
4051
|
+
const openControl = openInput
|
|
4052
|
+
? React.createElement('input', { className: 'edrv-path-input', autoFocus: true, placeholder: '输入工作区相对/绝对路径,回车打开', value: pathDraft, onChange: (e) => setPathDraft(e.target.value), onKeyDown: (e) => { if (e.key === 'Enter') openPath(); if (e.key === 'Escape') setOpenInput(false) } })
|
|
4053
|
+
: React.createElement('button', { className: 'edrv-tab-add', title: '打开文件(输入路径)', onClick: () => setOpenInput(true) }, '+')
|
|
3944
4054
|
|
|
3945
4055
|
const activePlan = planTabs.find((plan) => plan.address === planActive)
|
|
3946
4056
|
const pathBar = activePlan ? React.createElement('div', { className: 'edrv-pathbar', title: activePlan.address },
|
|
@@ -4022,8 +4132,9 @@ export function EditorView(props) {
|
|
|
4022
4132
|
const navBackTarget = navRef.current.peekBack()
|
|
4023
4133
|
const navForwardTarget = navRef.current.peekForward()
|
|
4024
4134
|
const navNameOf = (entry) => (entry && entry.path ? String(entry.path).split(/[\\/]/).pop() : '')
|
|
4025
|
-
|
|
4026
|
-
|
|
4135
|
+
// 行尾固定工具列:位于焦点分组页签行的滚动区右侧,横滚页签时不跟着滚走。
|
|
4136
|
+
const rowTools = React.createElement('div', { className: 'edrv-tabs-tools' },
|
|
4137
|
+
openControl,
|
|
4027
4138
|
(sum.totalFiles > 0
|
|
4028
4139
|
? React.createElement('button', { className: 'edrv-diffchip', title: '打开差异总览/归档', onClick: () => setLauncherOpen((o) => !o) }, '⚠ 差异 ' + sum.totalFiles + ' 文件')
|
|
4029
4140
|
: null),
|
|
@@ -4119,6 +4230,12 @@ export function EditorView(props) {
|
|
|
4119
4230
|
: React.createElement('pre', { className: 'edrv-mdview-pre' }, text)
|
|
4120
4231
|
body = React.createElement('div', { className: 'edrv-mdview', ref: planHostRef, 'data-plan-address': activePlan.address },
|
|
4121
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 })
|
|
4122
4239
|
} else if (!monaco && !monacoErr) {
|
|
4123
4240
|
body = loadingBody(loadStage.message, loadStage.progress)
|
|
4124
4241
|
} else if (monacoErr) {
|
|
@@ -4143,13 +4260,13 @@ export function EditorView(props) {
|
|
|
4143
4260
|
loadContent(active, sessionId)
|
|
4144
4261
|
} }, '重试'))
|
|
4145
4262
|
} else if (content === null && loadError) {
|
|
4146
|
-
body = loadingBody('文件加载失败:' + loadError, 0, () => {
|
|
4263
|
+
body = React.createElement(React.Fragment, null, loadingBody('文件加载失败:' + loadError, 0, () => {
|
|
4147
4264
|
setLoadError(null)
|
|
4148
4265
|
setContent(null)
|
|
4149
4266
|
setContentPath(null)
|
|
4150
4267
|
setLoadStage({ progress: 10, message: '重新读取文件…' })
|
|
4151
4268
|
loadContent(active, sessionId)
|
|
4152
|
-
})
|
|
4269
|
+
}), largeCandidate === pageIdentity && React.createElement('button', { className: 'edrv-pill edrv-pill-ghost', onClick: openTextPages }, '只读分段查看'))
|
|
4153
4270
|
} else if (isImageActive && imageSrc) {
|
|
4154
4271
|
body = imagePanel()
|
|
4155
4272
|
} else if (isImageActive) {
|
|
@@ -4441,7 +4558,8 @@ export function EditorView(props) {
|
|
|
4441
4558
|
'data-edrv-diff-dock-shell': '1',
|
|
4442
4559
|
}, React.createElement(DiffBox, { ...dockSnapshot, dock: true }))
|
|
4443
4560
|
: null
|
|
4444
|
-
//
|
|
4561
|
+
// 编辑区容器:pathBar 和页签行现在都由焦点分组自己渲染,这里只放编辑正文与各种浮层。
|
|
4562
|
+
|
|
4445
4563
|
const editorArea = React.createElement('div', { className: 'edrv-editor-area' },
|
|
4446
4564
|
dapBar,
|
|
4447
4565
|
addCfgBtn,
|
|
@@ -4466,11 +4584,11 @@ export function EditorView(props) {
|
|
|
4466
4584
|
: null
|
|
4467
4585
|
// 底部状态栏共用一整条:挂在 mainCol 层级(所有分组之下),不随焦点分组搬家(ddj 2026-10-16)
|
|
4468
4586
|
const fullView = createPortal(React.createElement(React.Fragment, null, pathBar, sideHintEl, diskBanner(), editorArea), fullHost, 'edrv-full-editor')
|
|
4587
|
+
// 页签行已并入焦点分组自身(EditorPane 内一行),不再有独立的顶部工具条。
|
|
4469
4588
|
const mainCol = React.createElement('div', { className: 'edrv-main-col edrv-workbench', style: { flex: 1, minWidth: 0, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' } },
|
|
4470
|
-
React.createElement('div', { className: 'edrv-workbench-tools' }, tabRow),
|
|
4471
4589
|
renderTree(workbench.tree), fullView,
|
|
4472
4590
|
statusBar,
|
|
4473
|
-
workbench.floats.map((item, layer) => React.createElement(FloatingEditor, { key: item.group, owner: String(scope), group: item.group, rect: item, layer, focused: item.group === workbench.focused, onBounds: floatBounds, onFocus: selectGroup, onReturn: returnPane }, renderGroup(item.group))),
|
|
4591
|
+
workbench.floats.map((item, layer) => React.createElement(FloatingEditor, { key: item.group, owner: String(scope), group: item.group, rect: item, layer, focused: item.group === workbench.focused, onBounds: floatBounds, onFocus: selectGroup, onReturn: returnPane }, renderGroup(item.group, true))),
|
|
4474
4592
|
dragPreview ? createPortal(React.createElement(React.Fragment, null,
|
|
4475
4593
|
dragPreview.rect ? React.createElement('div', { className: 'edrv-drag-overlay', style: dragPreview.rect }, React.createElement('span', { className: 'edrv-drop-label' }, dragPreview.label)) : null,
|
|
4476
4594
|
React.createElement('div', { className: 'edrv-drag-ghost', style: { left: dragPreview.point.x + 12, top: dragPreview.point.y + 12 } }, baseNameOf(dragPreview.path) + ' · ' + dragPreview.label)), document.body) : null)
|