dsh-vscode-mode 0.1.60 → 0.1.62

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.
@@ -0,0 +1,96 @@
1
+ /**
2
+ * dsh-vscode-mode client — 编辑区状态作用域键(scope key)。
3
+ * 编辑区 UI 状态(页签/展开树/视图状态/侧边栏等)按「工作区」隔离:同一工作区下
4
+ * 切换对话恢复同一份状态,无体感差别;会话没有 cwd 时回退按会话隔离(旧行为)。
5
+ * 另提供旧「会话键」数据到「工作区键」的一次性迁移(只复制不删除,幂等)。
6
+ * 键前缀统一走 paths.ts PathConst(CACHE_KEY),本模块只产出拼接用的作用域串。
7
+ * 作者 ddj 2026-09-09
8
+ */
9
+ import { CACHE_KEY } from '../paths.js'
10
+
11
+ // --region 归一化与作用域键
12
+
13
+ /** Windows 盘符绝对路径(如 D:/ 或 d:\),大小写不敏感。 */
14
+ const WIN_DRIVE_RE = /^[a-z]:\//i
15
+
16
+ /**
17
+ * 归一化工作区路径:trim、反斜杠→斜杠、去尾部斜杠;
18
+ * Windows 盘符绝对路径整体小写(文件系统大小写不敏感),POSIX 路径保留大小写。
19
+ * @author ddj 2026年09月09号
20
+ * @param cwd 工作区目录(sessions 快照 byId[sessionId].cwd)
21
+ * @returns 归一化路径;空值返回 ''
22
+ */
23
+ export function normalizeWsPath(cwd: string | undefined | null): string {
24
+ const s = String(cwd ?? '').trim().replace(/\\/g, '/').replace(/\/+$/, '')
25
+ if (!s) return ''
26
+ return WIN_DRIVE_RE.test(s) ? s.toLowerCase() : s
27
+ }
28
+
29
+ /**
30
+ * 计算编辑区状态作用域键:有 cwd → 'ws:' + 归一化路径(同工作区共享);
31
+ * 无 cwd → 'sid:' + sessionId(回退按会话隔离,对齐旧行为)。
32
+ * @author ddj 2026年09月09号
33
+ * @param cwd 工作区目录(可为 null/undefined)
34
+ * @param sessionId 会话 id(cwd 缺失时的隔离键)
35
+ * @returns 作用域键字符串
36
+ */
37
+ export function workspaceScopeOf(cwd: string | undefined | null, sessionId: string | undefined): string {
38
+ const ws = normalizeWsPath(cwd)
39
+ if (ws) return 'ws:' + ws
40
+ return 'sid:' + String(sessionId ?? '')
41
+ }
42
+ // --endregion
43
+
44
+ // --region 旧会话键迁移
45
+
46
+ /** 参与迁移的 localStorage 键前缀(编辑区全部按会话持久化的旧键)。 */
47
+ const MIGRATE_PREFIXES: readonly string[] = [
48
+ CACHE_KEY.editor,
49
+ CACHE_KEY.viewstate,
50
+ CACHE_KEY.sidebar + 'side.',
51
+ CACHE_KEY.sidebar,
52
+ CACHE_KEY.search,
53
+ CACHE_KEY.expanded,
54
+ CACHE_KEY.entries,
55
+ ]
56
+
57
+ /** 已迁移过的 scope+sessionId 组合(按 storage 实例分组;每页生命周期只跑一次)。 */
58
+ const migratedPairs = new WeakMap<object, Set<string>>()
59
+
60
+ /**
61
+ * 把旧「前缀+sessionId」键的数据一次性复制到「前缀+scope」键(目标已存在则跳过)。
62
+ * 只复制不删除旧键;幂等,可安全重复调用。升级后首个工作区键为空时兜底恢复旧数据。
63
+ * @author ddj 2026年09月09号
64
+ * @param scope 目标作用域键
65
+ * @param sessionId 旧数据所属会话 id
66
+ * @param storage localStorage 注入口(缺省 window.localStorage,测试可传内存桩)
67
+ */
68
+ export function migrateScopedKeys(scope: string, sessionId: string | undefined, storage?: Storage): void {
69
+ if (!scope || !sessionId) return
70
+ let store: Storage | null = null
71
+ try {
72
+ store = storage ?? window.localStorage
73
+ } catch (error) {
74
+ return
75
+ }
76
+ if (!store) return
77
+ let pairs = migratedPairs.get(store)
78
+ if (!pairs) {
79
+ pairs = new Set()
80
+ migratedPairs.set(store, pairs)
81
+ }
82
+ const pair = scope + '|' + sessionId
83
+ if (pairs.has(pair)) return
84
+ pairs.add(pair)
85
+ const legacy = String(sessionId)
86
+ for (const prefix of MIGRATE_PREFIXES) {
87
+ const target = prefix + scope
88
+ try {
89
+ if (store.getItem(target) !== null) continue
90
+ const raw = store.getItem(prefix + legacy)
91
+ if (raw === null) continue
92
+ store.setItem(target, raw)
93
+ } catch (error) { /* 配额满/隐私模式忽略,不阻塞 */ }
94
+ }
95
+ }
96
+ // --endregion
@@ -7,7 +7,7 @@
7
7
  */
