dsh-vscode-mode 0.2.0 → 0.3.0
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 +14 -2
- package/lib/client.js +822 -59
- package/lib/client.js.map +1 -1
- package/lib/index.js +476 -19
- package/lib/index.js.map +1 -1
- package/package.json +2 -2
- package/src/client/commandBridge.ts +2 -0
- package/src/client/commandPaletteStore.ts +56 -9
- package/src/client/commandRegistry.ts +6 -0
- package/src/client/monaco/loader.ts +24 -0
- package/src/client/snippets/provider.ts +172 -0
- package/src/client/styles/editor.css +39 -2
- package/src/client/ui/CommandPalette.ts +43 -7
- package/src/client/ui/EditorView.ts +97 -3
- package/src/client/ui/SnippetsPicker.ts +296 -0
- package/src/client/ui/commandCatalog.ts +25 -0
- package/src/rpc.ts +78 -0
- package/src/shared/keybindings.ts +2 -0
- package/src/shared/rpc.ts +11 -0
- package/src/shared/snippets.ts +156 -0
- package/src/snippets.ts +427 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-vscode-mode",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "DSH 上的类 VSCode 编码体验:Monaco 编辑器(文件页签/QuickOpen/状态栏,可驻 DSH 0.1.5+ 官方右侧 Sidebar 与对话同屏)+ 命令栏(Ctrl+Shift+P)与可扩展指令系统 + Agent 编辑差异审查(整文件差异/采纳/拒绝/归档/回滚)+ 语言服务器(LSP)智能(跳转定义/引用/hover/大纲 + VSIX 扩展市场安装),状态持久化到工作区旁车",
|
|
3
|
+
"version": "0.3.0",
|
|
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",
|
|
7
7
|
"dsh-plugin",
|
|
@@ -59,6 +59,8 @@ export function createCommandBridge(options: CommandBridgeOptions = {}): Command
|
|
|
59
59
|
const onKey = (event: KeyboardEvent): void => {
|
|
60
60
|
for (const command of dispatched) {
|
|
61
61
|
if (!matchEvent(event, bindingsOf(command.id))) continue
|
|
62
|
+
// 可用性前置:命令不可用时既不执行也不吞键(否则无编辑器时 Ctrl+U 会被吃掉)
|
|
63
|
+
if (!registry.isAvailable(command.id)) continue
|
|
62
64
|
event.preventDefault()
|
|
63
65
|
event.stopPropagation()
|
|
64
66
|
registry.run(command.id)
|
|
@@ -19,9 +19,15 @@ let registryTable: CommandRegistry | null = null
|
|
|
19
19
|
/** 命令栏是否展开。 */
|
|
20
20
|
let opened = false
|
|
21
21
|
|
|
22
|
+
/** 唤起序号:每次 openCommandPalette 递增(含已打开时的重复唤起),驱动重新聚焦。 */
|
|
23
|
+
let openSeq = 0
|
|
24
|
+
|
|
22
25
|
/** 当前真正渲染浮层的宿主令牌(null = 无宿主)。 */
|
|
23
26
|
let hostToken: object | null = null
|
|
24
27
|
|
|
28
|
+
/** 宿主令牌版本:每次释放认领递增,驱动其余实例重试认领(宿主更替后自愈)。 */
|
|
29
|
+
let hostRev = 0
|
|
30
|
+
|
|
25
31
|
/** 注入的命令执行器(commandBridge 装配时写入)。 */
|
|
26
32
|
let runner: ((id: string) => boolean) | null = null
|
|
27
33
|
|
|
@@ -107,18 +113,40 @@ function rememberFocus(): void {
|
|
|
107
113
|
}
|
|
108
114
|
|
|
109
115
|
/**
|
|
110
|
-
*
|
|
116
|
+
* 打开命令栏(记录当前焦点供关闭后归还)。
|
|
117
|
+
*
|
|
118
|
+
* 已打开时**不是空操作**:递增 openSeq 并通知订阅者,让命令栏重新获得焦点。
|
|
119
|
+
* 事故驱动:片段浮窗遮罩曾压在命令栏之上,此时命令栏处于「已打开但看不见」状态;
|
|
120
|
+
* 旧实现 `if (opened) return` 让快捷键毫无反应(用户报「调不出来」)。
|
|
121
|
+
* 递增 seq 使每次触发都有可观测响应(重聚焦 + 重新置顶)。
|
|
111
122
|
* @author ddj 2026年09月10号
|
|
112
123
|
* @param reason 唤起来源(键盘/命令/外部 API)
|
|
113
124
|
*/
|
|
114
125
|
export function openCommandPalette(reason = 'keybinding'): void {
|
|
115
|
-
if (opened)
|
|
126
|
+
if (opened) {
|
|
127
|
+
// 已打开:仍视为一次「唤起」,驱动订阅者重新聚焦(select 重置 / 重新置顶)
|
|
128
|
+
openSeq += 1
|
|
129
|
+
openReason = reason
|
|
130
|
+
notifyPalette()
|
|
131
|
+
return
|
|
132
|
+
}
|
|
116
133
|
rememberFocus()
|
|
117
134
|
openReason = reason
|
|
118
135
|
opened = true
|
|
136
|
+
openSeq += 1
|
|
119
137
|
notifyPalette()
|
|
120
138
|
}
|
|
121
139
|
|
|
140
|
+
/**
|
|
141
|
+
* 命令栏唤起序号(每次 open 调用递增,含「已打开时的重复唤起」)。
|
|
142
|
+
* useSyncExternalStore 快照用它感知「重复唤起」,从而重新聚焦输入框。
|
|
143
|
+
* @author ddj 2026年09月10号
|
|
144
|
+
* @returns 当前序号
|
|
145
|
+
*/
|
|
146
|
+
export function paletteOpenSeq(): number {
|
|
147
|
+
return openSeq
|
|
148
|
+
}
|
|
149
|
+
|
|
122
150
|
/**
|
|
123
151
|
* 关闭命令栏并归还焦点。
|
|
124
152
|
* @author ddj 2026年09月10号
|
|
@@ -144,22 +172,41 @@ export function paletteReason(): string {
|
|
|
144
172
|
}
|
|
145
173
|
|
|
146
174
|
/**
|
|
147
|
-
*
|
|
175
|
+
* 认领命令栏宿主:成功返回**令牌本身**(调用方须原样持有并在卸载时传回),
|
|
176
|
+
* 已被他人认领时返回 null。
|
|
177
|
+
*
|
|
178
|
+
* ⚠️ 必须返回 internal 存下的同一个对象:旧实现返回 boolean、由调用方自行 `{}` 造令牌,
|
|
179
|
+
* 导致 `releasePaletteHost` 的身份校验恒不通过 → hostToken 永久泄漏 → 之后**任何**实例
|
|
180
|
+
* 都认领失败、无人渲染浮层(表现为 Ctrl+Shift+P「完全没有反应」且永不恢复)。
|
|
148
181
|
* @author ddj 2026年09月10号
|
|
149
|
-
* @returns
|
|
182
|
+
* @returns 认领令牌;null 表示已有宿主
|
|
150
183
|
*/
|
|
151
|
-
export function claimPaletteHost():
|
|
152
|
-
if (hostToken !== null) return
|
|
153
|
-
|
|
154
|
-
|
|
184
|
+
export function claimPaletteHost(): object | null {
|
|
185
|
+
if (hostToken !== null) return null
|
|
186
|
+
const token = {}
|
|
187
|
+
hostToken = token
|
|
188
|
+
return token
|
|
155
189
|
}
|
|
156
190
|
|
|
157
191
|
/**
|
|
158
192
|
* 释放宿主认领(仅持有者可释放,卸载顺序错乱不会顶掉新宿主)。
|
|
193
|
+
* 释放成功后通知订阅者,让等待中的实例**重新尝试认领**(自愈,避免宿主更替后无人渲染)。
|
|
159
194
|
* @author ddj 2026年09月10号
|
|
160
|
-
* @param token 认领令牌(null
|
|
195
|
+
* @param token 认领令牌(null = 本实例未认领)
|
|
161
196
|
*/
|
|
162
197
|
export function releasePaletteHost(token: object | null): void {
|
|
163
198
|
if (token === null || hostToken !== token) return
|
|
164
199
|
hostToken = null
|
|
200
|
+
hostRev += 1
|
|
201
|
+
notifyPalette()
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* 宿主令牌版本(每次释放认领递增)。非宿主实例订阅它,在宿主更替后重试认领 ——
|
|
206
|
+
* 否则先挂载的实例卸载后无人接管,快捷键会「完全没有反应」且永不恢复。
|
|
207
|
+
* @author ddj 2026年09月10号
|
|
208
|
+
* @returns 当前版本号
|
|
209
|
+
*/
|
|
210
|
+
export function paletteHostRev(): number {
|
|
211
|
+
return hostRev
|
|
165
212
|
}
|
|
@@ -21,6 +21,8 @@ export interface CommandRegistry {
|
|
|
21
21
|
list(): CommandDef[]
|
|
22
22
|
/** 可用命令(available 缺省视为可用)。 */
|
|
23
23
|
available(): CommandDef[]
|
|
24
|
+
/** 命令是否已注册且当前可用(未注册返回 false;判定异常按不可用处理)。 */
|
|
25
|
+
isAvailable(id: string): boolean
|
|
24
26
|
/** 命令栏过滤(先按可用性过滤,再按相关度排序)。 */
|
|
25
27
|
match(query: string): CommandDef[]
|
|
26
28
|
/** 执行命令;不可用/未注册/抛异常统一返回 false(不抛出)。 */
|
|
@@ -127,6 +129,10 @@ export function createCommandRegistry(): CommandRegistry {
|
|
|
127
129
|
get: (id) => entries.get(id),
|
|
128
130
|
list,
|
|
129
131
|
available,
|
|
132
|
+
isAvailable: (id) => {
|
|
133
|
+
const command = entries.get(id)
|
|
134
|
+
return command !== undefined && isAvailable(command)
|
|
135
|
+
},
|
|
130
136
|
match: (query) => filterCommands(available(), query),
|
|
131
137
|
run(id: string): boolean {
|
|
132
138
|
const command = entries.get(id)
|
|
@@ -65,6 +65,8 @@ export const LANG_BY_EXT = {
|
|
|
65
65
|
lua51: 'lua', luac: 'lua',
|
|
66
66
|
gitattributes: 'ini', editorconfig: 'ini', env: 'ini', properties: 'ini',
|
|
67
67
|
json5: 'jsonc', log: 'plaintext', txt: 'plaintext',
|
|
68
|
+
// 代码片段文件按 JSON 高亮(VS Code 同款:.code-snippets 是带注释的 JSON)
|
|
69
|
+
'code-snippets': 'json',
|
|
68
70
|
}
|
|
69
71
|
|
|
70
72
|
/**
|
|
@@ -81,6 +83,28 @@ export function langOf(path) {
|
|
|
81
83
|
return LANG_BY_EXT[base.slice(dot + 1).toLowerCase()] ?? 'plaintext'
|
|
82
84
|
}
|
|
83
85
|
|
|
86
|
+
/** 片段文件后缀(判断「文件自身是片段文件」而非普通源码)。 */
|
|
87
|
+
const SNIPPET_FILE_SUFFIX = '.code-snippets'
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* 片段文件 → 它绑定的语言 id(VS Code 文件名约定 `<language>.code-snippets`)。
|
|
91
|
+
*
|
|
92
|
+
* 为什么不能直接用 {@link langOf}:`.code-snippets` 本身在 LANG_BY_EXT 里映射为 json
|
|
93
|
+
* (编辑片段文件时要按 JSON 高亮),于是 langOf('lua.code-snippets') 会得到 'json' 而不是
|
|
94
|
+
* 'lua' —— 曾导致「新建」默认文件名被算成 `json.code-snippets`。故这里按片段命名约定
|
|
95
|
+
* 单独解析:去掉 `.code-snippets` 后缀取语言前缀。
|
|
96
|
+
*
|
|
97
|
+
* @author ddj 2026年09月10号
|
|
98
|
+
* @param path 片段文件路径
|
|
99
|
+
* @returns 语言 id(`global.code-snippets` / 无法识别 → 空串 = 全语言)
|
|
100
|
+
*/
|
|
101
|
+
export function snippetLanguageOf(path) {
|
|
102
|
+
const base = String(path || '').split(/[\\/]/).pop() || ''
|
|
103
|
+
if (!base.toLowerCase().endsWith(SNIPPET_FILE_SUFFIX)) return langOf(path)
|
|
104
|
+
const lang = base.slice(0, -SNIPPET_FILE_SUFFIX.length).toLowerCase()
|
|
105
|
+
return lang === 'global' ? '' : lang
|
|
106
|
+
}
|
|
107
|
+
|
|
84
108
|
/**
|
|
85
109
|
* 加载 Monaco Editor(AMD 构建,随插件包离线分发):注入 loader.js → require.config → editor.main。
|
|
86
110
|
* @author ddj 2026年08月20号
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
/**
|
|
3
|
+
* dsh-vscode-mode client — 代码片段补全 provider(Monaco IntelliSense)。
|
|
4
|
+
* 数据源:host snippets.entries(全局 ~/.dsh/snippets + 当前工作区 <工作区>/.dsh/snippets),
|
|
5
|
+
* 条目按 model 语言过滤(language 空串 = 全语言);条目主体交给 Monaco 片段语法解析
|
|
6
|
+
* (${1:占位} / $TM_FILENAME 等由 InsertAsSnippet 规则展开)。
|
|
7
|
+
* 性能:条目模块级缓存 + TTL,不在每次按键打 RPC(与 ai/inlineProvider 的去抖策略异曲同工,
|
|
8
|
+
* 但片段是本地小表,缓存足矣);edrv:snippets-changed(配置文件保存后)强制失效。
|
|
9
|
+
* 作者 ddj 2026年09月10号
|
|
10
|
+
*/
|
|
11
|
+
import { rpc } from '../rpc.js'
|
|
12
|
+
|
|
13
|
+
/** 条目缓存 TTL(毫秒):片段文件本就只在用户编辑时变化。 */
|
|
14
|
+
const CACHE_TTL_MS = 10_000
|
|
15
|
+
|
|
16
|
+
/** 补全候选上限(超大片段库下防拖慢渲染)。 */
|
|
17
|
+
const MAX_ITEMS = 200
|
|
18
|
+
|
|
19
|
+
/** 已加载条目缓存(null = 未加载/已失效)。 */
|
|
20
|
+
let cache = null
|
|
21
|
+
let cacheAt = 0
|
|
22
|
+
let pending = null
|
|
23
|
+
let registered = false
|
|
24
|
+
let disposer = null
|
|
25
|
+
|
|
26
|
+
/** 当前会话 id(补全按会话工作区叠加项目片段;EditorView 装配时注入)。 */
|
|
27
|
+
let sessionId = null
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 设置当前会话(会话切换时更新;缓存一并失效,使项目片段跟随工作区)。
|
|
31
|
+
* @author ddj 2026年09月10号
|
|
32
|
+
* @param id 会话 id
|
|
33
|
+
*/
|
|
34
|
+
export function setSnippetsSession(id) {
|
|
35
|
+
if (sessionId === id) return
|
|
36
|
+
sessionId = id
|
|
37
|
+
invalidateSnippets()
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 失效条目缓存(配置文件保存/会话切换后调用)。
|
|
42
|
+
* @author ddj 2026年09月10号
|
|
43
|
+
*/
|
|
44
|
+
export function invalidateSnippets() {
|
|
45
|
+
cache = null
|
|
46
|
+
cacheAt = 0
|
|
47
|
+
pending = null
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 读取条目(带缓存与在飞去重);失败返回空表(补全静默降级,不打断输入)。
|
|
52
|
+
* @author ddj 2026年09月10号
|
|
53
|
+
* @returns 片段条目数组
|
|
54
|
+
*/
|
|
55
|
+
async function loadEntries() {
|
|
56
|
+
if (cache && Date.now() - cacheAt < CACHE_TTL_MS) return cache
|
|
57
|
+
if (pending) return pending
|
|
58
|
+
pending = rpc('snippets.entries', { sessionId })
|
|
59
|
+
.then((res) => {
|
|
60
|
+
const entries = res && res.ok && Array.isArray(res.entries) ? res.entries : []
|
|
61
|
+
cache = entries
|
|
62
|
+
cacheAt = Date.now()
|
|
63
|
+
return entries
|
|
64
|
+
})
|
|
65
|
+
.catch(() => {
|
|
66
|
+
cache = []
|
|
67
|
+
cacheAt = Date.now()
|
|
68
|
+
return cache
|
|
69
|
+
})
|
|
70
|
+
.finally(() => { pending = null })
|
|
71
|
+
return pending
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* 按语言过滤条目:条目语言为空(全语言)或与 model 语言一致即命中。
|
|
76
|
+
* 项目片段(scope=project)排在全局之后去重,同名同前缀时项目优先。
|
|
77
|
+
* @author ddj 2026年09月10号
|
|
78
|
+
* @param entries 全部条目
|
|
79
|
+
* @param languageId 当前模型语言
|
|
80
|
+
* @returns 生效条目(项目优先,已去重)
|
|
81
|
+
*/
|
|
82
|
+
export function entriesForLanguage(entries, languageId) {
|
|
83
|
+
const lang = String(languageId ?? '').toLowerCase()
|
|
84
|
+
const global = []
|
|
85
|
+
const project = []
|
|
86
|
+
for (const entry of entries) {
|
|
87
|
+
const entryLang = String(entry?.language ?? '').toLowerCase()
|
|
88
|
+
if (entryLang && entryLang !== lang) continue
|
|
89
|
+
if (entry?.scope === 'project') project.push(entry)
|
|
90
|
+
else global.push(entry)
|
|
91
|
+
}
|
|
92
|
+
// 项目条目覆盖同 key 的全局条目(工作区定制优先)
|
|
93
|
+
const projectKeys = new Set(project.map((entry) => entry.key))
|
|
94
|
+
const merged = global.filter((entry) => !projectKeys.has(entry.key)).concat(project)
|
|
95
|
+
return merged.slice(0, MAX_ITEMS)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* 注册代码片段补全 provider(幂等;Monaco 就绪后调用一次)。
|
|
100
|
+
* @author ddj 2026年09月10号
|
|
101
|
+
* @param monaco window.monaco
|
|
102
|
+
*/
|
|
103
|
+
export function registerSnippetProvider(monaco) {
|
|
104
|
+
if (registered || !monaco?.languages?.registerCompletionItemProvider) return
|
|
105
|
+
const snippetKind = monaco.languages.CompletionItemKind?.Snippet
|
|
106
|
+
const asSnippet = monaco.languages.CompletionItemInsertTextRule?.InsertAsSnippet
|
|
107
|
+
// 缺少片段枚举(精简版 Monaco)时不注册:否则候选项会退化为纯文本插入,误导用户
|
|
108
|
+
if (typeof snippetKind !== 'number' || typeof asSnippet !== 'number') return
|
|
109
|
+
registered = true
|
|
110
|
+
disposer = monaco.languages.registerCompletionItemProvider('*', {
|
|
111
|
+
async provideCompletionItems(model, position) {
|
|
112
|
+
const entries = await loadEntries()
|
|
113
|
+
if (!entries.length) return { suggestions: [] }
|
|
114
|
+
const items = entriesForLanguage(entries, model?.getLanguageId?.())
|
|
115
|
+
if (!items.length) return { suggestions: [] }
|
|
116
|
+
const word = model.getWordUntilPosition(position)
|
|
117
|
+
const range = {
|
|
118
|
+
startLineNumber: position.lineNumber,
|
|
119
|
+
endLineNumber: position.lineNumber,
|
|
120
|
+
startColumn: word.startColumn,
|
|
121
|
+
endColumn: word.endColumn,
|
|
122
|
+
}
|
|
123
|
+
return {
|
|
124
|
+
suggestions: items.map((entry) => ({
|
|
125
|
+
label: entry.prefix || entry.key,
|
|
126
|
+
// 无前缀条目不经键入触发(靠「插入代码片段」命令使用),补全里不展示空标签
|
|
127
|
+
insertText: entry.body,
|
|
128
|
+
detail: (entry.scope === 'project' ? '项目片段 · ' : '全局片段 · ') + entry.file,
|
|
129
|
+
documentation: entry.description || entry.key,
|
|
130
|
+
kind: snippetKind,
|
|
131
|
+
insertTextRules: asSnippet,
|
|
132
|
+
filterText: entry.prefix || entry.key,
|
|
133
|
+
range,
|
|
134
|
+
})).filter((item) => Boolean(item.label)),
|
|
135
|
+
}
|
|
136
|
+
},
|
|
137
|
+
})
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* 装配:Monaco 就绪后注册 provider(幂等)。
|
|
142
|
+
* @author ddj 2026年09月10号
|
|
143
|
+
* @param monaco window.monaco
|
|
144
|
+
*/
|
|
145
|
+
export function setupSnippets(monaco) {
|
|
146
|
+
registerSnippetProvider(monaco)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* 卸载:注销 provider(插件热重载/卸载时调用)。
|
|
151
|
+
* @author ddj 2026年09月10号
|
|
152
|
+
*/
|
|
153
|
+
export function disposeSnippets() {
|
|
154
|
+
if (typeof disposer === 'function') {
|
|
155
|
+
try { disposer.dispose?.() } catch { /* 已注销 */ }
|
|
156
|
+
}
|
|
157
|
+
disposer = null
|
|
158
|
+
registered = false
|
|
159
|
+
invalidateSnippets()
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* 读取可插入条目(「插入代码片段」命令用;含无前缀条目)。
|
|
164
|
+
* @author ddj 2026年09月10号
|
|
165
|
+
* @param languageId 当前模型语言(缺省返回全语言 + 该语言条目)
|
|
166
|
+
* @returns 生效条目数组
|
|
167
|
+
*/
|
|
168
|
+
export async function listSnippetsFor(languageId) {
|
|
169
|
+
const entries = await loadEntries()
|
|
170
|
+
if (!languageId) return entries.slice(0, MAX_ITEMS)
|
|
171
|
+
return entriesForLanguage(entries, languageId)
|
|
172
|
+
}
|
|
@@ -369,9 +369,15 @@
|
|
|
369
369
|
[data-edrv-view] .edrv-rules-save { height: 24px; padding: 0 14px; border: none; border-radius: 6px; background: var(--dsw-alias-brand-primary, #0f9d58); color: var(--dsw-alias-label-primary-inverted, #fff); font-size: 12px; cursor: pointer; }
|
|
370
370
|
[data-edrv-view] .edrv-rules-save:disabled { opacity: .5; cursor: default; }
|
|
371
371
|
|
|
372
|
-
/* 命令栏(Ctrl+Shift+P / F1):body 级 portal
|
|
372
|
+
/* 命令栏(Ctrl+Shift+P / F1):body 级 portal 浮层,故不依赖编辑区几何。
|
|
373
|
+
⚠️ 浮层 z-index 阶梯(自下而上;改动务必保持顺序,tests/overlayZOrder.test.ts 守约):
|
|
374
|
+
编辑器内浮层 60(.edrv-search-pop) / 71(.edrv-ctxmenu)
|
|
375
|
+
< 190 命令栏遮罩 < 200 命令栏
|
|
376
|
+
< 195 片段浮窗遮罩 < 197 片段二级弹窗遮罩
|
|
377
|
+
命令栏必须**高于**片段浮窗:曾因片段遮罩(192) 压过命令栏(191),导致片段浮窗打开时
|
|
378
|
+
命令栏被整体盖住,按 Ctrl+Shift+P 因「已打开即早退」而毫无反应(表现为快捷键失效)。 */
|
|
373
379
|
[data-edrv-view] .edrv-palette-mask { position: fixed; inset: 0; z-index: 190; background: rgba(0, 0, 0, .28); }
|
|
374
|
-
[data-edrv-view] .edrv-palette { position: fixed; top: 12vh; left: 50%; transform: translateX(-50%); z-index:
|
|
380
|
+
[data-edrv-view] .edrv-palette { position: fixed; top: 12vh; left: 50%; transform: translateX(-50%); z-index: 200; width: min(580px, calc(100vw - 32px)); max-height: 64vh; display: flex; flex-direction: column; border-radius: 10px; background: var(--dsw-alias-bg-overlay, #ffffff); border: 1px solid var(--dsw-alias-border-l2, #cbd2d9); box-shadow: 0 12px 34px rgba(31, 41, 51, .24); overflow: hidden; }
|
|
375
381
|
[data-edrv-view] .edrv-palette-input { height: 38px; flex: 0 0 auto; padding: 0 12px; border: none; border-bottom: 1px solid var(--dsw-alias-border-l1, #e0e6e8); background: transparent; color: var(--dsw-alias-label-primary, #1f2933); font-size: 13px; outline: none; }
|
|
376
382
|
[data-edrv-view] .edrv-palette-list { flex: 1 1 auto; min-height: 0; overflow: auto; padding: 4px; }
|
|
377
383
|
[data-edrv-view] .edrv-palette-row { display: flex; align-items: center; gap: 8px; padding: 6px 10px; border-radius: 6px; font-size: 12px; color: var(--dsw-alias-label-primary, #1f2933); cursor: pointer; }
|
|
@@ -382,6 +388,37 @@
|
|
|
382
388
|
[data-edrv-view] .edrv-palette-empty { padding: 14px 10px; font-size: 12px; color: var(--dsw-alias-label-tertiary, #7b8794); text-align: center; }
|
|
383
389
|
[data-edrv-view] .edrv-palette-notice { flex: 0 0 auto; padding: 6px 10px; border-top: 1px solid var(--dsw-alias-border-l1, #e0e6e8); font-size: 11px; color: var(--dsw-alias-state-warn-primary, #b7791f); }
|
|
384
390
|
|
|
391
|
+
/* 代码片段选择器(配置 / 插入):居中浮窗 + 新建二级弹窗。
|
|
392
|
+
卡片复用 mcp.css 的 .vsm-mcp-dialog(与「添加 MCP」「工作区选择」同一视觉语言),
|
|
393
|
+
此处只补遮罩、尺寸与列表布局。⚠️ 根节点须带 data-edrv-view,否则这些规则全部失配。 */
|
|
394
|
+
[data-edrv-view] .edrv-snip-mask { position: fixed; inset: 0; z-index: 195; display: flex; align-items: center; justify-content: center; padding: 24px; box-sizing: border-box; background: var(--dsw-alias-bg-mask-drop, rgba(0, 0, 0, .55)); }
|
|
395
|
+
/* 二级弹窗叠在一级浮窗之上(仍在命令栏之下,见上方阶梯说明) */
|
|
396
|
+
[data-edrv-view] .edrv-snip-mask-top { z-index: 197; }
|
|
397
|
+
[data-edrv-view] .edrv-snip-dialog { display: flex; flex-direction: column; overflow: hidden; width: min(620px, calc(100vw - 48px)); }
|
|
398
|
+
[data-edrv-view] .edrv-snip-hint { margin: 0 0 12px; font-size: 12px; line-height: 1.6; color: var(--dsw-alias-label-tertiary, #9aa5b1); }
|
|
399
|
+
[data-edrv-view] .edrv-snip-list { display: flex; flex-direction: column; gap: 1px; min-height: 0; max-height: 46vh; overflow: auto; }
|
|
400
|
+
[data-edrv-view] .edrv-snip-group { padding: 12px 2px 4px; font-size: 11px; font-weight: 600; color: var(--dsw-alias-label-secondary, #aaa); }
|
|
401
|
+
[data-edrv-view] .edrv-snip-row { display: flex; align-items: center; gap: 4px; width: 100%; box-sizing: border-box; padding: 2px; border: none; border-radius: 8px; background: transparent; text-align: left; color: var(--dsw-alias-label-primary, #e6e6e6); font: inherit; }
|
|
402
|
+
[data-edrv-view] .edrv-snip-row:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(255, 255, 255, .06)); }
|
|
403
|
+
[data-edrv-view] button.edrv-snip-row { cursor: pointer; }
|
|
404
|
+
[data-edrv-view] .edrv-snip-row-main { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 2px; padding: 8px 10px; border: none; border-radius: 6px; background: transparent; text-align: left; cursor: pointer; color: inherit; font: inherit; }
|
|
405
|
+
[data-edrv-view] .edrv-snip-label { font-size: 13px; font-weight: 600; color: var(--dsw-alias-label-primary, #e6e6e6); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
406
|
+
[data-edrv-view] .edrv-snip-desc { font-size: 11px; color: var(--dsw-alias-label-tertiary, #9aa5b1); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
407
|
+
[data-edrv-view] .edrv-snip-src { flex: 0 0 auto; padding-right: 6px; font-size: 11px; color: var(--dsw-alias-label-tertiary, #9aa5b1); }
|
|
408
|
+
[data-edrv-view] .edrv-snip-warn { flex: 0 0 auto; font-size: 11px; padding: 2px 7px; border-radius: 999px; background: var(--dsw-alias-state-warn-tertiary, rgba(229, 185, 63, .16)); color: var(--dsw-alias-state-warn-primary, #e5b93f); white-space: nowrap; }
|
|
409
|
+
[data-edrv-view] .edrv-snip-act { flex: 0 0 auto; width: 26px; height: 26px; border: none; border-radius: 6px; background: transparent; color: var(--dsw-alias-label-tertiary, #9aa5b1); font-size: 13px; line-height: 1; cursor: pointer; }
|
|
410
|
+
[data-edrv-view] .edrv-snip-act:hover { background: var(--dsw-alias-interactive-bg-hover-danger, rgba(217, 83, 79, .16)); color: var(--dsw-alias-state-error-primary, #f07878); }
|
|
411
|
+
[data-edrv-view] .edrv-snip-empty { padding: 12px 4px; font-size: 12px; color: var(--dsw-alias-label-tertiary, #9aa5b1); line-height: 1.6; }
|
|
412
|
+
/* 当前文件语言提示条(片段按语言绑定,明确告知生效范围) */
|
|
413
|
+
[data-edrv-view] .edrv-snip-langbar { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; margin: 0 0 10px; padding: 8px 10px; border-radius: 6px; background: var(--dsw-alias-bg-layer-1, #2b2b2b); font-size: 12px; color: var(--dsw-alias-label-secondary, #aaa); }
|
|
414
|
+
[data-edrv-view] .edrv-snip-langbar b { color: var(--dsw-alias-label-primary, #e6e6e6); }
|
|
415
|
+
/* 二级弹窗的动作按钮(原生 .vsm-mcp-dialog-actions 已提供排版与按钮样式) */
|
|
416
|
+
[data-edrv-view] .edrv-snip-dialog .vsm-mcp-dialog-actions { flex-wrap: wrap; }
|
|
417
|
+
/* 删除图标按钮:.vsm-danger 的规则全部限定在 .vsm-mcp-actions button 之下,裸用无样式,此处补自有类 */
|
|
418
|
+
[data-edrv-view] .edrv-snip-act.edrv-snip-danger:hover { background: var(--dsw-alias-interactive-bg-hover-danger, rgba(217, 83, 79, .16)); color: var(--dsw-alias-state-error-primary, #f07878); }
|
|
419
|
+
/* 浮窗内的原生空态(.vsm-mcp-empty 默认为整页尺寸)压缩为对话框尺寸 */
|
|
420
|
+
[data-edrv-view] .edrv-snip-dialog .vsm-mcp-empty { padding: 26px 16px; border: 0; }
|
|
421
|
+
|
|
385
422
|
|
|
386
423
|
|
|
387
424
|
|
|
@@ -14,8 +14,8 @@ import { createCommandRegistry } from '../commandRegistry.js'
|
|
|
14
14
|
import type { CommandRegistry } from '../commandRegistry.js'
|
|
15
15
|
import { chordOf } from '../keybindings.js'
|
|
16
16
|
import {
|
|
17
|
-
claimPaletteHost, closeCommandPalette, isPaletteOpen,
|
|
18
|
-
runPaletteCommand, subscribePalette,
|
|
17
|
+
claimPaletteHost, closeCommandPalette, isPaletteOpen, paletteHostRev, paletteOpenSeq, registryRef,
|
|
18
|
+
releasePaletteHost, runPaletteCommand, subscribePalette,
|
|
19
19
|
} from '../commandPaletteStore.js'
|
|
20
20
|
import type { CommandDef } from './commandCatalog.js'
|
|
21
21
|
|
|
@@ -64,23 +64,59 @@ function rowsOf(registry: CommandRegistry, query: string): PaletteRow[] {
|
|
|
64
64
|
*/
|
|
65
65
|
export function CommandPalette(): React.ReactElement | null {
|
|
66
66
|
const open = React.useSyncExternalStore(subscribePalette, isPaletteOpen)
|
|
67
|
+
// 唤起序号:已打开时每次快捷键再触发都会递增 → 重新聚焦输入框(否则看似"没反应")
|
|
68
|
+
const openSeq = React.useSyncExternalStore(subscribePalette, paletteOpenSeq)
|
|
69
|
+
// 宿主令牌版本:宿主释放时递增,驱动本实例重试认领(避免宿主更替后无人渲染浮层)
|
|
70
|
+
const hostRev = React.useSyncExternalStore(subscribePalette, paletteHostRev)
|
|
67
71
|
const registry = useRegistry()
|
|
68
|
-
const [token] = React.useState
|
|
72
|
+
const [token, setToken] = React.useState<object | null>(null)
|
|
69
73
|
const [query, setQuery] = React.useState('')
|
|
70
74
|
const [selected, setSelected] = React.useState(0)
|
|
71
75
|
const [notice, setNotice] = React.useState('')
|
|
72
76
|
const inputRef = React.useRef<HTMLInputElement | null>(null)
|
|
77
|
+
const tokenRef = React.useRef<object | null>(null)
|
|
73
78
|
const host = token !== null
|
|
74
79
|
|
|
75
|
-
|
|
80
|
+
/**
|
|
81
|
+
* 认领宿主:放在 effect(不在 render 期做副作用 —— StrictMode 双调用会让首次认领
|
|
82
|
+
* 被判定失败而永久无人渲染)。hostRev 变化(有实例释放了宿主)时重试认领。
|
|
83
|
+
*
|
|
84
|
+
* ⚠️ 此处**不**做 cleanup 释放:releasePaletteHost 会通知订阅者(hostRev 变化),
|
|
85
|
+
* 若在依赖 hostRev 的 effect 里释放,会「释放→通知→重跑→再释放」自激循环。
|
|
86
|
+
* 释放统一交给下面的「仅卸载时」effect。
|
|
87
|
+
*/
|
|
76
88
|
React.useEffect(() => {
|
|
77
|
-
if (
|
|
89
|
+
if (tokenRef.current) return
|
|
90
|
+
const claimed = claimPaletteHost()
|
|
91
|
+
if (!claimed) return
|
|
92
|
+
tokenRef.current = claimed
|
|
93
|
+
setToken(claimed)
|
|
94
|
+
}, [hostRev])
|
|
95
|
+
|
|
96
|
+
/** 仅卸载时释放宿主;释放后 store 通知其余实例接管(自愈)。 */
|
|
97
|
+
React.useEffect(() => () => {
|
|
98
|
+
releasePaletteHost(tokenRef.current)
|
|
99
|
+
tokenRef.current = null
|
|
100
|
+
}, [])
|
|
101
|
+
// 新开(open false→true)时重置查询/选择/提示
|
|
102
|
+
React.useEffect(() => {
|
|
103
|
+
if (!open) return
|
|
78
104
|
setQuery('')
|
|
79
105
|
setSelected(0)
|
|
80
106
|
setNotice('')
|
|
81
|
-
const timer = setTimeout(() => inputRef.current?.focus(), 0)
|
|
82
|
-
return () => clearTimeout(timer)
|
|
83
107
|
}, [open])
|
|
108
|
+
// 每次唤起(含已打开时的重复唤起 openSeq 变化)重新聚焦输入框 ——
|
|
109
|
+
// 否则命令栏被其它浮层短暂遮挡后,快捷键再按会「看似没反应」。
|
|
110
|
+
React.useEffect(() => {
|
|
111
|
+
if (!open) return undefined
|
|
112
|
+
const timer = setTimeout(() => {
|
|
113
|
+
const input = inputRef.current
|
|
114
|
+
if (!input) return
|
|
115
|
+
input.focus()
|
|
116
|
+
input.select?.()
|
|
117
|
+
}, 0)
|
|
118
|
+
return () => clearTimeout(timer)
|
|
119
|
+
}, [open, openSeq])
|
|
84
120
|
|
|
85
121
|
const rows = React.useMemo(
|
|
86
122
|
() => (open && host ? rowsOf(registry, query) : []),
|