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/README.md +8 -5
- package/lib/client.js +409 -30
- package/lib/client.js.map +1 -1
- package/lib/index.js +23 -2
- package/lib/index.js.map +1 -1
- package/package.json +4 -1
- package/src/client/monaco/diffRender.ts +3 -0
- package/src/client/sidebar/panels/FileExplorer.ts +42 -2
- package/src/client/state/explorerCache.ts +112 -0
- package/src/client/state/viewStateCache.ts +119 -0
- package/src/client/styles/editor.css +1 -0
- package/src/client/ui/ContextMenu.ts +37 -12
- package/src/client/ui/EditorView.ts +88 -4
- package/src/client/ui/menuPosition.ts +63 -0
- package/src/routes.ts +18 -2
- package/src/rpc.ts +7 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-vscode-mode",
|
|
3
|
-
"version": "0.1.
|
|
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'
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
|
50
|
-
const
|
|
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
|
-
|
|
75
|
-
React.createElement('div', {
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
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()
|