dsh-vscode-mode 0.1.49 → 0.1.50

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.49",
3
+ "version": "0.1.50",
4
4
  "description": "DSH 上的类 VSCode 编码体验:Monaco 编辑器(文件页签/QuickOpen/状态栏,可驻 dsh-better-sidebar 侧边栏与对话同屏)+ Agent 编辑差异审查(整文件差异/采纳/拒绝/归档/回滚)+ 语言服务器(LSP)智能(跳转定义/引用/hover/大纲 + VSIX 扩展市场安装),状态持久化到工作区旁车",
5
5
  "keywords": [
6
6
  "dsh",
@@ -0,0 +1,141 @@
1
+ /**
2
+ * dsh-vscode-mode client — 规则 frontmatter 纯函数(解析 + 按类型改写)。
3
+ * 与 host parseRuleMdc/ruleTypeOf 语义对齐的浏览器端实现:编辑器类型下拉与
4
+ * 原始 .mdc 文本域双向同步共用;纯字符串运算,禁 node/react 导入。
5
+ * 作者 ddj 2026年09月07号
6
+ */
7
+
8
+ /** 规则生效类型(与 shared/rules.ts RuleType 一致,此处独立声明避免引入 host 依赖)。 */
9
+ export type RuleFmType = 'always' | 'auto' | 'manual'
10
+
11
+ /** 一条 .mdc 文本的解析结果(改写 frontmatter 所需的全部上下文)。 */
12
+ export interface RuleFmParsed {
13
+ type: RuleFmType
14
+ description: string
15
+ alwaysApply: boolean
16
+ globs: string[]
17
+ enabled: boolean
18
+ /** 是否存在规则 frontmatter(首行 --- 且有闭合)。 */
19
+ hasFm: boolean
20
+ /** 闭合 --- 之后的正文(按 \n 归一,与 host 解析一致)。 */
21
+ body: string
22
+ /** 已知键之外的 frontmatter 原行(如 updatedAt),改写时原样保留。 */
23
+ fmExtra: string[]
24
+ /** 原文是否带 BOM(改写后回填)。 */
25
+ bom: boolean
26
+ /** 原文 frontmatter 是否显式写了 enabled 行(改写时保持该行存在与否)。 */
27
+ hasEnabledLine: boolean
28
+ }
29
+
30
+ /** 类型改写入参:下拉/描述/globs 控件的最新值。 */
31
+ export interface RuleMetaNext {
32
+ type: RuleFmType
33
+ description: string
34
+ globs: string[]
35
+ }
36
+
37
+ /**
38
+ * 去除值两侧成对引号(与 host stripQuotes 一致)。
39
+ * @author ddj 2026年09月07号
40
+ * @param raw 原始值
41
+ * @returns 去引号后的值
42
+ */
43
+ function stripQuotes(raw: string): string {
44
+ const value = raw.trim()
45
+ if (value.length >= 2 && ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")))) {
46
+ return value.slice(1, -1)
47
+ }
48
+ return value
49
+ }
50
+
51
+ // --region 解析
52
+ /**
53
+ * 解析 .mdc 全文:首行 --- 且 100 行内有闭合 --- 才视为 frontmatter;
54
+ * 已知键 description/alwaysApply/globs/enabled,其余原行进 fmExtra;永不抛错。
55
+ * @author ddj 2026年09月07号
56
+ * @param text 文件全文
57
+ * @returns 解析结果
58
+ */
59
+ export function parseRuleFm(text: string): RuleFmParsed {
60
+ const bom = text.startsWith('\uFEFF')
61
+ const lines = text.replace(/^\uFEFF/, '').split(/\r?\n/)
62
+ const none: RuleFmParsed = { type: 'manual', description: '', alwaysApply: false, globs: [], enabled: true, hasFm: false, body: text.replace(/^\uFEFF/, ''), fmExtra: [], bom, hasEnabledLine: false }
63
+ if (lines[0]?.trim() !== '---') return none
64
+ let close = -1
65
+ for (let i = 1; i < lines.length && i <= 101; i++) {
66
+ if (lines[i].trim() === '---') { close = i; break }
67
+ }
68
+ if (close < 0) return none
69
+ const parsed: RuleFmParsed = { type: 'manual', description: '', alwaysApply: false, globs: [], enabled: true, hasFm: true, body: lines.slice(close + 1).join('\n'), fmExtra: [], bom, hasEnabledLine: false }
70
+ for (let i = 1; i < close; i++) {
71
+ const match = /^([A-Za-z_-]+)[ \t]*:[ \t]?(.*)$/.exec(lines[i])
72
+ if (!match) { parsed.fmExtra.push(lines[i]); continue }
73
+ const [, key, raw] = match
74
+ if (key === 'description') parsed.description = stripQuotes(raw)
75
+ else if (key === 'alwaysApply') parsed.alwaysApply = stripQuotes(raw).toLowerCase() === 'true'
76
+ else if (key === 'enabled') { parsed.enabled = stripQuotes(raw).toLowerCase() !== 'false'; parsed.hasEnabledLine = true }
77
+ else if (key === 'globs') parsed.globs = parseGlobsValue(raw, lines, i, close)
78
+ else parsed.fmExtra.push(lines[i])
79
+ }
80
+ parsed.type = fmTypeOf(parsed)
81
+ return parsed
82
+ }
83
+
84
+ /**
85
+ * 解析 globs 值:内联逗号分隔串,或空值后接 YAML 短横列表(与 host parseGlobs 一致)。
86
+ * @author ddj 2026年09月07号
87
+ * @param value 内联值
88
+ * @param lines 全文行
89
+ * @param start globs 行下标
90
+ * @param close 闭合 --- 下标
91
+ * @returns glob 数组
92
+ */
93
+ function parseGlobsValue(value: string, lines: string[], start: number, close: number): string[] {
94
+ const inline = stripQuotes(value)
95
+ if (inline) return inline.split(',').map((item) => stripQuotes(item)).filter(Boolean)
96
+ const list: string[] = []
97
+ for (let j = start + 1; j < close; j++) {
98
+ const item = /^[ \t]*-[ \t]*(.+)$/.exec(lines[j])
99
+ if (!item) break
100
+ list.push(stripQuotes(item[1]))
101
+ }
102
+ return list
103
+ }
104
+
105
+ /**
106
+ * 由 frontmatter 推导类型:alwaysApply=true → 总是;有 globs → 自动;否则手动。
107
+ * @author ddj 2026年09月07号
108
+ * @param parsed 解析结果
109
+ * @returns 规则类型
110
+ */
111
+ function fmTypeOf(parsed: RuleFmParsed): RuleFmType {
112
+ if (parsed.alwaysApply) return 'always'
113
+ if (parsed.globs.length) return 'auto'
114
+ return 'manual'
115
+ }
116
+ // --endregion
117
+
118
+ // --region 改写
119
+ /**
120
+ * 按最新控件值重写 frontmatter:known 键固定顺序 + fmExtra + 闭合 --- + 原 body;
121
+ * 无 frontmatter 且目标为手动时:有描述则新建仅含描述的 frontmatter,否则原文返回。
122
+ * @author ddj 2026年09月07号
123
+ * @param parsed parseRuleFm 的结果(必须来自同一文本)
124
+ * @param next 控件最新值
125
+ * @returns 重写后的全文
126
+ */
127
+ export function applyRuleMeta(parsed: RuleFmParsed, next: RuleMetaNext): string {
128
+ if (!parsed.hasFm && next.type === 'manual') {
129
+ if (!next.description.trim()) return (parsed.bom ? '\uFEFF' : '') + parsed.body
130
+ return (parsed.bom ? '\uFEFF' : '') + ['---', 'description: ' + next.description.trim(), '---'].join('\n') + '\n' + parsed.body
131
+ }
132
+ const lines: string[] = ['---']
133
+ lines.push('description: ' + next.description.trim())
134
+ if (next.type === 'always') lines.push('alwaysApply: true')
135
+ if (next.type === 'auto') lines.push('globs: ' + next.globs.join(', '))
136
+ if (parsed.hasEnabledLine) lines.push('enabled: ' + (parsed.enabled ? 'true' : 'false'))
137
+ lines.push(...parsed.fmExtra)
138
+ lines.push('---')
139
+ return (parsed.bom ? '\uFEFF' : '') + lines.join('\n') + '\n' + parsed.body
140
+ }
141
+ // --endregion
@@ -125,7 +125,8 @@ export function SidebarView(props) {
125
125
 
126
126
  return React.createElement('div', {
127
127
  className: 'edrv-sidebar' + (visible ? '' : ' edrv-sidebar-rail-only'),
128
- style: { width: visible ? width : RAIL_ONLY_W + SEAM_W },
128
+ // maxWidth 兜底:持久化宽度超过容器可用空间时也不允许布局宽溢出(配合 flex-shrink:1 收缩)
129
+ style: { width: visible ? width : RAIL_ONLY_W + SEAM_W, maxWidth: '100%' },
129
130
  },
130
131
  rail,
131
132
  visible
@@ -2,18 +2,30 @@
2
2
  /**
3
3
  * dsh-vscode-mode client — 侧边栏「规则」面板(Codebuddy 规则管理形态)。
4
4
  * 双 Tab:用户规则(~/.dsh/rules/)与项目规则(<工作区>/.dsh/rules/);
5
- * 列表行 = 文件名 + 相对提示 + 类型徽标(总是/自动/手动)+ 描述 + 编辑/删除/启用开关;
6
- * 新建/编辑 = 内嵌表单(文件名 + 原始 .mdc 文本域,frontmatter 可见),保存走 rules.save。
7
- * 作者 ddj 2026年09月03号
5
+ * 列表行 = 文件名 + 相对提示,第二行类型徽标与描述,右侧 编辑/删除 图标 + 滑动启用开关;
6
+ * 新建/编辑 = 顶部面包屑 + 规则类型下拉(总是/自动/手动)+ 描述/globs 控件,与原始 .mdc
7
+ * 文本域双向同步(控件改写 frontmatter,文本域回读控件),保存走 rules.save。
8
+ * 作者 ddj 2026年09月03号(2026年09月07号 交互性调整)
8
9
  */
9
10
  import React from 'react'
10
11
  import { rpc } from '../../rpc.js'
11
12
  import type { SidebarCtx } from '../types.js'
12
13
  import { CACHE_KEY } from '../../paths.js'
14
+ import { parseRuleFm, applyRuleMeta } from '../../rulesMdc.js'
13
15
 
14
16
  /** 新建规则模板(frontmatter 字段与 host parseRuleMdc 对齐)。 */
15
17
  const NEW_TEMPLATE = '---\ndescription: \nalwaysApply: true\n---\n\n'
16
18
  const TYPE_LABEL = { always: '总是', auto: '自动', manual: '手动' }
19
+ /** 列表行描述的基础显示上限(字符数;宽面板下的封顶值,超出省略号)。 */
20
+ const DESC_MAX = 24
21
+ /** 动态字数下限(极窄面板的保底可见字符数)。 */
22
+ const DESC_CAP_MIN = 8
23
+ /** 动态字数估算:描述之外的行内固定占用 px(动作区/类型标签/徽标/间距/省略余量)。 */
24
+ const DESC_RESERVE_PX = 210
25
+ /** 动态字数估算:单字符平均宽度 px(11px 字号,中英混排)。 */
26
+ const DESC_CHAR_PX = 10
27
+ /** 窄面板阈值:低于此宽度隐藏路径提示(编辑界面面包屑与悬停仍有完整路径)。 */
28
+ const NARROW_W = 420
17
29
 
18
30
  /**
19
31
  * 读 localStorage(损坏/不可用安全)。
@@ -45,9 +57,85 @@ function typeBadge(type) {
45
57
  return React.createElement('span', { className: 'edrv-rules-type edrv-rules-type-' + type }, TYPE_LABEL[type] ?? type)
46
58
  }
47
59
 
60
+ /**
61
+ * 按字符上限截断列表行描述,超出以省略号收尾(上限随面板宽度动态变化;
62
+ * 窄面板下 CSS 省略号仍会按像素提前截断,二者取更小)。
63
+ * @author ddj 2026年09月07号
64
+ * @param text 完整描述
65
+ * @param cap 本帧字符上限
66
+ * @returns 截断后的展示文本
67
+ */
68
+ function clampDesc(text, cap) {
69
+ const chars = Array.from(String(text ?? '')) // 按码位切分,避免把 emoji 等代理对从中间截断
70
+ const limit = Math.max(4, Number(cap) || DESC_MAX)
71
+ return chars.length > limit ? chars.slice(0, limit).join('') + '…' : chars.join('')
72
+ }
73
+
74
+ /**
75
+ * 由面板宽度估算描述可用字符数:随宽度动态变化,并与基础上限取小、极窄时保底。
76
+ * @author ddj 2026年09月07号
77
+ * @param panelWidth 面板内容宽度(px)
78
+ * @returns 本帧描述字符上限
79
+ */
80
+ function descCapOf(panelWidth) {
81
+ const byWidth = Math.floor((Number(panelWidth) - DESC_RESERVE_PX) / DESC_CHAR_PX)
82
+ return Math.max(DESC_CAP_MIN, Math.min(DESC_MAX, byWidth))
83
+ }
84
+
85
+ /**
86
+ * 编辑图标(线性铅笔,与 DiffBox chevron 同风格:currentColor 描边装饰图)。
87
+ * @author ddj 2026年09月07号
88
+ * @returns 13×13 装饰性 SVG
89
+ */
90
+ function pencilIcon() {
91
+ return React.createElement('svg', {
92
+ width: 13, height: 13, viewBox: '0 0 24 24', fill: 'none', 'aria-hidden': true, focusable: false,
93
+ }, React.createElement('path', {
94
+ d: 'M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z',
95
+ stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round',
96
+ }))
97
+ }
98
+
99
+ /**
100
+ * 删除图标(线性垃圾桶,风格同 pencilIcon)。
101
+ * @author ddj 2026年09月07号
102
+ * @returns 13×13 装饰性 SVG
103
+ */
104
+ function trashIcon() {
105
+ return React.createElement('svg', {
106
+ width: 13, height: 13, viewBox: '0 0 24 24', fill: 'none', 'aria-hidden': true, focusable: false,
107
+ }, React.createElement('path', {
108
+ d: 'M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2m3 0v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6h14zM10 11v6M14 11v6',
109
+ stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round',
110
+ }))
111
+ }
112
+
113
+ /**
114
+ * 逗号分隔的 globs 输入串 → 数组(去空项)。
115
+ * @author ddj 2026年09月07号
116
+ * @param text 输入串
117
+ * @returns glob 数组
118
+ */
119
+ function globList(text) {
120
+ return String(text ?? '').split(',').map((item) => item.trim()).filter(Boolean)
121
+ }
122
+
123
+ /**
124
+ * 由控件值重建 .mdc 全文:frontmatter 按类型/描述/globs 重写,正文与其余键不动。
125
+ * @author ddj 2026年09月07号
126
+ * @param base 当前全文
127
+ * @param fmType 目标类型(always/auto/manual)
128
+ * @param desc 描述文本
129
+ * @param globsText globs 输入串
130
+ * @returns 重写后的全文
131
+ */
132
+ function rebuildContent(base, fmType, desc, globsText) {
133
+ return applyRuleMeta(parseRuleFm(base), { type: fmType, description: desc, globs: globList(globsText) })
134
+ }
135
+
48
136
  // --region 列表行
49
137
  /**
50
- * 渲染一条规则行:文件名 + 相对提示 + 类型徽标,第二行描述;右侧编辑/删除/开关。
138
+ * 渲染一条规则行:左侧两行(文件名+相对提示 / 类型徽标+描述),右侧 编辑/删除 图标 + 滑动开关。
51
139
  * @author ddj 2026年09月03号
52
140
  * @param props.rule 规则元信息
53
141
  * @param props.onEdit 编辑回调
@@ -61,14 +149,17 @@ function RuleRow(props) {
61
149
  .filter(Boolean).join(' · ')
62
150
  return React.createElement('div', { className: 'edrv-rules-row' + (rule.enabled ? '' : ' off') },
63
151
  React.createElement('div', { className: 'edrv-rules-main', onClick: () => props.onEdit(rule), title: rule.absPath },
64
- React.createElement('span', { className: 'edrv-rules-file' }, rule.file),
65
- React.createElement('span', { className: 'edrv-rules-path' }, '[' + rule.relHint + rule.file + ']'),
66
- typeBadge(rule.type),
67
- !rule.enabled ? React.createElement('span', { className: 'edrv-rules-offtag' }, '已停用') : null),
68
- React.createElement('div', { className: 'edrv-rules-desc', title: desc }, desc ? '描述: ' + desc : '描述: —'),
152
+ React.createElement('div', { className: 'edrv-rules-line1' },
153
+ React.createElement('span', { className: 'edrv-rules-file' }, rule.file),
154
+ !props.narrow ? React.createElement('span', { className: 'edrv-rules-path' }, '[' + rule.relHint + rule.file + ']') : null,
155
+ !rule.enabled ? React.createElement('span', { className: 'edrv-rules-offtag' }, '已停用') : null),
156
+ React.createElement('div', { className: 'edrv-rules-sub' },
157
+ React.createElement('span', { className: 'edrv-rules-sublabel' }, '类型:'),
158
+ typeBadge(rule.type),
159
+ desc ? React.createElement('span', { className: 'edrv-rules-desc', title: desc }, clampDesc(desc, props.descCap)) : null)),
69
160
  React.createElement('div', { className: 'edrv-rules-actions' },
70
- React.createElement('button', { className: 'edrv-rules-act', title: '编辑', onClick: () => props.onEdit(rule) }, '✏️'),
71
- React.createElement('button', { className: 'edrv-rules-act', title: '删除', onClick: () => props.onRemove(rule) }, '🗑'),
161
+ React.createElement('button', { className: 'edrv-rules-act', title: '编辑', onClick: () => props.onEdit(rule) }, pencilIcon()),
162
+ React.createElement('button', { className: 'edrv-rules-act', title: '删除', onClick: () => props.onRemove(rule) }, trashIcon()),
72
163
  React.createElement('button', {
73
164
  className: 'edrv-rules-switch' + (rule.enabled ? ' on' : ''), role: 'switch', 'aria-checked': rule.enabled,
74
165
  title: rule.enabled ? '已启用(点击停用)' : '已停用(点击启用)',
@@ -79,10 +170,13 @@ function RuleRow(props) {
79
170
 
80
171
  // --region 内嵌编辑器
81
172
  /**
82
- * 新建/编辑表单:文件名(仅新建可改)+ 原始 .mdc 文本域 + 保存/取消。
173
+ * 新建/编辑表单:面包屑头部 + 规则类型下拉/描述/globs 控件行 + 原始 .mdc 文本域 + 保存/取消。
83
174
  * @author ddj 2026年09月03号
84
- * @param props.form 表单状态(mode/scope/workspacePath/file/content/error/saving)
85
- * @param props.setForm 表单更新函数
175
+ * @param props.form 表单状态(mode/scope/workspacePath/relHint/file/content/fmType/desc/globs/error/saving)
176
+ * @param props.onType 类型下拉变更回调
177
+ * @param props.onDesc 描述输入回调
178
+ * @param props.onGlobs globs 输入回调
179
+ * @param props.onBody 文本域编辑回调
86
180
  * @param props.onSave 保存回调
87
181
  * @param props.onCancel 取消回调
88
182
  * @returns 表单元素
@@ -90,23 +184,33 @@ function RuleRow(props) {
90
184
  function RuleEditor(props) {
91
185
  const form = props.form
92
186
  const isNew = form.mode === 'new'
187
+ const crumb = (form.scope === 'project' ? '项目规则' : '用户规则') + ' > ' + form.relHint + ' > ' + (form.file || '新建规则.mdc')
93
188
  return React.createElement('div', { className: 'edrv-rules-editor' },
94
189
  React.createElement('div', { className: 'edrv-rules-editor-head' },
95
- isNew ? '新建' + (form.scope === 'project' ? '项目' : '用户') + '规则' : '编辑:' + form.file,
190
+ React.createElement('span', { className: 'edrv-rules-crumb', title: crumb }, crumb),
96
191
  React.createElement('button', { className: 'edrv-rules-act', title: '取消', onClick: props.onCancel }, '✕')),
97
- React.createElement('div', { className: 'edrv-rules-editor-file' },
98
- React.createElement('label', null, '文件名'),
192
+ React.createElement('div', { className: 'edrv-rules-editor-ctl' },
193
+ React.createElement('label', { className: 'edrv-rules-ctl-label' }, '规则类型'),
194
+ React.createElement('select', { className: 'edrv-rules-select', value: form.fmType, onChange: props.onType },
195
+ Object.keys(TYPE_LABEL).map((key) => React.createElement('option', { key, value: key }, TYPE_LABEL[key]))),
196
+ React.createElement('label', { className: 'edrv-rules-ctl-label' }, '描述'),
99
197
  React.createElement('input', {
100
- className: 'edrv-rules-input', value: form.file, disabled: !isNew, spellCheck: false,
101
- placeholder: 'my-rule.mdc', onChange: (e) => props.setForm({ ...form, file: e.target.value }),
198
+ className: 'edrv-rules-input edrv-rules-desc-input', value: form.desc, spellCheck: false,
199
+ placeholder: isNew ? '规则用途说明(写入 frontmatter description)' : '规则用途说明', onChange: props.onDesc,
102
200
  })),
201
+ form.fmType === 'auto' ? React.createElement('div', { className: 'edrv-rules-editor-ctl' },
202
+ React.createElement('label', { className: 'edrv-rules-ctl-label' }, 'globs'),
203
+ React.createElement('input', {
204
+ className: 'edrv-rules-input', value: form.globs, spellCheck: false,
205
+ placeholder: 'src/**/*.ts, *.md(逗号分隔,命中才注入)', onChange: props.onGlobs,
206
+ })) : null,
103
207
  React.createElement('textarea', {
104
208
  className: 'edrv-rules-body', spellCheck: false,
105
209
  value: form.content,
106
- onChange: (e) => props.setForm({ ...form, content: e.target.value }),
210
+ onChange: props.onBody,
107
211
  }),
