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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-vscode-mode",
3
- "version": "0.1.28",
3
+ "version": "0.1.30",
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
  }
@@ -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
- * 诊断日志:统一走 host edrv.debug 落盘,避免 console 刷屏。
20
- * @author ddj 2026年08月20号
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>