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.
Files changed (43) hide show
  1. package/README.md +8 -4
  2. package/lib/client.debug.js +934 -0
  3. package/lib/client.debug.js.map +1 -0
  4. package/lib/client.js +1425 -1724
  5. package/lib/client.js.map +1 -1
  6. package/lib/client.rules.js +793 -0
  7. package/lib/client.rules.js.map +1 -0
  8. package/lib/index.js +1078 -286
  9. package/lib/index.js.map +1 -1
  10. package/package.json +1 -1
  11. package/skills/dsh-vscodemode-mcp/SKILL.md +24 -7
  12. package/src/client/index.ts +24 -0
  13. package/src/client/lazyPanel.ts +68 -0
  14. package/src/client/mcpSessionState.ts +108 -0
  15. package/src/client/monaco/lsp/providers.ts +32 -3
  16. package/src/client/sidebar/PanelBoundary.ts +57 -0
  17. package/src/client/sidebar/SidebarView.ts +58 -21
  18. package/src/client/sidebar/panels/FileExplorer.ts +80 -27
  19. package/src/client/sidebar/panels/SvnPanel.ts +24 -8
  20. package/src/client/sidebar/panels/index.ts +3 -4
  21. package/src/client/sidebar/panels/treeWindow.ts +106 -0
  22. package/src/client/sidebar/panels/useExplorerReveal.ts +5 -2
  23. package/src/client/sidebar/registry.ts +4 -1
  24. package/src/client/state/modelWarmer.ts +198 -0
  25. package/src/client/styles/editor.css +13 -1
  26. package/src/client/textPages.ts +135 -0
  27. package/src/client/ui/EditorPane.ts +16 -4
  28. package/src/client/ui/EditorView.ts +147 -29
  29. package/src/client/ui/LargeTextView.ts +94 -0
  30. package/src/client/ui/McpSessionStatus.ts +75 -0
  31. package/src/client/ui/McpSettings.ts +47 -13
  32. package/src/mcp.ts +1 -0
  33. package/src/mcpBridge.ts +163 -0
  34. package/src/mcpDiscover.ts +38 -0
  35. package/src/mcpPool.ts +245 -0
  36. package/src/mcpProject.ts +15 -12
  37. package/src/mcpRuntime.ts +299 -254
  38. package/src/rpc.ts +42 -8
  39. package/src/shared/mcp.ts +47 -0
  40. package/src/shared/rpc.ts +8 -2
  41. package/src/shared/textPages.ts +28 -0
  42. package/src/textPages.ts +149 -0
  43. 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
- /** @private @author ddj 2026年10月14号 @param tab File record. @returns Draggable tab with isolated close control. */
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': tab.path === group.active,
39
- className: 'edrv-tab' + (tab.path === group.active ? ' edrv-tab-active' : '') + (tab.pinned ? ' edrv-tab-pinned' : ''),
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', role: 'tablist', 'data-edrv-group-tabs': group.id }, group.tabs.map(renderTab), props.extras),
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
- const onDiskChange = (change) => {
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
- const readDiskCompare = (path) => {
1286
+ function readDiskCompare(path) {
1287
+ // A read-only page has no full Monaco buffer to compare. Acknowledge the observed
1288
+ // version as changed so polling advances its baseline instead of invalidating every reread.
1289
+ if (path === activeRef.current && (paging || pageChanged === pageIdentity)) return Promise.resolve({ equal: false })
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 === address) setActive(tabsRef.current[0]?.path ?? null)
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
- const openPath = () => {
3789
+ /** @private @author ddj 2026年10月22号 @description Probe a typed path; let oversized files enter the existing read-only paging flow. @returns Probe completion. */
3790
+ function openPath() {
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
- if (res && res.ok) {
3800
+ return rpc('edrv.read', { sessionId, path: p }).then((res) => {
3801
+ // Only the explicit size-limit response may bypass the probe; other failures must remain visible.
3802
+ if (res && (res.ok || res.code === 'file-too-large')) {
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) { host.textContent = result?.error || '文件加载失败'; return }
3960
+ if (!result?.ok) {
3961
+ host.textContent = result?.error || '文件加载失败'
3962
+ if (result?.code === 'file-too-large' && !binary) {
3963
+ const button = document.createElement('button')
3964
+ button.textContent = '只读分段查看'
3965
+ /** @author ddj 2026年10月22号 @param event Pointer event. @description Do not focus and unmount the passive group before click. */
3966
+ button.onpointerdown = (event) => event.stopPropagation()
3967
+ /** @author ddj 2026年10月22号 @description Keep partial text out of passive models, autosave and editing commands. */
3968
+ button.onclick = () => {
3969
+ if (disposed || dirtyRef.current[path] || savingRef.current.has(path)) return
3970
+ flushSave()
3971
+ if (dirtyRef.current[path] || savingRef.current.has(path)) return
3972
+ host.textContent = ''
3973
+ host.removeEventListener('pointerdown', focus)
3974
+ pageRoot = createRoot(host)
3975
+ pageRoot.render(React.createElement(LargeTextView, { sessionId, path, loadPage: loadTextPage }))
3976
+ }
3977
+ host.appendChild(button)
3978
+ }
3979
+ return
3980
+ }
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
- return React.createElement(EditorPane, { key: id, group, focused, dirty: dirtyMap, onSelect: selectGroup, onClose: closeTab, onMenu: paneMenu, onDrag: dragTab, consumeClick: consumeDrag },
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: String(scope) + ':' + group.active, path: group.active, groupId: id, mount: passiveMount })))
4033
+ React.createElement(PassiveEditor, { key: JSON.stringify([scope, sessionId, group.active]), path: group.active, groupId: id, mount: passiveMount })))
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
- const tabsEl = React.createElement('div', { className: 'edrv-tabs', ref: tabsHostRef, style: { flex: '1 1 auto', minWidth: 0 } },
3936
- planTabs.map((plan) => React.createElement('div', {
3937
- key: plan.address, className: 'edrv-tab' + (plan.address === planActive ? ' edrv-tab-active' : ''),
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', { style: { overflow: 'hidden', textOverflow: 'ellipsis', maxWidth: 180 } }, plan.title),
3940
- React.createElement('span', { className: 'edrv-tab-x', title: '关闭', onClick: (event) => closePlan(plan.address, event) }, '×'))),
3941
- (openInput
3942
- ? 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) } })
3943
- : React.createElement('button', { className: 'edrv-tab-add', title: '打开文件(输入路径)', onClick: () => setOpenInput(true) }, '+')))
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
- const tabRow = React.createElement('div', { style: { display: 'flex', alignItems: 'center', background: 'var(--dsw-alias-bg-layer-1,transparent)', flexShrink: 0 } },
4026
- tabsEl,
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
- // 主编辑列(侧边栏右侧):pathBar + tabRow + 编辑/差异区。
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)