108
212
  React.createElement('div', { className: 'edrv-rules-editor-hint' },
109
- 'frontmatter 支持 description / alwaysApply / globs / enabled;无 frontmatter 的纯 markdown 视为「手动」规则'),
213
+ '类型/描述/globs 与 frontmatter 双向同步;enabled 与其余键原样保留;无 frontmatter 的纯 markdown 视为「手动」规则'),
110
214
  form.error ? React.createElement('div', { className: 'edrv-rules-err' }, form.error) : null,
111
215
  React.createElement('div', { className: 'edrv-rules-editor-foot' },
112
216
  React.createElement('button', { className: 'edrv-rules-save', disabled: form.saving, onClick: props.onSave }, form.saving ? '保存中…' : '保存')))
@@ -125,6 +229,8 @@ export function RulesPanel(props) {
125
229
  const [tab, setTab] = React.useState(() => (readLocal(CACHE_KEY.rules + 'tab') === 'project' ? 'project' : 'user'))
126
230
  const [data, setData] = React.useState(null) // { user: RuleInfo[], projects: RuleProject[] }
127
231
  const [error, setError] = React.useState('')
232
+ const panelRef = React.useRef(null)
233
+ const [panelW, setPanelW] = React.useState(0) // 面板内容宽度(ResizeObserver 跟踪;0=未测得)
128
234
  const [form, setForm] = React.useState(null) // 编辑器表单状态;null=列表态
129
235
  const mountedRef = React.useRef(true)
130
236
 
@@ -151,6 +257,20 @@ export function RulesPanel(props) {
151
257
  return () => { mountedRef.current = false }
152
258
  }, [])
