@dsh-plus/secret-env 0.1.0 → 0.1.2

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.
@@ -0,0 +1,301 @@
1
+ /**
2
+ * `$` 触发补全菜单(conversation.input.overlay 官方插槽,session 作用域):
3
+ * 在 composer 中输入 `$` 弹出密钥名候选(对齐官方 / 命令与 @ 引用的交互)。
4
+ *
5
+ * 官方 input-trigger 管线的检测核硬编码 '/' 与 '@'(TriggerChar 联合类型),
6
+ * '$' 进不了官方检测,故检测与菜单由本组件自理,插入复用官方会话作用域
7
+ * bail 通道 'slash/input-insert-text'(span + draftRev CAS,编辑器内应用)。
8
+ * @module secret-env/client/menu
9
+ */
10
+ import { type ReactElement, useEffect, useRef, useState } from 'react'
11
+
12
+ import { fetchSecrets, type SecretList } from './api.ts'
13
+ import {
14
+ applyChipCorrection,
15
+ type ChipCorrection,
16
+ detectSecretTrigger,
17
+ filterCandidates,
18
+ type SecretCandidate,
19
+ type SecretHit,
20
+ sameHit,
21
+ } from './menu-core.ts'
22
+
23
+ /** 输入状态投影(结构子集;官方 InputState 的读取面)。 */
24
+ interface InputStateLike {
25
+ readonly draft: string
26
+ readonly draftRev: number
27
+ readonly phase: string
28
+ readonly occurrences: readonly { readonly offset: number; readonly length: number }[]
29
+ }
30
+
31
+ /** 令牌区间(草稿投影坐标 + 单调修订号,CAS 用)。 */
32
+ export interface TokenSpanLike {
33
+ readonly start: number
34
+ readonly end: number
35
+ readonly draftRev: number
36
+ }
37
+
38
+ export interface SecretMenuProps {
39
+ /** 插槽 inject 工厂注入的当前会话 id。 */
40
+ sessionId: string
41
+ t(key: string): string
42
+ /** 框架标准钩子:输入状态快照订阅(SnapshotSelectorHook)。 */
43
+ useInput?<S>(selector: (state: InputStateLike) => S): S
44
+ /** inject 面注入:经官方 scoped bail 通道替换令牌文本;返回是否被编辑器应用。 */
45
+ insertToken(text: string, span: TokenSpanLike): boolean
46
+ }
47
+
48
+ const FALLBACK_INPUT: InputStateLike = { draft: '', draftRev: 0, phase: 'plain', occurrences: [] }
49
+
50
+ /** 本组件容器向上找到 composer 卡片与 Lexical contenteditable 根。 */
51
+ function editorRootOf(el: HTMLElement | null): HTMLElement | null {
52
+ const card = el?.closest('[data-composer-card]')
53
+ const root = card?.querySelector('[contenteditable="true"]')
54
+ return root instanceof HTMLElement ? root : null
55
+ }
56
+
57
+ /**
58
+ * 光标的草稿投影偏移:Range 求光标前渲染文本长,再按光标前的引用芯片
59
+ * (data-composer-chip,DOM 序与 occurrences 的 offset 序一致)修正长度差。
60
+ */
61
+ function caretDraftOffset(
62
+ root: HTMLElement,
63
+ occurrences: InputStateLike['occurrences'],
64
+ ): number | null {
65
+ const sel = window.getSelection()
66
+ if (sel === null || sel.rangeCount === 0 || !sel.isCollapsed) return null
67
+ const range = sel.getRangeAt(0)
68
+ if (!root.contains(range.startContainer)) return null
69
+ const pre = document.createRange()
70
+ pre.selectNodeContents(root)
71
+ pre.setEnd(range.startContainer, range.startOffset)
72
+ const rendered = pre.toString().length
73
+ const corrections: ChipCorrection[] = []
74
+ const chips = root.querySelectorAll('[data-composer-chip]')
75
+ for (let i = 0; i < chips.length; i++) {
76
+ const el = chips[i]
77
+ // 光标位于芯片结束之后(含边界)才计入修正
78
+ if (range.comparePoint(el, el.childNodes.length) > 0) continue
79
+ const occurrence = occurrences[i]
80
+ if (occurrence === undefined) continue
81
+ corrections.push({ rendered: el.textContent?.length ?? 0, draft: occurrence.length })
82
+ }
83
+ return applyChipCorrection(rendered, corrections)
84
+ }
85
+
86
+ function toCandidates(list: SecretList): SecretCandidate[] {
87
+ return [
88
+ ...list.session.map((entry) => ({
89
+ envName: entry.envName,
90
+ name: entry.name,
91
+ description: entry.description,
92
+ scope: 'session' as const,
93
+ once: entry.once,
94
+ })),
95
+ // 已屏蔽的变量不会注入,不出候选(避免误导补全)。
96
+ ...list.global
97
+ .filter((entry) => !entry.masked)
98
+ .map((entry) => ({
99
+ envName: entry.envName,
100
+ name: entry.name,
101
+ description: entry.description,
102
+ scope: 'global' as const,
103
+ })),
104
+ ...list.inherited
105
+ .filter((entry) => !entry.masked)
106
+ .map((entry) => ({
107
+ envName: entry.envName,
108
+ name: entry.name,
109
+ description: '',
110
+ scope: 'inherited' as const,
111
+ })),
112
+ ]
113
+ }
114
+
115
+ export function SecretMenu(props: SecretMenuProps): ReactElement | null {
116
+ const { sessionId, t, insertToken } = props
117
+ // useInput 是标准钩子 prop,必须无条件调用;旧壳缺席时回落常量(菜单永不展开)。
118
+ const useInput =
119
+ props.useInput ?? (<S,>(_selector: (state: InputStateLike) => S): S => FALLBACK_INPUT as S)
120
+ const input = useInput((state: InputStateLike) => state)
121
+
122
+ const [hit, setHit] = useState<SecretHit | null>(null)
123
+ const [items, setItems] = useState<SecretCandidate[] | null>(null)
124
+ const [highlight, setHighlight] = useState(0)
125
+ const wrapRef = useRef<HTMLDivElement | null>(null)
126
+ const composingRef = useRef(false)
127
+ /** Esc 抑制:记录被关闭的令牌,token 变化前不重复弹出。 */
128
+ const suppressedRef = useRef<SecretHit | null>(null)
129
+
130
+ /** 候选拉取(ref 稳定;挂载、会话切换与菜单展开沿调用)。 */
131
+ const reloadRef = useRef((_sid: string): void => {})
132
+ reloadRef.current = (sid: string) => {
133
+ fetchSecrets(sid)
134
+ .then((list) => setItems(toCandidates(list)))
135
+ .catch(() => setItems([]))
136
+ }
137
+ /** 上一次检测的展开态(展开沿判定用)。 */
138
+ const wasOpenRef = useRef(false)
139
+
140
+ // 挂载与会话切换时拉取候选。
141
+ useEffect(() => {
142
+ reloadRef.current(sessionId)
143
+ }, [sessionId])
144
+
145
+ // 检测:草稿变化(每次击键)与光标移动(selectionchange)双通道驱动。
146
+ useEffect(() => {
147
+ const update = (): void => {
148
+ const root = editorRootOf(wrapRef.current)
149
+ if (root === null || composingRef.current || input.phase !== 'plain') {
150
+ setHit((prev) => (prev === null ? prev : null))
151
+ wasOpenRef.current = false
152
+ return
153
+ }
154
+ const caret = caretDraftOffset(root, input.occurrences)
155
+ const next = caret === null ? null : detectSecretTrigger(input.draft, caret)
156
+ const suppressed = suppressedRef.current
157
+ const live =
158
+ next !== null &&
159
+ suppressed !== null &&
160
+ next.start === suppressed.start &&
161
+ next.query === suppressed.query
162
+ ? null
163
+ : next
164
+ // 展开沿:闭合 → 命中时刷新候选(密钥可能刚在别处改动)。
165
+ if (live !== null && !wasOpenRef.current) reloadRef.current(sessionId)
166
+ wasOpenRef.current = live !== null
167
+ setHit((prev) => (sameHit(prev, live) ? prev : live))
168
+ }
169
+ update()
170
+ document.addEventListener('selectionchange', update)
171
+ return () => document.removeEventListener('selectionchange', update)
172
+ }, [input, sessionId])
173
+
174
+ const candidates = hit === null || items === null ? [] : filterCandidates(items, hit.query)
175
+ const open = hit !== null && candidates.length > 0
176
+
177
+ // IME 组合期间不介入(输入法键不应被菜单截获)。
178
+ useEffect(() => {
179
+ const root = editorRootOf(wrapRef.current)
180
+ if (root === null) return
181
+ const onStart = (): void => {
182
+ composingRef.current = true
183
+ }
184
+ const onEnd = (): void => {
185
+ composingRef.current = false
186
+ }
187
+ root.addEventListener('compositionstart', onStart)
188
+ root.addEventListener('compositionend', onEnd)
189
+ return () => {
190
+ root.removeEventListener('compositionstart', onStart)
191
+ root.removeEventListener('compositionend', onEnd)
192
+ }
193
+ }, [])
194
+
195
+ const pick = (entry: SecretCandidate): void => {
196
+ if (hit === null) return
197
+ const span = { start: hit.start, end: hit.end, draftRev: input.draftRev }
198
+ insertToken(`$${entry.envName} `, span)
199
+ suppressedRef.current = null
200
+ setHit(null)
201
+ }
202
+
203
+ const pickRef = useRef(pick)
204
+ pickRef.current = pick
205
+ const candidatesRef = useRef(candidates)
206
+ candidatesRef.current = candidates
207
+ const highlightRef = useRef(highlight)
208
+ highlightRef.current = highlight
209
+
210
+ // 键盘导航:捕获阶段先於 Lexical/官方管线截获;仅在菜单展开时介入。
211
+ useEffect(() => {
212
+ if (!open) return
213
+ const root = editorRootOf(wrapRef.current)
214
+ if (root === null) return
215
+ const onKey = (event: KeyboardEvent): void => {
216
+ const list = candidatesRef.current
217
+ if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
218
+ event.preventDefault()
219
+ event.stopPropagation()
220
+ const delta = event.key === 'ArrowDown' ? 1 : -1
221
+ setHighlight((prev) => (prev + delta + list.length) % list.length)
222
+ return
223
+ }
224
+ if (event.key === 'Enter' || event.key === 'Tab') {
225
+ const entry = list[highlightRef.current] ?? list[0]
226
+ if (entry !== undefined) {
227
+ event.preventDefault()
228
+ event.stopPropagation()
229
+ pickRef.current(entry)
230
+ }
231
+ return
232
+ }
233
+ if (event.key === 'Escape') {
234
+ event.preventDefault()
235
+ event.stopPropagation()
236
+ suppressedRef.current = hit
237
+ setHit(null)
238
+ }
239
+ }
240
+ root.addEventListener('keydown', onKey, true)
241
+ return () => root.removeEventListener('keydown', onKey, true)
242
+ }, [open, hit])
243
+
244
+ // 点 composer 卡片之外处关闭(对齐官方菜单的外部 dismiss 语义)。
245
+ useEffect(() => {
246
+ if (!open) return
247
+ const onDown = (event: PointerEvent): void => {
248
+ if (!(event.target instanceof Node)) return
249
+ const card = wrapRef.current?.closest('[data-composer-card]')
250
+ if (card?.contains(event.target) === true) return
251
+ setHit(null)
252
+ }
253
+ document.addEventListener('pointerdown', onDown, true)
254
+ return () => document.removeEventListener('pointerdown', onDown, true)
255
+ }, [open])
256
+
257
+ // 高亮随候选集变化收敛到合法范围。
258
+ useEffect(() => {
259
+ setHighlight((prev) => (candidates.length === 0 ? 0 : Math.min(prev, candidates.length - 1)))
260
+ }, [candidates.length])
261
+
262
+ if (!open) {
263
+ // 锚点常驻:闭合态也要挂载容器,检测效果依赖它定位 composer 编辑器根。
264
+ return <div className="dse-menuWrap" ref={wrapRef} />
265
+ }
266
+ return (
267
+ <div className="dse-menuWrap" ref={wrapRef}>
268
+ <div className="dse-menu" role="listbox" aria-label={t('menu.aria')}>
269
+ <div className="dse-menuTitle">{t('menu.title')}</div>
270
+ {candidates.map((entry, index) => (
271
+ <button
272
+ key={`${entry.scope}:${entry.name}`}
273
+ type="button"
274
+ role="option"
275
+ aria-selected={index === highlight}
276
+ title={`$${entry.envName}`}
277
+ className={`dse-menuItem${index === highlight ? ' dse-menuItemActive' : ''}`}
278
+ onMouseEnter={() => setHighlight(index)}
279
+ onMouseDown={(event) => {
280
+ event.preventDefault()
281
+ pick(entry)
282
+ }}
283
+ >
284
+ <span className="dse-menuName">{entry.name}</span>
285
+ <span className="dse-menuDesc">{entry.description}</span>
286
+ <span className="dse-menuBadges">
287
+ {entry.once === true ? <span className="dse-badge">{t('scopeOnce')}</span> : null}
288
+ <span className="dse-badge dse-badgeDim">
289
+ {entry.scope === 'session'
290
+ ? t('scopeSession')
291
+ : entry.scope === 'inherited'
292
+ ? t('scopeInherited')
293
+ : t('scopeGlobal')}
294
+ </span>
295
+ </span>
296
+ </button>
297
+ ))}
298
+ </div>
299
+ </div>
300
+ )
301
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * 会话密钥面板的打开总线(bundle 内模块级 pub/sub):
3
+ * 斜杠命令的 onSelect 与 overlay 槽里的面板宿主在同一 bundle,
4
+ * 经此按 sessionId 定向投递「打开面板」信号,避免跨 cordis 作用域布线。
5
+ * @module secret-env/client/panel-bus
6
+ */
7
+
8
+ type Listener = (sessionId: string) => void
9
+
10
+ const listeners = new Set<Listener>()
11
+
12
+ /** 请求打开指定会话的密钥面板(由 /secret 命令的 onSelect 调用)。 */
13
+ export function requestOpenSessionPanel(sessionId: string): void {
14
+ for (const listener of listeners) listener(sessionId)
15
+ }
16
+
17
+ /** 订阅打开信号;返回退订器(随组件卸载清理)。 */
18
+ export function onOpenSessionPanel(listener: Listener): () => void {
19
+ listeners.add(listener)
20
+ return () => {
21
+ listeners.delete(listener)
22
+ }
23
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * 会话变量面板的 overlay 宿主(conversation.input.overlay 官方插槽,
3
+ * session 作用域):常驻监听打开总线,/var 命令选中后弹出面板。
4
+ * 弹层定位于 composer 卡片上方(与 $ 菜单同一浮层语言);
5
+ * 点外部或 Esc 关闭。闭合态不渲染面板(数据在打开时现取)。
6
+ * @module secret-env/client/panel-host
7
+ */
8
+ import { type ReactElement, useEffect, useRef, useState } from 'react'
9
+
10
+ import { SessionSecretsPanel } from './panel.tsx'
11
+ import { onOpenSessionPanel } from './panel-bus.ts'
12
+
13
+ export interface SessionPanelHostProps {
14
+ /** 插槽 inject 工厂注入的当前会话 id。 */
15
+ sessionId: string
16
+ t(key: string): string
17
+ }
18
+
19
+ export function SessionPanelHost(props: SessionPanelHostProps): ReactElement | null {
20
+ const { sessionId, t } = props
21
+ const [open, setOpen] = useState(false)
22
+ const wrapRef = useRef<HTMLDivElement | null>(null)
23
+
24
+ // 打开总线:只响应本会话的定向信号。
25
+ useEffect(
26
+ () =>
27
+ onOpenSessionPanel((target) => {
28
+ if (target === sessionId) setOpen(true)
29
+ }),
30
+ [sessionId],
31
+ )
32
+
33
+ // Esc / 点外部关闭(捕获阶段,先于编辑器吞键)。
34
+ useEffect(() => {
35
+ if (!open) return
36
+ const onKey = (event: KeyboardEvent): void => {
37
+ if (event.key !== 'Escape') return
38
+ event.preventDefault()
39
+ event.stopPropagation()
40
+ setOpen(false)
41
+ }
42
+ const onDown = (event: PointerEvent): void => {
43
+ if (!(event.target instanceof Node)) return
44
+ if (wrapRef.current?.contains(event.target) === true) return
45
+ setOpen(false)
46
+ }
47
+ document.addEventListener('keydown', onKey, true)
48
+ document.addEventListener('pointerdown', onDown, true)
49
+ return () => {
50
+ document.removeEventListener('keydown', onKey, true)
51
+ document.removeEventListener('pointerdown', onDown, true)
52
+ }
53
+ }, [open])
54
+
55
+ if (!open) return null
56
+ return (
57
+ <div className="dse-panelWrap" ref={wrapRef}>
58
+ <div className="dse-panelCard" role="dialog" aria-label={t('sessionSecrets')}>
59
+ <SessionSecretsPanel sessionId={sessionId} t={t} onClose={() => setOpen(false)} />
60
+ </div>
61
+ </div>
62
+ )
63
+ }