dsh-vscode-mode 0.3.1 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-vscode-mode",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "description": "DSH 上的类 VSCode 编码体验:Monaco 编辑器(文件页签/QuickOpen/状态栏,可驻 DSH 0.1.5+ 官方右侧 Sidebar 与对话同屏)+ 命令栏(Ctrl+Shift+P)与可扩展指令系统 + VS Code 兼容代码片段(.code-snippets 全局/项目,IntelliSense 展开)+ Agent 编辑差异审查(整文件差异/采纳/拒绝/归档/回滚)+ 语言服务器(LSP)智能(跳转定义/引用/hover/大纲 + VSIX 扩展市场安装),状态持久化到工作区旁车",
5
5
  "keywords": [
6
6
  "dsh",
@@ -12,27 +12,155 @@ export interface RefRange {
12
12
  endLine: number
13
13
  }
14
14
 
15
- /** 引用动作的返回状态。 */
16
- export type AddOutcome = 'ok' | 'busy' | 'unavailable'
15
+ /** 引用动作的返回状态(failed = 两条通道都没写成,内容未被改动)。 */
16
+ export type AddOutcome = 'ok' | 'busy' | 'unavailable' | 'failed'
17
17
 
18
18
  /** 引用外观类型:文件/文件夹(DSH reference source 均支持)。 */
19
19
  export type RefAppearance = 'file' | 'folder'
20
20
 
21
21
  /** 把追加引用结果映射为可读文案(ok 用 okText;busy 提示已降级纯文本;unavailable 提示不可用)。
22
- * @author ddj 2026年09月03号
22
+ * @author ddj 2026年09月03号 / 2026年09月11号
23
23
  * @param outcome 追加结果状态
24
24
  * @param okText 成功文案(如「已添加文件引用」/「已添加文件夹引用」)
25
25
  * @returns 状态栏/通知用文案
26
26
  */
27
27
  export function statusOfAdd(outcome: AddOutcome, okText: string): string {
28
28
  if (outcome === 'ok') return okText
29
- if (outcome === 'busy') return okText + '(输入框忙,已降级纯文本)'
29
+ if (outcome === 'busy') return okText + '(已降级纯文本)'
30
+ if (outcome === 'failed') return '添加引用失败(输入框忙或未就绪,请重试)'
30
31
  return '无法添加到对话(无会话或输入框不可用)'
31
32
  }
32
33
 
33
34
  /** DSH reference source 名(dsh-client-ui-reference 注册的 @file/@session 统一源)。 */
34
35
  const REF_SOURCE = 'reference'
35
36
 
37
+ /** 输入快照的引用出现项(clipboard 投影中的位置与长度,用于坐标换算)。 */
38
+ export interface OccurrenceLike {
39
+ /** clipboard 投影中的起始偏移(chip 左边界)。 */
40
+ offset?: number
41
+ /** clipboard 投影中的长度(chip 展开成 `@path` 全文的长度)。 */
42
+ length?: number
43
+ }
44
+
45
+ /** 输入快照(draft 为 clipboard 投影;occurrences 为 chip 列表)。 */
46
+ export interface CursorSnapshot {
47
+ draft: string
48
+ draftRev: number
49
+ occurrences?: readonly OccurrenceLike[]
50
+ }
51
+
52
+ /**
53
+ * 把 clipboard 投影的末尾折算成 **detect 投影**的末尾。
54
+ *
55
+ * ⚠️ 两个投影对 chip 的计长不同,混用会直接毁掉已插入的引用(真实缺陷,勿改回 `draft.length`):
56
+ * DSH 的 `$composerLayout()` 中 chip 在 detect 投影里只贡献 **1 个 U+FFFC**,
57
+ * 在 clipboard 投影里贡献 `clipboardText` 全文
58
+ * (`pushLeaf('chip', kid, '', kid.getTextContent())`)。
59
+ * 而 `insertReference(ref, span)` 的 span 走 detect 坐标并经 `selectSpan` 校验:
60
+ * `span.end > layout.detectLength` 时返回 null → 插入失败。
61
+ *
62
+ * 旧实现直接用 `draft.length`(clipboard 长度)当 detect 起点:草稿里一旦有 chip,
63
+ * 该值就超出 detect 长度 → 插入被拒 → 落入 `setDraft` 兜底,
64
+ * 而 `setDraft` 会整篇重建并剔除 U+FFFC(`text.replace(REFERENCE_PLACEHOLDER_RE, '')`),
65
+ * 于是**既有 chip 被销毁、引用退化为重复的纯文本**(即「连续添加多个引用显示异常」)。
66
+ *
67
+ * 换算:每个 chip 在 clipboard 中多算了 `length - 1`,逐个扣回即得 detect 末尾。
68
+ * 无 occurrences(旧版 DSH)时退化为 `draft.length`,与旧行为一致。
69
+ * @author ddj 2026年09月11号
70
+ * @param snapshot 输入快照
71
+ * @returns detect 投影中的文档末尾偏移
72
+ */
73
+ export function detectEndOf(snapshot: CursorSnapshot): number {
74
+ const total = String(snapshot?.draft ?? '').length
75
+ const occurrences = snapshot?.occurrences
76
+ if (!Array.isArray(occurrences) || !occurrences.length) return total
77
+ let clipped = 0
78
+ for (const occurrence of occurrences) {
79
+ const length = Number(occurrence?.length ?? 0)
80
+ if (!Number.isFinite(length) || length <= 1) continue
81
+ clipped += length - 1
82
+ }
83
+ return Math.max(0, total - clipped)
84
+ }
85
+
86
+ /** chip 列表按 clipboard 偏移升序(官方已排序,此处防御性归一,丢弃非法项)。 */
87
+ function chipsOf(snapshot: CursorSnapshot): Array<{ offset: number; length: number }> {
88
+ const occurrences = snapshot?.occurrences
89
+ if (!Array.isArray(occurrences) || !occurrences.length) return []
90
+ const out: Array<{ offset: number; length: number }> = []
91
+ for (const occurrence of occurrences) {
92
+ const offset = Number(occurrence?.offset ?? Number.NaN)
93
+ const length = Number(occurrence?.length ?? Number.NaN)
94
+ if (!Number.isFinite(offset) || !Number.isFinite(length) || length <= 1) continue
95
+ out.push({ offset: Math.max(0, offset), length })
96
+ }
97
+ return out.sort((a, b) => a.offset - b.offset)
98
+ }
99
+
100
+ /**
101
+ * detect 偏移 → clipboard 偏移(paddingAt 用:draft 是 clipboard 投影,不能直接按下标取字符)。
102
+ * 每个位于该偏移之前的 chip 在 clipboard 中多占 `length − 1`,累计补上即可。
103
+ * @author ddj 2026年09月11号
104
+ * @param snapshot 输入快照
105
+ * @param detectOffset detect 投影偏移
106
+ * @returns clipboard 投影偏移(已夹到 draft 范围内)
107
+ */
108
+ export function detectToClipboardOf(snapshot: CursorSnapshot, detectOffset: number): number {
109
+ const total = String(snapshot?.draft ?? '').length
110
+ const target = Math.max(0, Math.min(Number(detectOffset) || 0, detectEndOf(snapshot)))
111
+ let shift = 0
112
+ for (const chip of chipsOf(snapshot)) {
113
+ const chipDetect = chip.offset - shift
114
+ if (chipDetect >= target) break
115
+ shift += chip.length - 1
116
+ }
117
+ return Math.max(0, Math.min(target + shift, total))
118
+ }
119
+
120
+ /**
121
+ * 插入点两侧是否需要补空格(仅纯文本通道需要;chip 由 facade 自行补尾随空格)。
122
+ *
123
+ * 在 detect 坐标的任意位置求值,故**末尾追加的行为与旧实现逐字符一致**
124
+ * (末尾前为非空白 → 前后各补一个空格;末尾前已是空白 → 只补尾随空格),
125
+ * 同时正确支持「插在文本中间」不再贴字。
126
+ * @author ddj 2026年09月11号
127
+ * @param snapshot 输入快照
128
+ * @param detectOffset detect 投影插入点
129
+ * @returns 是否需要前导/尾随空格
130
+ */
131
+ export function paddingAt(snapshot: CursorSnapshot, detectOffset: number): { lead: boolean; tail: boolean } {
132
+ const draft = String(snapshot?.draft ?? '')
133
+ const at = detectToClipboardOf(snapshot, detectOffset)
134
+ const before = at > 0 ? draft.slice(at - 1, at) : ''
135
+ const after = at < draft.length ? draft.slice(at, at + 1) : ''
136
+ return {
137
+ lead: before !== '' && !/\s/.test(before),
138
+ // 末尾(after 为空)也补尾随空格:与旧「追加到末尾」行为一致
139
+ tail: after === '' || !/\s/.test(after),
140
+ }
141
+ }
142
+
143
+ /**
144
+ * 解析插入点(detect 坐标):优先用输入框**当前光标/选区**(`caretSpan()`,
145
+ * 官方契约即 detect 坐标,无选区时回落文档末尾),并夹到 `[0, detectLength]`。
146
+ *
147
+ * 选区非塌缩时取 `end`(右边界)—— 插入而非替换,绝不删除用户已选中的内容。
148
+ * caret 缺失/非法(旧版 facade 无该方法、抛异常、NaN 等)一律回落到文档末尾。
149
+ * @author ddj 2026年09月11号
150
+ * @param caret caretSpan() 的返回值(可为 null/非法)
151
+ * @param detectLength detect 投影文档长度
152
+ * @returns detect 投影插入偏移
153
+ */
154
+ export function insertOffsetOf(caret: unknown, detectLength: number): number {
155
+ const max = Math.max(0, Number(detectLength) || 0)
156
+ if (!caret || typeof caret !== 'object') return max
157
+ const span = caret as { start?: unknown; end?: unknown }
158
+ const raw = Number(span.end)
159
+ const value = Number.isFinite(raw) ? raw : Number(span.start)
160
+ if (!Number.isFinite(value)) return max
161
+ return Math.max(0, Math.min(value, max))
162
+ }
163
+
36
164
  /**
37
165
  * 生成 DSH @file 语法引用串:cwd 相对化、\ → /、含空白时按 @"path" 语法加引号。
38
166
  * cwd 外/无法相对化的路径回退原路径。
@@ -103,8 +231,15 @@ export function buildFileRef(
103
231
 
104
232
  /** 输入门面最小形状(运行时来自 ctx.conversation.input,避免引入新类型依赖)。 */
105
233
  export interface InputLike {
106
- state: { getSnapshot: () => { draft: string; draftRev: number } }
234
+ state: { getSnapshot: () => CursorSnapshot }
235
+ /**
236
+ * 当前光标/选区(**detect 坐标**;无选区时回落为文档末尾的塌缩 span)。
237
+ * 官方 facade 契约:`caretSpan(): { start, end }`,与 insertReference 同一坐标系。
238
+ */
239
+ caretSpan?: () => { start?: number; end?: number } | null
107
240
  insertReference: (ref: ReferenceInsertLike, span: TokenSpanLike) => boolean
241
+ /** 纯文本插入(官方 plain-text reference path;按 detect 坐标替换,不重建文档)。 */
242
+ insertText?: (text: string, span: TokenSpanLike, keepCompleting?: boolean) => boolean
108
243
  setDraft: (text: string) => void
109
244
  }
110
245
 
@@ -154,19 +289,24 @@ export function inputFor(ctx: CtxLike, sessionId: string | undefined): InputLike
154
289
  const conversation = ctx.get('conversation') as { input?: { for: (actx: unknown) => unknown } } | undefined
155
290
  const actx = sessions?.scope?.(sessionId)
156
291
  const shell = actx && conversation?.input?.for?.(actx)
157
- return (shell && typeof (shell as InputLike).setDraft === 'function') ? shell as InputLike : undefined
292
+ // 探针须是我们的**实际依赖**(insertReference),而不是会被整篇重建的 setDraft
293
+ return (shell && typeof (shell as InputLike).insertReference === 'function') ? shell as InputLike : undefined
158
294
  } catch {
159
295
  return undefined
160
296
  }
