dsh-vscode-mode 0.1.23 → 0.1.26

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-vscode-mode",
3
- "version": "0.1.23",
3
+ "version": "0.1.26",
4
4
  "description": "DSH 上的类 VSCode 编码体验:Monaco 编辑器(文件页签/QuickOpen/状态栏,可驻 dsh-better-sidebar 侧边栏与对话同屏)+ Agent 编辑差异审查(整文件差异/采纳/拒绝/归档/回滚),状态持久化到工作区旁车",
5
5
  "keywords": [
6
6
  "dsh",
@@ -51,13 +51,16 @@
51
51
  "@deepseek-ai/dsh-tools": ">=0.0.1-rc.1 <0.1.0 || >=0.1.0-rc.1 <0.2.0-0",
52
52
  "cordis": ">=4.0.0-rc <5",
53
53
  "react": "^18.2.0",
54
+ "react-dom": "^18.2.0",
54
55
  "schemastery": "^3.18.0"
55
56
  },
56
57
  "devDependencies": {
57
58
  "@deepseek-ai/dsh-settings": "0.1.0-rc.8",
58
59
  "@types/node": "^24.13.3",
59
60
  "@types/react": "^18.3.1",
61
+ "@types/react-dom": "^18.3.5",
60
62
  "react": "^18.3.1",
63
+ "react-dom": "^18.3.1",
61
64
  "schemastery": "3.18.0",
62
65
  "tsdown": "^0.22.14",
63
66
  "typescript": "^5.9.0",
@@ -21,6 +21,9 @@ export function createDiffRenderer(log) {
21
21
  * @param sessionId 会话 id
22
22
  */
23
23
  const render = (monaco, editor, pendingRegions, sessionId) => {
24
+ // 空渲染且无残留(5s 轮询 setRecords 常触发):无需清理时直接跳过,
25
+ // 不打 begin 日志、不做无谓 DOM 操作;有 zone/decoration 残留时仍需走清理。
26
+ if (!pendingRegions.length && viewZoneIds.length === 0 && decorations.length === 0) return
24
27
  const renderT0 = Date.now()
25
28
  const callIdAttr = (callId, idx) => String(callId) + ':' + String(idx)
26
29
  log(sessionId, '[diff-render] begin regs=' + pendingRegions.length + ' ' + pendingRegions.map((r) => callIdAttr(r.callId, r.idx) + '@' + (r.start ?? '?') + '-' + (r.end ?? '?') + '(-' + (r.oldLines ? r.oldLines.length : 0) + '/+' + (r.newLines ? r.newLines.length : 0) + ')').join('|'))
@@ -10,10 +10,13 @@
10
10
  import React from 'react'
11
11
  import { rpc } from '../../rpc.js'
12
12
  import { ContextMenu } from '../../ui/ContextMenu.js'
13
+ import { rowMenuPosition } from '../../ui/menuPosition.js'
13
14
  import { buildTreeMenu } from '../contextMenu.js'
15
+ import { explorerLoad, explorerSave } from '../../state/explorerCache.js'
14
16
  import type { SidebarCtx } from '../types.js'
15
17
 
16
18
  const DIR_CAP = 4000
19
+ const SAVE_DEBOUNCE_MS = 300
17
20
 
18
21
  /**
19
22
  * 目录树面板主体。
@@ -35,6 +38,8 @@ export function FileExplorer(props) {
35
38
  // expanded 的 ref 镜像:edrv:refresh 监听用首次渲染闭包,但需读到最新展开态
36
39
  const expandedRef = React.useRef({})
37
40
  expandedRef.current = expanded
41
+ // 展开状态防抖写回计时器
42
+ const saveTimerRef = React.useRef(null)
38
43
 
39
44
  const loadDir = (rel) => {
40
45
  const token = (tokensRef.current[rel] || 0) + 1
@@ -74,6 +79,7 @@ export function FileExplorer(props) {
74
79
  const refresh = () => {
75
80
  const keep = Object.keys(expandedRef.current).filter((k) => expandedRef.current[k] === true)
76
81
  tokensRef.current = {}
82
+ setMenu(null)
77
83
  setDirs({})
78
84
  setLoading({})
79
85
  setError(null)
@@ -94,11 +100,38 @@ export function FileExplorer(props) {
94
100
  setExpanded({})
95
101
  setLoading({})
96
102
  setError(null)
103
+ setMenu(null)
97
104
  setRoot(null)
105
+ // 恢复上次展开状态(对齐 VSCode:持久化展开路径,目录条目实时拉取)
106
+ const cached = sessionId ? explorerLoad(sessionId) : null
107
+ const restored = cached?.expanded ?? []
108
+ if (cached?.root) setRoot(cached.root)
109
+ if (restored.length) {
110
+ const next = {}
111
+ for (const rel of restored) next[rel] = true
112
+ setExpanded(next)
113
+ }
98
114
  void loadDir('')
115
+ for (const rel of restored) {
116
+ if (rel) void loadDir(rel)
117
+ }
99
118
  // eslint-disable-next-line react-hooks/exhaustive-deps
100
119
  }, [sessionId])
101
120
 
121
+ // 展开状态防抖写回 localStorage(重启后恢复展开结构)
122
+ React.useEffect(() => {
123
+ if (saveTimerRef.current) { clearTimeout(saveTimerRef.current); saveTimerRef.current = null }
124
+ if (!sessionId) return
125
+ saveTimerRef.current = setTimeout(() => {
126
+ saveTimerRef.current = null
127
+ const expandedList = Object.keys(expanded).filter((k) => expanded[k] === true)
128
+ explorerSave(sessionId, { root, expanded: expandedList })
129
+ }, SAVE_DEBOUNCE_MS)
130
+ return () => {
131
+ if (saveTimerRef.current) { clearTimeout(saveTimerRef.current); saveTimerRef.current = null }
132
+ }
133
+ }, [expanded, root, sessionId])
134
+
102
135
  React.useEffect(() => {
103
136
  const onRefresh = () => refresh()
104
137
  window.addEventListener('edrv:refresh', onRefresh)
@@ -111,17 +144,22 @@ export function FileExplorer(props) {
111
144
  const rowEl = (e, depth, isDir, isOpen) => {
112
145
  const pending = isDir ? 0 : (pendingByPath[e.path] ?? 0)
113
146
  const active = !isDir && e.path === activePath
147
+ const contextTarget = Boolean(menu && menuEntries.length && menu.target.path === e.path)
114
148
  const dim = e.type === 'other'
115
149
  return React.createElement('div', {
116
150
  key: e.path,
117
- className: 'edrv-tree-row' + (active ? ' edrv-tree-active' : ''),
151
+ className: 'edrv-tree-row'
152
+ + (active ? ' edrv-tree-active' : '')
153
+ + (contextTarget ? ' edrv-tree-context' : ''),
118
154
  title: e.path,
119
155
  style: { paddingLeft: 6 + depth * 14 },
120
156
  onClick: () => { if (isDir) toggle(e.path); else openFile(e.path) },
121
157
  onContextMenu: (ev) => {
122
158
  ev.preventDefault()
123
159
  ev.stopPropagation()
124
- setMenu({ x: ev.clientX, y: ev.clientY, target: { path: e.path, type: e.type } })
160
+ const rect = ev.currentTarget?.getBoundingClientRect?.()
161
+ const position = rowMenuPosition(rect, ev.clientX, ev.clientY)
162
+ setMenu({ x: position.left, y: position.top, target: { path: e.path, type: e.type } })
125
163
  },
126
164
  },
127
165
  React.createElement('span', { className: 'edrv-tree-chev' },
@@ -158,6 +196,8 @@ export function FileExplorer(props) {
158
196
  // 面板区右键(空白处)→ 工作区根目录菜单;行内右键已在 rowEl 阻止冒泡。
159
197
  const onPanelContext = (ev) => {
160
198
  ev.preventDefault()
199
+ ev.stopPropagation()
200
+ if (!ev.currentTarget.contains(ev.target)) return
161
201
  setMenu({ x: ev.clientX, y: ev.clientY, target: { path: '', type: 'directory' } })
162
202
  }
163
203
  // 当前右键目标的可视菜单项(构建时过滤/排序,registry 变化下次打开生效)。
@@ -0,0 +1,112 @@
1
+ /**
2
+ * dsh-vscode-mode client — 资源管理栏展开状态持久化(纯函数,可单测)。
3
+ * 对齐 VSCode:只持久化展开路径集合,不缓存目录条目数据(条目实时 listDir)。
4
+ * 作者 ddj 2026-08-28
5
+ */
6
+
7
+ /** localStorage 键前缀(按会话隔离)。 */
8
+ const KEY_PREFIX = 'edrv.cache.explorer.v1.'
9
+
10
+ /** 展开路径容量上限:超出丢弃最旧(按写入顺序近似 LRU)。 */
11
+ export const EXPANDED_CAP = 500
12
+
13
+ /** 展开状态缓存内容。 */
14
+ export interface ExplorerCacheData {
15
+ root: string | null
16
+ expanded: string[]
17
+ }
18
+
19
+ /**
20
+ * 归一化展开路径:去空白、反斜杠→斜杠、去首尾斜杠,`..` 段拒绝。
21
+ * @author ddj 2026年08月28号
22
+ * @param rel 目录相对路径('' = 根)
23
+ * @returns 归一化路径,非法返回 null
24
+ */
25
+ export function normalizeExpanded(rel: string | undefined | null): string | null {
26
+ const s = String(rel ?? '').trim().replace(/\\/g, '/').replace(/^\/+|\/+$/g, '')
27
+ if (!s) return ''
28
+ const segs = s.split('/').filter((seg) => seg !== '.')
29
+ if (!segs.length) return ''
30
+ for (const seg of segs) {
31
+ if (seg === '..') return null
32
+ }
33
+ return segs.join('/')
34
+ }
35
+
36
+ /**
37
+ * 裁剪展开路径集:去重、丢弃非法路径、超容量丢弃尾部(保持传入顺序)。
38
+ * @author ddj 2026年08月28号
39
+ * @param expanded 原始展开路径集合
40
+ * @returns 规范化后的路径数组
41
+ */
42
+ export function sanitizeExpanded(expanded: unknown): string[] {
43
+ if (!Array.isArray(expanded)) return []
44
+ const seen = new Set<string>()
45
+ const out: string[] = []
46
+ for (const item of expanded) {
47
+ const rel = normalizeExpanded(typeof item === 'string' ? item : null)
48
+ if (rel === null || seen.has(rel)) continue
49
+ seen.add(rel)
50
+ out.push(rel)
51
+ if (out.length >= EXPANDED_CAP) break
52
+ }
53
+ return out
54
+ }
55
+
56
+ /**
57
+ * 序列化缓存数据(JSON;root 为 null 时省略,减小体积)。
58
+ * @author ddj 2026年08月28号
59
+ * @param data 缓存数据
60
+ * @returns JSON 字符串
61
+ */
62
+ export function serializeExplorer(data: ExplorerCacheData): string {
63
+ const payload = data.root === null || data.root === undefined
64
+ ? { v: 1, expanded: data.expanded }
65
+ : { v: 1, root: data.root, expanded: data.expanded }
66
+ return JSON.stringify(payload)
67
+ }
68
+
69
+ /**
70
+ * 解析缓存文本;损坏/格式不符 → null。
71
+ * @author ddj 2026年08月28号
72
+ * @param text localStorage 原文
73
+ * @returns 解析后的缓存数据,非法返回 null
74
+ */
75
+ export function parseExplorer(text: string | null): ExplorerCacheData | null {
76
+ if (!text) return null
77
+ try {
78
+ const data = JSON.parse(text)
79
+ if (!data || typeof data !== 'object') return null
80
+ const root = typeof data.root === 'string' && data.root ? data.root : null
81
+ const expanded = sanitizeExpanded(data.expanded)
82
+ return { root, expanded }
83
+ } catch (error) {
84
+ return null
85
+ }
86
+ }
87
+
88
+ /**
89
+ * 读取某会话的展开状态缓存(localStorage 不可用 → null)。
90
+ * @author ddj 2026年08月28号
91
+ * @param sessionId 会话 id
92
+ * @returns 缓存数据或 null
93
+ */
94
+ export function explorerLoad(sessionId: string): ExplorerCacheData | null {
95
+ try {
96
+ return parseExplorer(window.localStorage.getItem(KEY_PREFIX + String(sessionId)))
97
+ } catch (error) {
98
+ return null
99
+ }
100
+ }
101
+
102
+ /**
103
+ * 写入某会话的展开状态缓存(损坏/配额满 → 忽略)。
104
+ * @author ddj 2026年08月28号
105
+ * @param sessionId 会话 id
106
+ * @param data 缓存数据
107
+ */
108
+ export function explorerSave(sessionId: string, data: ExplorerCacheData): void {
109
+ try {
110
+ window.localStorage.setItem(KEY_PREFIX + String(sessionId), serializeExplorer(data))
111
+ } catch (error) { /* 配额满/隐私模式忽略 */ }
112
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * dsh-vscode-mode client — 编辑器视图状态持久化(纯函数,可单测)。
3
+ * 对齐 VSCode workbench.editor.restoreViewState:恢复每个文件的光标/滚动/折叠位置。
4
+ * 视图状态 = Monaco editor.saveViewState() 返回值(JSON 可序列化),按 path 存储。
5
+ * 作者 ddj 2026-08-28
6
+ */
7
+
8
+ /** localStorage 键前缀(按会话隔离)。 */
9
+ const KEY_PREFIX = 'edrv.cache.viewstate.v1.'
10
+
11
+ /** 视图状态条目容量上限。 */
12
+ export const VIEWSTATE_CAP = 50
13
+
14
+ /** 视图状态序列化体积上限(单条目,防止超大选区/折叠数据撑爆配额)。 */
15
+ export const VIEWSTATE_BYTES_CAP = 64 * 1024
16
+
17
+ /**
18
+ * 判断对象是否为合法的 Monaco view state(保守形状校验,非深校验)。
19
+ * @author ddj 2026年08月28号
20
+ * @param value 待校验对象
21
+ * @returns 是否可接受
22
+ */
23
+ export function isViewStateLike(value: unknown): boolean {
24
+ if (!value || typeof value !== 'object') return false
25
+ const v = value as Record<string, unknown>
26
+ // 至少含光标状态与视口滚动信息之一;无内容时拒绝(空对象无恢复价值)
27
+ return Array.isArray(v.cursorState) || typeof v.viewState === 'object'
28
+ || Array.isArray(v.contributionsState)
29
+ }
30
+
31
+ /**
32
+ * 序列化视图状态映射(path → viewState)。
33
+ * @author ddj 2026年08月28号
34
+ * @param states path → viewState 映射
35
+ * @returns JSON 字符串
36
+ */
37
+ export function serializeViewStates(states: Record<string, unknown>): string {
38
+ return JSON.stringify({ v: 1, states })
39
+ }
40
+
41
+ /**
42
+ * 解析视图状态文本;损坏/格式不符 → null。
43
+ * @author ddj 2026年08月28号
44
+ * @param text localStorage 原文
45
+ * @returns path → viewState 映射,非法返回 null
46
+ */
47
+ export function parseViewStates(text: string | null): Record<string, unknown> | null {
48
+ if (!text) return null
49
+ try {
50
+ const data = JSON.parse(text)
51
+ if (!data || typeof data !== 'object') return null
52
+ const states = (data as Record<string, unknown>).states
53
+ if (!states || typeof states !== 'object' || Array.isArray(states)) return null
54
+ const out: Record<string, unknown> = {}
55
+ for (const [path, state] of Object.entries(states)) {
56
+ if (typeof path !== 'string' || !path) continue
57
+ if (!isViewStateLike(state)) continue
58
+ out[path] = state
59
+ }
60
+ return out
61
+ } catch (error) {
62
+ return null
63
+ }
64
+ }
65
+
66
+ /**
67
+ * 把新视图状态并入既有映射:同 path 覆盖、按写入顺序裁剪到容量上限。
68
+ * @author ddj 2026年08月28号
69
+ * @param states 既有映射
70
+ * @param path 文件路径
71
+ * @param state 视图状态
72
+ * @returns 新映射(容量裁剪后)
73
+ */
74
+ export function upsertViewState(
75
+ states: Record<string, unknown>,
76
+ path: string,
77
+ state: unknown,
78
+ ): Record<string, unknown> {
79
+ if (!path || !isViewStateLike(state)) return states
80
+ try {
81
+ if (JSON.stringify(state).length > VIEWSTATE_BYTES_CAP) return states
82
+ } catch (error) {
83
+ return states
84
+ }
85
+ const next = Object.assign({}, states)
86
+ delete next[path] // 先删后加,保证该 path 排到最末(近似 LRU:最新写入)
87
+ next[path] = state
88
+ const keys = Object.keys(next)
89
+ if (keys.length > VIEWSTATE_CAP) {
90
+ for (const k of keys.slice(0, keys.length - VIEWSTATE_CAP)) delete next[k]
91
+ }
92
+ return next
93
+ }
94
+
95
+ /**
96
+ * 读取某会话的视图状态缓存(localStorage 不可用 → 空映射)。
97
+ * @author ddj 2026年08月28号
98
+ * @param sessionId 会话 id
99
+ * @returns path → viewState 映射
100
+ */
101
+ export function viewStatesLoad(sessionId: string): Record<string, unknown> {
102
+ try {
103
+ return parseViewStates(window.localStorage.getItem(KEY_PREFIX + String(sessionId))) ?? {}
104
+ } catch (error) {
105
+ return {}
106
+ }
107
+ }
108
+
109
+ /**
110
+ * 写入某会话的视图状态缓存(损坏/配额满 → 忽略)。
111
+ * @author ddj 2026年08月28号
112
+ * @param sessionId 会话 id
113
+ * @param states path → viewState 映射
114
+ */
115
+ export function viewStatesSave(sessionId: string, states: Record<string, unknown>): void {
116
+ try {
117
+ window.localStorage.setItem(KEY_PREFIX + String(sessionId), serializeViewStates(states))
118
+ } catch (error) { /* 配额满/隐私模式忽略 */ }
119
+ }
@@ -213,6 +213,7 @@
213
213
  [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; }
214
214
  [data-edrv-view] .edrv-tree-row:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(15,157,88,.08)); }
215
215
  [data-edrv-view] .edrv-tree-row.edrv-tree-active { background: var(--dsw-alias-interactive-bg-hover, rgba(15,157,88,.14)); }
216
+ [data-edrv-view] .edrv-tree-row.edrv-tree-context { box-shadow: inset 0 0 0 1px var(--dsw-alias-brand-primary, #0f9d58); background: var(--dsw-alias-interactive-bg-hover, rgba(15,157,88,.12)); }
216
217
  [data-edrv-view] .edrv-tree-chev { width: 12px; flex-shrink: 0; color: var(--dsw-alias-label-tertiary, #9aa5b1); font-size: 10px; text-align: center; }
217
218
  [data-edrv-view] .edrv-tree-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
218
219
  [data-edrv-view] .edrv-tree-name.edrv-tree-dim { color: var(--dsw-alias-label-tertiary, #9aa5b1); }
@@ -1,10 +1,13 @@
1
1
  /**
2
2
  * dsh-vscode-mode client — 通用浮动右键菜单(createElement 风格、类型化)。
3
- * 全屏 backdrop(点击/右键/Esc 关闭)+ 固定定位(viewport clamp 防越界),
3
+ * 非阻塞式关闭:window capture 监听(点击菜单外/右键菜单外/Esc 关闭),
4
+ * 不拦截菜单外事件,允许同一事件内由发起方切换/重开菜单。
4
5
  * 条目支持 danger/disabled/separator,复用既有 edrv-ctxmenu* 样式类。
5
6
  * 作者 ddj 2026-08-27
6
7
  */
7
8
  import React from 'react'
9
+ import { createPortal } from 'react-dom'
10
+ import { clampMenuPosition } from './menuPosition.js'
8
11
 
9
12
  /** 单条菜单项(展示层形状;业务侧由 buildTreeMenu 映射而来)。 */
10
13
  export interface ContextMenuEntry {
@@ -37,17 +40,43 @@ const MENU_H = 176
37
40
  */
38
41
  export function ContextMenu(props: ContextMenuProps): React.ReactElement {
39
42
  const { x, y, entries, onClose } = props
43
+ const menuRef = React.useRef<HTMLDivElement | null>(null)
40
44
 
41
45
  React.useEffect(() => {
42
46
  const onKey = (e: KeyboardEvent): void => {
43
47
  if (e.key === 'Escape') onClose()
44
48
  }
49
+ const onPointerDown = (e: PointerEvent): void => {
50
+ if (e.button !== 0) return
51
+ if (menuRef.current && menuRef.current.contains(e.target as Node)) return
52
+ onClose()
53
+ }
54
+ const onContextMenu = (e: MouseEvent): void => {
55
+ if (menuRef.current && menuRef.current.contains(e.target as Node)) {
56
+ e.preventDefault()
57
+ }
58
+ onClose()
59
+ }
45
60
  window.addEventListener('keydown', onKey, true)
46
- return () => window.removeEventListener('keydown', onKey, true)
61
+ window.addEventListener('pointerdown', onPointerDown, true)
62
+ window.addEventListener('contextmenu', onContextMenu, true)
63
+ return () => {
64
+ window.removeEventListener('keydown', onKey, true)
65
+ window.removeEventListener('pointerdown', onPointerDown, true)
66
+ window.removeEventListener('contextmenu', onContextMenu, true)
67
+ }
47
68
  }, [onClose])
48
69
 
49
- const left = Math.max(4, Math.min(x, (window.innerWidth || 800) - MENU_W))
50
- const top = Math.max(4, Math.min(y, (window.innerHeight || 600) - MENU_H))
70
+ const safeX = Number.isFinite(x) ? x : 4
71
+ const safeY = Number.isFinite(y) ? y : 4
72
+ const position = clampMenuPosition(
73
+ safeX,
74
+ safeY,
75
+ window.innerWidth || 800,
76
+ window.innerHeight || 600,
77
+ MENU_W,
78
+ MENU_H,
79
+ )
51
80
 
52
81
  const children: React.ReactNode[] = []
53
82
  for (const entry of entries) {
@@ -71,12 +100,8 @@ export function ContextMenu(props: ContextMenuProps): React.ReactElement {
71
100
  }, entry.label))
72
101
  }
73
102
 
74
- return React.createElement(React.Fragment, null,
75
- React.createElement('div', {
76
- className: 'edrv-ctxmenu-backdrop',
77
- style: { position: 'fixed', inset: 0, zIndex: 70 },
78
- onClick: onClose,
79
- onContextMenu: (e: React.MouseEvent) => { e.preventDefault(); onClose() },
80
- }),
81
- React.createElement('div', { className: 'edrv-ctxmenu', style: { left, top } }, ...children))
103
+ const overlay = React.createElement('div', { 'data-edrv-view': '1' },
104
+ React.createElement('div', { className: 'edrv-ctxmenu', ref: menuRef, style: position }, ...children))
105
+
106
+ return typeof document === 'undefined' ? overlay : createPortal(overlay, document.body)
82
107
  }
@@ -20,6 +20,7 @@ import { displayDiffTotal, editorDockMode } from '../diffDock.js'
20
20
  import { editorHeight } from '../editorLayout.js'
21
21
  import { revealInExplorer as revealPathInExplorer } from '../fileReveal.js'
22
22
  import { setSidePending, SIDEBAR_INSTALL_CMD } from '../sidebarBridge.js'
23
+ import { upsertViewState, viewStatesLoad, viewStatesSave } from '../state/viewStateCache.js'
23
24
 
24
25
  /**
25
26
  * 中央编辑区:文件页签(脏点/关闭/打开路径)+ Ctrl+P 搜索 + Monaco 编辑器 +
@@ -67,6 +68,8 @@ export function EditorView(props) {
67
68
  const viewRootRef = React.useRef(null)
68
69
  const monacoRef = React.useRef(null)
69
70
  const modelsRef = React.useRef(new Map())
71
+ // 视图状态缓存(path → Monaco viewState;重启后恢复光标/滚动/折叠位置)
72
+ const viewStatesRef = React.useRef({})
70
73
  const saveTimerRef = React.useRef(null)
71
74
  const loadSeqRef = React.useRef(0)
72
75
  const programmaticRef = React.useRef(false)
@@ -120,8 +123,42 @@ export function EditorView(props) {
120
123
  if (select) setActive(path)
121
124
  }
122
125
 
126
+ /**
127
+ * 保存当前活动文件的视图状态(光标/滚动/折叠)到会话缓存。
128
+ * @author ddj 2026年08月28号
129
+ * @param path 要保存的文件路径(缺省 = 当前 active)
130
+ */
131
+ const saveViewState = (path) => {
132
+ const ed = editorRef.current
133
+ const target = path ?? active
134
+ if (!ed || !target) return
135
+ try {
136
+ const state = ed.saveViewState()
137
+ if (!state) return
138
+ viewStatesRef.current = upsertViewState(viewStatesRef.current, target, state)
139
+ viewStatesSave(sessionId, viewStatesRef.current)
140
+ } catch (e) { /* 视图状态保存失败忽略 */ }
141
+ }
142
+
143
+ /**
144
+ * 恢复指定文件的视图状态(内容就绪、setModel 后调用;恢复后即消费删除)。
145
+ * @author ddj 2026年08月28号
146
+ * @param path 文件路径
147
+ */
148
+ const restoreViewState = (path) => {
149
+ const ed = editorRef.current
150
+ if (!ed || !path) return
151
+ const saved = viewStatesRef.current[path]
152
+ if (!saved) return
153
+ const next = Object.assign({}, viewStatesRef.current)
154
+ delete next[path]
155
+ viewStatesRef.current = next
156
+ try { ed.restoreViewState(saved) } catch (e) { /* 恢复失败忽略(行号越界自动兜底) */ }
157
+ }
158
+
123
159
  const closeTab = (path) => {
124
160
  flushSave()
161
+ if (path === active) saveViewState(path)
125
162
  setTabs((prev) => {
126
163
  const idx = prev.findIndex((t) => t.path === path)
127
164
  if (idx < 0) return prev
@@ -131,6 +168,25 @@ export function EditorView(props) {
131
168
  })
132
169
  }
133
170
 
171
+ /**
172
+ * 轮询去重:前后记录内容一致时返回原引用,React 跳过重渲染,
173
+ * 阻断 5s 轮询 → memo 链 → diff 重渲染/日志 的空转。
174
+ * @author ddj 2026年08月26号
175
+ * @param prev 当前 records
176
+ * @param next 轮询新结果
177
+ * @returns 内容一致返回 true
178
+ */
179
+ const sameRecords = (prev, next) => {
180
+ const pk = Object.keys(prev)
181
+ const nk = Object.keys(next)
182
+ if (pk.length !== nk.length) return false
183
+ for (const k of pk) {
184
+ if (!(k in next)) return false
185
+ if (JSON.stringify(prev[k]) !== JSON.stringify(next[k])) return false
186
+ }
187
+ return true
188
+ }
189
+
134
190
  const refreshRecords = (skipStale) => {
135
191
  if (!sessionId) return
136
192
  rpc('edrv.list', { sessionId, ...(skipStale ? { skipStale: true } : {}) }).then((res) => {
@@ -140,12 +196,37 @@ export function EditorView(props) {
140
196
  if (r.archived === true) continue
141
197
  map[r.callId] = r
142
198
  }
143
- setRecords(map)
199
+ setRecords((prev) => (sameRecords(prev, map) ? prev : map))
144
200
  }).catch((e) => setError('list异常:' + String(e)))
145
201
  }
146
202
 
147
- const loadContent = (path, sid) => {
203
+ /**
204
+ * 加载文件内容(对齐 VSCode model 复用:会话内已打开的 model 直接秒显,
205
+ * 后台静默 RPC 校验防陈旧;首次打开走原读取流程)。
206
+ * @author ddj 2026年08月28号
207
+ * @param path 文件路径
208
+ * @param sid 会话 id
209
+ * @param force 强制走 RPC(reloadFile 用,跳过 model 复用)
210
+ */
211
+ const loadContent = (path, sid, force) => {
148
212
  const seq = ++loadSeqRef.current
213
+ const cachedModel = force ? null : modelsRef.current.get(path)
214
+ if (cachedModel) {
215
+ // model 命中:直接显示其内容(切 tab 零等待),后台静默校验
216
+ setContent(cachedModel.getValue())
217
+ setContentPath(path)
218
+ setLoadStage((prev) => ({ progress: Math.max(84, prev.progress), message: '文件已读取,准备创建编辑器…' }))
219
+ setStatus('已加载')
220
+ rpc('edrv.read', { sessionId: sid, path }).then((res) => {
221
+ if (seq !== loadSeqRef.current || path !== active) return
222
+ if (res && res.ok && res.content !== cachedModel.getValue()) {
223
+ saveViewState(path) // 内容更新前保留当前视图位置
224
+ setContent(res.content)
225
+ setStatus('已加载')
226
+ }
227
+ }).catch(() => { /* 校验失败保留缓存内容 */ })
228
+ return
229
+ }
149
230
  setLoadError(null)
150
231
  setLoadStage({ progress: monaco ? 72 : 12, message: '读取文件内容…' })
151
232
  rpc('edrv.read', { sessionId: sid, path }).then((res) => {
@@ -270,6 +351,7 @@ export function EditorView(props) {
270
351
  React.useEffect(() => {
271
352
  if (bootRef.current || !sessionId) return
272
353
  bootRef.current = true
354
+ viewStatesRef.current = viewStatesLoad(sessionId)
273
355
  try {
274
356
  const raw = localStorage.getItem('edrv.editor.v2.' + String(sessionId))
275
357
  if (raw) {
@@ -416,6 +498,7 @@ export function EditorView(props) {
416
498
  const ed = editorRef.current
417
499
  const model = getModel(active, content)
418
500
  if (ed.getModel() !== model) ed.setModel(model)
501
+ restoreViewState(active)
419
502
  setLoadStage((prev) => ({ progress: Math.max(96, prev.progress), message: '创建编辑器视图…' }))
420
503
  }, [monaco, active, content])
421
504
 
@@ -428,6 +511,7 @@ export function EditorView(props) {
428
511
 
429
512
  React.useEffect(() => () => {
430
513
  flushSave()
514
+ saveViewState(active)
431
515
  diffRendererRef.current?.dispose?.()
432
516
  if (editorRef.current) { editorRef.current.dispose(); editorRef.current = null }
433
517
  for (const m of modelsRef.current.values()) m.dispose()
@@ -606,7 +690,7 @@ export function EditorView(props) {
606
690
 
607
691
  const reloadFile = (skipStale) => {
608
692
  if (!active) return
609
- loadContent(active, sessionId)
693
+ loadContent(active, sessionId, true)
610
694
  refreshRecords(skipStale === true)
611
695
  }
612
696
 
@@ -816,7 +900,7 @@ export function EditorView(props) {
816
900
  key: t.path,
817
901
  className: 'edrv-tab' + (t.path === active ? ' edrv-tab-active' : ''),
818
902
  title: t.path,
819
- onClick: () => { if (t.path !== active) { flushSave(); setActive(t.path) } },
903
+ onClick: () => { if (t.path !== active) { flushSave(); saveViewState(active); setActive(t.path) } },
820
904
  onContextMenu: (e) => {
821
905
  e.preventDefault()
822
906
  e.stopPropagation()