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.
- package/README.md +78 -3
- package/lib/client.js +1152 -209
- package/lib/client.js.map +1 -1
- package/lib/index.js +467 -3
- package/lib/index.js.map +1 -1
- package/package.json +2 -1
- package/skills/dsh-vscodemode-mcp/SKILL.md +181 -0
- package/src/client/addToConversation.ts +192 -20
- package/src/client/editorModelState.ts +18 -0
- package/src/client/openFlow.ts +2 -1
- package/src/client/paths.ts +4 -2
- package/src/client/saveDebounce.ts +69 -0
- package/src/client/sidebar/panels/FileExplorer.ts +83 -2
- package/src/client/state/scopeStore.ts +2 -1
- package/src/client/styles/editor.css +17 -2
- package/src/client/tabActions.ts +326 -0
- package/src/client/tabMenu.ts +107 -0
- package/src/client/ui/ContextMenu.ts +52 -8
- package/src/client/ui/EditorView.ts +221 -57
- package/src/client/ui/QuickOpen.ts +61 -13
- package/src/client/ui/commandCatalog.ts +17 -1
- package/src/client/ui/searchNav.ts +24 -0
- package/src/compat.ts +4 -0
- package/src/index.ts +5 -1
- package/src/paths.ts +11 -0
- package/src/shared/keybindings.ts +3 -0
- package/src/skills.ts +534 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
+
}
|