dsh-vscode-mode 0.1.59 → 0.1.61
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 +21 -11
- package/lib/client.js +800 -107
- package/lib/client.js.map +1 -1
- package/package.json +2 -2
- package/src/client/fileOpeners.ts +41 -1
- package/src/client/index.ts +109 -23
- package/src/client/navHistory.ts +28 -1
- package/src/client/officialSidebar.ts +352 -0
- package/src/client/paths.ts +3 -3
- package/src/client/sidebar/panels/FileExplorer.ts +14 -11
- package/src/client/sidebar/panels/SearchPanel.ts +10 -7
- package/src/client/sidebar/types.ts +2 -0
- package/src/client/state/explorerCache.ts +9 -9
- package/src/client/state/explorerEntriesCache.ts +31 -31
- package/src/client/state/modelCache.ts +77 -0
- package/src/client/state/scopeStore.ts +96 -0
- package/src/client/state/viewStateCache.ts +9 -9
- package/src/client/ui/EditorView.ts +58 -27
- package/src/client/ui/OfficialSideTab.ts +92 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* dsh-vscode-mode client — 资源管理栏目录条目缓存(SWR:旧数据即时渲染 + 后台刷新)。
|
|
3
|
-
* 与 explorerCache
|
|
3
|
+
* 与 explorerCache(展开状态持久化)互补:本文件只缓存目录条目数据,键按工作区作用域隔离。
|
|
4
4
|
* 纯函数(序列化/解析/裁剪)导出可单测;内存镜像 Map 同步读避免每次渲染 parse
|
|
5
5
|
* localStorage,落盘 300ms 防抖;配额满/隐私模式 try/catch 降级纯内存。
|
|
6
6
|
* 作者 ddj 2026-08-31
|
|
@@ -78,9 +78,9 @@ export function entriesParse(text: string | null): EntriesCacheData | null {
|
|
|
78
78
|
}
|
|
79
79
|
|
|
80
80
|
// --region 内存镜像 + 防抖落盘
|
|
81
|
-
/**
|
|
81
|
+
/** scope → 缓存镜像(同步读,避免每次渲染 parse)。 */
|
|
82
82
|
const mirrors = new Map<string, EntriesCacheData>()
|
|
83
|
-
/**
|
|
83
|
+
/** scope → 落盘防抖计时器。 */
|
|
84
84
|
const saveTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
|
85
85
|
/** 单调时钟:连续 put 的 ts 严格递增(同毫秒不并列,逐出顺序可预期)。 */
|
|
86
86
|
let clock = 0
|
|
@@ -92,38 +92,38 @@ function nextTs(): number {
|
|
|
92
92
|
}
|
|
93
93
|
|
|
94
94
|
/** localStorage 读取(不可用 → null)。 */
|
|
95
|
-
function readRaw(
|
|
95
|
+
function readRaw(scope: string): string | null {
|
|
96
96
|
try {
|
|
97
|
-
return window.localStorage.getItem(KEY_PREFIX +
|
|
97
|
+
return window.localStorage.getItem(KEY_PREFIX + scope)
|
|
98
98
|
} catch (error) {
|
|
99
99
|
return null
|
|
100
100
|
}
|
|
101
101
|
}
|
|
102
102
|
|
|
103
103
|
/** localStorage 写入(配额/隐私模式失败静默)。 */
|
|
104
|
-
function writeRaw(
|
|
104
|
+
function writeRaw(scope: string, text: string): void {
|
|
105
105
|
try {
|
|
106
|
-
window.localStorage.setItem(KEY_PREFIX +
|
|
106
|
+
window.localStorage.setItem(KEY_PREFIX + scope, text)
|
|
107
107
|
} catch (error) { /* 配额满/隐私模式忽略 */ }
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
-
/**
|
|
111
|
-
function mirrorOf(
|
|
112
|
-
const existing = mirrors.get(
|
|
110
|
+
/** 取(或建)作用域缓存镜像。 */
|
|
111
|
+
function mirrorOf(scope: string): EntriesCacheData {
|
|
112
|
+
const existing = mirrors.get(scope)
|
|
113
113
|
if (existing) return existing
|
|
114
|
-
const data = entriesParse(readRaw(
|
|
115
|
-
mirrors.set(
|
|
114
|
+
const data = entriesParse(readRaw(scope)) ?? { v: 2, root: null, dirs: {} }
|
|
115
|
+
mirrors.set(scope, data)
|
|
116
116
|
return data
|
|
117
117
|
}
|
|
118
118
|
|
|
119
119
|
/** 调度防抖落盘(重置已有计时器)。 */
|
|
120
|
-
function scheduleSave(
|
|
121
|
-
const prev = saveTimers.get(
|
|
120
|
+
function scheduleSave(scope: string): void {
|
|
121
|
+
const prev = saveTimers.get(scope)
|
|
122
122
|
if (prev) clearTimeout(prev)
|
|
123
|
-
saveTimers.set(
|
|
124
|
-
saveTimers.delete(
|
|
125
|
-
const data = mirrors.get(
|
|
126
|
-
if (data) writeRaw(
|
|
123
|
+
saveTimers.set(scope, setTimeout(() => {
|
|
124
|
+
saveTimers.delete(scope)
|
|
125
|
+
const data = mirrors.get(scope)
|
|
126
|
+
if (data) writeRaw(scope, entriesSerialize(entriesTrim(data)))
|
|
127
127
|
}, CACHE_SAVE_DEBOUNCE_MS))
|
|
128
128
|
}
|
|
129
129
|
// --endregion
|
|
@@ -131,13 +131,13 @@ function scheduleSave(sessionId: string): void {
|
|
|
131
131
|
/**
|
|
132
132
|
* 读某目录缓存条目(无 → null;展示不受新鲜度限制)。
|
|
133
133
|
* @author ddj 2026年08月31号
|
|
134
|
-
* @param
|
|
134
|
+
* @param scope 作用域键(scopeStore.workspaceScopeOf 产物)
|
|
135
135
|
* @param rel 目录相对路径('' = 根)
|
|
136
136
|
* @returns 缓存条目或 null
|
|
137
137
|
*/
|
|
138
|
-
export function entriesCacheGet(
|
|
139
|
-
if (!
|
|
140
|
-
const dir = mirrorOf(
|
|
138
|
+
export function entriesCacheGet(scope: string | undefined, rel: string): TreeEntry[] | null {
|
|
139
|
+
if (!scope) return null
|
|
140
|
+
const dir = mirrorOf(scope).dirs[rel]
|
|
141
141
|
if (!dir || !dir.entries.length) return null
|
|
142
142
|
return dir.entries
|
|
143
143
|
}
|
|
@@ -145,28 +145,28 @@ export function entriesCacheGet(sessionId: string | undefined, rel: string): Tre
|
|
|
145
145
|
/**
|
|
146
146
|
* 某目录缓存是否新鲜(预取去重用)。
|
|
147
147
|
* @author ddj 2026年08月31号
|
|
148
|
-
* @param
|
|
148
|
+
* @param scope 作用域键(scopeStore.workspaceScopeOf 产物)
|
|
149
149
|
* @param rel 目录相对路径
|
|
150
150
|
* @returns 新鲜返回 true
|
|
151
151
|
*/
|
|
152
|
-
export function entriesCacheIsFresh(
|
|
153
|
-
if (!
|
|
154
|
-
const dir = mirrorOf(
|
|
152
|
+
export function entriesCacheIsFresh(scope: string | undefined, rel: string): boolean {
|
|
153
|
+
if (!scope) return false
|
|
154
|
+
const dir = mirrorOf(scope).dirs[rel]
|
|
155
155
|
return Boolean(dir && Date.now() - dir.ts < CACHE_FRESH_MS)
|
|
156
156
|
}
|
|
157
157
|
|
|
158
158
|
/**
|
|
159
159
|
* 写入某目录缓存条目(内存镜像 + 防抖落盘;单目录条目截断)。
|
|
160
160
|
* @author ddj 2026年08月31号
|
|
161
|
-
* @param
|
|
161
|
+
* @param scope 作用域键(scopeStore.workspaceScopeOf 产物)
|
|
162
162
|
* @param rel 目录相对路径
|
|
163
163
|
* @param entries 目录条目
|
|
164
164
|
*/
|
|
165
|
-
export function entriesCachePut(
|
|
166
|
-
if (!
|
|
167
|
-
const data = mirrorOf(
|
|
165
|
+
export function entriesCachePut(scope: string | undefined, rel: string, entries: TreeEntry[]): void {
|
|
166
|
+
if (!scope) return
|
|
167
|
+
const data = mirrorOf(scope)
|
|
168
168
|
data.dirs[rel] = { ts: nextTs(), entries: entries.slice(0, CACHE_DIR_ENTRIES) }
|
|
169
169
|
const trimmed = entriesTrim(data)
|
|
170
170
|
data.dirs = trimmed.dirs
|
|
171
|
-
scheduleSave(
|
|
171
|
+
scheduleSave(scope)
|
|
172
172
|
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-vscode-mode client — Monaco model 跨挂载缓存(按工作区作用域)。
|
|
3
|
+
* 编辑区每会话重挂载会销毁实例,但文件 model(内容)与工作区绑定:
|
|
4
|
+
* 按作用域把 model 缓存在模块级,重挂载后 loadContent 命中缓存同步种出内容,
|
|
5
|
+
* 消除切换对话时的加载闪烁。切换作用域时释放上一作用域未附着编辑器的模型
|
|
6
|
+
* (附着中的仅移出缓存,交由编辑器自身生命周期,防正在显示的模型被销毁)。
|
|
7
|
+
* 纯对象容器逻辑,不依赖 monaco,可单测。
|
|
8
|
+
* 作者 ddj 2026-09-09
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
// --region 常量与状态
|
|
12
|
+
|
|
13
|
+
/** 单作用域缓存上限(超出按写入序逐出最旧,近似 LRU)。 */
|
|
14
|
+
export const MODEL_CACHE_CAP = 40
|
|
15
|
+
|
|
16
|
+
/** scope →(path → model)缓存表。 */
|
|
17
|
+
const caches = new Map<string, Map<string, unknown>>()
|
|
18
|
+
/** 当前激活作用域(切换时释放上一个)。 */
|
|
19
|
+
let activeScope: string | null = null
|
|
20
|
+
// --endregion
|
|
21
|
+
|
|
22
|
+
/** 模型是否仍附着在某个编辑器实例上(缺方法视为未附着)。 */
|
|
23
|
+
function isAttached(model: unknown): boolean {
|
|
24
|
+
const fn = (model as { isAttachedToEditor?: () => boolean } | null)?.isAttachedToEditor
|
|
25
|
+
return typeof fn === 'function' ? fn.call(model) === true : false
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** 释放模型(仅未附着编辑器时真正 dispose;附着中的保留给编辑器自毁)。 */
|
|
29
|
+
function disposeIfFree(model: unknown): void {
|
|
30
|
+
if (!model || isAttached(model)) return
|
|
31
|
+
try { (model as { dispose: () => void }).dispose() } catch { /* 已释放/异常忽略 */ }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 取(或切)某作用域的 model 缓存:同作用域重复调用复用同一 Map(跨挂载存活);
|
|
36
|
+
* 作用域变化时释放上一作用域的模型(未附着的 dispose,附着中的仅移出缓存)。
|
|
37
|
+
* @author ddj 2026年09月09号
|
|
38
|
+
* @param scope 作用域键(scopeStore.workspaceScopeOf 产物)
|
|
39
|
+
* @returns 该作用域的 path → model Map
|
|
40
|
+
*/
|
|
41
|
+
export function modelsForScope(scope: string): Map<string, unknown> {
|
|
42
|
+
const existing = caches.get(scope)
|
|
43
|
+
if (existing && activeScope === scope) return existing
|
|
44
|
+
if (activeScope !== null && activeScope !== scope) {
|
|
45
|
+
const prev = caches.get(activeScope)
|
|
46
|
+
if (prev) {
|
|
47
|
+
for (const model of prev.values()) disposeIfFree(model)
|
|
48
|
+
prev.clear()
|
|
49
|
+
caches.delete(activeScope)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
activeScope = scope
|
|
53
|
+
if (existing) return existing
|
|
54
|
+
const map = new Map<string, unknown>()
|
|
55
|
+
caches.set(scope, map)
|
|
56
|
+
return map
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 记入模型并刷新 LRU 序(先删后插保证最新),超上限逐出最旧(未附着才 dispose)。
|
|
61
|
+
* @author ddj 2026年09月09号
|
|
62
|
+
* @param map 作用域缓存 Map
|
|
63
|
+
* @param path 工作区相对路径
|
|
64
|
+
* @param model Monaco model(或测试桩)
|
|
65
|
+
*/
|
|
66
|
+
export function rememberModel(map: Map<string, unknown>, path: string, model: unknown): void {
|
|
67
|
+
if (!path || !model) return
|
|
68
|
+
map.delete(path)
|
|
69
|
+
map.set(path, model)
|
|
70
|
+
while (map.size > MODEL_CACHE_CAP) {
|
|
71
|
+
const oldest = map.keys().next().value
|
|
72
|
+
if (oldest === undefined) break
|
|
73
|
+
const evicted = map.get(oldest)
|
|
74
|
+
map.delete(oldest)
|
|
75
|
+
disposeIfFree(evicted)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -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
|
-
*
|
|
98
|
+
* 读取某作用域的视图状态缓存(localStorage 不可用 → 空映射)。
|
|
99
99
|
* @author ddj 2026年08月28号
|
|
100
|
-
* @param
|
|
100
|
+
* @param scope 作用域键(scopeStore.workspaceScopeOf 产物)
|
|
101
101
|
* @returns path → viewState 映射
|
|
102
102
|
*/
|
|
103
|
-
export function viewStatesLoad(
|
|
103
|
+
export function viewStatesLoad(scope: string): Record<string, unknown> {
|
|
104
104
|
try {
|
|
105
|
-
return parseViewStates(window.localStorage.getItem(KEY_PREFIX + String(
|
|
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
|
|
114
|
+
* @param scope 作用域键(scopeStore.workspaceScopeOf 产物)
|
|
115
115
|
* @param states path → viewState 映射
|
|
116
116
|
*/
|
|
117
|
-
export function viewStatesSave(
|
|
117
|
+
export function viewStatesSave(scope: string, states: Record<string, unknown>): void {
|
|
118
118
|
try {
|
|
119
|
-
window.localStorage.setItem(KEY_PREFIX + String(
|
|
119
|
+
window.localStorage.setItem(KEY_PREFIX + String(scope), serializeViewStates(states))
|
|
120
120
|
} catch (error) { /* 配额满/隐私模式忽略 */ }
|
|
121
121
|
}
|
|
@@ -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 {
|
|
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(
|
|
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
|
|
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 =
|
|
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,7 @@ 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 saveViewStateRef = React.useRef(null) // 视图状态保存的最新闭包(卸载清理读取,避免过期 active)
|
|
123
146
|
const diffRendererRef = React.useRef(null)
|
|
124
147
|
const layoutRef = React.useRef(layout) // ensureEditor 空依赖闭包读取的稳定布局
|
|
125
148
|
layoutRef.current = layout
|
|
@@ -163,7 +186,7 @@ export function EditorView(props) {
|
|
|
163
186
|
}
|
|
164
187
|
|
|
165
188
|
/**
|
|
166
|
-
*
|
|
189
|
+
* 保存当前活动文件的视图状态(光标/滚动/折叠)到工作区作用域缓存。
|
|
167
190
|
* @author ddj 2026年08月28号
|
|
168
191
|
* @param path 要保存的文件路径(缺省 = 当前 active)
|
|
169
192
|
*/
|
|
@@ -175,9 +198,10 @@ export function EditorView(props) {
|
|
|
175
198
|
const state = ed.saveViewState()
|
|
176
199
|
if (!state) return
|
|
177
200
|
viewStatesRef.current = upsertViewState(viewStatesRef.current, target, state)
|
|
178
|
-
viewStatesSave(
|
|
201
|
+
viewStatesSave(scope, viewStatesRef.current)
|
|
179
202
|
} catch (e) { /* 视图状态保存失败忽略 */ }
|
|
180
203
|
}
|
|
204
|
+
saveViewStateRef.current = saveViewState
|
|
181
205
|
|
|
182
206
|
/**
|
|
183
207
|
* 恢复指定文件的视图状态(内容就绪、setModel 后调用;恢复后即消费删除)。
|
|
@@ -618,13 +642,15 @@ export function EditorView(props) {
|
|
|
618
642
|
}
|
|
619
643
|
}, [sessionId, layout])
|
|
620
644
|
|
|
621
|
-
// localStorage v2
|
|
645
|
+
// localStorage v2 恢复页签(按工作区作用域;cwd 晚到 sid→ws 切换时重恢复一次)
|
|
622
646
|
React.useEffect(() => {
|
|
623
|
-
if (
|
|
624
|
-
|
|
625
|
-
|
|
647
|
+
if (!sessionId || restoredScopeRef.current === scope) return
|
|
648
|
+
restoredScopeRef.current = scope
|
|
649
|
+
// model 缓存跟随作用域(切工作区释放旧作用域模型;同工作区跨挂载复用)
|
|
650
|
+
modelsRef.current = modelsForScope(scope)
|
|
651
|
+
viewStatesRef.current = viewStatesLoad(scope)
|
|
626
652
|
try {
|
|
627
|
-
const raw = localStorage.getItem(CACHE_KEY.editor + String(
|
|
653
|
+
const raw = localStorage.getItem(CACHE_KEY.editor + String(scope))
|
|
628
654
|
if (raw) {
|
|
629
655
|
const saved = JSON.parse(raw)
|
|
630
656
|
if (Array.isArray(saved.tabs) && saved.tabs.length) {
|
|
@@ -633,25 +659,25 @@ export function EditorView(props) {
|
|
|
633
659
|
}
|
|
634
660
|
}
|
|
635
661
|
} catch (e) { /* 损坏忽略 */ }
|
|
636
|
-
}, [sessionId])
|
|
662
|
+
}, [sessionId, scope])
|
|
637
663
|
|
|
638
|
-
//
|
|
664
|
+
// 导航历史按工作区作用域隔离:同工作区切换对话保留历史,跨工作区换实例
|
|
639
665
|
React.useEffect(() => {
|
|
640
|
-
navRef.current =
|
|
666
|
+
navRef.current = navHistoryFor(scope)
|
|
641
667
|
navPendingRef.current = null
|
|
642
668
|
if (navCursorTimerRef.current) { clearTimeout(navCursorTimerRef.current); navCursorTimerRef.current = null }
|
|
643
669
|
setNavTick((v) => v + 1)
|
|
644
|
-
}, [
|
|
670
|
+
}, [scope])
|
|
645
671
|
|
|
646
672
|
React.useEffect(() => {
|
|
647
673
|
if (!sessionId) return
|
|
648
|
-
try { localStorage.setItem(CACHE_KEY.editor + String(
|
|
674
|
+
try { localStorage.setItem(CACHE_KEY.editor + String(scope), JSON.stringify({ tabs: tabs.map((t) => t.path), active })) }
|
|
649
675
|
catch (e) { /* 忽略 */ }
|
|
650
|
-
}, [tabs, active, sessionId])
|
|
676
|
+
}, [tabs, active, sessionId, scope])
|
|
651
677
|
|
|
652
678
|
// 侧边栏状态:恢复(显隐/宽度/激活面板);侧栏形态独立键(默认收起,不共享页签形态偏好)
|
|
653
679
|
// 宽度下限来自通用设置 sidebarMinWidth(默认 300),恢复值按其重夹
|
|
654
|
-
const sidebarKey = CACHE_KEY.sidebar + (layout === 'side' ? 'side.' : '') + String(
|
|
680
|
+
const sidebarKey = CACHE_KEY.sidebar + (layout === 'side' ? 'side.' : '') + String(scope)
|
|
655
681
|
React.useEffect(() => {
|
|
656
682
|
if (!sessionId) return
|
|
657
683
|
try {
|
|
@@ -682,7 +708,6 @@ export function EditorView(props) {
|
|
|
682
708
|
try { localStorage.setItem(sidebarKey, JSON.stringify({ on: sidebarOn, width: sidebarW, panel: activePanel })) }
|
|
683
709
|
catch (e) { /* 忽略 */ }
|
|
684
710
|
}, [sidebarOn, sidebarW, activePanel, sessionId, sidebarKey])
|
|
685
|
-
|
|
686
711
|
// 切换侧边栏(capture 抢占,避免与 DSH 全局冲突;键位随快捷键配置)
|
|
687
712
|
React.useEffect(() => {
|
|
688
713
|
const onKey = (e) => {
|
|
@@ -836,8 +861,10 @@ export function EditorView(props) {
|
|
|
836
861
|
const cache = modelsRef.current
|
|
837
862
|
let model = cache.get(path)
|
|
838
863
|
if (!model) {
|
|
839
|
-
|
|
840
|
-
|
|
864
|
+
// 注册表守卫:同 URI 已存在的 model 直接复用(HMR 换 bundle 后防重复创建抛错)
|
|
865
|
+
const uri = window.monaco.Uri.parse('edrv:///' + encodeURI(path))
|
|
866
|
+
model = window.monaco.editor.getModel(uri) || window.monaco.editor.createModel(text ?? '', langOf(path), uri)
|
|
867
|
+
rememberModel(cache, path, model)
|
|
841
868
|
} else if (text !== undefined && model.getValue() !== text) {
|
|
842
869
|
programmaticRef.current = true
|
|
843
870
|
model.setValue(text)
|
|
@@ -930,12 +957,14 @@ export function EditorView(props) {
|
|
|
930
957
|
|
|
931
958
|
React.useEffect(() => () => {
|
|
932
959
|
flushSave()
|
|
933
|
-
|
|
960
|
+
// 卸载即切作用域/重挂载:先把待记录光标落进历史,再存当前文件视图状态
|
|
961
|
+
//(经 ref 取最新闭包,修正旧实现捕获挂载时 active 的问题)
|
|
962
|
+
flushNavCursor()
|
|
963
|
+
saveViewStateRef.current?.()
|
|
934
964
|
diffRendererRef.current?.dispose?.()
|
|
935
965
|
hideReferencesOverlay()
|
|
936
966
|
if (editorRef.current) { editorRef.current.dispose(); editorRef.current = null }
|
|
937
|
-
|
|
938
|
-
modelsRef.current.clear()
|
|
967
|
+
// model 不在此销毁:跨挂载缓存按作用域存活(modelCache 切作用域时统一释放)
|
|
939
968
|
for (const ctl of pdfCtlRef.current.values()) ctl.destroy()
|
|
940
969
|
pdfCtlRef.current.clear()
|
|
941
970
|
const root = editorRef.current && editorRef.current.getDomNode ? editorRef.current.getDomNode() : null
|
|
@@ -1683,8 +1712,10 @@ export function EditorView(props) {
|
|
|
1683
1712
|
const sidebarPanels = props.sidebarPanels
|
|
1684
1713
|
const sidebarCtx = {
|
|
1685
1714
|
sessionId,
|
|
1686
|
-
//
|
|
1687
|
-
cwd:
|
|
1715
|
+
// 当前会话工作区(反应式 cwd;规则面板项目 Tab 自动匹配)
|
|
1716
|
+
cwd: cwd ?? null,
|
|
1717
|
+
// 状态作用域键(工作区优先,无 cwd 回退会话;面板持久化统一走它)
|
|
1718
|
+
scope,
|
|
1688
1719
|
openFile: (p) => openFile(p, false),
|
|
1689
1720
|
openFileAt,
|
|
1690
1721
|
activePath: active,
|
|
@@ -1713,7 +1744,7 @@ export function EditorView(props) {
|
|
|
1713
1744
|
}
|
|
1714
1745
|
const sideHintEl = (layout === 'tab' && sideHint && !hintDismissed)
|
|
1715
1746
|
? React.createElement('div', { className: 'edrv-side-hint' },
|
|
1716
|
-
React.createElement('span', { className: 'edrv-side-hint-text' }, '
|
|
1747
|
+
React.createElement('span', { className: 'edrv-side-hint-text' }, 'DSH 0.1.5+ 自带官方右侧侧边栏(升级即用);旧版可安装 dsh-better-sidebar 启用侧边栏编辑(对话+编辑同屏):'),
|
|
1717
1748
|
React.createElement('code', { className: 'edrv-side-hint-cmd' }, SIDEBAR_INSTALL_CMD),
|
|
1718
1749
|
React.createElement('button', { className: 'edrv-pill edrv-pill-ghost', title: '复制安装命令', onClick: copyInstallCmd }, '复制命令'),
|
|
1719
1750
|
React.createElement('button', { className: 'edrv-side-hint-close', title: '关闭提示', 'aria-label': '关闭提示', onClick: dismissHint }, '×'))
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
/**
|
|
3
|
+
* dsh-vscode-mode client — OfficialSideTab:DSH 官方右侧 Sidebar「文件编辑」Tab 正文包装。
|
|
4
|
+
* 双用途:页类型 Tab(edrvEditor)经 navigation.params(openPath/focusDiff)打开;
|
|
5
|
+
* file 资源类型 Tab(edrvEditorFile,认领 dsh-resource://file/**)经 navigation.address
|
|
6
|
+
* 解码出真实路径并透传 params.line 行号定位。派发既有 edrv:open-editor 事件复用
|
|
7
|
+
* EditorView 打开流;按会话 key 保证切会话干净重建。
|
|
8
|
+
* Tab 正文切换后重挂载时按最新导航恢复打开(对齐 better-sidebar 形态的种子行为)。
|
|
9
|
+
* 作者 ddj 2026年09月09号
|
|
10
|
+
*/
|
|
11
|
+
import React from 'react'
|
|
12
|
+
import { EditorView } from './EditorView.js'
|
|
13
|
+
import { setSideEditorMounted } from '../sidebarBridge.js'
|
|
14
|
+
import { editorMountEpoch, markEditorMounted, parseOfficialFileAddress, resolveNavOpen } from '../officialSidebar.js'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* 官方侧边栏 Tab 正文组件(keyed slot sidebar.right.pane.tab 装配)。
|
|
18
|
+
* @param props slot 框架注入(useTabInfo/sessionId)+ 装配依赖(schedule 等)
|
|
19
|
+
* @returns EditorView 侧栏形态
|
|
20
|
+
*/
|
|
21
|
+
export function OfficialSideTab(props) {
|
|
22
|
+
const sessionId = props?.sessionId
|
|
23
|
+
const useTabInfo = props?.useTabInfo
|
|
24
|
+
let navigation
|
|
25
|
+
// useTabInfo 是 slot 框架按组件实例生成的 hook,须在组件顶层无条件调用
|
|
26
|
+
if (typeof useTabInfo === 'function') {
|
|
27
|
+
try {
|
|
28
|
+
navigation = useTabInfo()?.tab?.navigation
|
|
29
|
+
} catch {
|
|
30
|
+
navigation = undefined
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
const revision = typeof navigation?.revision === 'number' ? navigation.revision : -1
|
|
34
|
+
const revisionRef = React.useRef(-1)
|
|
35
|
+
// 最新 sessionId 镜像(卸载清理判读「切会话 vs 本会话关闭」用)
|
|
36
|
+
const sessionIdRef = React.useRef(sessionId)
|
|
37
|
+
sessionIdRef.current = sessionId
|
|
38
|
+
|
|
39
|
+
// 编辑 Tab 激活标记:挂载即激活;卸载时延迟判定「切会话(保持,供新会话自动恢复)
|
|
40
|
+
// vs 本会话关闭/切走其他 Tab(清除,尊重用户选择)」——通知早于会话状态落定,必须延后读;
|
|
41
|
+
// 纪元守卫:判定期间有新正文挂载(HMR/自动恢复)则放弃清除
|
|
42
|
+
React.useEffect(() => {
|
|
43
|
+
setSideEditorMounted(true)
|
|
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
|
+
}
|
|
58
|
+
}, [])
|
|
59
|
+
|
|
60
|
+
React.useEffect(() => {
|
|
61
|
+
if (revision === revisionRef.current) return
|
|
62
|
+
revisionRef.current = revision
|
|
63
|
+
// revision 0 = 无地址打开(种子/撤销恢复),没有可执行的打开请求
|
|
64
|
+
if (revision <= 0) return
|
|
65
|
+
// file 资源 Tab:地址即文件(解码出真实路径,透传 params.line 行号定位)
|
|
66
|
+
const fileHit = parseOfficialFileAddress(navigation?.address)
|
|
67
|
+
if (fileHit) {
|
|
68
|
+
const line = navigation?.params?.line
|
|
69
|
+
window.dispatchEvent(new CustomEvent('edrv:open-editor', {
|
|
70
|
+
detail: { path: fileHit.path, line: typeof line === 'number' ? line : undefined },
|
|
71
|
+
}))
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
// 页类型 Tab:params 携带打开请求
|
|
75
|
+
const request = resolveNavOpen(navigation?.params)
|
|
76
|
+
window.dispatchEvent(new CustomEvent('edrv:open-editor', {
|
|
77
|
+
detail: { path: request.path, focusDiff: request.focusDiff },
|
|
78
|
+
}))
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
return React.createElement(EditorView, {
|
|
82
|
+
key: sessionId,
|
|
83
|
+
sessionId,
|
|
84
|
+
layout: 'side',
|
|
85
|
+
schedule: props?.schedule,
|
|
86
|
+
addToConversation: props?.addToConversation,
|
|
87
|
+
sidebarPanels: props?.sidebarPanels,
|
|
88
|
+
outlineSources: props?.outlineSources,
|
|
89
|
+
fileMenuItems: props?.fileMenuItems,
|
|
90
|
+
sessions: props?.sessions,
|
|
91
|
+
})
|
|
92
|
+
}
|