dsh-vscode-mode 0.3.1 → 0.3.3

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.
@@ -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; }
@@ -26,6 +29,8 @@
26
29
  [data-edrv-view] .edrv-search-pop { position: absolute; top: calc(100% + 4px); right: 8px; width: min(460px, calc(100vw - 24px)); max-height: 300px; overflow: auto; background: var(--dsw-alias-bg-overlay, #1c1c1c); border: 1px solid var(--dsw-alias-border-l2, #555); border-radius: 10px; box-shadow: 0 6px 20px rgba(0,0,0,.4); z-index: 60; padding: 4px; }
27
30
  [data-edrv-view] .edrv-search-item { display: flex; flex-direction: column; gap: 1px; padding: 5px 8px; border-radius: 6px; cursor: pointer; }
28
31
  [data-edrv-view] .edrv-search-item:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(255,255,255,.07)); }
32
+ /* 键盘高亮项(↑↓ 选择):与 hover 同特异性但声明在后,故选中态稳定可辨 @author ddj 2026年09月11号 */
33
+ [data-edrv-view] .edrv-search-item.edrv-search-sel { background: var(--dsw-alias-interactive-bg-hover, rgba(255,255,255,.14)); }
29
34
  [data-edrv-view] .edrv-search-item .n { color: var(--dsw-alias-label-primary, #eee); font-size: 12px; word-break: break-all; }
30
35
  [data-edrv-view] .edrv-search-item .d { color: var(--dsw-alias-label-tertiary, #888); font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
31
36
  [data-edrv-view] .edrv-search-empty { padding: 8px; font-size: 12px; color: var(--dsw-alias-label-tertiary, #888); }
@@ -94,14 +99,21 @@
94
99
  [data-edrv-view] .edrv-diffmenu-item:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(15,157,88,.08)); }
95
100
  [data-edrv-view] .edrv-diffmenu-item.danger { color: var(--dsw-alias-state-error-primary, #d9534f); }
96
101
  [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; }
102
+ /* 编辑区 / Tab 右键菜单(页签右键菜单 + 文件树右键菜单共用)。
103
+ 页签菜单 11 + 键位提示,故限高可滚(ContextMenu 以实测尺寸做 viewport clamp)。
104
+ ⚠️ z-index 71 在既有浮层阶梯内(见文件末尾命令栏注释),不得上调越过命令栏/片段浮窗。 */
105
+ [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
106
  [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
107
  [data-edrv-view] .edrv-ctxmenu-item:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(15,157,88,.08)); }
101
108
  [data-edrv-view] .edrv-ctxmenu-item.edrv-ctxmenu-danger { color: var(--dsw-alias-state-error-primary, #d9534f); }
102
109
  [data-edrv-view] .edrv-ctxmenu-item:disabled,
103
110
  [data-edrv-view] .edrv-ctxmenu-item.edrv-ctxmenu-disabled { color: var(--dsw-alias-label-disabled, #b0b7c0); cursor: default; background: transparent; }
104
111
  [data-edrv-view] .edrv-ctxmenu-sep { height: 1px; margin: 3px 4px; background: var(--dsw-alias-border-l1, #e0e6e8); }
112
+ /* 菜单项文案(弹性占宽,超出省略)与右侧键位提示(不缩放,等宽字体) */
113
+ [data-edrv-view] .edrv-ctxmenu-label { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
114
+ [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; }
115
+ [data-edrv-view] .edrv-ctxmenu-item:disabled .edrv-ctxmenu-hint,
116
+ [data-edrv-view] .edrv-ctxmenu-item.edrv-ctxmenu-disabled .edrv-ctxmenu-hint { color: var(--dsw-alias-label-disabled, #b0b7c0); }
105
117
  [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
118
  [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
119
  [data-edrv-view] .edrv-diffrow:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(15,157,88,.08)); }
@@ -247,6 +259,9 @@
247
259
  [data-edrv-view] .edrv-tree-row:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(15,157,88,.08)); }
248
260
  [data-edrv-view] .edrv-tree-row.edrv-tree-active { background: var(--dsw-alias-interactive-bg-hover, rgba(15,157,88,.14)); }
249
261
  [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)); }
262
+ /* 「在资源管理器视图中显示」的定位高亮(短时淡出,区别于常驻的 active 态) */
263
+ [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; }
264
+ @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
265
  [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
266
  [data-edrv-view] .edrv-tree-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
252
267
  [data-edrv-view] .edrv-tree-name.edrv-tree-dim { color: var(--dsw-alias-label-tertiary, #9aa5b1); }
@@ -0,0 +1,326 @@
1
+ /**
2
+ * dsh-vscode-mode client — 文件页签操作纯函数(关闭族 / 固定 / 路径推导)。
3
+ * 页签状态是 EditorView 的 `{ path, pinned }[]`;本模块不触 React/DOM,可 node 单测,
4
+ * 菜单动作(tabMenu)与编辑器(EditorView)共用同一份语义,避免规则散落两处。
5
+ *
6
+ * 核心约定 —— **固定 = 保护**:
7
+ * 「关闭其他 / 关闭右侧 / 关闭已保存 / 全部关闭」一律不关固定页签;
8
+ * 只有单项「关闭」可以显式关掉一个固定页签(用户明确点了它)。
9
+ *
10
+ * 关闭后的活动页签:原活动页签未被关 → 不变;被关 → 右侧优先、否则左侧末位(VS Code 行为)。
11
+ * 作者 ddj 2026年09月11号
12
+ */
13
+
14
+ /** 页签的最小形状(EditorView 持有超集)。 */
15
+ export interface TabLike {
16
+ path: string
17
+ pinned?: boolean
18
+ }
19
+
20
+ /** 关闭操作结果(tabs 未变化时返回原引用,React 可跳过重渲染)。 */
21
+ export interface CloseResult {
22
+ tabs: TabLike[]
23
+ active: string | null
24
+ }
25
+
26
+ // --region 关闭族
27
+
28
+ /**
29
+ * 关闭指定集合:活动页签被关时按「右侧优先、否则左侧末位」补位。
30
+ * @author ddj 2026年09月11号
31
+ * @param tabs 当前页签
32
+ * @param closing 待关闭路径集合
33
+ * @param active 当前活动页签路径
34
+ * @returns 剩余页签与新的活动页签
35
+ */
36
+ export function applyClose(tabs: TabLike[], closing: Set<string>, active: string | null): CloseResult {
37
+ const remaining = tabs.filter((tab) => !closing.has(tab.path))
38
+ if (remaining.length === tabs.length) return { tabs, active }
39
+ if (active && !closing.has(active)) return { tabs: remaining, active }
40
+ return { tabs: remaining, active: pickNeighbor(tabs, remaining, active) }
41
+ }
42
+
43
+ /**
44
+ * 关闭其他:保留目标页签与全部固定页签。
45
+ * @author ddj 2026年09月11号
46
+ * @param tabs 当前页签
47
+ * @param target 保留的目标页签路径
48
+ * @param active 当前活动页签路径
49
+ * @returns 关闭结果
50
+ */
51
+ export function closeOthers(tabs: TabLike[], target: string, active: string | null): CloseResult {
52
+ return applyClose(tabs, closingExcept(tabs, (tab) => tab.path === target || tab.pinned === true), active)
53
+ }
54
+
55
+ /**
56
+ * 关闭右侧:关闭目标之后、未被固定的页签。
57
+ * @author ddj 2026年09月11号
58
+ * @param tabs 当前页签
59
+ * @param target 基准页签路径(其右侧才关)
60
+ * @param active 当前活动页签路径
61
+ * @returns 关闭结果
62
+ */
63
+ export function closeRight(tabs: TabLike[], target: string, active: string | null): CloseResult {
64
+ const at = tabs.findIndex((tab) => tab.path === target)
65
+ const closing = new Set<string>()
66
+ for (let i = at + 1; i > 0 && i < tabs.length; i += 1) {
67
+ if (tabs[i].pinned !== true) closing.add(tabs[i].path)
68
+ }
69
+ return applyClose(tabs, closing, active)
70
+ }
71
+
72
+ /**
73
+ * 关闭已保存:关闭未固定且无未保存修改的页签。
74
+ * @author ddj 2026年09月11号
75
+ * @param tabs 当前页签
76
+ * @param dirty 路径 → 是否有未保存修改
77
+ * @param active 当前活动页签路径
78
+ * @returns 关闭结果
79
+ */
80
+ export function closeSaved(tabs: TabLike[], dirty: Record<string, boolean>, active: string | null): CloseResult {
81
+ return applyClose(tabs, closingExcept(tabs, (tab) => tab.pinned === true || dirty[tab.path] === true), active)
82
+ }
83
+
84
+ /**
85
+ * 全部关闭:仅关未固定页签(全部固定时无操作)。
86
+ * @author ddj 2026年09月11号
87
+ * @param tabs 当前页签
88
+ * @param active 当前活动页签路径
89
+ * @returns 关闭结果
90
+ */
91
+ export function closeAll(tabs: TabLike[], active: string | null): CloseResult {
92
+ return applyClose(tabs, closingExcept(tabs, (tab) => tab.pinned === true), active)
93
+ }
94
+
95
+ /**
96
+ * 反选可关闭集合(保留 keep 命中的页签,其余进关闭集)。
97
+ * @author ddj 2026年09月11号
98
+ * @param tabs 当前页签
99
+ * @param keep 保留判定
100
+ * @returns 待关闭路径集合
101
+ */
102
+ function closingExcept(tabs: TabLike[], keep: (tab: TabLike) => boolean): Set<string> {
103
+ const closing = new Set<string>()
104
+ for (const tab of tabs) if (!keep(tab)) closing.add(tab.path)
105
+ return closing
106
+ }
107
+
108
+ /**
109
+ * 选补位页签:先向右找最近的存活页签,再向左找,都没有则取剩余首个。
110
+ * @author ddj 2026年09月11号
111
+ * @param tabs 关闭前的页签(用于取原索引)
112
+ * @param remaining 关闭后剩余的页签
113
+ * @param active 被关掉的活动页签路径
114
+ * @returns 补位页签路径;无剩余返回 null
115
+ */
116
+ function pickNeighbor(tabs: TabLike[], remaining: TabLike[], active: string | null): string | null {
117
+ const first = remaining[0]
118
+ if (!first) return null
119
+ const at = tabs.findIndex((tab) => tab.path === active)
120
+ if (at < 0) return first.path
121
+ const alive = new Set(remaining.map((tab) => tab.path))
122
+ for (let i = at + 1; i < tabs.length; i += 1) if (alive.has(tabs[i].path)) return tabs[i].path
123
+ for (let i = at - 1; i >= 0; i -= 1) if (alive.has(tabs[i].path)) return tabs[i].path
124
+ return first.path
125
+ }
126
+ // --endregion
127
+
128
+ // --region 固定与插入
129
+
130
+ /**
131
+ * 切换页签固定态:固定页签整体前移(两侧各自保持相对顺序)。
132
+ * @author ddj 2026年09月11号
133
+ * @param tabs 当前页签
134
+ * @param target 目标页签路径
135
+ * @returns 重排后的页签(目标不存在时原样返回)
136
+ */
137
+ export function togglePin(tabs: TabLike[], target: string): TabLike[] {
138
+ if (!tabs.some((tab) => tab.path === target)) return tabs
139
+ const next = tabs.map((tab) => (
140
+ tab.path === target ? { path: tab.path, pinned: tab.pinned !== true } : tab
141
+ ))
142
+ return pinnedFirst(next)
143
+ }
144
+
145
+ /**
146
+ * 新增页签:已存在则原样返回;否则插到最后一个固定页签之后。
147
+ * @author ddj 2026年09月11号
148
+ * @param tabs 当前页签
149
+ * @param path 新页签路径
150
+ * @returns 新页签数组
151
+ */
152
+ export function insertTab(tabs: TabLike[], path: string): TabLike[] {
153
+ if (tabs.some((tab) => tab.path === path)) return tabs
154
+ // 缺省追加到末尾;存在固定页签时插到最后一个固定页签之后
155
+ let at = tabs.length
156
+ for (let i = tabs.length - 1; i >= 0; i -= 1) {
157
+ if (tabs[i].pinned !== true) continue
158
+ at = i + 1
159
+ break
160
+ }
161
+ return tabs.slice(0, at).concat([{ path }], tabs.slice(at))
162
+ }
163
+
164
+ /**
165
+ * 固定分区:固定页签在前,两侧各自保持原相对顺序。
166
+ * @author ddj 2026年09月11号
167
+ * @param tabs 页签
168
+ * @returns 重排后的页签
169
+ */
170
+ function pinnedFirst(tabs: TabLike[]): TabLike[] {
171
+ return tabs.filter(isPinned).concat(tabs.filter((tab) => !isPinned(tab)))
172
+ }
173
+
174
+ /**
175
+ * 是否固定页签。
176
+ * @author ddj 2026年09月11号
177
+ * @param tab 页签
178
+ * @returns 是否固定
179
+ */
180
+ function isPinned(tab: TabLike): boolean {
181
+ return tab.pinned === true
182
+ }
183
+
184
+ /**
185
+ * 归一化持久化的页签数据:兼容旧版 `string[]`、去重、固定分区。
186
+ * 损坏项(非字符串/非对象/无 path)直接丢弃。
187
+ * @author ddj 2026年09月11号
188
+ * @param raw localStorage 解析结果(任意形状)
189
+ * @returns 归一化后的页签数组
190
+ */
191
+ export function normalizeTabs(raw: unknown): TabLike[] {
192
+ if (!Array.isArray(raw)) return []
193
+ const seen = new Set<string>()
194
+ const out: TabLike[] = []
195
+ for (const item of raw) {
196
+ const tab = tabOf(item)
197
+ if (!tab || seen.has(tab.path)) continue
198
+ seen.add(tab.path)
199
+ out.push(tab)
200
+ }
201
+ return pinnedFirst(out)
202
+ }
203
+
204
+ /**
205
+ * 解释单条持久化页签:字符串 = 路径(旧版),对象取 path/pinned。
206
+ * @author ddj 2026年09月11号
207
+ * @param item 原始条目
208
+ * @returns 页签或 null(无法解释)
209
+ */
210
+ function tabOf(item: unknown): TabLike | null {
211
+ if (typeof item === 'string') return item ? { path: item } : null
212
+ if (!item || typeof item !== 'object') return null
213
+ const raw = item as { path?: unknown; pinned?: unknown }
214
+ if (typeof raw.path !== 'string' || !raw.path) return null
215
+ return raw.pinned === true ? { path: raw.path, pinned: true } : { path: raw.path }
216
+ }
217
+
218
+ /**
219
+ * 选活动页签:恢复值仍存在则用它,否则取首个(无页签返回 null)。
220
+ * @author ddj 2026年09月11号
221
+ * @param tabs 页签
222
+ * @param wanted 持久化的活动路径
223
+ * @returns 活动页签路径
224
+ */
225
+ export function pickActive(tabs: TabLike[], wanted: unknown): string | null {
226
+ const first = tabs[0]
227
+ if (!first) return null
228
+ if (typeof wanted === 'string' && tabs.some((tab) => tab.path === wanted)) return wanted
229
+ return first.path
230
+ }
231
+ // --endregion
232
+
233
+ // --region 路径推导
234
+
235
+ /** Windows 盘符 / UNC / POSIX 根:视为工作区外的绝对路径。 */
236
+ const ABSOLUTE_RE = /^(?:[a-z]:[\\/]|\\\\|\/)/i
237
+
238
+ /**
239
+ * 是否绝对路径(工作区外)。
240
+ * @author ddj 2026年09月11号
241
+ * @param path 路径
242
+ * @returns 是否绝对路径
243
+ */
244
+ export function isAbsolutePath(path: string): boolean {
245
+ return ABSOLUTE_RE.test(String(path ?? ''))
246
+ }
247
+
248
+ /**
249
+ * 是否可在资源管理器视图中定位(工作区相对且不含 `..` 上跳段)。
250
+ * @author ddj 2026年09月11号
251
+ * @param path 路径
252
+ * @returns 是否可定位
253
+ */
254
+ export function isTreeRevealable(path: string): boolean {
255
+ const text = String(path ?? '').trim()
256
+ if (!text || isAbsolutePath(text)) return false
257
+ return !text.replace(/\\/g, '/').split('/').includes('..')
258
+ }
259
+
260
+ /**
261
+ * 相对路径:cwd 内路径去掉工作区前缀;工作区外/无 cwd 回退规范化原路径。
262
+ * @author ddj 2026年09月11号
263
+ * @param path 路径
264
+ * @param cwd 会话工作区目录(可空)
265
+ * @returns 展示/复制用的相对路径
266
+ */
267
+ export function relativeOf(path: string, cwd?: string | null): string {
268
+ const target = normalizeSlashes(path)
269
+ const base = normalizeSlashes(cwd).replace(/\/+$/, '')
270
+ if (base && target.toLowerCase().startsWith((base + '/').toLowerCase())) return target.slice(base.length + 1)
271
+ return target
272
+ }
273
+
274
+ /**
275
+ * 绝对路径:相对路径按 cwd 拼接;已是绝对路径或没有 cwd 时回退规范化原路径。
276
+ * @author ddj 2026年09月11号
277
+ * @param path 路径
278
+ * @param cwd 会话工作区目录(可空)
279
+ * @returns 展示/复制用的绝对路径
280
+ */
281
+ export function absoluteOf(path: string, cwd?: string | null): string {
282
+ const target = normalizeSlashes(path)
283
+ if (isAbsolutePath(target)) return target
284
+ const base = normalizeSlashes(cwd).replace(/\/+$/, '')
285
+ return base ? base + '/' + target : target
286
+ }
287
+
288
+ /**
289
+ * 反斜杠归一为斜杠(页签/引用/剪贴板统一用正斜杠,与既有 mentionOf 口径一致)。
290
+ * @author ddj 2026年09月11号
291
+ * @param path 路径
292
+ * @returns 正斜杠路径
293
+ */
294
+ function normalizeSlashes(path: string | null | undefined): string {
295
+ return String(path ?? '').replace(/\\/g, '/')
296
+ }
297
+
298
+ /**
299
+ * 文件路径的祖先目录(由浅到深):`a/b/c.ts` → `['a', 'a/b']`。
300
+ * 根级文件、绝对路径与含 `..` 的路径返回空数组。
301
+ * @author ddj 2026年09月11号
302
+ * @param path 工作区相对路径
303
+ * @returns 祖先目录相对路径数组
304
+ */
305
+ export function ancestorDirsOf(path: string): string[] {
306
+ if (!isTreeRevealable(path)) return []
307
+ const segs = normalizeSlashes(path).split('/').filter(Boolean)
308
+ const out: string[] = []
309
+ for (let i = 1; i < segs.length; i += 1) {
310
+ const prev = segs[i - 1]
311
+ out.push(out.length ? out[out.length - 1] + '/' + prev : prev)
312
+ }
313
+ return out
314
+ }
315
+
316
+ /**
317
+ * 文件名(页签/状态栏展示与 tooltip 用)。
318
+ * @author ddj 2026年09月11号
319
+ * @param path 路径
320
+ * @returns 末段文件名;空路径返回空串
321
+ */
322
+ export function baseNameOf(path: string): string {
323
+ const text = normalizeSlashes(path)
324
+ return text.split('/').filter(Boolean).pop() ?? ''
325
+ }
326
+ // --endregion
@@ -0,0 +1,107 @@
1
+ /**
2
+ * dsh-vscode-mode client — 文件页签右键菜单模型(纯函数,可单测)。
3
+ * 菜单是「数据」而非 JSX:条目顺序、分组分隔线、禁用规则与键位提示全部在此收敛,
4
+ * EditorView 只把结果映射成 ContextMenu 的 entries 并派发动作。
5
+ *
6
+ * 与参考图(VS Code / CodeBuddy 页签右键菜单)的对齐口径:
7
+ * 保留其分组顺序(对话 / 关闭族 / 路径 / 定位 / 固定),**省略**本架构无法实现的
8
+ * 「向右拆分 / Split & Move / 移动到新窗口 / 复制到新窗口」(浏览器内单编辑器实例)。
9
+ * 只显示**真实已绑定**的键位;参考图里的两步弦(Ctrl+K W 等)引擎不支持,故不伪造。
10
+ *
11
+ * 「固定 = 保护」:关闭其他 / 关闭右侧 / 关闭已保存 / 全部关闭 一律不关固定页签,
12
+ * 故这些条目在「固定页签是唯一可关对象」时判定为禁用(与 tabActions 语义同源)。
13
+ * 作者 ddj 2026年09月11号
14
+ */
15
+ import { closeAll, closeOthers, closeRight, closeSaved, isTreeRevealable, type TabLike } from './tabActions.js'
16
+
17
+ /** 菜单构建输入(EditorView 每次打开菜单时按最新状态快照传入)。 */
18
+ export interface TabMenuState {
19
+ /** 右键目标页签路径。 */
20
+ path: string
21
+ /** 当前全部页签。 */
22
+ tabs: TabLike[]
23
+ /** 当前活动页签路径。 */
24
+ active: string | null
25
+ /** 路径 → 是否有未保存修改。 */
26
+ dirty: Record<string, boolean>
27
+ /** 会话工作区目录(相对路径复制与「资源管理器视图中显示」用)。 */
28
+ cwd?: string | null
29
+ /** 是否有活动会话(复制/定位类动作依赖)。 */
30
+ hasSession: boolean
31
+ /** 「添加到对话」动作集是否可用。 */
32
+ canAddToConversation: boolean
33
+ /** 「关闭」项的键位提示(缺省无提示;由调用方读 chordOf 注入)。 */
34
+ closeChord?: string | null
35
+ }
36
+
37
+ /** 一条页签菜单项(ContextMenu 的展示形状 + 动作 id)。 */
38
+ export interface TabMenuEntry {
39
+ id: string
40
+ label: string
41
+ /** 右侧键位提示(仅在真实绑定键位时出现)。 */
42
+ hint?: string
43
+ disabled?: boolean
44
+ danger?: boolean
45
+ /** 前置分隔线(分组的首条)。 */
46
+ separator?: boolean
47
+ }
48
+
49
+ /** 关闭族 id(EditorView 的动作表按 id 分派;导出便于测试与静态校验)。 */
50
+ export const CLOSE_MENU_IDS = ['close', 'close-others', 'close-right', 'close-saved', 'close-all'] as const
51
+
52
+ /** 本架构不支持的条目 id(参考图有、浏览器单编辑器实例无法实现)——显式登记以防误加。 */
53
+ export const UNSUPPORTED_MENU_IDS = ['split-right', 'split-move', 'move-new-window', 'copy-new-window'] as const
54
+
55
+ /**
56
+ * 构建页签右键菜单条目。
57
+ * @author ddj 2026年09月11号
58
+ * @param state 菜单状态快照
59
+ * @returns 菜单条目(已按参考图分组排序)
60
+ */
61
+ export function buildTabMenu(state: TabMenuState): TabMenuEntry[] {
62
+ const { path, tabs, active, dirty, cwd, hasSession, canAddToConversation, closeChord } = state
63
+ const current = tabs.find((tab) => tab.path === path)
64
+ const pinned = current?.pinned === true
65
+ return [
66
+ {
67
+ id: 'add-to-conversation',
68
+ label: '添加到对话',
69
+ disabled: !(hasSession && canAddToConversation),
70
+ },
71
+ { id: 'close', label: '关闭', separator: true, ...(closeChord ? { hint: closeChord } : {}) },
72
+ { id: 'close-others', label: '关闭其他', disabled: nothingToClose(closeOthers(tabs, path, active), tabs) },
73
+ { id: 'close-right', label: '关闭右侧标签页', disabled: nothingToClose(closeRight(tabs, path, active), tabs) },
74
+ { id: 'close-saved', label: '关闭已保存', disabled: nothingToClose(closeSaved(tabs, dirty, active), tabs) },
75
+ { id: 'close-all', label: '全部关闭', disabled: nothingToClose(closeAll(tabs, active), tabs) },
76
+ { id: 'copy-path', label: '复制路径', separator: true },
77
+ { id: 'copy-relative-path', label: '复制相对路径', disabled: !hasCwd(cwd) },
78
+ { id: 'reveal-in-os', label: '在文件资源管理器中显示', separator: true, disabled: !hasSession },
79
+ { id: 'reveal-in-view', label: '在资源管理器视图中显示', disabled: !isTreeRevealable(path) },
80
+ {
81
+ id: 'toggle-pinned',
82
+ label: pinned ? '取消固定' : '固定',
83
+ separator: true,
84
+ },
85
+ ]
86
+ }
87
+
88
+ /**
89
+ * 关闭族是否无可关对象(结果页签数与关闭前一致 = 一个也没关掉)。
90
+ * @author ddj 2026年09月11号
91
+ * @param result 关闭结果
92
+ * @param tabs 关闭前的页签
93
+ * @returns 是否无可关对象
94
+ */
95
+ function nothingToClose(result: { tabs: TabLike[] }, tabs: TabLike[]): boolean {
96
+ return result.tabs.length >= tabs.length
97
+ }
98
+
99
+ /**
100
+ * 是否有可用工作区目录(复制相对路径需要)。
101
+ * @author ddj 2026年09月11号
102
+ * @param cwd 会话工作区目录
103
+ * @returns 是否可用
104
+ */
105
+ function hasCwd(cwd: string | null | undefined): boolean {
106
+ return typeof cwd === 'string' && cwd.trim() !== ''
107
+ }