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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-vscode-mode",
3
- "version": "0.1.25",
3
+ "version": "0.1.26",
4
4
  "description": "DSH 上的类 VSCode 编码体验:Monaco 编辑器(文件页签/QuickOpen/状态栏,可驻 dsh-better-sidebar 侧边栏与对话同屏)+ Agent 编辑差异审查(整文件差异/采纳/拒绝/归档/回滚),状态持久化到工作区旁车",
5
5
  "keywords": [
6
6
  "dsh",
@@ -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
- 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) => {
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