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
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-vscode-mode client — 官方右侧 Sidebar 桥:探测 DSH 0.1.5-alpha.1+ 的
|
|
3
|
+
* ctx.sidebarRightTabs / ctx.sidebarRight 服务,两段式注册「文件编辑」Tab
|
|
4
|
+
* (类型定义进注册表,正文进 keyed slot sidebar.right.pane.tab)。
|
|
5
|
+
* 可选依赖模式:服务缺失时调用方回退 better-sidebar / 中央页签;本模块不注册任何东西。
|
|
6
|
+
* 维护约定:官方侧边栏(DSH 0.1.5+)是侧边栏编辑形态的唯一维护面;
|
|
7
|
+
* dsh-better-sidebar 桥(sidebarBridge.ts)已归档,仅作旧版 DSH 回退,不再新增能力。
|
|
8
|
+
* 纯逻辑(探测/导航参数解析)可单测,注册装配仅存在于 install 路径。
|
|
9
|
+
* 作者 ddj 2026年09月09号
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { log } from './log.js'
|
|
13
|
+
import { setEnsureSideEditor } from './sidebarBridge.js'
|
|
14
|
+
|
|
15
|
+
/** 官方 Tab 类型注册表服务名(ctx.get 用;不进 inject——旧版 DSH 无此包,inject 会停等)。 */
|
|
16
|
+
export const OFFICIAL_TABS_SERVICE = 'sidebarRightTabs'
|
|
17
|
+
/** 官方导航控制器服务名(ctx.get 用)。 */
|
|
18
|
+
export const OFFICIAL_SERVICE = 'sidebarRight'
|
|
19
|
+
/** 官方 keyed slot 名(Tab 正文挂点,由官方侧边栏的 rightbar seat 声明)。 */
|
|
20
|
+
export const OFFICIAL_SLOT_NAME = 'sidebar.right.pane.tab'
|
|
21
|
+
/** 本插件 Tab 类型定义 id(官方约定用包名;正文 slot 的 key 与之相同)。 */
|
|
22
|
+
export const OFFICIAL_TAB_ID = 'dsh-vscode-mode'
|
|
23
|
+
/** 本插件 Tab 的类型判别符(ctx.sidebarRight.openTab 的 kind)。 */
|
|
24
|
+
export const OFFICIAL_TAB_KIND = 'edrvEditor'
|
|
25
|
+
/** Tab 标题(与 better-sidebar 形态一致)。 */
|
|
26
|
+
export const OFFICIAL_TAB_TITLE = '文件编辑'
|
|
27
|
+
/** 官方 file 资源地址前缀(0.1.5+ 聊天文件链接/文件树打开的统一地址语法)。 */
|
|
28
|
+
export const OFFICIAL_FILE_PREFIX = 'dsh-resource://file/'
|
|
29
|
+
/** file 地址认领的 patterns(extension 档接管官方 textpreview 的 fallback 认领,注销自动回落)。 */
|
|
30
|
+
export const OFFICIAL_FILE_PATTERN = 'dsh-resource://file/**'
|
|
31
|
+
/** file 资源类型的定义 id(正文 slot 的 key)。 */
|
|
32
|
+
export const OFFICIAL_FILE_TAB_ID = 'dsh-vscode-mode/file'
|
|
33
|
+
/** file 资源类型的 kind(与页类型 edrvEditor 区分:同 kind 只允许一份 extension)。 */
|
|
34
|
+
export const OFFICIAL_FILE_KIND = 'edrvEditorFile'
|
|
35
|
+
|
|
36
|
+
/** 官方 Tab 打开参数(官方运行时不校验的 JSON,正文经 navigation.params 读回)。 */
|
|
37
|
+
export interface OfficialTabParams {
|
|
38
|
+
openPath?: string
|
|
39
|
+
focusDiff?: boolean
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** sidebarRightTabs 服务的最小结构面(结构性探测,不 import 官方类型)。 */
|
|
43
|
+
export interface SidebarRightTabsLike {
|
|
44
|
+
register: (definition: unknown) => () => void
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** sidebarRight 服务的最小结构面(openTab 页路由 + openResource 资源路由 + 可选查询/定向打开)。 */
|
|
48
|
+
export interface SidebarRightServiceLike {
|
|
49
|
+
openTab: (kind: string, options?: { params?: unknown }) => void
|
|
50
|
+
openResource?: (address: string, options?: { params?: unknown }) => void
|
|
51
|
+
/** 挂载会话的激活 Tab(判读编辑 Tab 是否已激活;无 seat 时 undefined)。 */
|
|
52
|
+
active?: () => { kind?: string } | undefined
|
|
53
|
+
/** 向指定会话的停靠面打开页类型(store 未 adopt 时 no-op)。 */
|
|
54
|
+
openTabIn?: (sessionId: string, kind: string, options?: { params?: unknown }) => void
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** slots 服务的最小结构面(等待声明 + 注册)。 */
|
|
58
|
+
export interface SlotsLike {
|
|
59
|
+
inject: (name: string, register: () => unknown) => unknown
|
|
60
|
+
register: (spec: object, render: unknown) => unknown
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** 探测命中的一对官方服务。 */
|
|
64
|
+
export interface OfficialSidebar {
|
|
65
|
+
tabs: SidebarRightTabsLike
|
|
66
|
+
service: SidebarRightServiceLike
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* 结构化探测官方右侧 Sidebar 服务(可选依赖:缺失/降级返回 undefined)。
|
|
71
|
+
* 服务仅存在于 DSH 0.1.5-alpha.1+,探测命中 ≡ 版本判定成立。
|
|
72
|
+
* @author ddj 2026年09月09号
|
|
73
|
+
* @param ctx 客户端服务上下文
|
|
74
|
+
* @returns 注册表与控制器对,或 undefined
|
|
75
|
+
*/
|
|
76
|
+
export function detectOfficial(ctx: { get: (name: string) => unknown }): OfficialSidebar | undefined {
|
|
77
|
+
try {
|
|
78
|
+
const tabs = ctx.get(OFFICIAL_TABS_SERVICE) as SidebarRightTabsLike | undefined
|
|
79
|
+
const service = ctx.get(OFFICIAL_SERVICE) as SidebarRightServiceLike | undefined
|
|
80
|
+
if (!tabs || typeof tabs.register !== 'function') return undefined
|
|
81
|
+
if (!service || typeof service.openTab !== 'function') return undefined
|
|
82
|
+
return { tabs, service }
|
|
83
|
+
} catch {
|
|
84
|
+
return undefined
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* 从官方导航参数安全解析打开请求(缺字段/坏类型一律降级为空请求,不抛错)。
|
|
90
|
+
* @author ddj 2026年09月09号
|
|
91
|
+
* @param params openTab 传入的 params(正文经 navigation.params 读回)
|
|
92
|
+
* @returns 待打开路径与是否聚焦首个差异
|
|
93
|
+
*/
|
|
94
|
+
export function resolveNavOpen(params: unknown): { path: string | null; focusDiff: boolean } {
|
|
95
|
+
const value = (params ?? {}) as OfficialTabParams
|
|
96
|
+
const path = typeof value?.openPath === 'string' && value.openPath ? value.openPath : null
|
|
97
|
+
return { path, focusDiff: value?.focusDiff === true }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** 逐段 component 解码(失败返回 null,对齐官方 parseFileAddress 的容错语义)。 */
|
|
101
|
+
function decodeSegment(raw: string | undefined): string | null {
|
|
102
|
+
if (raw === undefined) return null
|
|
103
|
+
try {
|
|
104
|
+
return decodeURIComponent(raw)
|
|
105
|
+
} catch {
|
|
106
|
+
return null
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* 解析官方 file 资源地址(镜像 dsh-util-workspace-path 的 parseFileAddress 语法,不 import 官方包)。
|
|
112
|
+
* session 域:`dsh-resource://file/session/<sessionId>/<逐段编码的相对路径>`;
|
|
113
|
+
* absolute 域:`dsh-resource://file/absolute/<去前导 / 的绝对路径>`(POSIX 补回前导 /,
|
|
114
|
+
* Windows 盘符 `C:/` 原样,UNC 保留空首段 `//server/share`)。
|
|
115
|
+
* @author ddj 2026年09月09号
|
|
116
|
+
* @param address 候选地址
|
|
117
|
+
* @returns 文件路径(session 域附带 sessionId);非 file 地址/畸形/解码失败返回 null
|
|
118
|
+
*/
|
|
119
|
+
export function parseOfficialFileAddress(address: unknown): { path: string; sessionId?: string } | null {
|
|
120
|
+
if (typeof address !== 'string' || !address.startsWith(OFFICIAL_FILE_PREFIX)) return null
|
|
121
|
+
const [scope, ...tail] = address.slice(OFFICIAL_FILE_PREFIX.length).split('/')
|
|
122
|
+
const kind = decodeSegment(scope)
|
|
123
|
+
if (kind === 'session') {
|
|
124
|
+
const sessionId = decodeSegment(tail[0])
|
|
125
|
+
const segments = tail.slice(1).map(decodeSegment)
|
|
126
|
+
if (!sessionId || segments.length === 0 || segments.some((s) => s === null)) return null
|
|
127
|
+
const path = (segments as string[]).join('/')
|
|
128
|
+
return path ? { path, sessionId } : null
|
|
129
|
+
}
|
|
130
|
+
if (kind === 'absolute') {
|
|
131
|
+
const unc = tail[0] === '' && tail.length > 1
|
|
132
|
+
const segments = (unc ? tail.slice(1) : tail).map(decodeSegment)
|
|
133
|
+
if (segments.length === 0 || (segments as string[])[0] === '' || segments.some((s) => s === null)) return null
|
|
134
|
+
const list = segments as string[]
|
|
135
|
+
if (unc) return { path: '//' + list.join('/') }
|
|
136
|
+
if (/^[A-Za-z]:$/.test(list[0])) return { path: list.join('/') }
|
|
137
|
+
return { path: '/' + list.join('/') }
|
|
138
|
+
}
|
|
139
|
+
return null
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* file 地址的 Tab chip 标题:解码后的 basename(解码失败回退原始串切片)。
|
|
144
|
+
* @author ddj 2026年09月09号
|
|
145
|
+
* @param address file 资源地址
|
|
146
|
+
* @returns 标题文本
|
|
147
|
+
*/
|
|
148
|
+
export function officialFileTitle(address: unknown): string {
|
|
149
|
+
const parsed = parseOfficialFileAddress(address)
|
|
150
|
+
const source = parsed?.path ?? (typeof address === 'string' ? address : '')
|
|
151
|
+
const normalized = source.replace(/\\/g, '/')
|
|
152
|
+
return normalized.slice(normalized.lastIndexOf('/') + 1) || source
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** component 编码一段(对齐官方 encodeSegment:盘符冒号保持字面)。 */
|
|
156
|
+
function encodeSegment(segment: string): string {
|
|
157
|
+
return encodeURIComponent(segment).replace(/%3A/gi, ':')
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* 构造官方 file 资源地址(镜像 fileAddressFor 的分派:绝对路径→absolute 域,相对→session 域)。
|
|
162
|
+
* @author ddj 2026年09月09号
|
|
163
|
+
* @param path 绝对或工作区相对路径(两种分隔符均可)
|
|
164
|
+
* @param sessionId 会话 id(相对路径必需;绝对路径可省)
|
|
165
|
+
* @returns `dsh-resource://file/…` 地址
|
|
166
|
+
* @throws 路径为空,或相对路径缺 sessionId
|
|
167
|
+
*/
|
|
168
|
+
export function buildFileAddress(path: string, sessionId?: string): string {
|
|
169
|
+
const posix = (typeof path === 'string' ? path : '').replace(/\\/g, '/')
|
|
170
|
+
if (!posix.trim()) throw new Error('buildFileAddress:路径为空')
|
|
171
|
+
const isAbsolute = posix.startsWith('/') || posix.startsWith('//') || /^[A-Za-z]:[/\\]/.test(posix)
|
|
172
|
+
if (isAbsolute) {
|
|
173
|
+
const unc = posix.startsWith('//')
|
|
174
|
+
const body = posix.replace(/^\/+/, '')
|
|
175
|
+
return OFFICIAL_FILE_PREFIX + 'absolute/' + (unc ? '/' : '') + body.split('/').map(encodeSegment).join('/')
|
|
176
|
+
}
|
|
177
|
+
if (!sessionId) throw new Error('buildFileAddress:相对路径需要会话 id')
|
|
178
|
+
const relative = posix.replace(/^(?:\.\/)+/, '').replace(/^\/+/, '')
|
|
179
|
+
return OFFICIAL_FILE_PREFIX + 'session/' + encodeSegment(sessionId) + '/' + relative.split('/').map(encodeSegment).join('/')
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* 注册官方 Tab 正文(keyed slot,key=定义 id;slots.inject 等待 rightbar seat 声明)。
|
|
184
|
+
* @author ddj 2026年09月09号
|
|
185
|
+
* @param slots slots 服务
|
|
186
|
+
* @param key 正文注册键(= 类型定义 id)
|
|
187
|
+
* @param renderTab 正文组件(包装后的 OfficialSideTab)
|
|
188
|
+
* @returns 注销函数;注册抛错返回 null
|
|
189
|
+
*/
|
|
190
|
+
function registerBodySlot(slots: SlotsLike, key: string, renderTab: unknown): (() => void) | null {
|
|
191
|
+
try {
|
|
192
|
+
const disposer = slots.inject(OFFICIAL_SLOT_NAME, () => slots.register(
|
|
193
|
+
{ name: OFFICIAL_SLOT_NAME, key },
|
|
194
|
+
renderTab,
|
|
195
|
+
))
|
|
196
|
+
return typeof disposer === 'function' ? (disposer as () => void) : null
|
|
197
|
+
} catch (error) {
|
|
198
|
+
log.warn('官方侧边栏正文 slot 注册失败(' + String(error) + ')')
|
|
199
|
+
return null
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* 注册官方右侧 Sidebar「文件编辑」Tab 并接管打开路由(两段式 + 路由)。
|
|
205
|
+
* 调用方须把返回值挂进 ctx.effect;类型注册抛错时返回 null,由调用方回退旧形态。
|
|
206
|
+
* @author ddj 2026年09月09号
|
|
207
|
+
* @param options 装配参数(探测命中的一对服务 + slots + 正文组件 + guide 文案)
|
|
208
|
+
* @returns 卸载器(反注册类型与正文 + 撤销路由);类型注册失败返回 null
|
|
209
|
+
*/
|
|
210
|
+
export function installOfficial(options: {
|
|
211
|
+
tabs: SidebarRightTabsLike
|
|
212
|
+
service: SidebarRightServiceLike
|
|
213
|
+
slots: SlotsLike
|
|
214
|
+
renderTab: unknown
|
|
215
|
+
guide: { title: () => string; description: () => string }
|
|
216
|
+
}): (() => void) | null {
|
|
217
|
+
const { tabs, service, slots, renderTab, guide } = options
|
|
218
|
+
const open = (path: string | null, focusDiff: boolean): boolean => {
|
|
219
|
+
try {
|
|
220
|
+
// 官方语义:打开即展开侧栏;seat 未挂载(无会话画面)时抛错,降级其他打开器
|
|
221
|
+
service.openTab(OFFICIAL_TAB_KIND, { params: { openPath: path || undefined, focusDiff } })
|
|
222
|
+
} catch (error) {
|
|
223
|
+
log.warn('官方侧边栏打开失败(' + String(error) + ')')
|
|
224
|
+
return false
|
|
225
|
+
}
|
|
226
|
+
return true
|
|
227
|
+
}
|
|
228
|
+
let typeDisposer: (() => void) | null = null
|
|
229
|
+
try {
|
|
230
|
+
// extension 档(默认且最高):与官方内置类型同 kind 时接管,卸载后官方恢复
|
|
231
|
+
typeDisposer = tabs.register({
|
|
232
|
+
id: OFFICIAL_TAB_ID,
|
|
233
|
+
kind: OFFICIAL_TAB_KIND,
|
|
234
|
+
priority: 'extension',
|
|
235
|
+
title: () => OFFICIAL_TAB_TITLE,
|
|
236
|
+
guide: [{ order: 20, title: guide.title, description: guide.description }],
|
|
237
|
+
})
|
|
238
|
+
} catch (error) {
|
|
239
|
+
log.warn('官方侧边栏 Tab 类型注册失败(' + String(error) + '),回退旧形态')
|
|
240
|
+
return null
|
|
241
|
+
}
|
|
242
|
+
setEnsureSideEditor(open)
|
|
243
|
+
const bodyDisposer = registerBodySlot(slots, OFFICIAL_TAB_ID, renderTab)
|
|
244
|
+
return () => {
|
|
245
|
+
if (typeof typeDisposer === 'function') typeDisposer()
|
|
246
|
+
if (typeof bodyDisposer === 'function') bodyDisposer()
|
|
247
|
+
setEnsureSideEditor(null)
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* 注册官方 file 资源地址认领(extension 档接管 `dsh-resource://file/**`,
|
|
253
|
+
* 聊天文件链接/文件树打开改落本插件编辑器;注销后官方 textpreview 自动恢复)。
|
|
254
|
+
* 认领与否由「文件链接使用工具」设置驱动(见 fileOpeners.shouldClaimFiles)。
|
|
255
|
+
* @author ddj 2026年09月09号
|
|
256
|
+
* @param options 装配参数(探测命中的注册表 + slots + 正文组件)
|
|
257
|
+
* @returns 卸载器(反注册类型与正文);类型注册抛错返回 null(回落官方查看器)
|
|
258
|
+
*/
|
|
259
|
+
export function registerOfficialFileClaim(options: {
|
|
260
|
+
tabs: SidebarRightTabsLike
|
|
261
|
+
slots: SlotsLike
|
|
262
|
+
renderTab: unknown
|
|
263
|
+
}): (() => void) | null {
|
|
264
|
+
const { tabs, slots, renderTab } = options
|
|
265
|
+
let typeDisposer: (() => void) | null = null
|
|
266
|
+
try {
|
|
267
|
+
typeDisposer = tabs.register({
|
|
268
|
+
id: OFFICIAL_FILE_TAB_ID,
|
|
269
|
+
kind: OFFICIAL_FILE_KIND,
|
|
270
|
+
patterns: [OFFICIAL_FILE_PATTERN],
|
|
271
|
+
priority: 'extension',
|
|
272
|
+
// 畸形/解码失败地址否决:回落官方 textpreview,不产生白屏 Tab
|
|
273
|
+
canOpen: (address: string) => parseOfficialFileAddress(address) !== null,
|
|
274
|
+
title: (address: string) => officialFileTitle(address),
|
|
275
|
+
})
|
|
276
|
+
} catch (error) {
|
|
277
|
+
log.warn('官方 file 地址认领注册失败(' + String(error) + '),链接走官方查看器')
|
|
278
|
+
return null
|
|
279
|
+
}
|
|
280
|
+
const bodyDisposer = registerBodySlot(slots, OFFICIAL_FILE_TAB_ID, renderTab)
|
|
281
|
+
return () => {
|
|
282
|
+
if (typeof typeDisposer === 'function') typeDisposer()
|
|
283
|
+
if (typeof bodyDisposer === 'function') bodyDisposer()
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// --region 编辑 Tab 跨会话自动恢复
|
|
288
|
+
|
|
289
|
+
/** 编辑 Tab 是否处于激活态(正文挂载 = 激活;模块级,跨会话存活)。 */
|
|
290
|
+
let editorTabActive = false
|
|
291
|
+
/** 正文挂载纪元(每次挂载自增;延迟判定期间有新挂载则放弃清除)。 */
|
|
292
|
+
let mountEpoch = 0
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* 标记编辑 Tab 激活并推进挂载纪元(正文挂载时调用)。
|
|
296
|
+
* @author ddj 2026年09月09号
|
|
297
|
+
*/
|
|
298
|
+
export function markEditorMounted(): void {
|
|
299
|
+
mountEpoch += 1
|
|
300
|
+
markEditorActive(true)
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* 直接设置激活标记(正文卸载延迟判定用;测试 also 用作状态编排入口)。
|
|
305
|
+
* @author ddj 2026年09月09号
|
|
306
|
+
* @param active 是否激活
|
|
307
|
+
*/
|
|
308
|
+
export function markEditorActive(active: boolean): void {
|
|
309
|
+
editorTabActive = active
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* 读编辑 Tab 激活标记(会话切换时决定是否自动恢复编辑栏)。
|
|
314
|
+
* @author ddj 2026年09月09号
|
|
315
|
+
* @returns 上次标记的激活状态
|
|
316
|
+
*/
|
|
317
|
+
export function isEditorTabActive(): boolean {
|
|
318
|
+
return editorTabActive
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* 读当前挂载纪元(卸载延迟判定用:纪元已推进 = 有新正文接管,不清标记)。
|
|
323
|
+
* @author ddj 2026年09月09号
|
|
324
|
+
* @returns 当前纪元
|
|
325
|
+
*/
|
|
326
|
+
export function editorMountEpoch(): number {
|
|
327
|
+
return mountEpoch
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* 在挂载会话恢复编辑 Tab:已激活跳过(官方幂等);seat 未就绪抛错时有界重试。
|
|
332
|
+
* openTab 官方语义「打开即展开侧栏」,一次调用同时完成展开 + 激活。
|
|
333
|
+
* @author ddj 2026年09月09号
|
|
334
|
+
* @param service 官方 sidebarRight 服务(缺失/未装配直接返回)
|
|
335
|
+
* @param schedule 延时调度(ctx.timeout;重试节拍)
|
|
336
|
+
* @param attempts 剩余尝试次数(默认 10 次 × 150ms ≈ 1.5s 窗口)
|
|
337
|
+
*/
|
|
338
|
+
export function restoreEditorTab(
|
|
339
|
+
service: SidebarRightServiceLike | undefined | null,
|
|
340
|
+
schedule: (fn: () => void, ms: number) => void,
|
|
341
|
+
attempts = 10,
|
|
342
|
+
): void {
|
|
343
|
+
if (!service || typeof service.openTab !== 'function' || !editorTabActive) return
|
|
344
|
+
try {
|
|
345
|
+
if (service.active?.()?.kind === OFFICIAL_TAB_KIND) return
|
|
346
|
+
service.openTab(OFFICIAL_TAB_KIND, {})
|
|
347
|
+
} catch {
|
|
348
|
+
// seat 未挂载/未绑定(会话面尚未渲染):等一拍再试,放弃后保持现状不阻塞
|
|
349
|
+
if (attempts > 1) schedule(() => restoreEditorTab(service, schedule, attempts - 1), 150)
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
// --endregion
|
package/src/client/paths.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* 作者 ddj 2026-09-01
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
/** localStorage
|
|
9
|
+
/** localStorage 键前缀表(按功能隔离;与 scopeStore 作用域键拼接:工作区优先,无 cwd 回退会话)。 */
|
|
10
10
|
export const CACHE_KEY = {
|
|
11
11
|
/** 展开状态(explorerCache v1,当前使用)。 */
|
|
12
12
|
expanded: 'edrv.cache.explorer.v1.',
|
|
@@ -20,7 +20,7 @@ export const CACHE_KEY = {
|
|
|
20
20
|
sidebar: 'edrv.sidebar.',
|
|
21
21
|
/** 搜索面板条件(SearchPanel v1)。 */
|
|
22
22
|
search: 'edrv.search.v1.',
|
|
23
|
-
/**
|
|
23
|
+
/** 侧边栏提示已关闭标记(全局,无作用域)。 */
|
|
24
24
|
sideHint: 'edrv.side-hint-dismissed',
|
|
25
25
|
/** 「性能优化」页工作区栏目展开状态(workspaceFoldCache v1,全局不按会话)。 */
|
|
26
26
|
workspaceFold: 'edrv.ws-fold.v1.',
|
|
@@ -28,7 +28,7 @@ export const CACHE_KEY = {
|
|
|
28
28
|
rules: 'edrv.rules.v1.',
|
|
29
29
|
} as const
|
|
30
30
|
|
|
31
|
-
/**
|
|
31
|
+
/** 按前缀拼作用域键(scopeStore 产出作用域;侧边栏/编辑器等需要布局段时自行拼接后传入)。 */
|
|
32
32
|
export function cacheKey(prefix: string, sessionId: string): string {
|
|
33
33
|
return prefix + sessionId
|
|
34
34
|
}
|
|
@@ -14,6 +14,7 @@ import { ContextMenu } from '../../ui/ContextMenu.js'
|
|
|
14
14
|
import { buildTreeMenu } from '../contextMenu.js'
|
|
15
15
|
import { explorerLoad, explorerSave } from '../../state/explorerCache.js'
|
|
16
16
|
import { entriesCacheGet, entriesCacheIsFresh, entriesCachePut } from '../../state/explorerEntriesCache.js'
|
|
17
|
+
import { workspaceScopeOf } from '../../state/scopeStore.js'
|
|
17
18
|
import type { SidebarCtx } from '../types.js'
|
|
18
19
|
|
|
19
20
|
const DIR_CAP = 4000
|
|
@@ -29,6 +30,8 @@ const PREFETCH_EXCLUDED = new Set(['node_modules', '.git', '.hg', '.svn', '.pnpm
|
|
|
29
30
|
export function FileExplorer(props) {
|
|
30
31
|
const ctx = props?.ctx
|
|
31
32
|
const sessionId = ctx?.sessionId
|
|
33
|
+
// 状态作用域:展开树/条目缓存按工作区隔离(同工作区切对话恢复同一份),无 cwd 回退会话
|
|
34
|
+
const scope = ctx?.scope ?? workspaceScopeOf(null, sessionId)
|
|
32
35
|
const openFile = ctx?.openFile
|
|
33
36
|
const activePath = ctx?.activePath ?? null
|
|
34
37
|
const pendingByPath = ctx?.pendingByPath ?? {}
|
|
@@ -51,7 +54,7 @@ export function FileExplorer(props) {
|
|
|
51
54
|
const refreshRef = React.useRef(null)
|
|
52
55
|
|
|
53
56
|
/** 渲染取数:内存态 → 本地条目缓存 → null(显示加载态)。 */
|
|
54
|
-
const entriesOf = (rel) => dirsRef.current[rel] ?? entriesCacheGet(
|
|
57
|
+
const entriesOf = (rel) => dirsRef.current[rel] ?? entriesCacheGet(scope, rel) ?? null
|
|
55
58
|
|
|
56
59
|
/** 预取子目录:≤4 个、排除重型目录、缓存新鲜跳过、已加载跳过、不级联。 */
|
|
57
60
|
const prefetchDirs = (rel, entries) => {
|
|
@@ -59,7 +62,7 @@ export function FileExplorer(props) {
|
|
|
59
62
|
for (const e of entries) {
|
|
60
63
|
if (e.type !== 'directory') continue
|
|
61
64
|
if (PREFETCH_EXCLUDED.has(e.name)) continue
|
|
62
|
-
if (entriesCacheIsFresh(
|
|
65
|
+
if (entriesCacheIsFresh(scope, e.path)) continue
|
|
63
66
|
if (dirsRef.current[e.path] !== undefined) continue
|
|
64
67
|
want.push(e.path)
|
|
65
68
|
if (want.length >= PREFETCH_MAX) break
|
|
@@ -90,7 +93,7 @@ export function FileExplorer(props) {
|
|
|
90
93
|
if (res && res.ok && Array.isArray(res.entries)) {
|
|
91
94
|
setDirs((prev) => Object.assign({}, prev, { [rel]: res.entries }))
|
|
92
95
|
if (res.root) setRoot((prev) => prev || res.root)
|
|
93
|
-
entriesCachePut(
|
|
96
|
+
entriesCachePut(scope, rel, res.entries)
|
|
94
97
|
if (doPrefetch) prefetchDirs(rel, res.entries)
|
|
95
98
|
} else {
|
|
96
99
|
// 有可渲染数据时静默(旧数据可能过期但可用);全无数据才报错
|
|
@@ -151,7 +154,7 @@ export function FileExplorer(props) {
|
|
|
151
154
|
setMenu(null)
|
|
152
155
|
setRoot(null)
|
|
153
156
|
// 恢复上次展开状态(对齐 VSCode:持久化展开路径,条目缓存即时渲染、后台刷新)
|
|
154
|
-
const cached =
|
|
157
|
+
const cached = scope ? explorerLoad(scope) : null
|
|
155
158
|
const restored = cached?.expanded ?? []
|
|
156
159
|
if (cached?.root) setRoot(cached.root)
|
|
157
160
|
if (restored.length) {
|
|
@@ -164,21 +167,21 @@ export function FileExplorer(props) {
|
|
|
164
167
|
if (rel) void loadDir(rel, { prefetch: true })
|
|
165
168
|
}
|
|
166
169
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
167
|
-
}, [
|
|
170
|
+
}, [scope])
|
|
168
171
|
|
|
169
|
-
// 展开状态防抖写回 localStorage
|
|
172
|
+
// 展开状态防抖写回 localStorage(重启后恢复展开结构;按工作区作用域隔离)
|
|
170
173
|
React.useEffect(() => {
|
|
171
174
|
if (saveTimerRef.current) { clearTimeout(saveTimerRef.current); saveTimerRef.current = null }
|
|
172
|
-
if (!
|
|
175
|
+
if (!scope) return
|
|
173
176
|
saveTimerRef.current = setTimeout(() => {
|
|
174
177
|
saveTimerRef.current = null
|
|
175
178
|
const expandedList = Object.keys(expanded).filter((k) => expanded[k] === true)
|
|
176
|
-
explorerSave(
|
|
179
|
+
explorerSave(scope, { root, expanded: expandedList })
|
|
177
180
|
}, SAVE_DEBOUNCE_MS)
|
|
178
181
|
return () => {
|
|
179
182
|
if (saveTimerRef.current) { clearTimeout(saveTimerRef.current); saveTimerRef.current = null }
|
|
180
183
|
}
|
|
181
|
-
}, [expanded, root,
|
|
184
|
+
}, [expanded, root, scope])
|
|
182
185
|
|
|
183
186
|
// edrv:refresh(差异决策/回滚后):保留旧条目,强制后台重列
|
|
184
187
|
React.useEffect(() => {
|
|
@@ -190,7 +193,7 @@ export function FileExplorer(props) {
|
|
|
190
193
|
|
|
191
194
|
// 10s 轻量跟随:已展开目录后台刷新(命中 host 索引,近零成本),树跟随 agent 写入
|
|
192
195
|
React.useEffect(() => {
|
|
193
|
-
if (!
|
|
196
|
+
if (!scope) return
|
|
194
197
|
const timer = setInterval(() => {
|
|
195
198
|
const open = Object.keys(expandedRef.current).filter((k) => expandedRef.current[k] === true)
|
|
196
199
|
if (!open.length) return
|
|
@@ -198,7 +201,7 @@ export function FileExplorer(props) {
|
|
|
198
201
|
}, FOLLOW_INTERVAL_MS)
|
|
199
202
|
return () => clearInterval(timer)
|
|
200
203
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
201
|
-
}, [
|
|
204
|
+
}, [scope])
|
|
202
205
|
|
|
203
206
|
const rootName = root ? String(root).split(/[\\/]/).pop() || root : ''
|
|
204
207
|
|
|
@@ -11,6 +11,7 @@ import React from 'react'
|
|
|
11
11
|
import { rpc } from '../../rpc.js'
|
|
12
12
|
import type { SidebarCtx } from '../types.js'
|
|
13
13
|
import { CACHE_KEY } from '../../paths.js'
|
|
14
|
+
import { workspaceScopeOf } from '../../state/scopeStore.js'
|
|
14
15
|
|
|
15
16
|
const DEBOUNCE_MS = 250
|
|
16
17
|
const INCLUDE_PLACEHOLDER = '例如 *.ts, src/**/include'
|
|
@@ -44,6 +45,8 @@ function splitHit(text, start, end) {
|
|
|
44
45
|
export function SearchPanel(props) {
|
|
45
46
|
const ctx = props?.ctx
|
|
46
47
|
const sessionId = ctx?.sessionId
|
|
48
|
+
// 搜索条件按工作区作用域记忆(同工作区切对话恢复同一份),无 cwd 回退会话
|
|
49
|
+
const scope = ctx?.scope ?? workspaceScopeOf(null, sessionId)
|
|
47
50
|
const openFileAt = ctx?.openFileAt ?? (() => {})
|
|
48
51
|
const activePath = ctx?.activePath ?? null
|
|
49
52
|
const [query, setQuery] = React.useState('')
|
|
@@ -76,11 +79,11 @@ export function SearchPanel(props) {
|
|
|
76
79
|
exclude: excludeOn ? splitGlobs(excludeText) : [],
|
|
77
80
|
}
|
|
78
81
|
|
|
79
|
-
//
|
|
82
|
+
// 按作用域恢复上次查询、选项与过滤模式(VSCode 记忆搜索词)
|
|
80
83
|
React.useEffect(() => {
|
|
81
|
-
if (!
|
|
84
|
+
if (!scope) return
|
|
82
85
|
try {
|
|
83
|
-
const saved = JSON.parse(localStorage.getItem(CACHE_KEY.search + String(
|
|
86
|
+
const saved = JSON.parse(localStorage.getItem(CACHE_KEY.search + String(scope)) || 'null')
|
|
84
87
|
if (saved && typeof saved === 'object') {
|
|
85
88
|
if (typeof saved.query === 'string') setQuery(saved.query)
|
|
86
89
|
if (typeof saved.matchCase === 'boolean') setMatchCase(saved.matchCase)
|
|
@@ -93,17 +96,17 @@ export function SearchPanel(props) {
|
|
|
93
96
|
if (typeof saved.sectionOpen === 'boolean') setSectionOpen(saved.sectionOpen)
|
|
94
97
|
}
|
|
95
98
|
} catch (e) { /* 损坏忽略 */ }
|
|
96
|
-
}, [
|
|
99
|
+
}, [scope])
|
|
97
100
|
|
|
98
101
|
// 查询/选项/过滤变化 → 持久化
|
|
99
102
|
React.useEffect(() => {
|
|
100
|
-
if (!
|
|
103
|
+
if (!scope) return
|
|
101
104
|
try {
|
|
102
|
-
localStorage.setItem(CACHE_KEY.search + String(
|
|
105
|
+
localStorage.setItem(CACHE_KEY.search + String(scope), JSON.stringify({
|
|
103
106
|
query, matchCase, wholeWord, regex, includeText, excludeText, onlyActive, excludeOn, sectionOpen,
|
|
104
107
|
}))
|
|
105
108
|
} catch (e) { /* 忽略 */ }
|
|
106
|
-
}, [
|
|
109
|
+
}, [scope, query, matchCase, wholeWord, regex, includeText, excludeText, onlyActive, excludeOn, sectionOpen])
|
|
107
110
|
|
|
108
111
|
/**
|
|
109
112
|
* 执行内容搜索(请求参数从 ref 读最新值)。
|
|
@@ -12,6 +12,8 @@ export interface SidebarCtx {
|
|
|
12
12
|
sessionId?: string
|
|
13
13
|
/** 当前会话工作区 cwd(来自 sessions 快照;规则面板项目 Tab 自动匹配用)。 */
|
|
14
14
|
cwd?: string | null
|
|
15
|
+
/** 状态作用域键(工作区优先,无 cwd 回退会话;面板持久化统一走它,scopeStore 产出)。 */
|
|
16
|
+
scope?: string
|
|
15
17
|
openFile: (path: string) => void
|
|
16
18
|
/** 打开文件并跳转到指定行/列(搜索面板命中跳转;缺省仅打开)。 */
|
|
17
19
|
openFileAt?: (path: string, line?: number, column?: number) => void
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { CACHE_KEY } from '../paths.js'
|
|
8
8
|
|
|
9
|
-
/** localStorage
|
|
9
|
+
/** localStorage 键前缀(按工作区作用域隔离,无 cwd 会话回退会话键)。 */
|
|
10
10
|
const KEY_PREFIX = CACHE_KEY.expanded
|
|
11
11
|
|
|
12
12
|
/** 展开路径容量上限:超出丢弃最旧(按写入顺序近似 LRU)。 */
|
|
@@ -88,27 +88,27 @@ export function parseExplorer(text: string | null): ExplorerCacheData | null {
|
|
|
88
88
|
}
|
|
89
89
|
|
|
90
90
|
/**
|
|
91
|
-
*
|
|
91
|
+
* 读取某作用域的展开状态缓存(localStorage 不可用 → null)。
|
|
92
92
|
* @author ddj 2026年08月28号
|
|
93
|
-
* @param
|
|
93
|
+
* @param scope 作用域键(scopeStore.workspaceScopeOf 产物)
|
|
94
94
|
* @returns 缓存数据或 null
|
|
95
95
|
*/
|
|
96
|
-
export function explorerLoad(
|
|
96
|
+
export function explorerLoad(scope: string): ExplorerCacheData | null {
|
|
97
97
|
try {
|
|
98
|
-
return parseExplorer(window.localStorage.getItem(KEY_PREFIX + String(
|
|
98
|
+
return parseExplorer(window.localStorage.getItem(KEY_PREFIX + String(scope)))
|
|
99
99
|
} catch (error) {
|
|
100
100
|
return null
|
|
101
101
|
}
|
|
102
102
|
}
|
|
103
103
|
|
|
104
104
|
/**
|
|
105
|
-
*
|
|
105
|
+
* 写入某作用域的展开状态缓存(损坏/配额满 → 忽略)。
|
|
106
106
|
* @author ddj 2026年08月28号
|
|
107
|
-
* @param
|
|
107
|
+
* @param scope 作用域键(scopeStore.workspaceScopeOf 产物)
|
|
108
108
|
* @param data 缓存数据
|
|
109
109
|
*/
|
|
110
|
-
export function explorerSave(
|
|
110
|
+
export function explorerSave(scope: string, data: ExplorerCacheData): void {
|
|
111
111
|
try {
|
|
112
|
-
window.localStorage.setItem(KEY_PREFIX + String(
|
|
112
|
+
window.localStorage.setItem(KEY_PREFIX + String(scope), serializeExplorer(data))
|
|
113
113
|
} catch (error) { /* 配额满/隐私模式忽略 */ }
|
|
114
114
|
}
|