dsh-vscode-mode 0.1.28 → 0.1.30
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 +859 -27
- package/lib/client.js.map +1 -1
- package/lib/index.js +513 -41
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/client/compat.ts +1 -1
- package/src/client/index.ts +15 -0
- package/src/client/keybindings.ts +169 -0
- package/src/client/monaco/diffRender.ts +7 -0
- package/src/client/rpc.ts +13 -2
- 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 +76 -15
- 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 +106 -22
- 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
|
}
|
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 提供的
|
|
@@ -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,
|
|
@@ -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
|
+
}
|
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
export function createDiffRenderer(log) {
|
|
13
13
|
let viewZoneIds = []
|
|
14
14
|
let decorations = []
|
|
15
|
+
/** 上次已渲染的差异指纹(callId:idx@start-end:oldLen/newLen 串):内容未变时跳过重建。 */
|
|
16
|
+
let lastKey = null
|
|
15
17
|
|
|
16
18
|
/**
|
|
17
19
|
* 渲染当前 pending 差异:绿底 decoration + 删除块 view zones + '-' 号 overlay(滚动同步)。
|
|
@@ -26,6 +28,11 @@ export function createDiffRenderer(log) {
|
|
|
26
28
|
if (!pendingRegions.length && viewZoneIds.length === 0 && decorations.length === 0) return
|
|
27
29
|
const renderT0 = Date.now()
|
|
28
30
|
const callIdAttr = (callId, idx) => String(callId) + ':' + String(idx)
|
|
31
|
+
// 指纹相同 = 内容未变(轮询/引用空转/重复点击触发的重渲染):残留 DOM 已在位,
|
|
32
|
+
// 直接跳过整轮重建(view zones 拆建 / overlay DOM / 50ms 校正),消除同区域重复渲染成本。
|
|
33
|
+
const key = pendingRegions.map((r) => callIdAttr(r.callId, r.idx) + '@' + (r.start ?? '?') + '-' + (r.end ?? '?') + ':' + (r.oldLines ? r.oldLines.length : 0) + '/' + (r.newLines ? r.newLines.length : 0)).join('|')
|
|
34
|
+
if (key === lastKey) return
|
|
35
|
+
lastKey = key
|
|
29
36
|
log(sessionId, '[diff-render] begin regs=' + pendingRegions.length + ' ' + pendingRegions.map((r) => callIdAttr(r.callId, r.idx) + '@' + (r.start ?? '?') + '-' + (r.end ?? '?') + '(-' + (r.oldLines ? r.oldLines.length : 0) + '/+' + (r.newLines ? r.newLines.length : 0) + ')').join('|'))
|
|
30
37
|
|
|
31
38
|
const decos = []
|
package/src/client/rpc.ts
CHANGED
|
@@ -6,6 +6,16 @@
|
|
|
6
6
|
import { RPC_PATH } from '../shared/rpc.js'
|
|
7
7
|
import type { RpcMethod, RpcRequestMap, RpcResult } from '../shared/rpc.js'
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* 诊断日志开关(默认关):diff 渲染热路径每条日志 = 1 次 RPC 往返 +
|
|
11
|
+
* host 端 debug 文件全量读改写(上限 512KB),高频渲染下开销可观。
|
|
12
|
+
* 排查问题时在浏览器控制台执行 localStorage.setItem('edrv.debug','1') 后刷新开启。
|
|
13
|
+
* @author ddj 2026年08月26号
|
|
14
|
+
*/
|
|
15
|
+
const DEBUG = (() => {
|
|
16
|
+
try { return localStorage.getItem('edrv.debug') === '1' } catch (e) { return false }
|
|
17
|
+
})()
|
|
18
|
+
|
|
9
19
|
/** 同源 RPC 调用(host /edrv/rpc 精确路由)。 */
|
|
10
20
|
export function rpc<M extends RpcMethod>(method: M, args: RpcRequestMap[M]): Promise<RpcResult<M>> {
|
|
11
21
|
return fetch(RPC_PATH, {
|
|
@@ -16,12 +26,13 @@ export function rpc<M extends RpcMethod>(method: M, args: RpcRequestMap[M]): Pro
|
|
|
16
26
|
}
|
|
17
27
|
|
|
18
28
|
/**
|
|
19
|
-
*
|
|
20
|
-
* @author ddj 2026年08月
|
|
29
|
+
* 诊断日志:开关关闭时直接返回(零 RPC 零落盘);开启时走 host edrv.debug 落盘。
|
|
30
|
+
* @author ddj 2026年08月26号
|
|
21
31
|
* @param sessionId 会话 id
|
|
22
32
|
* @param text 日志文本
|
|
23
33
|
*/
|
|
24
34
|
export function dbg(sessionId: string | undefined, text: string): void {
|
|
35
|
+
if (!DEBUG) return
|
|
25
36
|
try {
|
|
26
37
|
rpc('edrv.debug', { sessionId, text }).catch(() => {})
|
|
27
38
|
} catch (e) { /* 日志失败忽略 */ }
|
|
@@ -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
|
+
}
|
|
@@ -10,6 +10,8 @@ import type { TreeMenuRegistry } from './contextMenu.js'
|
|
|
10
10
|
export interface SidebarCtx {
|
|
11
11
|
sessionId?: string
|
|
12
12
|
openFile: (path: string) => void
|
|
13
|
+
/** 打开文件并跳转到指定行/列(搜索面板命中跳转;缺省仅打开)。 */
|
|
14
|
+
openFileAt?: (path: string, line?: number, column?: number) => void
|
|
13
15
|
activePath: string | null
|
|
14
16
|
/** 文件路径 → 待处理差异数(>0 才收录)。 */
|
|
15
17
|
pendingByPath: Record<string, number>
|