161
297
  }
162
298
 
163
- /** 取当前草稿长度与版本;无输入门面时返回 null。
164
- * @author ddj 2026年08月25号 */
165
- function draftCursor(input: InputLike | undefined): { draft: string; draftRev: number } | null {
299
+ /** 取当前草稿快照(长度/版本/chip 列表);无输入门面时返回 null。
300
+ * @author ddj 2026年08月25号 / 2026年09月11号 */
301
+ function draftCursor(input: InputLike | undefined): CursorSnapshot | null {
166
302
  try {
167
303
  const s = input?.state?.getSnapshot?.()
168
304
  if (!s) return null
169
- return { draft: String(s.draft ?? ''), draftRev: Number(s.draftRev ?? 0) }
305
+ return {
306
+ draft: String(s.draft ?? ''),
307
+ draftRev: Number(s.draftRev ?? 0),
308
+ occurrences: Array.isArray(s.occurrences) ? s.occurrences : undefined,
309
+ }
170
310
  } catch {
171
311
  return null
172
312
  }
@@ -196,9 +336,40 @@ function safeInsert(input: InputLike, reference: ReferenceInsertLike, span: Toke
196
336
  }
197
337
  }
198
338
 
339
+ /** 输入门面插入纯文本(官方 plain-text 通道;不存在或异常视作未应用)。
340
+ * @author ddj 2026年09月11号 */
341
+ function safeInsertText(input: InputLike, text: string, span: TokenSpanLike): boolean {
342
+ if (typeof input.insertText !== 'function') return false
343
+ try {
344
+ return input.insertText(text, span) === true
345
+ } catch {
346
+ return false
347
+ }
348
+ }
349
+
350
+ /**
351
+ * 读输入框当前光标(detect 坐标);门面缺失/异常返回 null(由调用方回落到文档末尾)。
352
+ * @author ddj 2026年09月11号
353
+ * @param input 输入门面
354
+ * @returns caretSpan 结果或 null
355
+ */
356
+ function caretOf(input: InputLike): { start?: number; end?: number } | null {
357
+ if (typeof input.caretSpan !== 'function') return null
358
+ try {
359
+ return input.caretSpan() ?? null
360
+ } catch {
361
+ return null
362
+ }
363
+ }
364
+
199
365
  /**
200
366
  * 创建「添加到对话」动作集(apply 阶段构建一次,随 props 传给 EditorView)。
201
- * @author ddj 2026年08月25号
367
+ *
368
+ * 插入位置 = 输入框**当前光标**(`caretSpan()`,detect 坐标;无光标提示时回落文档末尾),
369
+ * 不再固定追加到最末尾。坐标一律用 **detect 投影**(见 detectEndOf 的缺陷说明);
370
+ * 两条写入通道都失败时**不写任何内容**并返回 'failed' —— 旧实现在此处退回 `setDraft`
371
+ * 整篇重建,会把既有 chip 全部销毁(连续添加多个引用即触发)。
372
+ * @author ddj 2026年08月25号 / 2026年09月11号
202
373
  * @param ctx 客户端服务上下文(sessions + conversation)
203
374
  * @returns 动作集
204
375
  */
@@ -209,15 +380,16 @@ export function createAddToConversation(ctx: CtxLike): AddToConversation {
209
380
  const cur = draftCursor(input)
210
381
  if (!cur) return 'unavailable'
211
382
  const { reference, mention } = buildFileRef(path, cwdOf(ctx, sessionId), range, appearance)
212
- const span: TokenSpanLike = { start: cur.draft.length, end: cur.draft.length, draftRev: cur.draftRev }
213
- const ok = safeInsert(input, reference, span)
214
- if (!ok) {
215
- // 忙态(adjudicating/submitting)或 CAS 失败:降级纯文本追加,保证动作有落点。
216
- const gap = cur.draft.length > 0 && !/\s$/.test(cur.draft) ? ' ' : ''
217
- input.setDraft(cur.draft + gap + mention + ' ')
218
- return 'busy'
219
- }
220
- return 'ok'
383
+ // 光标位置(detect 坐标);caretSpan 不可用时 = 文档末尾(旧行为)
384
+ const at = insertOffsetOf(caretOf(input), detectEndOf(cur))
385
+ const span: TokenSpanLike = { start: at, end: at, draftRev: cur.draftRev }
386
+ if (safeInsert(input, reference, span)) return 'ok'
387
+ // chip 通道被拒(忙态/坐标 CAS 过期):改用纯文本通道就地替换,**保留既有 chip**。
388
+ const pad = paddingAt(cur, at)
389
+ const text = (pad.lead ? ' ' : '') + mention + (pad.tail ? ' ' : '')
390
+ if (safeInsertText(input, text, span)) return 'busy'
391
+ // 两条通道都不可用:宁可不写,也不整篇重建(那会销毁用户已插入的引用)
392
+ return 'failed'
221
393
  }
222
394
 
223
395
  return { appendReference }
@@ -19,6 +19,12 @@ export const EDITOR_ROOT_SELECTOR = '.edrv-editor-row'
19
19
  /** Monaco 编辑器实例选择器(限定在插件编辑器行内,排除官方预览等其它 Monaco)。 */
20
20
  export const EDITOR_MODEL_SELECTOR = EDITOR_ROOT_SELECTOR + ' .monaco-editor'
21
21
 
22
+ /**
23
+ * 已打开文件页签选择器(页签栏在编辑器行内;空态时无此节点)。
24
+ * 判据同样不依赖 Monaco:图片/PDF 页签没有 Monaco 实例,但仍是「有页签可关」。
25
+ */
26
+ export const TAB_SELECTOR = EDITOR_ROOT_SELECTOR + ' .edrv-tabs .edrv-tab'
27
+
22
28
  /**
23
29
  * 当前文档是否挂载了编辑器视图(无 document 的运行环境返回 false)。
24
30
  * @author ddj 2026年09月10号
@@ -40,3 +46,15 @@ export function hasEditorModel(): boolean {
40
46
  if (typeof document === 'undefined') return false
41
47
  return document.querySelector(EDITOR_MODEL_SELECTOR) !== null
42
48
  }
49
+
50
+ /**
51
+ * 当前是否已打开文件页签(「关闭当前页签」等页签级命令的可用性判据)。
52
+ * 用页签 DOM 而非 Monaco model 判定:图片/PDF 页签没有 Monaco 实例但可以关闭;
53
+ * 无页签时返回 false,让 Ctrl+F4 之类按键放行给浏览器(与 addSelectionRef「不吞键」同款约定)。
54
+ * @author ddj 2026年09月11号
55
+ * @returns 是否存在已打开的文件页签
56
+ */
57
+ export function hasOpenTabs(): boolean {
58
+ if (typeof document === 'undefined') return false
59
+ return document.querySelector(TAB_SELECTOR) !== null
60
+ }
@@ -312,7 +312,8 @@ function realDeps(ctx: unknown, overrides: Partial<OpenDeps> = {}): OpenDeps {
312
312
  openSession: (id) => service('sessions').open(id),
313
313
  reference: async (sessionId, path, appearance) => {
314
314
  const outcome: AddOutcome = await add.appendReference(sessionId, path, undefined, appearance)
315
- return outcome !== 'unavailable'
315
+ // ok/busy(纯文本降级)都算已落点;unavailable/failed 交给 settleRef 继续重试
316
+ return outcome === 'ok' || outcome === 'busy'
316
317
  },
317
318
  choose: (title, folder) => chooseWorkspace(title, folder),
318
319
  openEditor: (path, line, column) => {
@@ -14,8 +14,10 @@ export const CACHE_KEY = {
14
14
  entries: 'edrv.cache.entries.v2.',
15
15
  /** 编辑区视图状态(viewStateCache v1)。 */
16
16
  viewstate: 'edrv.cache.viewstate.v1.',
17
- /** 编辑器页签/活动文件(EditorView v2)。 */
18
- editor: 'edrv.editor.v2.',
17
+ /** 编辑器页签/活动文件(EditorView v3:页签带 pinned 固定标记)。 */
18
+ editor: 'edrv.editor.v3.',
19
+ /** 旧编辑器页签键(v2:页签为纯路径数组)——仅用于一次性回读迁移,不再写入。 */
20
+ editorLegacy: 'edrv.editor.v2.',
19
21
  /** 侧边栏状态(EditorView;按布局 side/central 追加段)。 */
20
22
  sidebar: 'edrv.sidebar.',
21
23
  /** 搜索面板条件(SearchPanel v1)。 */
@@ -0,0 +1,69 @@
1
+ /**
2
+ * dsh-vscode-mode client — 防抖保存槽(纯逻辑,可单测)。
3
+ *
4
+ * 存在意义(真实缺陷,勿把「取消」与「保存」再合并回一个句柄):
5
+ * 自动保存由 `schedule(fn, 700)` 承载,而 `schedule` = `ctx.timeout(fn, ms)` 的返回值是
6
+ * **cordis disposer**(只 `clearTimeout`,不执行 fn)。旧实现把定时器句柄直接存下,并在
7
+ * 「切页签 / 关闭文件 / 卸载」时调用它来「立即保存」—— 实际只取消了这一轮保存,
8
+ * 于是 700ms 防抖窗口内的编辑**既不落盘也不再重试**,静默丢改动
9
+ * (实测:改文件后 150ms 内关闭页签,磁盘仍是旧内容)。
10
+ *
11
+ * 故把「取消」与「立即提交」拆成两个明确入口:
12
+ * - `arm()` 重新计时(先取消上一轮,到点自动执行)
13
+ * - `flush()` 立即执行待提交的保存(先取消定时器防重复,再执行回调体)
14
+ * - `cancel()` 纯取消(确实要放弃时才用)
15
+ *
16
+ * 作者 ddj 2026年09月11号
17
+ */
18
+
19
+ /** 定时器调度器(EditorView 传入 ctx.timeout 包装;返回值为 canceller)。 */
20
+ export type Scheduler = (fn: () => void, ms: number) => (() => void) | undefined
21
+
22
+ /** 防抖保存槽句柄。 */
23
+ export interface SaveTimer {
24
+ /** 重新计时:取消上一轮并挂新定时器(到点自动执行并清槽)。 */
25
+ arm(schedule: Scheduler, delay: number, run: () => void): void
26
+ /** 立即提交待执行的保存(无待提交时为空操作);幂等。 */
27
+ flush(): void
28
+ /** 纯取消(不保存)。 */
29
+ cancel(): void
30
+ /** 当前是否有待提交的保存。 */
31
+ isPending(): boolean
32
+ }
33
+
34
+ /**
35
+ * 创建防抖保存槽。
36
+ * @author ddj 2026年09月11号
37
+ * @returns 保存槽句柄
38
+ */
39
+ export function createSaveTimer(): SaveTimer {
40
+ let pending: { cancel?: () => void; run: () => void } | null = null
41
+ return {
42
+ arm(schedule, delay, run) {
43
+ if (pending) { pending.cancel?.(); pending = null }
44
+ const cancel = schedule(() => {
45
+ // 到点:先清槽再执行,避免回调内 flush 重入同一份保存
46
+ pending = null
47
+ run()
48
+ }, delay)
49
+ pending = { cancel: typeof cancel === 'function' ? cancel : undefined, run }
50
+ },
51
+ flush() {
52
+ const slot = pending
53
+ if (!slot) return
54
+ pending = null
55
+ // 先取消定时器再执行:否则到点后会重复保存一次
56
+ slot.cancel?.()
57
+ slot.run()
58
+ },
59
+ cancel() {
60
+ const slot = pending
61
+ if (!slot) return
62
+ pending = null
63
+ slot.cancel?.()
64
+ },
65
+ isPending() {
66
+ return pending !== null
67
+ },
68
+ }
69
+ }
@@ -16,6 +16,7 @@ import { buildTreeMenu } from '../contextMenu.js'
16
16
  import { explorerLoad, explorerSave } from '../../state/explorerCache.js'
17
17
  import { entriesCacheGet, entriesCacheIsFresh, entriesCachePut } from '../../state/explorerEntriesCache.js'
18
18
  import { workspaceScopeOf } from '../../state/scopeStore.js'
19
+ import { ancestorDirsOf } from '../../tabActions.js'
19
20
  import type { SidebarCtx } from '../types.js'
20
21
 
21
22
  const DIR_CAP = 4000
@@ -23,6 +24,10 @@ const SAVE_DEBOUNCE_MS = 300
23
24
  const FOLLOW_INTERVAL_MS = 10_000
24
25
  const PREFETCH_MAX = 4
25
26
  const PREFETCH_EXCLUDED = new Set(['node_modules', '.git', '.hg', '.svn', '.pnpm', '.pnpm-store'])
27
+ /** 「在资源管理器视图中显示」高亮时长与定位重试上限(目录懒加载需等待行渲染)。 */
28
+ const REVEAL_HIGHLIGHT_MS = 2000
29
+ const REVEAL_RETRY_MAX = 6
30
+ const REVEAL_RETRY_MS = 120
26
31
 
27
32
  // --region 行图标(官方原语:目录文件夹图标 + 文件类型图标;缺失时回落纯文本)
28
33
 
@@ -76,6 +81,16 @@ function refreshIconEl() {
76
81
  }
77
82
  // --endregion
78
83
 
84
+ /**
85
+ * 转义 CSS 属性选择器的值(路径含引号/反斜杠时避免选择器语法错误)。
86
+ * @author ddj 2026年09月11号
87
+ * @param value 原始值
88
+ * @returns 可安全嵌入 `[attr="…"]` 的字符串
89
+ */
90
+ function cssEscape(value) {
91
+ return String(value ?? '').replace(/\\/g, '\\\\').replace(/"/g, '\\"')
92
+ }
93
+
79
94
  /**
80
95
  * 目录树面板主体(SWR:有缓存先渲染,无缓存才显示加载态;加载总在后台)。
81
96
  * @param props.ctx 面板共享上下文(sessionId/openFile/activePath/pendingByPath/fileMenuItems/notify)
@@ -94,6 +109,7 @@ export function FileExplorer(props) {
94
109
  const [loading, setLoading] = React.useState({})
95
110
  const [errors, setErrors] = React.useState({}) // rel → 错误文案(仅无任何数据时展示)
96
111
  const [menu, setMenu] = React.useState(null) // 右键菜单 { x, y, target }
112
+ const [revealPath, setRevealPath] = React.useState(null) // 高亮的相对路径(「在资源管理器视图中显示」)
97
113
  // 各状态 ref 镜像:定时器/监听用最新闭包
98
114
  const tokensRef = React.useRef({})
99
115
  const expandedRef = React.useRef({})
@@ -105,6 +121,10 @@ export function FileExplorer(props) {
105
121
  const saveTimerRef = React.useRef(null)
106
122
  const loadDirRef = React.useRef(null)
107
123
  const refreshRef = React.useRef(null)
124
+ const revealTimerRef = React.useRef(null) // 高亮清除计时器
125
+ const revealTryRef = React.useRef(0) // 当前定位的重试计数(行渲染需等目录加载)
126
+ const revealInTreeRef = React.useRef(null) // 定位动作最新闭包(窗口监听读取)
127
+ const treeRef = React.useRef(null) // 目录树容器(定位时按 data-edrv-path 查行)
108
128
 
109
129
  /** 渲染取数:内存态 → 本地条目缓存 → null(显示加载态)。 */
110
130
  const entriesOf = (rel) => dirsRef.current[rel] ?? entriesCacheGet(scope, rel) ?? null
@@ -179,6 +199,50 @@ export function FileExplorer(props) {
179
199
  void loadDir(rel, { prefetch: true })
180
200
  }
181
201
 
202
+ /**
203
+ * 尝试把目标行滚入可见并高亮;行尚未渲染(祖先目录仍在加载)时按上限重试。
204
+ * @author ddj 2026年09月11号
205
+ * @param path 目标相对路径
206
+ */
207
+ const tryReveal = (path) => {
208
+ const host = treeRef.current
209
+ const row = host?.querySelector?.('[data-edrv-path="' + cssEscape(path) + '"]')
210
+ if (!row) {
211
+ revealTryRef.current += 1
212
+ if (revealTryRef.current < REVEAL_RETRY_MAX) {
213
+ window.setTimeout(() => tryReveal(path), REVEAL_RETRY_MS)
214
+ }
215
+ return
216
+ }
217
+ row.scrollIntoView?.({ block: 'center' })
218
+ setRevealPath(path)
219
+ if (revealTimerRef.current) clearTimeout(revealTimerRef.current)
220
+ revealTimerRef.current = window.setTimeout(() => {
221
+ revealTimerRef.current = null
222
+ setRevealPath(null)
223
+ }, REVEAL_HIGHLIGHT_MS)
224
+ }
225
+
226
+ /**
227
+ * 在资源管理器视图中显示:展开全部祖先目录并定位高亮目标文件。
228
+ * 目录条目懒加载,故先展开祖先并触发加载,再由 tryReveal 轮询等行渲染。
229
+ * @author ddj 2026年09月11号
230
+ * @param path 目标相对路径
231
+ */
232
+ const revealInTree = (path) => {
233
+ if (!path) return
234
+ const ancestors = ancestorDirsOf(path)
235
+ revealTryRef.current = 0
236
+ setExpanded((prev) => {
237
+ const next = Object.assign({}, prev)
238
+ for (const dir of ancestors) next[dir] = true
239
+ return next
240
+ })
241
+ for (const dir of ancestors) void loadDir(dir, { prefetch: false })
242
+ tryReveal(path)
243
+ }
244
+ revealInTreeRef.current = revealInTree
245
+
182
246
  const refresh = () => {
183
247
  const keep = Object.keys(expandedRef.current).filter((k) => expandedRef.current[k] === true)
184
248
  tokensRef.current = {}
@@ -206,6 +270,7 @@ export function FileExplorer(props) {
206
270
  setErrors({})
207
271
  setMenu(null)
208
272
  setRoot(null)
273
+ setRevealPath(null)
209
274
  // 恢复上次展开状态(对齐 VSCode:持久化展开路径,条目缓存即时渲染、后台刷新)
210
275
  const cached = scope ? explorerLoad(scope) : null
211
276
  const restored = cached?.expanded ?? []
@@ -244,6 +309,20 @@ export function FileExplorer(props) {
244
309
  // eslint-disable-next-line react-hooks/exhaustive-deps
245
310
  }, [])
246
311
 
312
+ // 在资源管理器视图中显示(页签右键菜单):展开祖先目录并定位高亮
313
+ React.useEffect(() => {
314
+ const onReveal = (event) => {
315
+ const path = event?.detail?.path
316
+ if (typeof path === 'string' && path) revealInTreeRef.current?.(path)
317
+ }
318
+ window.addEventListener('edrv:reveal-path', onReveal)
319
+ return () => {
320
+ window.removeEventListener('edrv:reveal-path', onReveal)
321
+ if (revealTimerRef.current) { clearTimeout(revealTimerRef.current); revealTimerRef.current = null }
322
+ }
323
+ // eslint-disable-next-line react-hooks/exhaustive-deps
324
+ }, [])
325
+
247
326
  // 10s 轻量跟随:已展开目录后台刷新(命中 host 索引,近零成本),树跟随 agent 写入
248
327
  React.useEffect(() => {
249
328
  if (!scope) return
@@ -267,8 +346,10 @@ export function FileExplorer(props) {
267
346
  key: e.path,
268
347
  className: 'edrv-tree-row'
269
348
  + (active ? ' edrv-tree-active' : '')
270
- + (contextTarget ? ' edrv-tree-context' : ''),
349
+ + (contextTarget ? ' edrv-tree-context' : '')
350
+ + (revealPath === e.path ? ' edrv-tree-reveal' : ''),
271
351
  title: e.path,
352
+ 'data-edrv-path': e.path,
272
353
  style: { paddingLeft: 6 + depth * 14 },
273
354
  onClick: () => { if (isDir) toggle(e.path); else openFile(e.path) },
274
355
  onContextMenu: (ev) => {
@@ -338,7 +419,7 @@ export function FileExplorer(props) {
338
419
  React.createElement('span', { className: 'edrv-side-root', title: root || '' }, rootName),
339
420
  React.createElement('span', { style: { flex: 1 } }),
340
421
  React.createElement('button', { className: 'edrv-side-btn', title: '刷新目录树', onClick: refresh }, refreshIconEl())),
341
- React.createElement('div', { className: 'edrv-tree' },
422
+ React.createElement('div', { className: 'edrv-tree', ref: treeRef },
342
423
  (errorText
343
424
  ? React.createElement('div', { className: 'edrv-tree-error' },
344
425
  React.createElement('span', null, String(errorText)),
@@ -45,7 +45,8 @@ export function workspaceScopeOf(cwd: string | undefined | null, sessionId: stri
45
45
 
46
46
  /** 参与迁移的 localStorage 键前缀(编辑区全部按会话持久化的旧键)。 */
47
47
  const MIGRATE_PREFIXES: readonly string[] = [
48
- CACHE_KEY.editor,
48
+ // 页签键已升 v3;v2 键仍按会话持久化过,故迁移对象用 legacy 前缀(v3 无旧会话数据)
49
+ CACHE_KEY.editorLegacy,
49
50
  CACHE_KEY.viewstate,
50
51
  CACHE_KEY.sidebar + 'side.',
51
52
  CACHE_KEY.sidebar,
@@ -12,6 +12,9 @@
12
12
  [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号 */
13
13
  [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; }
14
14
  [data-edrv-view] .edrv-tab .edrv-tab-x:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(255,255,255,.12)); color: var(--dsw-alias-label-primary, #eee); }
15
+ /* 固定页签:📌 标记 + 免关闭按钮(固定页签不渲染 ×,仅可经右键菜单取消固定) */
16
+ [data-edrv-view] .edrv-tab .edrv-tab-pin { flex-shrink: 0; font-size: 10px; line-height: 1; opacity: .85; }
17
+ [data-edrv-view] .edrv-tab.edrv-tab-pinned { background: var(--dsw-alias-bg-layer-2, rgba(255,255,255,.04)); }
15
18
  [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; }
16
19
  [data-edrv-view] .edrv-tab-add:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(255,255,255,.06)); }
17
20
  [data-edrv-view] .edrv-pathbar { display: flex; align-items: center; gap: 8px; height: 26px; padding: 0 10px; background: var(--dsw-alias-bg-layer-1, #ffffff); border-bottom: 1px solid var(--dsw-alias-border-l1, #e0e6e8); font-size: 12px; flex-shrink: 0; overflow: hidden; }
@@ -94,14 +97,21 @@
94
97
  [data-edrv-view] .edrv-diffmenu-item:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(15,157,88,.08)); }
95
98
  [data-edrv-view] .edrv-diffmenu-item.danger { color: var(--dsw-alias-state-error-primary, #d9534f); }
96
99
  [data-edrv-view] .edrv-diffmenu-sep { height: 1px; margin: 2px 4px; background: var(--dsw-alias-border-l1, #e0e6e8); }
97
- /* 编辑区 / Tab 右键菜单(添加文件/选中内容到对话) */
98
- [data-edrv-view] .edrv-ctxmenu { position: fixed; z-index: 71; min-width: 200px; max-width: 320px; border-radius: 10px; background: var(--dsw-alias-bg-overlay, #ffffff); border: 1px solid var(--dsw-alias-border-l2, #cbd2d9); box-shadow: 0 8px 26px rgba(31,41,51,.2); padding: 4px; display: flex; flex-direction: column; gap: 1px; }
100
+ /* 编辑区 / Tab 右键菜单(页签右键菜单 + 文件树右键菜单共用)。
101
+ 页签菜单 11 + 键位提示,故限高可滚(ContextMenu 以实测尺寸做 viewport clamp)。
102
+ ⚠️ z-index 71 在既有浮层阶梯内(见文件末尾命令栏注释),不得上调越过命令栏/片段浮窗。 */
103
+ [data-edrv-view] .edrv-ctxmenu { position: fixed; z-index: 71; min-width: 220px; max-width: 360px; max-height: min(70vh, 520px); overflow-y: auto; border-radius: 10px; background: var(--dsw-alias-bg-overlay, #ffffff); border: 1px solid var(--dsw-alias-border-l2, #cbd2d9); box-shadow: 0 8px 26px rgba(31,41,51,.2); padding: 4px; display: flex; flex-direction: column; gap: 1px; }
99
104
  [data-edrv-view] .edrv-ctxmenu-item { display: flex; align-items: center; gap: 8px; font-size: 12px; line-height: 18px; padding: 7px 10px; border: none; border-radius: 6px; background: transparent; color: var(--dsw-alias-label-primary, #1f2933); cursor: pointer; text-align: left; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
100
105
  [data-edrv-view] .edrv-ctxmenu-item:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(15,157,88,.08)); }
101
106
  [data-edrv-view] .edrv-ctxmenu-item.edrv-ctxmenu-danger { color: var(--dsw-alias-state-error-primary, #d9534f); }
102
107
  [data-edrv-view] .edrv-ctxmenu-item:disabled,
103
108
  [data-edrv-view] .edrv-ctxmenu-item.edrv-ctxmenu-disabled { color: var(--dsw-alias-label-disabled, #b0b7c0); cursor: default; background: transparent; }
104
109
  [data-edrv-view] .edrv-ctxmenu-sep { height: 1px; margin: 3px 4px; background: var(--dsw-alias-border-l1, #e0e6e8); }
110
+ /* 菜单项文案(弹性占宽,超出省略)与右侧键位提示(不缩放,等宽字体) */
111
+ [data-edrv-view] .edrv-ctxmenu-label { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
112
+ [data-edrv-view] .edrv-ctxmenu-hint { flex: 0 0 auto; padding-left: 18px; color: var(--dsw-alias-label-tertiary, #9aa5b1); font-family: var(--ds-font-family-code, ui-monospace, monospace); font-size: 11px; }
113
+ [data-edrv-view] .edrv-ctxmenu-item:disabled .edrv-ctxmenu-hint,
114
+ [data-edrv-view] .edrv-ctxmenu-item.edrv-ctxmenu-disabled .edrv-ctxmenu-hint { color: var(--dsw-alias-label-disabled, #b0b7c0); }
105
115
  [data-edrv-view] .edrv-diffbar-body { max-height: 130px; max-width: min(560px, calc(100vw - 40px)); overflow: auto; padding: 4px 8px 8px; display: flex; flex-direction: column; gap: 4px; border-top: 1px solid var(--dsw-alias-border-l1, #e0e6e8); }
106
116
  [data-edrv-view] .edrv-diffrow { display: flex; align-items: center; gap: 8px; padding: 3px 6px; border: 1px solid var(--dsw-alias-border-l1, #e0e6e8); border-radius: 6px; background: var(--dsw-alias-bg-layer-2, #f0f4f4); cursor: pointer; flex-wrap: wrap; }
107
117
  [data-edrv-view] .edrv-diffrow:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(15,157,88,.08)); }
@@ -247,6 +257,9 @@
247
257
  [data-edrv-view] .edrv-tree-row:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(15,157,88,.08)); }
248
258
  [data-edrv-view] .edrv-tree-row.edrv-tree-active { background: var(--dsw-alias-interactive-bg-hover, rgba(15,157,88,.14)); }
249
259
  [data-edrv-view] .edrv-tree-row.edrv-tree-context { box-shadow: inset 0 0 0 1px var(--dsw-alias-brand-primary, #0f9d58); background: var(--dsw-alias-interactive-bg-hover, rgba(15,157,88,.12)); }
260
+ /* 「在资源管理器视图中显示」的定位高亮(短时淡出,区别于常驻的 active 态) */
261
+ [data-edrv-view] .edrv-tree-row.edrv-tree-reveal { box-shadow: inset 0 0 0 1px var(--dsw-alias-brand-primary, #0f9d58); background: var(--dsw-alias-interactive-bg-hover, rgba(15,157,88,.18)); animation: edrv-tree-reveal-fade 2s ease-out; }
262
+ @keyframes edrv-tree-reveal-fade { from { background: var(--dsw-alias-interactive-bg-hover, rgba(15,157,88,.32)); } to { background: var(--dsw-alias-interactive-bg-hover, rgba(15,157,88,.18)); } }
250
263
  [data-edrv-view] .edrv-tree-chev { width: 12px; flex-shrink: 0; color: var(--dsw-alias-label-tertiary, #9aa5b1); font-size: 10px; text-align: center; }
251
264
  [data-edrv-view] .edrv-tree-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
252
265
  [data-edrv-view] .edrv-tree-name.edrv-tree-dim { color: var(--dsw-alias-label-tertiary, #9aa5b1); }