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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-vscode-mode",
3
- "version": "0.1.27",
3
+ "version": "0.1.29",
4
4
  "description": "DSH 上的类 VSCode 编码体验:Monaco 编辑器(文件页签/QuickOpen/状态栏,可驻 dsh-better-sidebar 侧边栏与对话同屏)+ Agent 编辑差异审查(整文件差异/采纳/拒绝/归档/回滚),状态持久化到工作区旁车",
5
5
  "keywords": [
6
6
  "dsh",
@@ -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
 
@@ -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
- const sideService = detectSidebarService(ctx)
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
- if (sideService) {
159
- // 侧边栏形态:注册「文件编辑」Tab(单实例、可按会话持久化、内容打开自动展开面板)
160
- ctx.effect(() => installSideEditor({
161
- service: sideService,
162
- renderTab: (props: Record<string, unknown>) => React.createElement(SideEditorTab, Object.assign({}, props, { schedule, addToConversation, sidebarPanels, outlineSources, fileMenuItems })),
163
- activeSession: () => {
164
- const snapshot = sessions?.list?.getSnapshot?.() as { current?: string; byId?: Record<string, { cwd?: string }> } | undefined
165
- const sessionId = snapshot?.current
166
- if (!sessionId) return undefined
167
- return { sessionId, cwd: snapshot?.byId?.[sessionId]?.cwd }
168
- },
169
- registerLegacyFallback: registerLegacyTab,
170
- }), 'vscode-mode: sidebar editor tab')
171
- } else {
172
- setEnsureSideEditor(null)
173
- registerLegacyTab()
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
+ }