153
259
 
260
+ // 面板宽度跟踪:描述字数上限与窄面板模式都随宽度动态变化
261
+ React.useEffect(() => {
262
+ const el = panelRef.current
263
+ if (!el || typeof ResizeObserver === 'undefined') return undefined
264
+ const observer = new ResizeObserver(() => setPanelW(el.clientWidth))
265
+ observer.observe(el)
266
+ setPanelW(el.clientWidth)
267
+ return () => observer.disconnect()
268
+ }, [])
269
+
270
+ // 描述字数上限与窄面板模式(未测得宽度前用基础上限/完整展示)
271
+ const descCap = panelW > 0 ? descCapOf(panelW) : DESC_MAX
272
+ const narrow = panelW > 0 && panelW < NARROW_W
273
+
154
274
  /**
155
275
  * 切换 Tab(记忆到 localStorage)。
156
276
  * @param next 目标 Tab
@@ -166,27 +286,61 @@ export function RulesPanel(props) {
166
286
  const openNew = () => {
167
287
  const scope = tab === 'project' ? 'project' : 'user'
168
288
  if (scope === 'project' && !cwd) { notify('当前会话没有工作区,无法新建项目规则'); return }
169
- setForm({ mode: 'new', scope, workspacePath: scope === 'project' ? cwd : undefined, file: '', content: NEW_TEMPLATE, error: '', saving: false })
289
+ const parsed = parseRuleFm(NEW_TEMPLATE)
290
+ setForm({
291
+ mode: 'new', scope, workspacePath: scope === 'project' ? cwd : undefined,
292
+ relHint: scope === 'project' ? '.dsh/rules/' : 'rules/', file: '', content: NEW_TEMPLATE,
293
+ fmType: parsed.type, desc: parsed.description, globs: parsed.globs.join(', '), error: '', saving: false,
294
+ })
170
295
  }
171
296
 
172
297
  /**
173
- * 打开编辑表单:先拉全文再进入编辑态。
298
+ * 打开编辑表单:先拉全文,再解析 frontmatter 回填类型/描述/globs 控件。
174
299
  * @param rule 目标规则
175
300
  */
