dsh-vscode-mode 0.1.27 → 0.1.29
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/lib/client.js +888 -52
- package/lib/client.js.map +1 -1
- package/lib/index.js +441 -27
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/client/compat.ts +7 -4
- package/src/client/index.ts +63 -19
- package/src/client/keybindings.ts +169 -0
- package/src/client/sidebar/panels/SearchPanel.ts +294 -0
- package/src/client/sidebar/panels/index.ts +18 -2
- package/src/client/sidebar/types.ts +2 -0
- package/src/client/styles/editor.css +40 -0
- package/src/client/styles/mcp.css +18 -0
- package/src/client/ui/EditorView.ts +65 -12
- package/src/client/ui/KeybindingsPanel.ts +187 -0
- package/src/client/ui/McpSettings.ts +7 -4
- package/src/client/ui/QuickOpen.ts +6 -3
- package/src/fileOpenSettings.ts +20 -4
- package/src/index.ts +4 -1
- package/src/rpc.ts +27 -3
- package/src/search/content.ts +319 -0
- package/src/search/fallback.ts +1 -1
- package/src/search/orchestrator.ts +17 -10
- package/src/search/ripgrep.ts +17 -2
- package/src/search/types.ts +40 -0
- package/src/shared/keybindings.ts +39 -0
- package/src/shared/rpc.ts +11 -0
package/package.json
CHANGED
package/src/client/compat.ts
CHANGED
|
@@ -11,7 +11,7 @@ export const SIDEBAR_PLUGIN = 'dsh-better-sidebar'
|
|
|
11
11
|
|
|
12
12
|
/** 设置 scope 的跨组件上下文形状(与官方 settingsScope 绑定的最小面)。 */
|
|
13
13
|
export interface SettingsScopeLike {
|
|
14
|
-
getSnapshot: () => { status?: string; value?: { fileOpenTool?: unknown }; writable?: boolean }
|
|
14
|
+
getSnapshot: () => { status?: string; value?: { fileOpenTool?: unknown; keybindings?: Record<string, string> }; writable?: boolean }
|
|
15
15
|
subscribe: (listener: () => void) => () => void
|
|
16
16
|
set: (field: string, value: unknown) => Promise<void>
|
|
17
17
|
}
|
|
@@ -54,17 +54,20 @@ export interface SlotSpec {
|
|
|
54
54
|
* @param ctx 客户端上下文(slots 服务)
|
|
55
55
|
* @param spec slot 描述
|
|
56
56
|
* @param render 渲染函数
|
|
57
|
+
* @returns 注销函数(未注册返回 null;形态切换时用于卸载旧 slot)
|
|
57
58
|
*/
|
|
58
|
-
export function registerSlotSafely(ctx: { slots?: { inject?: (name: string, register: () => unknown) => unknown; register?: (...args: unknown[]) => unknown } }, spec: SlotSpec, render: (props: unknown) => unknown): void {
|
|
59
|
+
export function registerSlotSafely(ctx: { slots?: { inject?: (name: string, register: () => unknown) => unknown; register?: (...args: unknown[]) => unknown } }, spec: SlotSpec, render: (props: unknown) => unknown): (() => void) | null {
|
|
59
60
|
const slots = ctx?.slots as { inject: (name: string, register: () => unknown) => unknown; register: (...args: unknown[]) => unknown } | undefined
|
|
60
61
|
if (!slots || typeof slots.inject !== 'function' || typeof slots.register !== 'function') {
|
|
61
62
|
console.warn('[' + PLUGIN_NAME + '] slots 服务不可用,跳过 slot ' + spec.name)
|
|
62
|
-
return
|
|
63
|
+
return null
|
|
63
64
|
}
|
|
64
65
|
try {
|
|
65
|
-
slots.inject(spec.name, () => slots.register(spec, render))
|
|
66
|
+
const disposer = slots.inject(spec.name, () => slots.register(spec, render))
|
|
67
|
+
return typeof disposer === 'function' ? disposer as () => void : null
|
|
66
68
|
} catch (error) {
|
|
67
69
|
console.warn('[' + PLUGIN_NAME + '] slot ' + spec.name + ' 注册失败(' + String(error) + '),已跳过')
|
|
70
|
+
return null
|
|
68
71
|
}
|
|
69
72
|
}
|
|
70
73
|
|
package/src/client/index.ts
CHANGED
|
@@ -31,10 +31,12 @@ import { SideEditorTab } from './ui/SideEditorTab.js'
|
|
|
31
31
|
import { createAddToConversation } from './addToConversation.js'
|
|
32
32
|
import { createSidebarPanelRegistry } from './sidebar/registry.js'
|
|
33
33
|
import { createFilePanel } from './sidebar/panels/index.js'
|
|
34
|
+
import { createSearchPanel } from './sidebar/panels/index.js'
|
|
34
35
|
import { createTreeMenuRegistry } from './sidebar/contextMenu.js'
|
|
35
36
|
import { createDefaultFileMenuItems } from './sidebar/menuItems.js'
|
|
36
37
|
import { createOutlinePanel } from './outline/index.js'
|
|
37
38
|
import { createOutlineSourceRegistry, registerBuiltinOutlineSources } from './outline/sources.js'
|
|
39
|
+
import { keybindingsApply } from './keybindings.js'
|
|
38
40
|
import type { CompatAdapter } from '../shared/compat.js'
|
|
39
41
|
|
|
40
42
|
// ⚠️ inject 只列必需服务:webUiSettings 是 @linxin666/dsh-client-ui-web-ui-settings 提供的
|
|
@@ -79,7 +81,7 @@ export function apply(ctx: any): void {
|
|
|
79
81
|
const settings = binder.scope
|
|
80
82
|
let selected = autoValue('auto')
|
|
81
83
|
/** 可选探测 betterSidebar 服务(不进 inject:缺失会让插件停靠等待,杀死回退路径)。 */
|
|
82
|
-
|
|
84
|
+
let sideService = detectSidebarService(ctx)
|
|
83
85
|
/** 「添加到对话」动作集:编辑区/Tab 右键菜单注入文件引用与代码块(conversation 服务缺失时各动作安全降级)。 */
|
|
84
86
|
const addToConversation = createAddToConversation(ctx)
|
|
85
87
|
|
|
@@ -98,6 +100,8 @@ export function apply(ctx: any): void {
|
|
|
98
100
|
const sidebarPanels = createSidebarPanelRegistry()
|
|
99
101
|
ctx.provide('edrvSidebarPanels', sidebarPanels)
|
|
100
102
|
ctx.effect(() => sidebarPanels.register(createFilePanel()), 'vscode-mode: sidebar panel')
|
|
103
|
+
// 搜索面板:活动栏「搜索」页签(Ctrl+Shift+F 唤起,VSCode 搜索视图形态)
|
|
104
|
+
ctx.effect(() => sidebarPanels.register(createSearchPanel()), 'vscode-mode: sidebar panel search')
|
|
101
105
|
// 文件右键菜单项注册表(对外 provide,供本插件/第三方注册;内置「在文件浏览器中打开」)
|
|
102
106
|
const fileMenuItems = createTreeMenuRegistry()
|
|
103
107
|
ctx.provide('edrvFileContextMenuItems', fileMenuItems)
|
|
@@ -128,6 +132,17 @@ export function apply(ctx: any): void {
|
|
|
128
132
|
sync()
|
|
129
133
|
return settings.subscribe(sync)
|
|
130
134
|
}, 'vscode-mode: file opener setting sync')
|
|
135
|
+
// 快捷键配置同步:设置提交后立即刷新键位匹配(编辑器/QuickOpen 按事件时读取)
|
|
136
|
+
ctx.effect(() => {
|
|
137
|
+
if (!settings) return undefined
|
|
138
|
+
const sync = (): void => {
|
|
139
|
+
const snapshot = settings.getSnapshot()
|
|
140
|
+
if (snapshot.status === 'loading') return
|
|
141
|
+
keybindingsApply(snapshot.value?.keybindings)
|
|
142
|
+
}
|
|
143
|
+
sync()
|
|
144
|
+
return settings.subscribe(sync)
|
|
145
|
+
}, 'vscode-mode: keybindings setting sync')
|
|
131
146
|
if (workspaces?.openPath) {
|
|
132
147
|
ctx.effect(() => installOpenPathRouter({
|
|
133
148
|
workspaces,
|
|
@@ -145,8 +160,8 @@ export function apply(ctx: any): void {
|
|
|
145
160
|
|
|
146
161
|
// 中央「文件编辑」页签(旧形态回退):类 VSCode 编辑器;侧边栏形态可用时编辑器住 betterSidebar Tab,
|
|
147
162
|
// 本页签不注册(避免双实例:Monaco×2 + diff dock 每会话单源抢占)。
|
|
148
|
-
const registerLegacyTab = (): void => {
|
|
149
|
-
registerSlotSafely(ctx, {
|
|
163
|
+
const registerLegacyTab = (): (() => void) | null => {
|
|
164
|
+
return registerSlotSafely(ctx, {
|
|
150
165
|
name: 'conversation.view',
|
|
151
166
|
id: 'edrv-editor',
|
|
152
167
|
order: 5,
|
|
@@ -155,23 +170,52 @@ export function apply(ctx: any): void {
|
|
|
155
170
|
}, (props: unknown) => React.createElement(EditorView, Object.assign({}, props, { layout: 'tab', sideHint: SIDEBAR_INSTALL_CMD, schedule, addToConversation, sidebarPanels, outlineSources, fileMenuItems })))
|
|
156
171
|
}
|
|
157
172
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
173
|
+
// 侧边栏/中央页签两形态互斥切换:sideDisposer(侧边栏 Tab)与 legacyDisposer(中央页签)只保留其一。
|
|
174
|
+
// ⚠️ betterSidebar 服务由 dsh-better-sidebar 的 client bundle(1MB+)在页面加载时提供,本插件
|
|
175
|
+
// bundle 较小往往先执行完启动检测——一次性探测会误报「未检测到」并永久停在中央页签形态(实测
|
|
176
|
+
// 竞态:better-sidebar 已装,但「文件编辑」Tab 未注册)。故启动后按 2s 间隔重试(窗口 ~30s),
|
|
177
|
+
// 命中即自动切换侧边栏形态;始终未命中则保持中央页签回退。
|
|
178
|
+
let sideDisposer: (() => void) | null = null
|
|
179
|
+
let legacyDisposer: (() => void) | null = null
|
|
180
|
+
const applySideForm = (): void => {
|
|
181
|
+
if (sideService) {
|
|
182
|
+
if (sideDisposer !== null) return
|
|
183
|
+
if (legacyDisposer !== null) { legacyDisposer(); legacyDisposer = null }
|
|
184
|
+
sideDisposer = ctx.effect(() => installSideEditor({
|
|
185
|
+
service: sideService as NonNullable<typeof sideService>,
|
|
186
|
+
renderTab: (props: Record<string, unknown>) => React.createElement(SideEditorTab, Object.assign({}, props, { schedule, addToConversation, sidebarPanels, outlineSources, fileMenuItems })),
|
|
187
|
+
activeSession: () => {
|
|
188
|
+
const snapshot = sessions?.list?.getSnapshot?.() as { current?: string; byId?: Record<string, { cwd?: string }> } | undefined
|
|
189
|
+
const sessionId = snapshot?.current
|
|
190
|
+
if (!sessionId) return undefined
|
|
191
|
+
return { sessionId, cwd: snapshot?.byId?.[sessionId]?.cwd }
|
|
192
|
+
},
|
|
193
|
+
registerLegacyFallback: registerLegacyTab,
|
|
194
|
+
}), 'vscode-mode: sidebar editor tab')
|
|
195
|
+
} else {
|
|
196
|
+
if (legacyDisposer !== null) return
|
|
197
|
+
if (sideDisposer !== null) { sideDisposer(); sideDisposer = null }
|
|
198
|
+
setEnsureSideEditor(null)
|
|
199
|
+
legacyDisposer = registerLegacyTab()
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
applySideForm()
|
|
203
|
+
let sideRetries = 0
|
|
204
|
+
const retrySideService = (): void => {
|
|
205
|
+
if (sideService !== undefined || sideRetries >= 15) return
|
|
206
|
+
sideRetries += 1
|
|
207
|
+
schedule(() => {
|
|
208
|
+
if (sideService !== undefined) return
|
|
209
|
+
sideService = detectSidebarService(ctx)
|
|
210
|
+
if (sideService !== undefined) {
|
|
211
|
+
console.info('[dsh-vscode-mode] 检测到 ' + SIDEBAR_PLUGIN + ',切换侧边栏编辑形态')
|
|
212
|
+
applySideForm()
|
|
213
|
+
} else {
|
|
214
|
+
retrySideService()
|
|
215
|
+
}
|
|
216
|
+
}, 2000)
|
|
174
217
|
}
|
|
218
|
+
retrySideService()
|
|
175
219
|
|
|
176
220
|
// 对话输入框上方差异 dock:普通对话显示单文案按钮,文件编辑页由 EditorView 隐藏
|
|
177
221
|
registerSlotSafely(ctx, {
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-vscode-mode client — 快捷键配置模块(解析/匹配/状态同步)。
|
|
3
|
+
* 纯逻辑(parseChord/formatChord/matchEvent/chordFromEvent/normalizeKey)不依赖 DOM,可单测;
|
|
4
|
+
* 模块状态由 settings 订阅驱动(client/index.ts 调 keybindingsApply)。
|
|
5
|
+
* 键位语义:Ctrl 与 Cmd 互认(延续现有 Ctrl+P/Ctrl+B 捕获行为)。
|
|
6
|
+
* 作者 ddj 2026年08月26号
|
|
7
|
+
*/
|
|
8
|
+
import React from 'react'
|
|
9
|
+
import { KEYBINDING_DEFAULTS, normalizeKeybindings } from '../shared/keybindings.js'
|
|
10
|
+
|
|
11
|
+
/** 解析后的键位(修饰符 + 规范化主键)。 */
|
|
12
|
+
export interface Binding {
|
|
13
|
+
ctrl: boolean
|
|
14
|
+
shift: boolean
|
|
15
|
+
alt: boolean
|
|
16
|
+
meta: boolean
|
|
17
|
+
key: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** 命令目录(设置页展示标签;执行按目录序先匹配先执行,冲突键位确定性)。 */
|
|
21
|
+
export const COMMANDS: Array<{ id: string; label: string }> = [
|
|
22
|
+
{ id: 'edrv.save', label: '保存文件' },
|
|
23
|
+
{ id: 'edrv.quickOpen', label: '快速打开文件' },
|
|
24
|
+
{ id: 'edrv.toggleSidebar', label: '切换侧边栏' },
|
|
25
|
+
{ id: 'edrv.searchInFiles', label: '在工作区中搜索' },
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
const MODIFIERS: Record<string, 'ctrl' | 'shift' | 'alt' | 'meta'> = {
|
|
29
|
+
ctrl: 'ctrl', cmd: 'meta', meta: 'meta', shift: 'shift', alt: 'alt',
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
let current: Record<string, string> = { ...KEYBINDING_DEFAULTS }
|
|
33
|
+
let parsed: Record<string, Binding | null> = {}
|
|
34
|
+
const listeners = new Set<() => void>()
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 应用设置快照(与默认值合并;未知 id 丢弃;空对象 = 全部默认)。
|
|
38
|
+
* @author ddj 2026年08月26号
|
|
39
|
+
* @param raw 设置 scope 的 keybindings 字段
|
|
40
|
+
*/
|
|
41
|
+
export function keybindingsApply(raw: unknown): void {
|
|
42
|
+
current = { ...KEYBINDING_DEFAULTS, ...normalizeKeybindings(raw) }
|
|
43
|
+
parsed = {}
|
|
44
|
+
for (const id of Object.keys(current)) parsed[id] = parseChord(current[id])
|
|
45
|
+
for (const listener of listeners) {
|
|
46
|
+
try { listener() } catch { /* 监听器异常不影响其他订阅 */ }
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 订阅键位配置变化。
|
|
52
|
+
* @author ddj 2026年08月26号
|
|
53
|
+
* @param listener 变化回调
|
|
54
|
+
* @returns 取消订阅函数
|
|
55
|
+
*/
|
|
56
|
+
export function subscribeKeybindings(listener: () => void): () => void {
|
|
57
|
+
listeners.add(listener)
|
|
58
|
+
return () => listeners.delete(listener)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 当前键位弦(未绑定/空 → null)。
|
|
63
|
+
* @author ddj 2026年08月26号
|
|
64
|
+
* @param id 命令 id
|
|
65
|
+
* @returns 键位弦或 null
|
|
66
|
+
*/
|
|
67
|
+
export function chordOf(id: string): string | null {
|
|
68
|
+
const chord = current[id]
|
|
69
|
+
return typeof chord === 'string' && chord.trim() !== '' ? chord : null
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* 当前命令的解析键位(未绑定/非法 → null)。
|
|
74
|
+
* @author ddj 2026年08月26号
|
|
75
|
+
* @param id 命令 id
|
|
76
|
+
* @returns 解析键位或 null
|
|
77
|
+
*/
|
|
78
|
+
export function bindingOf(id: string): Binding | null {
|
|
79
|
+
return parsed[id] ?? null
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* 主键规范化:小写字母大写、空格归一为 Space,其余原样(与事件 e.key 对照)。
|
|
84
|
+
* @author ddj 2026年08月26号
|
|
85
|
+
* @param key 事件主键
|
|
86
|
+
* @returns 规范化主键
|
|
87
|
+
*/
|
|
88
|
+
export function normalizeKey(key: string): string {
|
|
89
|
+
const value = String(key ?? '')
|
|
90
|
+
if (value === ' ') return 'Space'
|
|
91
|
+
if (value.length === 1 && value >= 'a' && value <= 'z') return value.toUpperCase()
|
|
92
|
+
return value
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* 解析键位弦(如 `Ctrl+Shift+F`;纯修饰键/重复主键/空 → null)。
|
|
97
|
+
* @author ddj 2026年08月26号
|
|
98
|
+
* @param chord 键位弦
|
|
99
|
+
* @returns 解析键位或 null
|
|
100
|
+
*/
|
|
101
|
+
export function parseChord(chord: string): Binding | null {
|
|
102
|
+
const parts = String(chord ?? '').split('+').map((part) => part.trim()).filter(Boolean)
|
|
103
|
+
if (!parts.length) return null
|
|
104
|
+
const binding: Binding = { ctrl: false, shift: false, alt: false, meta: false, key: '' }
|
|
105
|
+
for (const part of parts) {
|
|
106
|
+
const modifier = MODIFIERS[part.toLocaleLowerCase('en-US')]
|
|
107
|
+
if (modifier) {
|
|
108
|
+
binding[modifier] = true
|
|
109
|
+
continue
|
|
110
|
+
}
|
|
111
|
+
if (binding.key) return null
|
|
112
|
+
binding.key = normalizeKey(part)
|
|
113
|
+
}
|
|
114
|
+
return binding.key ? binding : null
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* 格式化键位(Ctrl/Cmd/Shift/Alt + 主键;meta 显示 Cmd)。
|
|
119
|
+
* @author ddj 2026年08月26号
|
|
120
|
+
* @param binding 解析键位
|
|
121
|
+
* @returns 键位弦
|
|
122
|
+
*/
|
|
123
|
+
export function formatChord(binding: Binding): string {
|
|
124
|
+
const parts: string[] = []
|
|
125
|
+
if (binding.ctrl) parts.push('Ctrl')
|
|
126
|
+
if (binding.meta) parts.push('Cmd')
|
|
127
|
+
if (binding.shift) parts.push('Shift')
|
|
128
|
+
if (binding.alt) parts.push('Alt')
|
|
129
|
+
parts.push(binding.key)
|
|
130
|
+
return parts.join('+')
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* 事件是否命中键位(Ctrl 与 Cmd 互认)。
|
|
135
|
+
* @author ddj 2026年08月26号
|
|
136
|
+
* @param e 键盘事件(鸭子类型,便于单测)
|
|
137
|
+
* @param binding 解析键位(null = 未绑定,永不命中)
|
|
138
|
+
* @returns 是否命中
|
|
139
|
+
*/
|
|
140
|
+
export function matchEvent(e: { ctrlKey?: boolean; metaKey?: boolean; shiftKey?: boolean; altKey?: boolean; key?: string }, binding: Binding | null): boolean {
|
|
141
|
+
if (!binding) return false
|
|
142
|
+
if ((e.ctrlKey || e.metaKey) !== (binding.ctrl || binding.meta)) return false
|
|
143
|
+
if (Boolean(e.shiftKey) !== binding.shift) return false
|
|
144
|
+
if (Boolean(e.altKey) !== binding.alt) return false
|
|
145
|
+
return normalizeKey(e.key ?? '') === binding.key
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* 从键盘事件构造键位弦(纯修饰键 → null,供设置页录制)。
|
|
150
|
+
* @author ddj 2026年08月26号
|
|
151
|
+
* @param e 键盘事件
|
|
152
|
+
* @returns 键位弦或 null
|
|
153
|
+
*/
|
|
154
|
+
export function chordFromEvent(e: { ctrlKey?: boolean; metaKey?: boolean; shiftKey?: boolean; altKey?: boolean; key?: string }): string | null {
|
|
155
|
+
const key = normalizeKey(e.key ?? '')
|
|
156
|
+
if (!key || key === 'Control' || key === 'Shift' || key === 'Alt' || key === 'Meta') return null
|
|
157
|
+
return formatChord({ ctrl: Boolean(e.ctrlKey), shift: Boolean(e.shiftKey), alt: Boolean(e.altKey), meta: Boolean(e.metaKey), key })
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* 键位版本 hook:配置变化时返回新版本(tooltip/占位文案随键位刷新)。
|
|
162
|
+
* @author ddj 2026年08月26号
|
|
163
|
+
* @returns 当前版本号
|
|
164
|
+
*/
|
|
165
|
+
export function useKeybindingsVersion(): number {
|
|
166
|
+
const [version, setVersion] = React.useState(0)
|
|
167
|
+
React.useEffect(() => subscribeKeybindings(() => setVersion((v) => v + 1)), [])
|
|
168
|
+
return version
|
|
169
|
+
}
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
/**
|
|
3
|
+
* dsh-vscode-mode client — 侧边栏「搜索」面板(VSCode 搜索视图形态)。
|
|
4
|
+
* 内容搜索:edrv.searchContent RPC;结果按文件分组展示命中行(行号 + 高亮 + 文件计数),
|
|
5
|
+
* 点击经 ctx.openFileAt 打开文件并跳转行列。
|
|
6
|
+
* 选项:大小写/全词/正则;折叠区(⚙ 齿轮展开):包含的文件(📖 toggle = 仅当前打开文件)
|
|
7
|
+
* 与排除的文件(⚙ toggle = 启用排除),模式为逗号分隔 rg glob;无搜索按钮,防抖 + 回车触发。
|
|
8
|
+
* 作者 ddj 2026-08-26
|
|
9
|
+
*/
|
|
10
|
+
import React from 'react'
|
|
11
|
+
import { rpc } from '../../rpc.js'
|
|
12
|
+
import type { SidebarCtx } from '../types.js'
|
|
13
|
+
|
|
14
|
+
const DEBOUNCE_MS = 250
|
|
15
|
+
const INCLUDE_PLACEHOLDER = '例如 *.ts, src/**/include'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 逗号切分 glob 列表(trim + 去空)。
|
|
19
|
+
* @param text 用户输入的逗号分隔模式
|
|
20
|
+
* @returns glob 数组
|
|
21
|
+
*/
|
|
22
|
+
export function splitGlobs(text) {
|
|
23
|
+
return String(text ?? '').split(',').map((item) => item.trim()).filter(Boolean)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 命中行文本三段切分(before/命中/after,列为 1-based UTF-16)。
|
|
28
|
+
* @param text 行文本
|
|
29
|
+
* @param start 起始列
|
|
30
|
+
* @param end 结束列
|
|
31
|
+
* @returns 三段文本
|
|
32
|
+
*/
|
|
33
|
+
function splitHit(text, start, end) {
|
|
34
|
+
const s = Math.max(0, (start | 0) - 1)
|
|
35
|
+
const e = Math.max(s, end | 0)
|
|
36
|
+
return { before: String(text).slice(0, s), hit: String(text).slice(s, e), after: String(text).slice(e) }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 搜索面板主体。
|
|
41
|
+
* @param props.ctx 面板共享上下文(sessionId/openFileAt/activePath)
|
|
42
|
+
*/
|
|
43
|
+
export function SearchPanel(props) {
|
|
44
|
+
const ctx = props?.ctx
|
|
45
|
+
const sessionId = ctx?.sessionId
|
|
46
|
+
const openFileAt = ctx?.openFileAt ?? (() => {})
|
|
47
|
+
const activePath = ctx?.activePath ?? null
|
|
48
|
+
const [query, setQuery] = React.useState('')
|
|
49
|
+
const [matchCase, setMatchCase] = React.useState(false)
|
|
50
|
+
const [wholeWord, setWholeWord] = React.useState(false)
|
|
51
|
+
const [regex, setRegex] = React.useState(false)
|
|
52
|
+
const [includeText, setIncludeText] = React.useState('')
|
|
53
|
+
const [excludeText, setExcludeText] = React.useState('')
|
|
54
|
+
const [onlyActive, setOnlyActive] = React.useState(false)
|
|
55
|
+
const [excludeOn, setExcludeOn] = React.useState(false)
|
|
56
|
+
const [sectionOpen, setSectionOpen] = React.useState(false)
|
|
57
|
+
const [status, setStatus] = React.useState('idle') // idle | searching | done | error
|
|
58
|
+
const [error, setError] = React.useState('')
|
|
59
|
+
const [matches, setMatches] = React.useState(null)
|
|
60
|
+
const [truncated, setTruncated] = React.useState(false)
|
|
61
|
+
const [collapsed, setCollapsed] = React.useState({})
|
|
62
|
+
const seqRef = React.useRef(0)
|
|
63
|
+
const timerRef = React.useRef(null)
|
|
64
|
+
const inputRef = React.useRef(null)
|
|
65
|
+
const queryRef = React.useRef('')
|
|
66
|
+
queryRef.current = query
|
|
67
|
+
const activePathRef = React.useRef(activePath)
|
|
68
|
+
activePathRef.current = activePath
|
|
69
|
+
// 请求参数 ref:防抖/立即重搜闭包统一读最新值,避免陈旧选项
|
|
70
|
+
const requestRef = React.useRef({ matchCase, wholeWord, regex, include: [], exclude: [] })
|
|
71
|
+
requestRef.current = {
|
|
72
|
+
matchCase, wholeWord, regex,
|
|
73
|
+
include: onlyActive ? (activePath ? [activePath] : []) : splitGlobs(includeText),
|
|
74
|
+
exclude: excludeOn ? splitGlobs(excludeText) : [],
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// 按会话恢复上次查询、选项与过滤模式(VSCode 记忆搜索词)
|
|
78
|
+
React.useEffect(() => {
|
|
79
|
+
if (!sessionId) return
|
|
80
|
+
try {
|
|
81
|
+
const saved = JSON.parse(localStorage.getItem('edrv.search.v1.' + String(sessionId)) || 'null')
|
|
82
|
+
if (saved && typeof saved === 'object') {
|
|
83
|
+
if (typeof saved.query === 'string') setQuery(saved.query)
|
|
84
|
+
if (typeof saved.matchCase === 'boolean') setMatchCase(saved.matchCase)
|
|
85
|
+
if (typeof saved.wholeWord === 'boolean') setWholeWord(saved.wholeWord)
|
|
86
|
+
if (typeof saved.regex === 'boolean') setRegex(saved.regex)
|
|
87
|
+
if (typeof saved.includeText === 'string') setIncludeText(saved.includeText)
|
|
88
|
+
if (typeof saved.excludeText === 'string') setExcludeText(saved.excludeText)
|
|
89
|
+
if (typeof saved.onlyActive === 'boolean') setOnlyActive(saved.onlyActive)
|
|
90
|
+
if (typeof saved.excludeOn === 'boolean') setExcludeOn(saved.excludeOn)
|
|
91
|
+
if (typeof saved.sectionOpen === 'boolean') setSectionOpen(saved.sectionOpen)
|
|
92
|
+
}
|
|
93
|
+
} catch (e) { /* 损坏忽略 */ }
|
|
94
|
+
}, [sessionId])
|
|
95
|
+
|
|
96
|
+
// 查询/选项/过滤变化 → 持久化
|
|
97
|
+
React.useEffect(() => {
|
|
98
|
+
if (!sessionId) return
|
|
99
|
+
try {
|
|
100
|
+
localStorage.setItem('edrv.search.v1.' + String(sessionId), JSON.stringify({
|
|
101
|
+
query, matchCase, wholeWord, regex, includeText, excludeText, onlyActive, excludeOn, sectionOpen,
|
|
102
|
+
}))
|
|
103
|
+
} catch (e) { /* 忽略 */ }
|
|
104
|
+
}, [sessionId, query, matchCase, wholeWord, regex, includeText, excludeText, onlyActive, excludeOn, sectionOpen])
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* 执行内容搜索(请求参数从 ref 读最新值)。
|
|
108
|
+
* @param text 搜索词
|
|
109
|
+
*/
|
|
110
|
+
const runSearch = (text) => {
|
|
111
|
+
const s = ++seqRef.current
|
|
112
|
+
const clean = String(text ?? '').trim()
|
|
113
|
+
if (clean.length < 2) { setMatches(null); setStatus('idle'); return }
|
|
114
|
+
const req = requestRef.current
|
|
115
|
+
setStatus('searching')
|
|
116
|
+
setError('')
|
|
117
|
+
rpc('edrv.searchContent', {
|
|
118
|
+
sessionId, query: clean,
|
|
119
|
+
matchCase: req.matchCase, wholeWord: req.wholeWord, regex: req.regex,
|
|
120
|
+
include: req.include, exclude: req.exclude,
|
|
121
|
+
}).then((res) => {
|
|
122
|
+
if (s !== seqRef.current) return
|
|
123
|
+
if (res && res.ok) {
|
|
124
|
+
setMatches(Array.isArray(res.matches) ? res.matches : [])
|
|
125
|
+
setTruncated(res.truncated === true)
|
|
126
|
+
setStatus('done')
|
|
127
|
+
} else {
|
|
128
|
+
setError(res?.error ? String(res.error) : '搜索失败')
|
|
129
|
+
setMatches(null)
|
|
130
|
+
setStatus('error')
|
|
131
|
+
}
|
|
132
|
+
}).catch((e) => {
|
|
133
|
+
if (s !== seqRef.current) return
|
|
134
|
+
setError('搜索异常:' + String(e))
|
|
135
|
+
setMatches(null)
|
|
136
|
+
setStatus('error')
|
|
137
|
+
})
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* 输入变化:防抖触发搜索。
|
|
142
|
+
* @param value 新输入
|
|
143
|
+
*/
|
|
144
|
+
const onChange = (value) => {
|
|
145
|
+
setQuery(value)
|
|
146
|
+
if (timerRef.current) clearTimeout(timerRef.current)
|
|
147
|
+
timerRef.current = setTimeout(() => runSearch(value), DEBOUNCE_MS)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* 输入框通用防抖重搜(包含/排除模式输入)。
|
|
152
|
+
* @param setter 状态写入函数
|
|
153
|
+
* @param value 新值
|
|
154
|
+
*/
|
|
155
|
+
const onChangeDebounced = (setter, value) => {
|
|
156
|
+
setter(value)
|
|
157
|
+
if (timerRef.current) clearTimeout(timerRef.current)
|
|
158
|
+
timerRef.current = setTimeout(() => runSearch(queryRef.current), DEBOUNCE_MS)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* 立即重搜(查询 ≥2 字符时);先同步 ref 避免本次调用读到旧请求参数。
|
|
163
|
+
* @param next 新的请求参数字段
|
|
164
|
+
*/
|
|
165
|
+
const rerun = (next) => {
|
|
166
|
+
requestRef.current = Object.assign({}, requestRef.current, next)
|
|
167
|
+
if (timerRef.current) clearTimeout(timerRef.current)
|
|
168
|
+
if (String(queryRef.current).trim().length >= 2) runSearch(queryRef.current)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** 选项开关切换(大小写/全词/正则/书本/设置):更新状态 + 立即重搜。 */
|
|
172
|
+
const toggleOpt = (key, value) => {
|
|
173
|
+
if (key === 'matchCase') setMatchCase(value)
|
|
174
|
+
if (key === 'wholeWord') setWholeWord(value)
|
|
175
|
+
if (key === 'regex') setRegex(value)
|
|
176
|
+
if (key === 'onlyActive') setOnlyActive(value)
|
|
177
|
+
if (key === 'excludeOn') setExcludeOn(value)
|
|
178
|
+
const base = { matchCase, wholeWord, regex, onlyActive, excludeOn }
|
|
179
|
+
base[key] = value
|
|
180
|
+
rerun({
|
|
181
|
+
matchCase: base.matchCase, wholeWord: base.wholeWord, regex: base.regex,
|
|
182
|
+
include: base.onlyActive ? (activePathRef.current ? [activePathRef.current] : []) : splitGlobs(includeText),
|
|
183
|
+
exclude: base.excludeOn ? splitGlobs(excludeText) : [],
|
|
184
|
+
})
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Ctrl+Shift+F 重复触发时聚焦输入框(EditorView 派发 edrv:search-focus)
|
|
188
|
+
React.useEffect(() => {
|
|
189
|
+
const onFocus = () => inputRef.current?.focus?.()
|
|
190
|
+
window.addEventListener('edrv:search-focus', onFocus)
|
|
191
|
+
return () => window.removeEventListener('edrv:search-focus', onFocus)
|
|
192
|
+
}, [])
|
|
193
|
+
|
|
194
|
+
// 结果按文件分组(保持 rg 输出顺序)
|
|
195
|
+
const groups = React.useMemo(() => {
|
|
196
|
+
if (!Array.isArray(matches)) return []
|
|
197
|
+
const map = new Map()
|
|
198
|
+
for (const m of matches) {
|
|
199
|
+
let list = map.get(m.path)
|
|
200
|
+
if (!list) { list = []; map.set(m.path, list) }
|
|
201
|
+
list.push(m)
|
|
202
|
+
}
|
|
203
|
+
return [...map.entries()].map(([path, list]) => ({ path, count: list.length, matches: list }))
|
|
204
|
+
}, [matches])
|
|
205
|
+
|
|
206
|
+
const totalFiles = groups.length
|
|
207
|
+
const totalMatches = Array.isArray(matches) ? matches.length : 0
|
|
208
|
+
const toggleGroup = (path) => setCollapsed((prev) => Object.assign({}, prev, { [path]: prev[path] === true ? false : true }))
|
|
209
|
+
|
|
210
|
+
const optsBar = React.createElement('div', { className: 'edrv-search-opts' },
|
|
211
|
+
React.createElement('button', { className: 'edrv-search-opt' + (matchCase ? ' on' : ''), title: '区分大小写', 'aria-pressed': matchCase, onClick: () => toggleOpt('matchCase', !matchCase) }, 'Aa'),
|
|
212
|
+
React.createElement('button', { className: 'edrv-search-opt' + (wholeWord ? ' on' : ''), title: '全字匹配', 'aria-pressed': wholeWord, onClick: () => toggleOpt('wholeWord', !wholeWord) }, 'Ab'),
|
|
213
|
+
React.createElement('button', { className: 'edrv-search-opt' + (regex ? ' on' : ''), title: '使用正则表达式', 'aria-pressed': regex, onClick: () => toggleOpt('regex', !regex) }, '.*'),
|
|
214
|
+
React.createElement('span', { className: 'edrv-search-spacer' }),
|
|
215
|
+
React.createElement('button', { className: 'edrv-search-opt' + (sectionOpen ? ' on' : ''), title: sectionOpen ? '收起文件过滤' : '文件过滤(包含/排除)', 'aria-pressed': sectionOpen, onClick: () => setSectionOpen((v) => !v) }, '⚙'))
|
|
216
|
+
|
|
217
|
+
const filterRows = sectionOpen
|
|
218
|
+
? React.createElement('div', { className: 'edrv-search-rows' },
|
|
219
|
+
React.createElement('div', { className: 'edrv-search-row' },
|
|
220
|
+
React.createElement('input', {
|
|
221
|
+
className: 'edrv-search-field' + (onlyActive ? ' dim' : ''), placeholder: INCLUDE_PLACEHOLDER,
|
|
222
|
+
value: includeText, disabled: onlyActive,
|
|
223
|
+
title: onlyActive ? '已启用「仅当前打开文件」,忽略此输入' : '',
|
|
224
|
+
onChange: (e) => onChangeDebounced(setIncludeText, e.target.value),
|
|
225
|
+
}),
|
|
226
|
+
React.createElement('button', {
|
|
227
|
+
className: 'edrv-search-toggle' + (onlyActive ? ' on' : ''), title: activePath ? '仅在当前打开的文件中搜索' : '无打开的文件(需先打开文件)',
|
|
228
|
+
'aria-pressed': onlyActive, disabled: !activePath,
|
|
229
|
+
onClick: () => toggleOpt('onlyActive', !onlyActive),
|
|
230
|
+
}, '📖')),
|
|
231
|
+
React.createElement('div', { className: 'edrv-search-row' },
|
|
232
|
+
React.createElement('input', {
|
|
233
|
+
className: 'edrv-search-field' + (excludeOn ? '' : ' dim'), placeholder: INCLUDE_PLACEHOLDER,
|
|
234
|
+
value: excludeText, disabled: !excludeOn,
|
|
235
|
+
title: excludeOn ? '' : '排除功能未启用,先点击右侧按钮开启',
|
|
236
|
+
onChange: (e) => onChangeDebounced(setExcludeText, e.target.value),
|
|
237
|
+
}),
|
|
238
|
+
React.createElement('button', {
|
|
239
|
+
className: 'edrv-search-toggle' + (excludeOn ? ' on' : ''), title: '启用排除文件(忽略下方模式的命中)',
|
|
240
|
+
'aria-pressed': excludeOn,
|
|
241
|
+
onClick: () => toggleOpt('excludeOn', !excludeOn),
|
|
242
|
+
}, '⚙')))
|
|
243
|
+
: null
|
|
244
|
+
|
|
245
|
+
const summary = status === 'done'
|
|
246
|
+
? React.createElement('div', { className: 'edrv-search-summary' },
|
|
247
|
+
totalFiles > 0
|
|
248
|
+
? totalFiles + ' 个文件 · ' + totalMatches + ' 处匹配' + (truncated ? '(已截断)' : '')
|
|
249
|
+
: '无匹配结果')
|
|
250
|
+
: null
|
|
251
|
+
|
|
252
|
+
const body = (() => {
|
|
253
|
+
if (status === 'searching') return React.createElement('div', { className: 'edrv-search-empty' }, '搜索中…')
|
|
254
|
+
if (status === 'error') return React.createElement('div', { className: 'edrv-search-error' }, error)
|
|
255
|
+
if (status === 'done' && !groups.length) return React.createElement('div', { className: 'edrv-search-empty' }, '无匹配结果')
|
|
256
|
+
if (status !== 'done') return React.createElement('div', { className: 'edrv-search-empty' }, '输入至少 2 个字符开始搜索')
|
|
257
|
+
return React.createElement('div', { className: 'edrv-search-results' },
|
|
258
|
+
groups.map((group) => {
|
|
259
|
+
const open = collapsed[group.path] !== true
|
|
260
|
+
return React.createElement('div', { key: group.path, className: 'edrv-search-group' },
|
|
261
|
+
React.createElement('div', { className: 'edrv-search-file', title: group.path, onClick: () => toggleGroup(group.path) },
|
|
262
|
+
React.createElement('span', { className: 'edrv-search-chev' }, open ? '▾' : '▸'),
|
|
263
|
+
React.createElement('span', { className: 'edrv-search-fname' }, String(group.path).split(/[\\/]/).pop() || group.path),
|
|
264
|
+
React.createElement('span', { className: 'edrv-search-fdir' }, String(group.path).split(/[\\/]/).slice(0, -1).join('/')),
|
|
265
|
+
React.createElement('span', { className: 'edrv-search-fcount' }, group.count)),
|
|
266
|
+
open ? group.matches.map((m, i) => {
|
|
267
|
+
const parts = splitHit(m.text, m.startColumn, m.endColumn)
|
|
268
|
+
return React.createElement('div', { key: m.path + ':' + m.line + ':' + i, className: 'edrv-search-line' + (i % 2 ? ' alt' : ''), title: group.path + ':' + m.line, onClick: () => openFileAt(m.path, m.line, m.startColumn) },
|
|
269
|
+
React.createElement('span', { className: 'edrv-search-ln' }, m.line),
|
|
270
|
+
React.createElement('span', { className: 'edrv-search-text' },
|
|
271
|
+
React.createElement('span', null, parts.before),
|
|
272
|
+
React.createElement('mark', { className: 'edrv-search-hit' }, parts.hit),
|
|
273
|
+
React.createElement('span', null, parts.after)))
|
|
274
|
+
}) : null)
|
|
275
|
+
}))
|
|
276
|
+
})()
|
|
277
|
+
|
|
278
|
+
return React.createElement('div', { className: 'edrv-search-panel' },
|
|
279
|
+
React.createElement('div', { className: 'edrv-search-qrow' },
|
|
280
|
+
React.createElement('input', {
|
|
281
|
+
ref: inputRef, className: 'edrv-search-input', autoFocus: true, autoComplete: 'off', spellCheck: false,
|
|
282
|
+
placeholder: '搜索',
|
|
283
|
+
value: query,
|
|
284
|
+
onChange: (e) => onChange(e.target.value),
|
|
285
|
+
onKeyDown: (e) => {
|
|
286
|
+
if (e.key === 'Enter') { if (timerRef.current) clearTimeout(timerRef.current); runSearch(queryRef.current) }
|
|
287
|
+
if (e.key === 'Escape') inputRef.current?.blur?.()
|
|
288
|
+
},
|
|
289
|
+
})),
|
|
290
|
+
optsBar,
|
|
291
|
+
filterRows,
|
|
292
|
+
summary,
|
|
293
|
+
body)
|
|
294
|
+
}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
// @ts-nocheck
|
|
2
2
|
/**
|
|
3
|
-
* dsh-vscode-mode client —
|
|
4
|
-
* 作者 ddj 2026-08-26
|
|
3
|
+
* dsh-vscode-mode client — 侧边栏面板定义(文件管理 + 搜索)。
|
|
4
|
+
* 作者 ddj 2026-08-26 / 2026-08-26
|
|
5
5
|
*/
|
|
6
6
|
import React from 'react'
|
|
7
7
|
import { FileExplorer } from './FileExplorer.js'
|
|
8
|
+
import { SearchPanel } from './SearchPanel.js'
|
|
8
9
|
import type { SidebarPanelDef, SidebarCtx } from '../types.js'
|
|
9
10
|
|
|
10
11
|
/**
|
|
@@ -22,3 +23,18 @@ export function createFilePanel(): SidebarPanelDef {
|
|
|
22
23
|
render: (ctx: SidebarCtx) => React.createElement(FileExplorer, { ctx }),
|
|
23
24
|
}
|
|
24
25
|
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 构造搜索面板定义。
|
|
29
|
+
* @author ddj 2026年08月30号
|
|
30
|
+
* @returns 面板定义(无徽标;活动栏图标 🔍)
|
|
31
|
+
*/
|
|
32
|
+
export function createSearchPanel(): SidebarPanelDef {
|
|
33
|
+
return {
|
|
34
|
+
id: 'search',
|
|
35
|
+
title: '搜索',
|
|
36
|
+
icon: '🔍',
|
|
37
|
+
order: 15,
|
|
38
|
+
render: (ctx: SidebarCtx) => React.createElement(SearchPanel, { ctx }),
|
|
39
|
+
}
|
|
40
|
+
}
|