dsh-vscode-mode 0.13.1 → 0.14.1
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 +47 -5
- package/lib/client.js +927 -147
- package/lib/client.js.map +1 -1
- package/lib/index.js +235 -14
- package/lib/index.js.map +1 -1
- package/package.json +8 -8
- package/src/client/index.ts +76 -2
- package/src/client/navHistory.ts +45 -0
- package/src/client/officialSidebar.ts +59 -1
- package/src/client/paths.ts +6 -0
- package/src/client/state/mdPreviewCache.ts +74 -0
- package/src/client/state/navStateCache.ts +83 -0
- package/src/client/state/uiStatePersist.ts +562 -0
- package/src/client/ui/EditorView.ts +65 -10
- package/src/dshVersion.ts +4 -2
- package/src/paths.ts +59 -3
- package/src/rpc.ts +50 -3
- package/src/shared/rpc.ts +24 -0
- package/src/uiState.ts +165 -0
|
@@ -50,12 +50,15 @@ import { editorHeight } from '../editorLayout.js'
|
|
|
50
50
|
import { revealInExplorer as revealPathInExplorer } from '../fileReveal.js'
|
|
51
51
|
import { setSidePending, SIDEBAR_INSTALL_CMD } from '../sidebarBridge.js'
|
|
52
52
|
import { upsertViewState, viewStatesLoad, viewStatesSave } from '../state/viewStateCache.js'
|
|
53
|
+
import { mdPreviewLoad, mdPreviewSave } from '../state/mdPreviewCache.js'
|
|
54
|
+
import { onUiStateReady, reportUiStateEvent, uiStateSettled } from '../state/uiStatePersist.js'
|
|
53
55
|
import { migrateScopedKeys, workspaceScopeOf } from '../state/scopeStore.js'
|
|
54
56
|
import { modelsForScope, rememberModel } from '../state/modelCache.js'
|
|
55
57
|
import { navFlashRangeOf } from '../navHighlight.js'
|
|
56
58
|
import { chordOf, useKeybindingsVersion } from '../keybindings.js'
|
|
57
59
|
import { getSidebarMinWidth } from '../sidebarMin.js'
|
|
58
60
|
import { navHistoryFor } from '../navHistory.js'
|
|
61
|
+
import { navStateLoad, navStateSave } from '../state/navStateCache.js'
|
|
59
62
|
import { statusOfAdd } from '../addToConversation.js'
|
|
60
63
|
import { setSearchScope, setSearchSeed } from '../searchSeed.js'
|
|
61
64
|
import { CACHE_KEY } from '../paths.js'
|
|
@@ -324,6 +327,8 @@ export function EditorView(props) {
|
|
|
324
327
|
const loadSeqRef = React.useRef(0)
|
|
325
328
|
const programmaticRef = React.useRef(false)
|
|
326
329
|
const restoredScopeRef = React.useRef(null) // 已恢复状态的作用域(cwd 晚到 sid→ws 时允许重恢复)
|
|
330
|
+
// 界面状态镜像迟到回填的触发器(重启换 origin:首帧 localStorage 为空,回填后需重跑恢复)
|
|
331
|
+
const [hydrateTick, setHydrateTick] = React.useState(0)
|
|
327
332
|
const pendingFocusRef = React.useRef(null) // { path, region, line, column } 内容加载后跳转
|
|
328
333
|
const navFlashRef = React.useRef([]) // 跳转目标高亮装饰 id(独立于 diff/下划线,随跳转替换)
|
|
329
334
|
const navFlashTimerRef = React.useRef(null) // 跳转目标高亮自动清除计时器
|
|
@@ -1299,6 +1304,8 @@ export function EditorView(props) {
|
|
|
1299
1304
|
// model 缓存跟随作用域(切工作区释放旧作用域模型;同工作区跨挂载复用)
|
|
1300
1305
|
modelsRef.current = modelsForScope(scope)
|
|
1301
1306
|
viewStatesRef.current = viewStatesLoad(scope)
|
|
1307
|
+
// Markdown 预览态同样按作用域恢复(重启/换端口后由界面状态镜像回填,见 lateHydrateRef 分支)
|
|
1308
|
+
mdPreviewRef.current = mdPreviewLoad(String(scope))
|
|
1302
1309
|
try {
|
|
1303
1310
|
// v3 优先;缺失时回读 v2(旧版纯路径数组)——normalizeTabs 兼容两种形状
|
|
1304
1311
|
const raw = localStorage.getItem(CACHE_KEY.editor + String(scope))
|
|
@@ -1320,28 +1327,64 @@ export function EditorView(props) {
|
|
|
1320
1327
|
const kept = plan.length ? restored.filter((tab) => !dropped.has(tab.path)) : restored
|
|
1321
1328
|
setTabs(kept)
|
|
1322
1329
|
setActive(kept.some((tab) => tab.path === activePath) ? activePath : (kept[0]?.path ?? null))
|
|
1330
|
+
reportUiStateEvent(
|
|
1331
|
+
'页签恢复(scope=' + String(scope) + '):存档 ' + restored.length + ' 个、保留 ' + kept.length
|
|
1332
|
+
+ ' 个、活动 ' + String(activePath),
|
|
1333
|
+
)
|
|
1334
|
+
} else {
|
|
1335
|
+
reportUiStateEvent('页签恢复(scope=' + String(scope) + '):存档非空但归一化后为 0 个')
|
|
1323
1336
|
}
|
|
1337
|
+
} else {
|
|
1338
|
+
reportUiStateEvent('页签恢复(scope=' + String(scope) + '):localStorage 无存档')
|
|
1324
1339
|
}
|
|
1325
|
-
} catch (e) {
|
|
1326
|
-
|
|
1340
|
+
} catch (e) {
|
|
1341
|
+
reportUiStateEvent('页签恢复失败(scope=' + String(scope) + '): ' + String(e), 'warn')
|
|
1342
|
+
}
|
|
1343
|
+
}, [sessionId, scope, hydrateTick])
|
|
1344
|
+
|
|
1345
|
+
// 镜像迟到回填:重启换 origin 时首帧读不到存档,host 回填完成后重跑一次恢复。
|
|
1346
|
+
// 仅在本页签尚未恢复出任何内容时重跑(用户已打开/关闭过页签则不打扰)。
|
|
1347
|
+
React.useEffect(() => onUiStateReady(() => {
|
|
1348
|
+
if (tabsRef.current.length) return
|
|
1349
|
+
restoredScopeRef.current = null
|
|
1350
|
+
setHydrateTick((v) => v + 1)
|
|
1351
|
+
}), [])
|
|
1327
1352
|
|
|
1328
1353
|
// 导航历史按工作区作用域隔离:同工作区切换对话保留历史,跨工作区换实例
|
|
1329
1354
|
React.useEffect(() => {
|
|
1330
|
-
|
|
1355
|
+
const history = navHistoryFor(scope)
|
|
1356
|
+
// 空实例(首次创建/换作用域)才用存档恢复,避免覆盖本次会话已积累的历史;
|
|
1357
|
+
// 换 origin 时存档由镜像迟到回填,hydrateTick 变化后再跑一次本效应
|
|
1358
|
+
const current = history.snapshot()
|
|
1359
|
+
if (!current.past.length && !current.future.length) {
|
|
1360
|
+
const saved = navStateLoad(String(scope))
|
|
1361
|
+
if (saved) history.restore(saved)
|
|
1362
|
+
reportUiStateEvent(
|
|
1363
|
+
'导航历史恢复(scope=' + String(scope) + '):'
|
|
1364
|
+
+ (saved ? 'past=' + saved.past.length + ' future=' + saved.future.length : 'localStorage 无存档'),
|
|
1365
|
+
)
|
|
1366
|
+
}
|
|
1367
|
+
navRef.current = history
|
|
1331
1368
|
navPendingRef.current = null
|
|
1332
1369
|
if (navCursorTimerRef.current) { clearTimeout(navCursorTimerRef.current); navCursorTimerRef.current = null }
|
|
1333
1370
|
setNavTick((v) => v + 1)
|
|
1334
|
-
}, [scope])
|
|
1371
|
+
}, [scope, hydrateTick])
|
|
1335
1372
|
|
|
1373
|
+
// 导航双栈落 localStorage(同原文去重写;镜像层负责跨重启):刷新/重启后前进后退与当前位置仍在
|
|
1336
1374
|
React.useEffect(() => {
|
|
1337
|
-
if (!sessionId) return
|
|
1375
|
+
if (!sessionId || !uiStateSettled()) return
|
|
1376
|
+
navStateSave(String(scope), navRef.current?.snapshot?.() ?? null)
|
|
1377
|
+
}, [navTick, cursor, active, sessionId, scope, hydrateTick])
|
|
1378
|
+
|
|
1379
|
+
React.useEffect(() => {
|
|
1380
|
+
if (!sessionId || !uiStateSettled()) return
|
|
1338
1381
|
try {
|
|
1339
1382
|
// v3 写入:页签带 pinned(只写有值字段,减小体积);固定态随工作区作用域持久化
|
|
1340
1383
|
const payload = { tabs: tabs.map((t) => (t.pinned ? { path: t.path, pinned: true } : { path: t.path })), active }
|
|
1341
1384
|
localStorage.setItem(CACHE_KEY.editor + String(scope), JSON.stringify(payload))
|
|
1342
1385
|
}
|
|
1343
1386
|
catch (e) { /* 忽略 */ }
|
|
1344
|
-
}, [tabs, active, sessionId, scope])
|
|
1387
|
+
}, [tabs, active, sessionId, scope, hydrateTick])
|
|
1345
1388
|
|
|
1346
1389
|
/**
|
|
1347
1390
|
* 标记页签为「最近使用」(LRU 序号单调递增;编辑动作也算使用,见 onEdit)。
|
|
@@ -1394,9 +1437,17 @@ export function EditorView(props) {
|
|
|
1394
1437
|
if (typeof saved.on === 'boolean') setSidebarOn(saved.on)
|
|
1395
1438
|
if (typeof saved.width === 'number') setSidebarW(Math.max(getSidebarMinWidth(), Math.min(560, saved.width)))
|
|
1396
1439
|
if (typeof saved.panel === 'string') setActivePanel(saved.panel)
|
|
1440
|
+
reportUiStateEvent(
|
|
1441
|
+
'侧栏恢复(键 ' + sidebarKey + '):on=' + String(saved.on) + ' width=' + String(saved.width)
|
|
1442
|
+
+ ' panel=' + String(saved.panel),
|
|
1443
|
+
)
|
|
1444
|
+
} else {
|
|
1445
|
+
reportUiStateEvent('侧栏恢复(键 ' + sidebarKey + '):localStorage 无存档')
|
|
1397
1446
|
}
|
|
1398
|
-
} catch (e) {
|
|
1399
|
-
|
|
1447
|
+
} catch (e) {
|
|
1448
|
+
reportUiStateEvent('侧栏恢复失败(键 ' + sidebarKey + '): ' + String(e), 'warn')
|
|
1449
|
+
}
|
|
1450
|
+
}, [sessionId, sidebarKey, hydrateTick])
|
|
1400
1451
|
|
|
1401
1452
|
// 侧边栏最小宽度设置变更(edrv:sidebar-min-width)→ 更新 minW 并重夹当前宽度
|
|
1402
1453
|
const [sidebarMinW, setSidebarMinW] = React.useState(() => getSidebarMinWidth())
|
|
@@ -1411,10 +1462,10 @@ export function EditorView(props) {
|
|
|
1411
1462
|
|
|
1412
1463
|
// 侧边栏状态持久化
|
|
1413
1464
|
React.useEffect(() => {
|
|
1414
|
-
if (!sessionId) return
|
|
1465
|
+
if (!sessionId || !uiStateSettled()) return
|
|
1415
1466
|
try { localStorage.setItem(sidebarKey, JSON.stringify({ on: sidebarOn, width: sidebarW, panel: activePanel })) }
|
|
1416
1467
|
catch (e) { /* 忽略 */ }
|
|
1417
|
-
}, [sidebarOn, sidebarW, activePanel, sessionId, sidebarKey])
|
|
1468
|
+
}, [sidebarOn, sidebarW, activePanel, sessionId, sidebarKey, hydrateTick])
|
|
1418
1469
|
// 编辑器键位(侧栏/保存/搜索/Markdown 预览/导航/页签循环)已归官方 shortcuts 机制派发:
|
|
1419
1470
|
// 官方 resolve → 指令注册表 run → `edrv.command.*` 事件 → 下方「指令系统接线」表执行。
|
|
1420
1471
|
// 历史 capture 监听移除(与官方派发并存会双执行);Markdown 预览的「非 .md 不吞键」
|
|
@@ -2014,6 +2065,8 @@ export function EditorView(props) {
|
|
|
2014
2065
|
// 卸载即切作用域/重挂载:先把待记录光标落进历史,再存当前文件视图状态
|
|
2015
2066
|
//(经 ref 取最新闭包,修正旧实现捕获挂载时 active 的问题)
|
|
2016
2067
|
flushNavCursor()
|
|
2068
|
+
// 关页面/切作用域前把双栈落到 localStorage(镜像层 flush 负责写穿 host)
|
|
2069
|
+
navStateSave(String(scope), navRef.current?.snapshot?.() ?? null)
|
|
2017
2070
|
saveViewStateRef.current?.()
|
|
2018
2071
|
diffRendererRef.current?.dispose?.()
|
|
2019
2072
|
hideReferencesOverlay()
|
|
@@ -2536,6 +2589,8 @@ export function EditorView(props) {
|
|
|
2536
2589
|
next.delete(path)
|
|
2537
2590
|
}
|
|
2538
2591
|
mdPreviewRef.current = next
|
|
2592
|
+
// 预览态按作用域落 localStorage(镜像层写穿 host):刷新/重启后仍以预览打开
|
|
2593
|
+
mdPreviewSave(String(scope), next)
|
|
2539
2594
|
setStatus(entering ? '已切换为 Markdown 预览' : '已切换为源码编辑')
|
|
2540
2595
|
}
|
|
2541
2596
|
toggleMdPreviewRef.current = toggleMdPreview
|
package/src/dshVersion.ts
CHANGED
|
@@ -84,14 +84,16 @@ export function inDshRange(version: DshVersion | null | undefined, range: DshRan
|
|
|
84
84
|
|
|
85
85
|
/**
|
|
86
86
|
* 版本线标签:报告与文档展示版本归属;不可解析返回 '未知'。
|
|
87
|
-
*
|
|
88
|
-
* @author ddj 2026年09月02号 / 2026年09月18号 / 2026年09月22号
|
|
87
|
+
* 逐线自新到旧匹配,先命中先返回;仅 0.2.0-rc.1 精确标记为已审计版本。
|
|
88
|
+
* @author ddj 2026年09月02号 / 2026年09月18号 / 2026年09月22号 / 2026年09月29号
|
|
89
89
|
* @param input 版本串(如 '0.1.6-alpha.2')
|
|
90
90
|
* @returns 版本线标签
|
|
91
91
|
*/
|
|
92
92
|
export function familyLabel(input: string): string {
|
|
93
93
|
const version = parseDshVersion(input)
|
|
94
94
|
if (!version) return '未知'
|
|
95
|
+
if (inDshRange(version, { from: '0.2.0-rc.1', to: '0.2.0-rc.1' })) return '0.2.0-rc.1(设置=profile Config;按能力探测)'
|
|
96
|
+
if (inDshRange(version, { from: '0.2.0-0' })) return '0.2.0 及更新(除 rc.1 外未经逐版适配;按能力探测)'
|
|
95
97
|
if (inDshRange(version, { from: '0.1.7-alpha.1' })) return '0.1.7-alpha.1 及更新(设置=profile Config + configForms 客户端面 + 会话 V4)'
|
|
96
98
|
if (inDshRange(version, { from: '0.1.6-alpha.2' })) return '0.1.6-alpha.2 及更新(会话多实例共存 + 回合改动卡片 + Office 侧栏预览 + 侧栏浏览器)'
|
|
97
99
|
if (inDshRange(version, { from: '0.1.6-alpha.1' })) return '0.1.6-alpha 及更新(MCP SDK v2 + Web 侧边栏终端 + 文件链接默认侧栏预览)'
|
package/src/paths.ts
CHANGED
|
@@ -25,6 +25,8 @@ export const DSH_DIR_NAME = '.dsh'
|
|
|
25
25
|
export const DSH_HOME_ENV = 'DSH_HOME'
|
|
26
26
|
/** 树缓存 schema 版本(文件名携带;版本递增即触发旧版清理)。 */
|
|
27
27
|
export const TREE_CACHE_SCHEMA = 1
|
|
28
|
+
/** UI 状态镜像 schema 版本(文件名携带;版本递增即触发旧版清理)。 */
|
|
29
|
+
export const UI_STATE_SCHEMA = 1
|
|
28
30
|
/** LSP provider 发现缓存 schema 版本(格式变更即递增,旧文件自动失效)。 */
|
|
29
31
|
export const LSP_SPEC_CACHE_SCHEMA = 4
|
|
30
32
|
/** 树缓存保留期:超期文件视为废弃(工作区搬迁/废弃残留)。 */
|
|
@@ -161,6 +163,31 @@ export function treeCacheFile(cwd: string, home = dshHome(), schema = TREE_CACHE
|
|
|
161
163
|
return join(workspaceCacheDir(hashOf(cwd), home), 'tree.v' + schema + '.json')
|
|
162
164
|
}
|
|
163
165
|
|
|
166
|
+
/**
|
|
167
|
+
* 每工作区 UI 状态镜像文件(工作区级:workspace/<id>/ui.v<schema>.json)。
|
|
168
|
+
* 存编辑器页签/侧栏/预览态等界面状态的 localStorage 原值副本,供换 origin 后回填。
|
|
169
|
+
* @author ddj 2026年09月28号
|
|
170
|
+
* @param cwd 工作区绝对路径
|
|
171
|
+
* @param home DSH home(测试可注入)
|
|
172
|
+
* @param schema schema 版本(缺省 UI_STATE_SCHEMA)
|
|
173
|
+
* @returns UI 状态镜像文件绝对路径
|
|
174
|
+
*/
|
|
175
|
+
export function uiStateFile(cwd: string, home = dshHome(), schema = UI_STATE_SCHEMA): string {
|
|
176
|
+
return join(workspaceCacheDir(hashOf(cwd), home), 'ui.v' + schema + '.json')
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* 用户级 UI 状态镜像文件(跨工作区共享:user/ui-globals.v<schema>.json)。
|
|
181
|
+
* 存与工作区无关的全局键(侧栏提示已忽略、工作区折叠态等)。
|
|
182
|
+
* @author ddj 2026年09月28号
|
|
183
|
+
* @param home DSH home(测试可注入)
|
|
184
|
+
* @param schema schema 版本(缺省 UI_STATE_SCHEMA)
|
|
185
|
+
* @returns 全局 UI 状态镜像文件绝对路径
|
|
186
|
+
*/
|
|
187
|
+
export function uiGlobalsFile(home = dshHome(), schema = UI_STATE_SCHEMA): string {
|
|
188
|
+
return join(userCacheDir(home), 'ui-globals.v' + schema + '.json')
|
|
189
|
+
}
|
|
190
|
+
|
|
164
191
|
/** 每工作区 debug 日志文件(用户级日志根下,按 cwd hash 隔离)。 */
|
|
165
192
|
export function debugLogFile(cwd: string, home = dshHome()): string {
|
|
166
193
|
return join(pluginLogRoot(home), 'debug.' + hashOf(cwd) + '.log')
|
|
@@ -201,6 +228,37 @@ export function treeSchemaOf(filename: string): number | null {
|
|
|
201
228
|
return m ? Number(m[1]) : null
|
|
202
229
|
}
|
|
203
230
|
|
|
231
|
+
/**
|
|
232
|
+
* 已知缓存文件种类 → 当前 schema 版本(sweep 判旧依据)。
|
|
233
|
+
* 新增缓存文件必须登记在此,否则被当作未知残留清理。
|
|
234
|
+
*/
|
|
235
|
+
const CACHE_SCHEMAS: Record<string, number> = {
|
|
236
|
+
tree: TREE_CACHE_SCHEMA,
|
|
237
|
+
'lsp-specs': LSP_SPEC_CACHE_SCHEMA,
|
|
238
|
+
ui: UI_STATE_SCHEMA,
|
|
239
|
+
'ui-globals': UI_STATE_SCHEMA,
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** 缓存文件名 → 种类与 schema 版本(`<kind>.v<schema>.json`;未登记种类/命名不合规 → null)。 */
|
|
243
|
+
function parseCacheName(filename: string): { kind: string; schema: number } | null {
|
|
244
|
+
const m = /^([a-z-]+)\.v(\d+)\.json$/.exec(filename)
|
|
245
|
+
if (!m) return null
|
|
246
|
+
const kind = m[1]
|
|
247
|
+
if (CACHE_SCHEMAS[kind] === undefined) return null
|
|
248
|
+
return { kind, schema: Number(m[2]) }
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* 该缓存文件名是否为已登记种类的当前 schema 版本(sweep 判旧/判残留依据)。
|
|
253
|
+
* @author ddj 2026年09月28号
|
|
254
|
+
* @param filename 缓存文件名(不含目录)
|
|
255
|
+
* @returns 已登记种类且版本为当前版本
|
|
256
|
+
*/
|
|
257
|
+
export function isCurrentCacheFile(filename: string): boolean {
|
|
258
|
+
const parsed = parseCacheName(filename)
|
|
259
|
+
return parsed !== null && parsed.schema === CACHE_SCHEMAS[parsed.kind]
|
|
260
|
+
}
|
|
261
|
+
|
|
204
262
|
/** 按「schema 版本 + 保留期」清扫某目录内文件(目录级,返回相对路径清单)。 */
|
|
205
263
|
async function sweepDirFiles(dir: string, removed: string[], now: number, dropAll: boolean): Promise<void> {
|
|
206
264
|
const names = await readdir(dir).catch(() => [])
|
|
@@ -208,9 +266,7 @@ async function sweepDirFiles(dir: string, removed: string[], now: number, dropAl
|
|
|
208
266
|
const full = join(dir, name)
|
|
209
267
|
let drop = dropAll
|
|
210
268
|
if (!drop) {
|
|
211
|
-
|
|
212
|
-
if (schema === null) drop = true // 未知残留
|
|
213
|
-
else if (schema !== TREE_CACHE_SCHEMA) drop = true // 旧版本
|
|
269
|
+
if (!isCurrentCacheFile(name)) drop = true // 未知残留 / 旧版本
|
|
214
270
|
else {
|
|
215
271
|
try {
|
|
216
272
|
const info = await stat(full)
|
package/src/rpc.ts
CHANGED
|
@@ -46,7 +46,8 @@ import { baseNameOf, checkNewName, checkRenameName, isSubPath, joinRelPath, pare
|
|
|
46
46
|
import { invalidateIndex, listDirCached } from './treeIndex.js'
|
|
47
47
|
import { findRemoteWs, remoteDisplayOf, type RemoteWs } from './remoteWorkspace.js'
|
|
48
48
|
import { revealInExplorer } from './reveal.js'
|
|
49
|
-
import { dshHome, debugLogFile, pluginLogRoot } from './paths.js'
|
|
49
|
+
import { dshHome, debugLogFile, pluginLogRoot, uiGlobalsFile, uiStateFile } from './paths.js'
|
|
50
|
+
import { readUiState, writeUiState } from './uiState.js'
|
|
50
51
|
import { clearDebugLog, debugRecord, isDebugLogName, listDebugLogs, readDebugLog } from './debugLog.js'
|
|
51
52
|
import { markActiveSessions, markOfficialFlags, moveOutSessions, planMoveOut, purgeArchive, restoreSession, scanSessionInventory, sessionsArchiveRoot, sessionSizeOf, sidecarSummaryOf, unarchiveOfficial } from './perf.js'
|
|
52
53
|
import { patchHasPerfConfig, patchInsertPerfConfig, patchRemovePerfConfig, perfConfigBlock } from './perfPatch.js'
|
|
@@ -57,6 +58,12 @@ import { settingsDocPath } from './settingsDocPath.js'
|
|
|
57
58
|
const staleCheckedAt = new Map<string, number>()
|
|
58
59
|
/** stale 自动清理最小间隔。 */
|
|
59
60
|
const STALE_CHECK_MIN_MS = 10_000
|
|
61
|
+
/**
|
|
62
|
+
* 无会话期诊断的伪 cwd:冷启动早期本插件客户端就会上报界面状态镜像事件
|
|
63
|
+
* (此时 `sessions` 可能尚未就绪,`requireSession` 会失败),退回本固定值落盘
|
|
64
|
+
* `logs/debug.<hashOf(该值)>.log`,保证「换 origin 后没恢复」这类问题的诊断不丢。
|
|
65
|
+
*/
|
|
66
|
+
const UI_AUDIT_CWD = 'ui-state'
|
|
60
67
|
|
|
61
68
|
/** 记录 → 客户端视图(不含 before 全文,仅长度);远程工作区附远端展示路径(issue #7)。 */
|
|
62
69
|
function recView(record: DiffRecord, ws: RemoteWs | null = null): RecordView {
|
|
@@ -465,6 +472,40 @@ export function buildHandlers(
|
|
|
465
472
|
out.sort((a, b) => (a.at < b.at ? -1 : 1))
|
|
466
473
|
return { ok: true, records: out }
|
|
467
474
|
},
|
|
475
|
+
/**
|
|
476
|
+
* 界面状态镜像读取:回填换 origin(Web GUI 重启换端口)后丢失的 localStorage 界面状态。
|
|
477
|
+
* 文件缺失/损坏 → 空表(客户端自然不动作)。
|
|
478
|
+
* @author ddj 2026年09月28号
|
|
479
|
+
*/
|
|
480
|
+
'edrv.uiState.get': async (args) => {
|
|
481
|
+
const sc = await requireSession(ctx, args.sessionId)
|
|
482
|
+
if ('err' in sc) return { ok: false, error: sc.err }
|
|
483
|
+
const keys = await readUiState(uiStateFile(sc.cwd))
|
|
484
|
+
const globals = await readUiState(uiGlobalsFile())
|
|
485
|
+
return { ok: true, keys, globals }
|
|
486
|
+
},
|
|
487
|
+
/**
|
|
488
|
+
* 界面状态镜像写入:把客户端改动过的 `edrv.*` 键增量同步到 host 文件(空补丁不落盘)。
|
|
489
|
+
* 失败时写一条诊断日志(默认静默的客户端 dbg 之外的可查证据),并如实回错误由客户端逐键回退。
|
|
490
|
+
* @author ddj 2026年09月28号 / 2026年09月29号
|
|
491
|
+
*/
|
|
492
|
+
'edrv.uiState.set': async (args) => {
|
|
493
|
+
const sc = await requireSession(ctx, args.sessionId)
|
|
494
|
+
if ('err' in sc) return { ok: false, error: sc.err }
|
|
495
|
+
const tasks: Array<Promise<{ ok: true } | { ok: false; error: string }>> = []
|
|
496
|
+
if (args.keys && Object.keys(args.keys).length) tasks.push(writeUiState(uiStateFile(sc.cwd), args.keys))
|
|
497
|
+
if (args.globals && Object.keys(args.globals).length) tasks.push(writeUiState(uiGlobalsFile(), args.globals))
|
|
498
|
+
const results = await Promise.all(tasks)
|
|
499
|
+
const failed = results.find((item) => !item.ok)
|
|
500
|
+
if (failed && !failed.ok) {
|
|
501
|
+
const keyCount = Object.keys(args.keys ?? {}).length
|
|
502
|
+
const globalCount = Object.keys(args.globals ?? {}).length
|
|
503
|
+
debugRecord(ctx, sc.cwd, '界面状态镜像写入失败: ' + failed.error
|
|
504
|
+
+ '(keys=' + keyCount + ' 键, globals=' + globalCount + ' 键)', 'warn')
|
|
505
|
+
return { ok: false, error: failed.error }
|
|
506
|
+
}
|
|
507
|
+
return { ok: true }
|
|
508
|
+
},
|
|
468
509
|
'edrv.accept': async (args) => {
|
|
469
510
|
const sc = await requireSession(ctx, args.sessionId)
|
|
470
511
|
if ('err' in sc) return { ok: false, error: sc.err }
|
|
@@ -749,9 +790,15 @@ export function buildHandlers(
|
|
|
749
790
|
'edrv.debug': async (args) => {
|
|
750
791
|
// 诊断日志:client 上报 → debugLog 模块缓冲批量落盘 ~/.dsh/dsh-vscode-mode/logs/
|
|
751
792
|
// (console 不一定落盘,文件可靠)。只出现在调试开关开启时(client dbg 默认关)。
|
|
793
|
+
const text = String(args.text ?? '')
|
|
752
794
|
const sc = await requireSession(ctx, args.sessionId)
|
|
753
|
-
if ('err' in sc)
|
|
754
|
-
|
|
795
|
+
if ('err' in sc) {
|
|
796
|
+
// 会话未就绪(冷启动早期、界面状态镜像水合期)也必须能落盘:否则换 origin 后的
|
|
797
|
+
// 「水合失败/放弃」诊断全部丢失,只剩「界面没恢复」这个不可观测的现象。
|
|
798
|
+
debugRecord(ctx, UI_AUDIT_CWD, text, args.level)
|
|
799
|
+
return { ok: true }
|
|
800
|
+
}
|
|
801
|
+
debugRecord(ctx, sc.cwd, text, args.level)
|
|
755
802
|
return { ok: true }
|
|
756
803
|
},
|
|
757
804
|
'edrv.dlog.list': async (args) => {
|
package/src/shared/rpc.ts
CHANGED
|
@@ -331,6 +331,24 @@ export interface DebugLogRead {
|
|
|
331
331
|
truncated: boolean
|
|
332
332
|
}
|
|
333
333
|
|
|
334
|
+
/** 界面状态镜像快照(edrv.uiState.get):界面状态键值表 + 跨工作区全局键值表。 */
|
|
335
|
+
export interface UiStateSnapshot {
|
|
336
|
+
/** 工作区级键值表(localStorage 键 → 字符串原值)。 */
|
|
337
|
+
keys: Record<string, string>
|
|
338
|
+
/** 全局键值表(与工作区无关,跨工作区共享)。 */
|
|
339
|
+
globals: Record<string, string>
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** 界面状态镜像写入补丁(edrv.uiState.set):值 null 表示删除该键。 */
|
|
343
|
+
export interface UiStatePatchInput {
|
|
344
|
+
/** 会话 id(缺省取唯一活跃会话)。 */
|
|
345
|
+
sessionId?: string
|
|
346
|
+
/** 工作区级增量补丁。 */
|
|
347
|
+
keys?: Record<string, string | null>
|
|
348
|
+
/** 全局增量补丁。 */
|
|
349
|
+
globals?: Record<string, string | null>
|
|
350
|
+
}
|
|
351
|
+
|
|
334
352
|
/** 每个方法的请求参数(sessionId 为公共可选字段)。 */
|
|
335
353
|
export interface RpcRequestMap {
|
|
336
354
|
'edrv.list': { sessionId?: string; callIds?: string[]; skipStale?: boolean }
|
|
@@ -481,6 +499,10 @@ export interface RpcRequestMap {
|
|
|
481
499
|
'edrv.dap.variables': { ref: number }
|
|
482
500
|
'edrv.dap.evaluate': { expression: string; frameId?: number }
|
|
483
501
|
'edrv.dap.command': { action: DapAction }
|
|
502
|
+
/** 界面状态镜像读取:回填换 origin(重启换端口)后丢失的 localStorage 界面状态。 */
|
|
503
|
+
'edrv.uiState.get': { sessionId?: string }
|
|
504
|
+
/** 界面状态镜像写入:把客户端改动过的 `edrv.*` 键增量同步到 host 文件。 */
|
|
505
|
+
'edrv.uiState.set': UiStatePatchInput
|
|
484
506
|
}
|
|
485
507
|
|
|
486
508
|
export type RpcMethod = keyof RpcRequestMap
|
|
@@ -632,6 +654,8 @@ export interface RpcOkMap {
|
|
|
632
654
|
'edrv.dap.variables': { variables: DapVariableView[] }
|
|
633
655
|
'edrv.dap.evaluate': { result: string; type?: string; ref: number }
|
|
634
656
|
'edrv.dap.command': object
|
|
657
|
+
'edrv.uiState.get': UiStateSnapshot
|
|
658
|
+
'edrv.uiState.set': object
|
|
635
659
|
}
|
|
636
660
|
|
|
637
661
|
/** 统一响应:{ok:true, ...payload} 或 {ok:false, error}。 */
|
package/src/uiState.ts
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-vscode-mode host — UI 状态镜像存储(界面状态的跨重启记忆)。
|
|
3
|
+
* 背景:Web GUI 的 origin(含端口)可能每次启动变化,而 localStorage 按 origin 分区,
|
|
4
|
+
* 于是页签/侧栏/预览态等状态随重启一并丢失。这里把客户端 `edrv.*` 键的字符串原值
|
|
5
|
+
* 按工作区镜像到 host 文件(workspace/<hash>/ui.v<schema>.json;全局键走 user/),
|
|
6
|
+
* 启动时原样回填本地缺失的键。
|
|
7
|
+
* 值不解析、不校验业务格式(客户端自会解析并丢弃损坏值),只保证键值对可靠落盘:
|
|
8
|
+
* - 原子写:先写临时文件再 rename,避免半截文件
|
|
9
|
+
* - 串行化:同一文件的写操作链式排队,避免并发丢更新
|
|
10
|
+
* - 容量上限:序列化超过 UI_STATE_BYTES_CAP 即拒写(客户端保留 localStorage,不丢数据)
|
|
11
|
+
* 纯函数(parse/serialize/merge)与文件读写均参数注入,可单测。
|
|
12
|
+
* 作者 ddj 2026-09-28
|
|
13
|
+
*/
|
|
14
|
+
import { Buffer } from 'node:buffer'
|
|
15
|
+
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
|
|
16
|
+
import { dirname } from 'node:path'
|
|
17
|
+
|
|
18
|
+
// --region 常量与类型
|
|
19
|
+
/** UI 状态镜像文档版本(与文件名 schema 解耦:文件名管失效,字段管结构)。 */
|
|
20
|
+
export const UI_STATE_VERSION = 1
|
|
21
|
+
/** UI 状态镜像容量上限:序列化后超出即拒写(客户端回退纯 localStorage)。 */
|
|
22
|
+
export const UI_STATE_BYTES_CAP = 512 * 1024
|
|
23
|
+
|
|
24
|
+
/** UI 状态镜像文档。 */
|
|
25
|
+
export interface UiStateDoc {
|
|
26
|
+
/** 文档版本(不匹配即视为损坏)。 */
|
|
27
|
+
version: number
|
|
28
|
+
/** 最后写入时间(ISO 字符串,仅供排查)。 */
|
|
29
|
+
updatedAt: string
|
|
30
|
+
/** 界面状态键值表(localStorage 键 → 字符串原值)。 */
|
|
31
|
+
keys: Record<string, string>
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** 增量补丁:值 null/undefined 表示删除该键(localStorage 值恒为字符串,语义无歧义)。 */
|
|
35
|
+
export type UiStatePatch = Record<string, string | null | undefined>
|
|
36
|
+
// --endregion
|
|
37
|
+
|
|
38
|
+
// --region 纯函数
|
|
39
|
+
/**
|
|
40
|
+
* 合并增量补丁到键值表(不改入参)。
|
|
41
|
+
* @author ddj 2026年09月28号
|
|
42
|
+
* @param base 已有键值表
|
|
43
|
+
* @param patch 增量补丁(null/undefined 值删除该键;空键跳过)
|
|
44
|
+
* @returns 合并后的新键值表
|
|
45
|
+
*/
|
|
46
|
+
export function mergeUiStateKeys(base: Record<string, string>, patch: UiStatePatch): Record<string, string> {
|
|
47
|
+
const merged: Record<string, string> = { ...base }
|
|
48
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
49
|
+
if (!key) continue
|
|
50
|
+
if (value === null || value === undefined) delete merged[key]
|
|
51
|
+
else merged[key] = String(value)
|
|
52
|
+
}
|
|
53
|
+
return merged
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 解析镜像文件文本;损坏/版本不符 → null(调用方视为空)。
|
|
58
|
+
* @author ddj 2026年09月28号
|
|
59
|
+
* @param text 文件文本(可为空)
|
|
60
|
+
* @returns 解析后的文档;不可用时 null
|
|
61
|
+
*/
|
|
62
|
+
export function parseUiState(text: string | null | undefined): UiStateDoc | null {
|
|
63
|
+
if (!text) return null
|
|
64
|
+
let raw: unknown
|
|
65
|
+
try {
|
|
66
|
+
raw = JSON.parse(text)
|
|
67
|
+
} catch (error) {
|
|
68
|
+
return null
|
|
69
|
+
}
|
|
70
|
+
if (!raw || typeof raw !== 'object') return null
|
|
71
|
+
const doc = raw as { version?: unknown; updatedAt?: unknown; keys?: unknown }
|
|
72
|
+
if (doc.version !== UI_STATE_VERSION) return null
|
|
73
|
+
if (!doc.keys || typeof doc.keys !== 'object') return null
|
|
74
|
+
const keys: Record<string, string> = {}
|
|
75
|
+
for (const [key, value] of Object.entries(doc.keys as Record<string, unknown>)) {
|
|
76
|
+
if (!key || typeof value !== 'string') continue
|
|
77
|
+
keys[key] = value
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
version: UI_STATE_VERSION,
|
|
81
|
+
updatedAt: typeof doc.updatedAt === 'string' ? doc.updatedAt : '',
|
|
82
|
+
keys,
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* 序列化为镜像文件文本。
|
|
88
|
+
* @author ddj 2026年09月28号
|
|
89
|
+
* @param keys 键值表
|
|
90
|
+
* @param updatedAt 更新时间(缺省当前时间)
|
|
91
|
+
* @returns 文件文本
|
|
92
|
+
*/
|
|
93
|
+
export function serializeUiState(keys: Record<string, string>, updatedAt = new Date().toISOString()): string {
|
|
94
|
+
const doc: UiStateDoc = { version: UI_STATE_VERSION, updatedAt, keys }
|
|
95
|
+
return JSON.stringify(doc)
|
|
96
|
+
}
|
|
97
|
+
// --endregion
|
|
98
|
+
|
|
99
|
+
// --region 文件读写
|
|
100
|
+
/** 同文件写队列尾(串行化并发写入;队列清空即移除条目)。 */
|
|
101
|
+
const writeChains = new Map<string, Promise<unknown>>()
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* 把写任务挂到该文件的队尾串行执行(前序失败不影响后续)。
|
|
105
|
+
* @author ddj 2026年09月28号
|
|
106
|
+
* @param file 目标文件绝对路径
|
|
107
|
+
* @param run 实际写操作
|
|
108
|
+
* @returns 本次写操作结果
|
|
109
|
+
*/
|
|
110
|
+
function chainWrite<T>(file: string, run: () => Promise<T>): Promise<T> {
|
|
111
|
+
const prev = writeChains.get(file) ?? Promise.resolve()
|
|
112
|
+
const next = prev.then(run, run)
|
|
113
|
+
const tail = next.then(() => undefined, () => undefined)
|
|
114
|
+
writeChains.set(file, tail)
|
|
115
|
+
void tail.then(() => {
|
|
116
|
+
if (writeChains.get(file) === tail) writeChains.delete(file)
|
|
117
|
+
})
|
|
118
|
+
return next
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* 读取镜像文件的键值表(文件缺失/损坏/不可读 → 空表,静默)。
|
|
123
|
+
* @author ddj 2026年09月28号
|
|
124
|
+
* @param file 镜像文件绝对路径
|
|
125
|
+
* @returns 键值表(localStorage 键 → 字符串原值)
|
|
126
|
+
*/
|
|
127
|
+
export async function readUiState(file: string): Promise<Record<string, string>> {
|
|
128
|
+
try {
|
|
129
|
+
const doc = parseUiState(await readFile(file, 'utf8'))
|
|
130
|
+
return doc ? doc.keys : {}
|
|
131
|
+
} catch (error) {
|
|
132
|
+
return {}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* 合并写入镜像文件(读旧 → 合并补丁 → 容量校验 → 原子替换),同文件写操作串行。
|
|
138
|
+
* @author ddj 2026年09月28号
|
|
139
|
+
* @param file 镜像文件绝对路径
|
|
140
|
+
* @param patch 增量补丁(null/undefined 值删除该键)
|
|
141
|
+
* @returns ok;超限或写入失败时 ok:false + 中文错误文案
|
|
142
|
+
*/
|
|
143
|
+
export async function writeUiState(
|
|
144
|
+
file: string,
|
|
145
|
+
patch: UiStatePatch,
|
|
146
|
+
): Promise<{ ok: true } | { ok: false; error: string }> {
|
|
147
|
+
return chainWrite(file, async () => {
|
|
148
|
+
try {
|
|
149
|
+
const merged = mergeUiStateKeys(await readUiState(file), patch)
|
|
150
|
+
const json = serializeUiState(merged)
|
|
151
|
+
if (Buffer.byteLength(json, 'utf8') > UI_STATE_BYTES_CAP) {
|
|
152
|
+
return { ok: false as const, error: '界面状态镜像超出容量上限(' + UI_STATE_BYTES_CAP + ' 字节)' }
|
|
153
|
+
}
|
|
154
|
+
await mkdir(dirname(file), { recursive: true })
|
|
155
|
+
const tmp = file + '.tmp'
|
|
156
|
+
await writeFile(tmp, json, 'utf8')
|
|
157
|
+
await rename(tmp, file)
|
|
158
|
+
return { ok: true as const }
|
|
159
|
+
} catch (error) {
|
|
160
|
+
const reason = error instanceof Error ? error.message : String(error)
|
|
161
|
+
return { ok: false as const, error: '界面状态写入失败: ' + reason }
|
|
162
|
+
}
|
|
163
|
+
})
|
|
164
|
+
}
|
|
165
|
+
// --endregion
|