176
301
  const openEdit = (rule) => {
177
302
  rpc('rules.read', { scope: rule.scope, workspacePath: rule.scope === 'project' ? rule.workspacePath : undefined, file: rule.file })
178
303
  .then((res) => {
179
- if (res && res.ok) {
180
- setForm({ mode: 'edit', scope: rule.scope, workspacePath: rule.workspacePath, file: rule.file, content: res.content, error: '', saving: false })
181
- } else notify(res?.error ?? '读取规则失败')
304
+ if (!res || !res.ok) { notify(res?.error ?? '读取规则失败'); return }
305
+ const parsed = parseRuleFm(res.content)
306
+ setForm({
307
+ mode: 'edit', scope: rule.scope, workspacePath: rule.workspacePath, relHint: rule.relHint,
308
+ file: rule.file, content: res.content,
309
+ fmType: parsed.type, desc: parsed.description, globs: parsed.globs.join(', '), error: '', saving: false,
310
+ })
182
311
  }).catch((e) => notify('读取规则异常: ' + String(e)))
183
312
  }
184
313
 
185
- /** 保存表单(rules.save;文件名仅新建校验非空)。 */
314
+ /** 类型下拉变更:frontmatter 按新类型重写,控件同步。 */
315
+ const onTypeChange = (e) => {
316
+ const nextType = e.target.value
317
+ setForm((prev) => ({ ...prev, fmType: nextType, content: rebuildContent(prev.content, nextType, prev.desc, prev.globs), error: '' }))
318
+ }
319
+
320
+ /** 描述输入变更:同步 frontmatter description 行。 */
321
+ const onDescChange = (e) => {
322
+ const nextDesc = e.target.value
323
+ setForm((prev) => ({ ...prev, desc: nextDesc, content: rebuildContent(prev.content, prev.fmType, nextDesc, prev.globs), error: '' }))
324
+ }
325
+
326
+ /** globs 输入变更(自动规则):同步 frontmatter globs 行。 */
327
+ const onGlobsChange = (e) => {
328
+ const nextGlobs = e.target.value
329
+ setForm((prev) => ({ ...prev, globs: nextGlobs, content: rebuildContent(prev.content, prev.fmType, prev.desc, nextGlobs), error: '' }))
330
+ }
331
+
332
+ /** 文本域直接编辑:回读 frontmatter,同步类型/描述/globs 控件(文本域是唯一真源)。 */
333
+ const onBodyChange = (e) => {
334
+ const parsed = parseRuleFm(e.target.value)
335
+ setForm((prev) => ({ ...prev, content: e.target.value, fmType: parsed.type, desc: parsed.description, globs: parsed.globs.join(', ') }))
336
+ }
337
+
338
+ /** 保存表单(rules.save;文件名非空 + 自动规则必须有 globs)。 */
186
339
  const saveForm = () => {
187
340
  const next = { ...form, saving: true, error: '' }
188
341
  const file = String(form.file ?? '').trim()
189
342
  if (!file) { setForm({ ...next, saving: false, error: '文件名不能为空' }); return }
343
+ if (form.fmType === 'auto' && globList(form.globs).length === 0) { setForm({ ...next, saving: false, error: '自动规则需要至少一个 glob(逗号分隔)' }); return }
190
344
  setForm(next)
191
345
  rpc('rules.save', { scope: form.scope, workspacePath: form.workspacePath, file, content: form.content })
192
346
  .then((res) => {
@@ -261,12 +415,19 @@ export function RulesPanel(props) {
261
415
  }
262
416
 
263
417
  const renderBody = () => {
264
- if (form) return React.createElement(RuleEditor, { form, setForm, onSave: saveForm, onCancel: () => setForm(null) })
418
+ if (form) {
419
+ return React.createElement(RuleEditor, {
420
+ form, onType: onTypeChange, onDesc: onDescChange, onGlobs: onGlobsChange, onBody: onBodyChange,
421
+ onSave: saveForm, onCancel: () => setForm(null),
422
+ })
423
+ }
265
424
  if (!data) return React.createElement('div', { className: 'edrv-rules-empty' }, '加载中…')
266
425
  if (error) return React.createElement('div', { className: 'edrv-rules-err' }, error)
267
426
  const rows = rowsOf()
268
427
  if (!rows.length) return React.createElement('div', { className: 'edrv-rules-empty' }, renderEmpty())
269
428
  const common = {
429
+ descCap,
430
+ narrow,
270
431
  onEdit: (rule) => openEdit({ ...rule, workspacePath: rule.scope === 'project' ? cwd : undefined }),
271
432
  onRemove: (rule) => removeRule({ ...rule, workspacePath: rule.scope === 'project' ? cwd : undefined }),
272
433
  onToggle: (rule) => toggleRule({ ...rule, workspacePath: rule.scope === 'project' ? cwd : undefined }),
@@ -275,7 +436,7 @@ export function RulesPanel(props) {
275
436
  rows.map((rule) => React.createElement(RuleRow, { key: rule.scope + ':' + rule.file, rule, ...common })))
276
437
  }
277
438
 
278
- return React.createElement('div', { className: 'edrv-rules-panel' },
439
+ return React.createElement('div', { className: 'edrv-rules-panel', ref: panelRef },
279
440
  renderTabs(),
280
441
  renderWsLine(),
281
442
  React.createElement('div', { className: 'edrv-rules-hint' },
@@ -206,7 +206,7 @@
206
206
  [data-edrv-view] .edrv-main-col > .edrv-pathbar,
207
207
  [data-edrv-view] .edrv-main-col > .edrv-tabs { flex: 0 0 auto; }
208
208
  [data-edrv-view] .edrv-editor-area { position: relative; display: flex; flex: 1 1 auto; flex-direction: column; min-width: 0; min-height: 0; overflow: hidden; }
209
- [data-edrv-view] .edrv-sidebar { display: flex; flex-direction: row; flex-shrink: 0; min-width: 0; height: 100%; background: var(--dsw-alias-bg-layer-1, #ffffff); border-right: 1px solid var(--dsw-alias-border-l1, #e0e6e8); overflow: hidden; }
209
+ [data-edrv-view] .edrv-sidebar { display: flex; flex-direction: row; flex-shrink: 1; min-width: 0; height: 100%; background: var(--dsw-alias-bg-layer-1, #ffffff); border-right: 1px solid var(--dsw-alias-border-l1, #e0e6e8); overflow: hidden; }
210
210
  [data-edrv-view] .edrv-rail { display: flex; flex-direction: column; align-items: center; width: 44px; flex-shrink: 0; padding: 6px 0; background: var(--dsw-alias-bg-layer-2, #f0f4f4); border-right: 1px solid var(--dsw-alias-border-l1, #e0e6e8); gap: 4px; }
211
211
  [data-edrv-view] .edrv-rail-btn { position: relative; display: inline-flex; align-items: center; justify-content: center; width: 32px; height: 32px; border: none; border-radius: 8px; background: transparent; color: var(--dsw-alias-label-secondary, #52606d); cursor: pointer; flex-shrink: 0; }
212
212
  [data-edrv-view] .edrv-rail-btn:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(15,157,88,.08)); }
@@ -296,7 +296,7 @@
296
296
  [data-edrv-view] .edrv-search-toggle:disabled { opacity: .4; cursor: default; }
297
297
 
298
298
  /* ===== rules panel (sidebar, Codebuddy rules view) ===== */
299
- [data-edrv-view] .edrv-rules-panel { display: flex; flex-direction: column; min-height: 0; flex: 1; padding: 6px; gap: 4px; }
299
+ [data-edrv-view] .edrv-rules-panel { display: flex; flex-direction: column; min-width: 0; min-height: 0; flex: 1; padding: 6px; gap: 4px; }
300
300
  [data-edrv-view] .edrv-rules-tabs { display: flex; align-items: center; gap: 2px; flex: 0 0 auto; }
301
301
  [data-edrv-view] .edrv-rules-tab { height: 24px; padding: 0 10px; border: none; border-radius: 6px; background: transparent; color: var(--dsw-alias-label-secondary, #52606d); font-size: 12px; cursor: pointer; }
302
302
  [data-edrv-view] .edrv-rules-tab:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(15,157,88,.08)); }
@@ -306,19 +306,23 @@
306
306
  [data-edrv-view] .edrv-rules-ws-line { font-size: 11px; color: var(--dsw-alias-label-secondary, #52606d); padding: 0 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex: 0 0 auto; user-select: none; }
307
307
  [data-edrv-view] .edrv-rules-hint { font-size: 11px; color: var(--dsw-alias-label-tertiary, #9aa5b1); padding: 0 2px; flex: 0 0 auto; }
308
308
  [data-edrv-view] .edrv-rules-list { flex: 1; min-height: 0; overflow: auto; }
309
- [data-edrv-view] .edrv-rules-row { position: relative; padding: 7px 100px 6px 4px; border-bottom: 1px solid var(--dsw-alias-border-l1, #e0e6e8); }
310
- [data-edrv-view] .edrv-rules-row.off .edrv-rules-file, [data-edrv-view] .edrv-rules-row.off .edrv-rules-desc { opacity: .5; }
311
- [data-edrv-view] .edrv-rules-main { display: flex; align-items: center; gap: 6px; min-width: 0; cursor: pointer; }
309
+ [data-edrv-view] .edrv-rules-row { display: flex; align-items: center; gap: 4px; padding: 6px 4px; border-bottom: 1px solid var(--dsw-alias-border-l1, #e0e6e8); border-radius: 6px; }
310
+ [data-edrv-view] .edrv-rules-row:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(15,157,88,.08)); }
311
+ [data-edrv-view] .edrv-rules-row.off .edrv-rules-main { opacity: .5; }
312
+ [data-edrv-view] .edrv-rules-main { flex: 1; min-width: 0; cursor: pointer; overflow: hidden; }
313
+ [data-edrv-view] .edrv-rules-line1 { display: flex; align-items: center; gap: 6px; min-width: 0; }
314
+ [data-edrv-view] .edrv-rules-sub { display: flex; align-items: center; gap: 5px; margin-top: 3px; min-width: 0; }
315
+ [data-edrv-view] .edrv-rules-sublabel { font-size: 11px; color: var(--dsw-alias-label-tertiary, #9aa5b1); flex-shrink: 0; }
312
316
  [data-edrv-view] .edrv-rules-file { font-weight: 600; font-size: 12px; color: var(--dsw-alias-label-primary, #1f2933); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
313
- [data-edrv-view] .edrv-rules-path { font-size: 10px; color: var(--dsw-alias-label-tertiary, #9aa5b1); flex-shrink: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
317
+ [data-edrv-view] .edrv-rules-path { font-size: 10px; color: var(--dsw-alias-label-tertiary, #9aa5b1); flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
314
318
  [data-edrv-view] .edrv-rules-type { flex-shrink: 0; height: 15px; padding: 0 5px; border-radius: 999px; font-size: 10px; line-height: 15px; }
315
319
  [data-edrv-view] .edrv-rules-type-always { background: rgba(15,157,88,.15); color: #0b7c46; }
316
320
  [data-edrv-view] .edrv-rules-type-auto { background: rgba(79,140,255,.16); color: #3367c9; }
317
321
  [data-edrv-view] .edrv-rules-type-manual { background: var(--dsw-alias-bg-layer-2, #f0f4f4); color: var(--dsw-alias-label-secondary, #52606d); }
318
322
  [data-edrv-view] .edrv-rules-offtag { font-size: 10px; color: var(--dsw-alias-label-tertiary, #9aa5b1); flex-shrink: 0; }
319
- [data-edrv-view] .edrv-rules-desc { margin-top: 3px; font-size: 11px; color: var(--dsw-alias-label-secondary, #52606d); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
320
- [data-edrv-view] .edrv-rules-actions { position: absolute; top: 5px; right: 4px; display: flex; align-items: center; gap: 2px; }
321
- [data-edrv-view] .edrv-rules-act { min-width: 22px; height: 22px; padding: 0 4px; border: none; border-radius: 5px; background: transparent; font-size: 12px; line-height: 1; cursor: pointer; color: var(--dsw-alias-label-secondary, #52606d); }
323
+ [data-edrv-view] .edrv-rules-desc { font-size: 11px; color: var(--dsw-alias-label-secondary, #52606d); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; flex: 0 1 auto; }
324
+ [data-edrv-view] .edrv-rules-actions { display: flex; align-items: center; gap: 2px; flex-shrink: 0; margin-left: auto; }
325
+ [data-edrv-view] .edrv-rules-act { min-width: 22px; height: 22px; padding: 0 4px; border: none; border-radius: 5px; background: transparent; font-size: 12px; line-height: 1; cursor: pointer; color: var(--dsw-alias-label-secondary, #52606d); display: inline-flex; align-items: center; justify-content: center; }
322
326
  [data-edrv-view] .edrv-rules-act:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(15,157,88,.08)); }
323
327
  [data-edrv-view] .edrv-rules-switch { position: relative; width: 30px; height: 16px; border-radius: 999px; border: none; background: #b9c4cc; cursor: pointer; transition: background .15s; flex-shrink: 0; padding: 0; }
324
328
  [data-edrv-view] .edrv-rules-switch.on { background: #7c5cff; }
@@ -327,8 +331,13 @@
327
331
  [data-edrv-view] .edrv-rules-empty { padding: 10px 4px; font-size: 12px; color: var(--dsw-alias-label-tertiary, #9aa5b1); line-height: 1.6; }
328
332
  [data-edrv-view] .edrv-rules-err { padding: 6px 8px; border: 1px solid rgba(217,83,79,.4); border-radius: 6px; background: #fdeaea; color: var(--dsw-alias-state-error-primary, #d9534f); font-size: 11px; word-break: break-all; }
329
333
  [data-edrv-view] .edrv-rules-editor { display: flex; flex-direction: column; min-height: 0; flex: 1; gap: 6px; }
330
- [data-edrv-view] .edrv-rules-editor-head { display: flex; align-items: center; justify-content: space-between; font-size: 12px; font-weight: 600; color: var(--dsw-alias-label-primary, #1f2933); flex: 0 0 auto; }
331
- [data-edrv-view] .edrv-rules-editor-file { display: flex; align-items: center; gap: 6px; font-size: 11px; color: var(--dsw-alias-label-secondary, #52606d); flex: 0 0 auto; }
334
+ [data-edrv-view] .edrv-rules-editor-head { display: flex; align-items: center; justify-content: space-between; gap: 6px; font-size: 12px; font-weight: 600; color: var(--dsw-alias-label-primary, #1f2933); flex: 0 0 auto; }
335
+ [data-edrv-view] .edrv-rules-crumb { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
336
+ [data-edrv-view] .edrv-rules-editor-ctl { display: flex; align-items: center; gap: 6px; flex: 0 0 auto; }
337
+ [data-edrv-view] .edrv-rules-ctl-label { font-size: 11px; color: var(--dsw-alias-label-secondary, #52606d); flex-shrink: 0; }
338
+ [data-edrv-view] .edrv-rules-select { height: 24px; padding: 0 4px; border: 1px solid var(--dsw-alias-border-l2, #555); border-radius: 6px; background: var(--dsw-alias-bg-base, #111); color: var(--dsw-alias-label-primary, #eee); font-size: 11px; outline: none; cursor: pointer; flex-shrink: 0; }
339
+ [data-edrv-view] .edrv-rules-select:focus { border-color: var(--dsw-alias-brand-primary, #4f8cff); }
340
+ [data-edrv-view] .edrv-rules-desc-input { flex: 1; min-width: 0; }
332
341
  [data-edrv-view] .edrv-rules-input { flex: 1; min-width: 0; height: 24px; padding: 0 8px; border: 1px solid var(--dsw-alias-border-l2, #555); border-radius: 6px; background: var(--dsw-alias-bg-base, #111); color: var(--dsw-alias-label-primary, #eee); font-size: 11px; outline: none; font-family: var(--ds-font-family-code, ui-monospace, monospace); }
333
342
  [data-edrv-view] .edrv-rules-input:focus { border-color: var(--dsw-alias-brand-primary, #4f8cff); }
334
343
  [data-edrv-view] .edrv-rules-body { flex: 1; min-height: 0; resize: none; padding: 8px; border: 1px solid var(--dsw-alias-border-l2, #555); border-radius: 6px; background: var(--dsw-alias-bg-base, #111); color: var(--dsw-alias-label-primary, #eee); font-size: 12px; line-height: 1.5; outline: none; font-family: var(--ds-font-family-code, ui-monospace, monospace); }