dsh-vscode-mode 0.1.25 → 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/lib/client.js +312 -2
- package/lib/client.js.map +1 -1
- package/lib/index.js +19 -1
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/client/sidebar/panels/FileExplorer.ts +30 -0
- package/src/client/state/explorerCache.ts +112 -0
- package/src/client/state/viewStateCache.ts +119 -0
- package/src/client/ui/EditorView.ts +68 -3
- package/src/routes.ts +18 -2
package/package.json
CHANGED
|
@@ -12,9 +12,11 @@ import { rpc } from '../../rpc.js'
|
|
|
12
12
|
import { ContextMenu } from '../../ui/ContextMenu.js'
|
|
13
13
|
import { rowMenuPosition } from '../../ui/menuPosition.js'
|
|
14
14
|
import { buildTreeMenu } from '../contextMenu.js'
|
|
15
|
+
import { explorerLoad, explorerSave } from '../../state/explorerCache.js'
|
|
15
16
|
import type { SidebarCtx } from '../types.js'
|
|
16
17
|
|
|
17
18
|
const DIR_CAP = 4000
|
|
19
|
+
const SAVE_DEBOUNCE_MS = 300
|
|
18
20
|
|
|
19
21
|
/**
|
|
20
22
|
* 目录树面板主体。
|
|
@@ -36,6 +38,8 @@ export function FileExplorer(props) {
|
|
|
36
38
|
// expanded 的 ref 镜像:edrv:refresh 监听用首次渲染闭包,但需读到最新展开态
|
|
37
39
|
const expandedRef = React.useRef({})
|
|
38
40
|
expandedRef.current = expanded
|
|
41
|
+
// 展开状态防抖写回计时器
|
|
42
|
+
const saveTimerRef = React.useRef(null)
|
|
39
43
|
|
|
40
44
|
const loadDir = (rel) => {
|
|
41
45
|
const token = (tokensRef.current[rel] || 0) + 1
|
|
@@ -98,10 +102,36 @@ export function FileExplorer(props) {
|
|
|
98
102
|
setError(null)
|
|
99
103
|
setMenu(null)
|
|
100
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
|
+
}
|
|
101
114
|
void loadDir('')
|
|
115
|
+
for (const rel of restored) {
|
|
116
|
+
if (rel) void loadDir(rel)
|
|
117
|
+
}
|
|
102
118
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
103
119
|
}, [sessionId])
|
|
104
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
|
+
|
|
105
135
|
React.useEffect(() => {
|
|
106
136
|
const onRefresh = () => refresh()
|
|
107
137
|
window.addEventListener('edrv:refresh', onRefresh)
|
|
@@ -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
|
+
}
|
|
@@ -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
|
|
@@ -163,8 +200,33 @@ export function EditorView(props) {
|
|
|
163
200
|
}).catch((e) => setError('list异常:' + String(e)))
|
|
164
201
|
}
|
|
165
202
|
|
|
166
|
-
|
|
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) => {
|
|
167
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
|
+
}
|
|
168
230
|
setLoadError(null)
|
|
169
231
|
setLoadStage({ progress: monaco ? 72 : 12, message: '读取文件内容…' })
|
|
170
232
|
rpc('edrv.read', { sessionId: sid, path }).then((res) => {
|
|
@@ -289,6 +351,7 @@ export function EditorView(props) {
|
|
|
289
351
|
React.useEffect(() => {
|
|
290
352
|
if (bootRef.current || !sessionId) return
|
|
291
353
|
bootRef.current = true
|
|
354
|
+
viewStatesRef.current = viewStatesLoad(sessionId)
|
|
292
355
|
try {
|
|
293
356
|
const raw = localStorage.getItem('edrv.editor.v2.' + String(sessionId))
|
|
294
357
|
if (raw) {
|
|
@@ -435,6 +498,7 @@ export function EditorView(props) {
|
|
|
435
498
|
const ed = editorRef.current
|
|
436
499
|
const model = getModel(active, content)
|
|
437
500
|
if (ed.getModel() !== model) ed.setModel(model)
|
|
501
|
+
restoreViewState(active)
|
|
438
502
|
setLoadStage((prev) => ({ progress: Math.max(96, prev.progress), message: '创建编辑器视图…' }))
|
|
439
503
|
}, [monaco, active, content])
|
|
440
504
|
|
|
@@ -447,6 +511,7 @@ export function EditorView(props) {
|
|
|
447
511
|
|
|
448
512
|
React.useEffect(() => () => {
|
|
449
513
|
flushSave()
|
|
514
|
+
saveViewState(active)
|
|
450
515
|
diffRendererRef.current?.dispose?.()
|
|
451
516
|
if (editorRef.current) { editorRef.current.dispose(); editorRef.current = null }
|
|
452
517
|
for (const m of modelsRef.current.values()) m.dispose()
|
|
@@ -625,7 +690,7 @@ export function EditorView(props) {
|
|
|
625
690
|
|
|
626
691
|
const reloadFile = (skipStale) => {
|
|
627
692
|
if (!active) return
|
|
628
|
-
loadContent(active, sessionId)
|
|
693
|
+
loadContent(active, sessionId, true)
|
|
629
694
|
refreshRecords(skipStale === true)
|
|
630
695
|
}
|
|
631
696
|
|
|
@@ -835,7 +900,7 @@ export function EditorView(props) {
|
|
|
835
900
|
key: t.path,
|
|
836
901
|
className: 'edrv-tab' + (t.path === active ? ' edrv-tab-active' : ''),
|
|
837
902
|
title: t.path,
|
|
838
|
-
onClick: () => { if (t.path !== active) { flushSave(); setActive(t.path) } },
|
|
903
|
+
onClick: () => { if (t.path !== active) { flushSave(); saveViewState(active); setActive(t.path) } },
|
|
839
904
|
onContextMenu: (e) => {
|
|
840
905
|
e.preventDefault()
|
|
841
906
|
e.stopPropagation()
|
package/src/routes.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* 迁移自原 src/index.ts 的路由部分,路径/行为一字不改。
|
|
4
4
|
* 作者 ddj 2026-08-20
|
|
5
5
|
*/
|
|
6
|
-
import { readFile } from 'node:fs/promises'
|
|
6
|
+
import { readFile, stat } from 'node:fs/promises'
|
|
7
7
|
import { dirname, extname, join, normalize, resolve, sep } from 'node:path'
|
|
8
8
|
import { fileURLToPath } from 'node:url'
|
|
9
9
|
import { ROUTE_PREFIX, noteOwnRoute, resetOwnRoutes, routeConflict } from './compat.js'
|
|
@@ -124,10 +124,11 @@ export function registerRoutes(
|
|
|
124
124
|
}
|
|
125
125
|
|
|
126
126
|
// Monaco Editor AMD 构建:/edrv/vendor/* → assets/vendor/*(路径穿越防护 + 按扩展名 MIME)
|
|
127
|
+
// ETag 协商缓存:弱 etag(mtime+size),配合 max-age 让浏览器 304 复用,插件升级内容变化自动失效。
|
|
127
128
|
ctx.effect(() => guarded({
|
|
128
129
|
kind: 'prefix',
|
|
129
130
|
path: VENDOR_PREFIX,
|
|
130
|
-
handler: async (req: { method?: string; url?: string }, res: { statusCode?: number; setHeader: (k: string, v: string) => void; end: (b?: string | Uint8Array) => void }) => {
|
|
131
|
+
handler: async (req: { method?: string; url?: string; headers?: Record<string, string | string[] | undefined> }, res: { statusCode?: number; setHeader: (k: string, v: string) => void; end: (b?: string | Uint8Array) => void }) => {
|
|
131
132
|
try {
|
|
132
133
|
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
133
134
|
res.statusCode = 405
|
|
@@ -143,10 +144,25 @@ export function registerRoutes(
|
|
|
143
144
|
res.end()
|
|
144
145
|
return
|
|
145
146
|
}
|
|
147
|
+
const info = await stat(target).catch(() => null)
|
|
148
|
+
if (!info || !info.isFile()) { res.statusCode = 404; res.end(); return }
|
|
149
|
+
const etag = 'W/"' + info.mtimeMs.toString(16) + '-' + info.size.toString(16) + '"'
|
|
150
|
+
const reqEtag = (() => {
|
|
151
|
+
const raw = req.headers?.['if-none-match']
|
|
152
|
+
if (Array.isArray(raw)) return raw[0]
|
|
153
|
+
return raw
|
|
154
|
+
})()
|
|
155
|
+
if (reqEtag === etag) {
|
|
156
|
+
res.statusCode = 304
|
|
157
|
+
res.setHeader('etag', etag)
|
|
158
|
+
res.end()
|
|
159
|
+
return
|
|
160
|
+
}
|
|
146
161
|
const body = await readFile(target)
|
|
147
162
|
res.statusCode = 200
|
|
148
163
|
res.setHeader('content-type', VENDOR_MIME[extname(target)] ?? 'application/octet-stream')
|
|
149
164
|
res.setHeader('cache-control', 'public, max-age=3600')
|
|
165
|
+
res.setHeader('etag', etag)
|
|
150
166
|
res.end(body)
|
|
151
167
|
} catch (error) {
|
|
152
168
|
res.statusCode = 404
|