8
8
  import { CACHE_KEY } from '../paths.js'
9
9
 
10
- /** localStorage 键前缀(按会话隔离)。 */
10
+ /** localStorage 键前缀(按工作区作用域隔离,无 cwd 会话回退会话键)。 */
11
11
  const KEY_PREFIX = CACHE_KEY.viewstate
12
12
 
13
13
  /** 视图状态条目容量上限。 */
@@ -95,27 +95,27 @@ export function upsertViewState(
95
95
  }
96
96
 
97
97
  /**
98
- * 读取某会话的视图状态缓存(localStorage 不可用 → 空映射)。
98
+ * 读取某作用域的视图状态缓存(localStorage 不可用 → 空映射)。
99
99
  * @author ddj 2026年08月28号
100
- * @param sessionId 会话 id
100
+ * @param scope 作用域键(scopeStore.workspaceScopeOf 产物)
101
101
  * @returns path → viewState 映射
102
102
  */
103
- export function viewStatesLoad(sessionId: string): Record<string, unknown> {
103
+ export function viewStatesLoad(scope: string): Record<string, unknown> {
104
104
  try {
105
- return parseViewStates(window.localStorage.getItem(KEY_PREFIX + String(sessionId))) ?? {}
105
+ return parseViewStates(window.localStorage.getItem(KEY_PREFIX + String(scope))) ?? {}
106
106
  } catch (error) {
107
107
  return {}
108
108
  }
109
109
  }
110
110
 
111
111
  /**
112
- * 写入某会话的视图状态缓存(损坏/配额满 → 忽略)。
112
+ * 写入某作用域的视图状态缓存(损坏/配额满 → 忽略)。
113
113
  * @author ddj 2026年08月28号
114
- * @param sessionId 会话 id
114
+ * @param scope 作用域键(scopeStore.workspaceScopeOf 产物)
115
115
  * @param states path → viewState 映射
116
116
  */
117
- export function viewStatesSave(sessionId: string, states: Record<string, unknown>): void {
117
+ export function viewStatesSave(scope: string, states: Record<string, unknown>): void {
118
118
  try {
119
- window.localStorage.setItem(KEY_PREFIX + String(sessionId), serializeViewStates(states))
119
+ window.localStorage.setItem(KEY_PREFIX + String(scope), serializeViewStates(states))
120
120
  } catch (error) { /* 配额满/隐私模式忽略 */ }
121
121
  }
@@ -7,7 +7,7 @@
7
7
  [data-edrv-view] .edrv-tab { display: inline-flex; align-items: center; gap: 6px; height: 28px; padding: 0 8px 0 12px; border: none; border-radius: 6px 6px 0 0; background: transparent; color: var(--dsw-alias-label-secondary, #aaa); font-size: 12px; cursor: pointer; white-space: nowrap; flex-shrink: 0; }
8
8
  [data-edrv-view] .edrv-tab:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(255,255,255,.06)); }
9
9
  [data-edrv-view] .edrv-tab.edrv-tab-active { background: var(--dsw-alias-bg-base, #111); color: var(--dsw-alias-label-primary, #eee); box-shadow: inset 0 -2px 0 var(--dsw-alias-brand-primary, #4f8cff); }
10
- [data-edrv-view] .edrv-tab .edrv-tab-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--dsw-alias-brand-primary, #4f8cff); flex-shrink: 0; }
10
+ [data-edrv-view] .edrv-tab .edrv-tab-star { color: var(--dsw-alias-brand-primary, #4f8cff); font-size: 13px; font-weight: 700; line-height: 1; flex-shrink: 0; } /* 未保存修改星号 @author ddj 2026年09月09号 */
11
11
  [data-edrv-view] .edrv-tab .edrv-tab-x { display: inline-flex; align-items: center; justify-content: center; width: 16px; height: 16px; border-radius: 4px; color: var(--dsw-alias-label-tertiary, #888); font-size: 11px; }
12
12
  [data-edrv-view] .edrv-tab .edrv-tab-x:hover { background: rgba(255,255,255,.12); color: var(--dsw-alias-label-primary, #eee); }
13
13
  [data-edrv-view] .edrv-tab-add { display: inline-flex; align-items: center; justify-content: center; width: 24px; height: 24px; margin-left: 2px; border: none; border-radius: 6px; background: transparent; color: var(--dsw-alias-label-secondary, #aaa); font-size: 14px; cursor: pointer; flex-shrink: 0; }
@@ -3,6 +3,8 @@
3
3
  * dsh-vscode-mode client — EditorView:中央 VSCode 式文件编辑器(编排层)。
4
4
  * 迁移自原 src/client/index.ts 的 EditorView,语义不改;差异自绘已抽到 monaco/diffRender。
5
5
  * 职责:页签/QuickOpen/Monaco 编辑器/差异审查(DiffBox/Launcher/Badge 事件装配)/状态栏/自动保存。
6
+ * 状态作用域:页签/视图状态/侧边栏/展开树等 UI 状态按工作区作用域(cwd)隔离,
7
+ * 同一工作区切换对话恢复同一份状态;无 cwd 会话回退按会话隔离(scopeStore)。
6
8
  * 作者 ddj 2026-08-20
7
9
  */
8
10
  import React from 'react'
@@ -25,9 +27,11 @@ import { editorHeight } from '../editorLayout.js'
25
27
  import { revealInExplorer as revealPathInExplorer } from '../fileReveal.js'
26
28
  import { setSidePending, SIDEBAR_INSTALL_CMD } from '../sidebarBridge.js'
27
29
  import { upsertViewState, viewStatesLoad, viewStatesSave } from '../state/viewStateCache.js'
30
+ import { migrateScopedKeys, workspaceScopeOf } from '../state/scopeStore.js'
31
+ import { modelsForScope, rememberModel } from '../state/modelCache.js'
28
32
  import { bindingsOf, chordOf, matchEvent, useKeybindingsVersion } from '../keybindings.js'
29
33
  import { getSidebarMinWidth } from '../sidebarMin.js'
30
- import { createNavHistory } from '../navHistory.js'
34
+ import { navHistoryFor } from '../navHistory.js'
31
35
  import { statusOfAdd } from '../addToConversation.js'
32
36
  import { CACHE_KEY } from '../paths.js'
33
37
  import { bindLspEditor, runGoToDefinition, runFindReferences, hideReferencesOverlay } from '../monaco/lsp/providers.js'
@@ -49,6 +53,22 @@ export function EditorView(props) {
49
53
  const addToConversation = props.addToConversation
50
54
  const layout = props?.layout === 'side' ? 'side' : 'tab'
51
55
  const sideHint = props?.sideHint
56
+ // 当前会话工作区 cwd(sessions 快照反应式读取;cwd 变化时作用域跟随刷新)
57
+ const sessionsList = props?.sessions?.list
58
+ const cwd = React.useSyncExternalStore(
59
+ React.useCallback((onStoreChange) => (
60
+ typeof sessionsList?.subscribe === 'function' ? sessionsList.subscribe(onStoreChange) : () => {}
61
+ ), [sessionsList]),
62
+ () => sessionsList?.getSnapshot?.()?.byId?.[sessionId]?.cwd ?? null,
63
+ () => null,
64
+ )
65
+ // 状态作用域(scopeStore):同工作区共享一份编辑区状态,无 cwd 回退会话隔离;
66
+ // 迁移放渲染期(useMemo)——子面板恢复 effect 先于父 effect 执行,须先补齐工作区键
67
+ const scope = React.useMemo(() => {
68
+ const s = workspaceScopeOf(cwd, sessionId)
69
+ migrateScopedKeys(s, sessionId)
70
+ return s
71
+ }, [cwd, sessionId])
52
72
  const [monaco, setMonaco] = React.useState(null)
53
73
  const [monacoErr, setMonacoErr] = React.useState(null)
54
74
  const [records, setRecords] = React.useState({})
@@ -93,17 +113,19 @@ export function EditorView(props) {
93
113
  const editorRef = React.useRef(null)
94
114
  const viewRootRef = React.useRef(null)
95
115
  const monacoRef = React.useRef(null)
96
- const modelsRef = React.useRef(new Map())
116
+ const modelsRef = React.useRef(null)
117
+ // model 跨挂载缓存(按工作区作用域):重挂载秒显内容,消除切换对话闪烁
118
+ if (!modelsRef.current) modelsRef.current = modelsForScope(scope)
97
119
  // 视图状态缓存(path → Monaco viewState;重启后恢复光标/滚动/折叠位置)
98
120
  const viewStatesRef = React.useRef({})
99
121
  const saveTimerRef = React.useRef(null)
100
122
  const loadSeqRef = React.useRef(0)
101
123
  const programmaticRef = React.useRef(false)
102
- const bootRef = React.useRef(false)
124
+ const restoredScopeRef = React.useRef(null) // 已恢复状态的作用域(cwd 晚到 sid→ws 时允许重恢复)
103
125
  const pendingFocusRef = React.useRef(null) // { path, region } 内容加载后跳转
104
126
  // 导航历史(后退/前进):跨文件焦点位置;按会话隔离,会话切换重置
105
127
  const navRef = React.useRef(null)
106
- if (!navRef.current) navRef.current = createNavHistory()
128
+ if (!navRef.current) navRef.current = navHistoryFor(scope)
107
129
  const navPendingRef = React.useRef(null) // { path,line,column,viewState } 待恢复条目(内容就绪后消费)
108
130
  const navCursorTimerRef = React.useRef(null) // 光标位置防抖记录计时器
109
131
  const navBackRef = React.useRef(null) // 后退动作最新闭包(窗口级键盘监听读取)
@@ -120,6 +142,8 @@ export function EditorView(props) {
120
142
  const batchBusyRef = React.useRef(false) // 批量 Keep All/Undo All 防重入
121
143
  const menuHandlersRef = React.useRef(null) // 右键菜单动作的最新闭包(Monaco addAction 空依赖回调读取)
122
144
  const doSaveRef = React.useRef(null) // 保存动作的最新闭包(窗口级保存监听读取)
145
+ const onEditRef = React.useRef(null) // 编辑置脏的最新闭包(Monaco 内容变化监听经 ref 调用,防首帧 active=null 陈旧闭包)
146
+ const saveViewStateRef = React.useRef(null) // 视图状态保存的最新闭包(卸载清理读取,避免过期 active)
123
147
  const diffRendererRef = React.useRef(null)
124
148
  const layoutRef = React.useRef(layout) // ensureEditor 空依赖闭包读取的稳定布局
125
149
  layoutRef.current = layout
@@ -163,7 +187,7 @@ export function EditorView(props) {
163
187
  }
164
188
 
165
189
  /**
166
- * 保存当前活动文件的视图状态(光标/滚动/折叠)到会话缓存。
190
+ * 保存当前活动文件的视图状态(光标/滚动/折叠)到工作区作用域缓存。
167
191
  * @author ddj 2026年08月28号
168
192
  * @param path 要保存的文件路径(缺省 = 当前 active)
169
193
  */
@@ -175,9 +199,10 @@ export function EditorView(props) {
175
199
  const state = ed.saveViewState()
176
200
  if (!state) return
177
201
  viewStatesRef.current = upsertViewState(viewStatesRef.current, target, state)
178
- viewStatesSave(sessionId, viewStatesRef.current)
202
+ viewStatesSave(scope, viewStatesRef.current)
179
203
  } catch (e) { /* 视图状态保存失败忽略 */ }
180
204
  }
205
+ saveViewStateRef.current = saveViewState
181
206
 
182
207
  /**
183
208
  * 恢复指定文件的视图状态(内容就绪、setModel 后调用;恢复后即消费删除)。
@@ -618,13 +643,15 @@ export function EditorView(props) {
618
643
  }
619
644
  }, [sessionId, layout])
620
645
 
621
- // localStorage v2 恢复页签
646
+ // localStorage v2 恢复页签(按工作区作用域;cwd 晚到 sid→ws 切换时重恢复一次)
622
647
  React.useEffect(() => {
623
- if (bootRef.current || !sessionId) return
624
- bootRef.current = true
625
- viewStatesRef.current = viewStatesLoad(sessionId)
648
+ if (!sessionId || restoredScopeRef.current === scope) return
649
+ restoredScopeRef.current = scope
650
+ // model 缓存跟随作用域(切工作区释放旧作用域模型;同工作区跨挂载复用)
651
+ modelsRef.current = modelsForScope(scope)
652
+ viewStatesRef.current = viewStatesLoad(scope)
626
653
  try {
627
- const raw = localStorage.getItem(CACHE_KEY.editor + String(sessionId))
654
+ const raw = localStorage.getItem(CACHE_KEY.editor + String(scope))
628
655
  if (raw) {
629
656
  const saved = JSON.parse(raw)
630
657
  if (Array.isArray(saved.tabs) && saved.tabs.length) {
@@ -633,25 +660,25 @@ export function EditorView(props) {
633
660
  }
634
661
  }
635
662
  } catch (e) { /* 损坏忽略 */ }
636
- }, [sessionId])
663
+ }, [sessionId, scope])
637
664
 
638
- // 导航历史会话隔离:切换会话时重置(历史仅会话内存,不跨会话串扰)
665
+ // 导航历史按工作区作用域隔离:同工作区切换对话保留历史,跨工作区换实例
639
666
  React.useEffect(() => {
640
- navRef.current = createNavHistory()
667
+ navRef.current = navHistoryFor(scope)
641
668
  navPendingRef.current = null
642
669
  if (navCursorTimerRef.current) { clearTimeout(navCursorTimerRef.current); navCursorTimerRef.current = null }
643
670
  setNavTick((v) => v + 1)
644
- }, [sessionId])
671
+ }, [scope])
645
672
 
646
673
  React.useEffect(() => {
647
674
  if (!sessionId) return
648
- try { localStorage.setItem(CACHE_KEY.editor + String(sessionId), JSON.stringify({ tabs: tabs.map((t) => t.path), active })) }
675
+ try { localStorage.setItem(CACHE_KEY.editor + String(scope), JSON.stringify({ tabs: tabs.map((t) => t.path), active })) }
649
676
  catch (e) { /* 忽略 */ }
650
- }, [tabs, active, sessionId])
677
+ }, [tabs, active, sessionId, scope])
651
678
 
652
679
  // 侧边栏状态:恢复(显隐/宽度/激活面板);侧栏形态独立键(默认收起,不共享页签形态偏好)
653
680
  // 宽度下限来自通用设置 sidebarMinWidth(默认 300),恢复值按其重夹
654
- const sidebarKey = CACHE_KEY.sidebar + (layout === 'side' ? 'side.' : '') + String(sessionId)
681
+ const sidebarKey = CACHE_KEY.sidebar + (layout === 'side' ? 'side.' : '') + String(scope)
655
682
  React.useEffect(() => {
656
683
  if (!sessionId) return
657
684
  try {
@@ -682,7 +709,6 @@ export function EditorView(props) {
682
709
  try { localStorage.setItem(sidebarKey, JSON.stringify({ on: sidebarOn, width: sidebarW, panel: activePanel })) }
683
710
  catch (e) { /* 忽略 */ }
684
711
  }, [sidebarOn, sidebarW, activePanel, sessionId, sidebarKey])
685
-
686
712
  // 切换侧边栏(capture 抢占,避免与 DSH 全局冲突;键位随快捷键配置)
687
713
  React.useEffect(() => {
688
714
  const onKey = (e) => {
@@ -836,8 +862,10 @@ export function EditorView(props) {
836
862
  const cache = modelsRef.current
837
863
  let model = cache.get(path)
838
864
  if (!model) {
839
- model = window.monaco.editor.createModel(text ?? '', langOf(path), window.monaco.Uri.parse('edrv:///' + encodeURI(path)))
840
- cache.set(path, model)
865
+ // 注册表守卫:同 URI 已存在的 model 直接复用(HMR 换 bundle 后防重复创建抛错)
866
+ const uri = window.monaco.Uri.parse('edrv:///' + encodeURI(path))
867
+ model = window.monaco.editor.getModel(uri) || window.monaco.editor.createModel(text ?? '', langOf(path), uri)
868
+ rememberModel(cache, path, model)
841
869
  } else if (text !== undefined && model.getValue() !== text) {
842
870
  programmaticRef.current = true
843
871
  model.setValue(text)
@@ -886,6 +914,8 @@ export function EditorView(props) {
886
914
  if (saveTimerRef.current) saveTimerRef.current()
887
915
  saveTimerRef.current = schedule(() => doSave(true), 700)
888
916
  }
917
+ // @author ddj 2026年09月09号 空依赖监听只持有首帧 onEdit(active 恒 null→提前返回,星号与自动保存从未生效):每次渲染同步最新闭包
918
+ onEditRef.current = onEdit
889
919
 
890
920
  // model 同步(当前内容)
891
921
  React.useEffect(() => {
@@ -930,12 +960,14 @@ export function EditorView(props) {
930
960
 
931
961
  React.useEffect(() => () => {
932
962
  flushSave()
933
- saveViewState(active)
963
+ // 卸载即切作用域/重挂载:先把待记录光标落进历史,再存当前文件视图状态
964
+ //(经 ref 取最新闭包,修正旧实现捕获挂载时 active 的问题)
965
+ flushNavCursor()
966
+ saveViewStateRef.current?.()
934
967
  diffRendererRef.current?.dispose?.()
935
968
  hideReferencesOverlay()
936
969
  if (editorRef.current) { editorRef.current.dispose(); editorRef.current = null }
937
- for (const m of modelsRef.current.values()) m.dispose()
938
- modelsRef.current.clear()
970
+ // model 不在此销毁:跨挂载缓存按作用域存活(modelCache 切作用域时统一释放)
939
971
  for (const ctl of pdfCtlRef.current.values()) ctl.destroy()
940
972
  pdfCtlRef.current.clear()
941
973
  const root = editorRef.current && editorRef.current.getDomNode ? editorRef.current.getDomNode() : null
@@ -978,7 +1010,7 @@ export function EditorView(props) {
978
1010
  // 保存改由窗口级快捷键监听执行(键位可配置;见上方 edrv.save 监听)
979
1011
  ed.onDidChangeModelContent(() => {
980
1012
  if (!ed.getModel() || programmaticRef.current) return
981
- onEdit()
1013
+ onEditRef.current?.()
982
1014
  })
983
1015
  ed.onDidChangeCursorPosition((e) => {
984
1016
  setCursor('Ln ' + e.position.lineNumber + ', Col ' + e.position.column)
@@ -1451,7 +1483,9 @@ export function EditorView(props) {
1451
1483
  },
1452
1484
  },
1453
1485
  React.createElement('span', { style: { overflow: 'hidden', textOverflow: 'ellipsis', maxWidth: 180 } }, t.path.split(/[\\/]/).pop() || t.path),
1454
- (dirtyMap[t.path] ? React.createElement('span', { className: 'edrv-tab-dot' }) : null),
1486
+ // 未保存修改页签显示 * 星号(保存成功消失、失败持续);替换原圆点脏标记
1487
+ // @author ddj 2026年09月09号
1488
+ (dirtyMap[t.path] ? React.createElement('span', { className: 'edrv-tab-star', title: '未保存修改' }, '*') : null),
1455
1489
  React.createElement('span', { className: 'edrv-tab-x', onClick: (e) => { e.stopPropagation(); closeTab(t.path) } }, '×'))),
1456
1490
  (openInput
1457
1491
  ? React.createElement('input', { className: 'edrv-path-input', autoFocus: true, placeholder: '输入工作区相对/绝对路径,回车打开', value: pathDraft, onChange: (e) => setPathDraft(e.target.value), onKeyDown: (e) => { if (e.key === 'Enter') openPath(); if (e.key === 'Escape') setOpenInput(false) } })
@@ -1683,8 +1717,10 @@ export function EditorView(props) {
1683
1717
  const sidebarPanels = props.sidebarPanels
1684
1718
  const sidebarCtx = {
1685
1719
  sessionId,
1686
- // 当前会话工作区(sessions 快照 byId[sessionId].cwd;规则面板项目 Tab 自动匹配)
1687
- cwd: props.sessions?.list?.getSnapshot?.()?.byId?.[sessionId]?.cwd ?? null,
1720
+ // 当前会话工作区(反应式 cwd;规则面板项目 Tab 自动匹配)
1721
+ cwd: cwd ?? null,
1722
+ // 状态作用域键(工作区优先,无 cwd 回退会话;面板持久化统一走它)
1723
+ scope,
1688
1724
  openFile: (p) => openFile(p, false),
1689
1725
  openFileAt,
1690
1726
  activePath: active,
@@ -11,7 +11,7 @@
11
11
  import React from 'react'
12
12
  import { EditorView } from './EditorView.js'
13
13
  import { setSideEditorMounted } from '../sidebarBridge.js'
14
- import { parseOfficialFileAddress, resolveNavOpen } from '../officialSidebar.js'
14
+ import { editorMountEpoch, markEditorMounted, parseOfficialFileAddress, resolveNavOpen } from '../officialSidebar.js'
15
15
 
16
16
  /**
17
17
  * 官方侧边栏 Tab 正文组件(keyed slot sidebar.right.pane.tab 装配)。
@@ -32,10 +32,29 @@ export function OfficialSideTab(props) {
32
32
  }
33
33
  const revision = typeof navigation?.revision === 'number' ? navigation.revision : -1
34
34
  const revisionRef = React.useRef(-1)
35
+ // 最新 sessionId 镜像(卸载清理判读「切会话 vs 本会话关闭」用)
36
+ const sessionIdRef = React.useRef(sessionId)
37
+ sessionIdRef.current = sessionId
35
38
 
39
+ // 编辑 Tab 激活标记:挂载即激活;卸载时延迟判定「切会话(保持,供新会话自动恢复)
40
+ // vs 本会话关闭/切走其他 Tab(清除,尊重用户选择)」——通知早于会话状态落定,必须延后读;
41
+ // 纪元守卫:判定期间有新正文挂载(HMR/自动恢复)则放弃清除
36
42
  React.useEffect(() => {
37
43
  setSideEditorMounted(true)
38
- return () => setSideEditorMounted(false)
44
+ markEditorMounted()
45
+ return () => {
46
+ setSideEditorMounted(false)
47
+ const epoch = editorMountEpoch()
48
+ const self = sessionIdRef.current
49
+ const schedule = props?.schedule
50
+ const decide = () => {
51
+ if (editorMountEpoch() !== epoch) return
52
+ const current = props?.sessions?.list?.getSnapshot?.()?.current
53
+ markEditorActive(current === self)
54
+ }
55
+ if (typeof schedule === 'function') schedule(decide, 0)
56
+ else decide()
57
+ }
39
58
  }, [])
40
59
 
41
60
  React.useEffect(() => {