@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.
- package/lib/client.js +1222 -338
- package/lib/index.d.ts +40 -8
- package/lib/index.js +211 -57
- package/package.json +8 -4
- package/src/api.ts +24 -1
- package/src/client/api.ts +18 -0
- package/src/client/client.ts +59 -14
- package/src/client/command.ts +62 -0
- package/src/client/common.ts +14 -1
- package/src/client/i18n.ts +67 -33
- package/src/client/menu-core.ts +103 -0
- package/src/client/menu.tsx +301 -0
- package/src/client/panel-bus.ts +23 -0
- package/src/client/panel-host.tsx +63 -0
- package/src/client/panel.tsx +385 -0
- package/src/client/section.tsx +194 -38
- package/src/client/styles.ts +25 -14
- package/src/config.ts +4 -0
- package/src/contributors.ts +115 -0
- package/src/index.ts +1 -1
- package/src/inventory.ts +101 -0
- package/src/names.ts +3 -3
- package/src/service.ts +107 -94
- package/src/client/composer.tsx +0 -260
|
@@ -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
|
+
}
|