@dsh-plus/secret-env 0.1.0 → 0.1.1
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 +339 -2
- package/package.json +4 -2
- package/src/client/client.ts +39 -1
- package/src/client/i18n.ts +5 -0
- package/src/client/menu-core.ts +103 -0
- package/src/client/menu.tsx +285 -0
- package/src/client/styles.ts +15 -1
package/lib/client.js
CHANGED
|
@@ -555,6 +555,8 @@ ${extra}
|
|
|
555
555
|
scopeGlobal: "全局",
|
|
556
556
|
scopeSession: "会话",
|
|
557
557
|
scopeOnce: "一次性",
|
|
558
|
+
"menu.title": "密钥变量",
|
|
559
|
+
"menu.aria": "密钥变量候选",
|
|
558
560
|
"error.invalid-name": "变量名不合法:大写字母开头,仅字母/数字/下划线,≤64 字符。",
|
|
559
561
|
"error.empty-value": "值不能为空。",
|
|
560
562
|
"error.shadowed": "进程环境中已存在同名变量且优先级更高,写入无效;请先取消该环境变量。",
|
|
@@ -601,6 +603,8 @@ ${extra}
|
|
|
601
603
|
scopeGlobal: "global",
|
|
602
604
|
scopeSession: "session",
|
|
603
605
|
scopeOnce: "once",
|
|
606
|
+
"menu.title": "Secret variables",
|
|
607
|
+
"menu.aria": "Secret variable suggestions",
|
|
604
608
|
"error.invalid-name": "Invalid name: start with an uppercase letter; letters/digits/underscore only, ≤64 chars.",
|
|
605
609
|
"error.empty-value": "Value must not be empty.",
|
|
606
610
|
"error.shadowed": "A same-named process environment variable shadows this write; unset it first.",
|
|
@@ -612,6 +616,300 @@ ${extra}
|
|
|
612
616
|
const zh = mergeDict(commonZh, ownZh);
|
|
613
617
|
const en = mergeDict(commonEn, ownEn);
|
|
614
618
|
//#endregion
|
|
619
|
+
//#region src/client/menu-core.ts
|
|
620
|
+
const WORD_CHAR = /[\p{L}\p{N}_]/u;
|
|
621
|
+
const TOKEN_CHAR = /^[A-Za-z0-9_]$/;
|
|
622
|
+
/**
|
|
623
|
+
* 检测光标处的 `$` 触发令牌。词法:
|
|
624
|
+
* - 从光标向左扫描,token 字符(字母/数字/下划线)继续,空白直接判负;
|
|
625
|
+
* - 遇到 `$` 时校验词边界:起草开头 / 前字符为空白 / 前字符为非词字符(标点)
|
|
626
|
+
* 才开闸——`foo$BAR` 这类词中 $ 不触发(与官方 URL 内 / 不触发同理);
|
|
627
|
+
* - 其余字符(如 `{`、`(`)终止扫描。
|
|
628
|
+
*/
|
|
629
|
+
function detectSecretTrigger(draft, caret) {
|
|
630
|
+
if (caret <= 0 || caret > draft.length) return null;
|
|
631
|
+
for (let i = caret - 1; i >= 0; i--) {
|
|
632
|
+
const ch = draft.charAt(i);
|
|
633
|
+
if (/\s/u.test(ch)) return null;
|
|
634
|
+
if (ch !== "$") {
|
|
635
|
+
if (!TOKEN_CHAR.test(ch)) return null;
|
|
636
|
+
continue;
|
|
637
|
+
}
|
|
638
|
+
if (i > 0) {
|
|
639
|
+
const prev = draft.charAt(i - 1);
|
|
640
|
+
if (!/\s/u.test(prev) && WORD_CHAR.test(prev)) return null;
|
|
641
|
+
}
|
|
642
|
+
return {
|
|
643
|
+
query: draft.slice(i + 1, caret),
|
|
644
|
+
start: i,
|
|
645
|
+
end: caret
|
|
646
|
+
};
|
|
647
|
+
}
|
|
648
|
+
return null;
|
|
649
|
+
}
|
|
650
|
+
/** 两个命中的等价判定(抑制重复 setState)。 */
|
|
651
|
+
function sameHit(a, b) {
|
|
652
|
+
if (a === null || b === null) return a === b;
|
|
653
|
+
return a.query === b.query && a.start === b.start && a.end === b.end;
|
|
654
|
+
}
|
|
655
|
+
/** 按 query 过滤候选(大小写不敏感;变量名前缀命中排前,其余子串命中随后)。 */
|
|
656
|
+
function filterCandidates(entries, query) {
|
|
657
|
+
const q = query.toUpperCase();
|
|
658
|
+
const prefixed = [];
|
|
659
|
+
const partial = [];
|
|
660
|
+
for (const entry of entries) {
|
|
661
|
+
if (q === "") {
|
|
662
|
+
prefixed.push(entry);
|
|
663
|
+
continue;
|
|
664
|
+
}
|
|
665
|
+
if (entry.envName.toUpperCase().includes(q) || entry.name.toUpperCase().includes(q)) {
|
|
666
|
+
if (entry.name.toUpperCase().startsWith(q) || entry.envName.toUpperCase().includes(`_${q}`)) prefixed.push(entry);
|
|
667
|
+
else partial.push(entry);
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
return [...prefixed, ...partial];
|
|
671
|
+
}
|
|
672
|
+
/**
|
|
673
|
+
* 渲染文本偏移 → 草稿投影偏移。contenteditable 中芯片(引用装饰器)的
|
|
674
|
+
* 渲染文本与其草稿投影长度不等,光标渲染偏移需逐芯片修正。
|
|
675
|
+
*/
|
|
676
|
+
function applyChipCorrection(renderedOffset, chipsBeforeCaret) {
|
|
677
|
+
let offset = renderedOffset;
|
|
678
|
+
for (const chip of chipsBeforeCaret) offset -= chip.rendered - chip.draft;
|
|
679
|
+
return Math.max(0, offset);
|
|
680
|
+
}
|
|
681
|
+
//#endregion
|
|
682
|
+
//#region src/client/menu.tsx
|
|
683
|
+
/**
|
|
684
|
+
* `$` 触发补全菜单(conversation.input.overlay 官方插槽,session 作用域):
|
|
685
|
+
* 在 composer 中输入 `$` 弹出密钥名候选(对齐官方 / 命令与 @ 引用的交互)。
|
|
686
|
+
*
|
|
687
|
+
* 官方 input-trigger 管线的检测核硬编码 '/' 与 '@'(TriggerChar 联合类型),
|
|
688
|
+
* '$' 进不了官方检测,故检测与菜单由本组件自理,插入复用官方会话作用域
|
|
689
|
+
* bail 通道 'slash/input-insert-text'(span + draftRev CAS,编辑器内应用)。
|
|
690
|
+
* @module secret-env/client/menu
|
|
691
|
+
*/
|
|
692
|
+
const FALLBACK_INPUT = {
|
|
693
|
+
draft: "",
|
|
694
|
+
draftRev: 0,
|
|
695
|
+
phase: "plain",
|
|
696
|
+
occurrences: []
|
|
697
|
+
};
|
|
698
|
+
/** 本组件容器向上找到 composer 卡片与 Lexical contenteditable 根。 */
|
|
699
|
+
function editorRootOf(el) {
|
|
700
|
+
const root = (el?.closest("[data-composer-card]"))?.querySelector("[contenteditable=\"true\"]");
|
|
701
|
+
return root instanceof HTMLElement ? root : null;
|
|
702
|
+
}
|
|
703
|
+
/**
|
|
704
|
+
* 光标的草稿投影偏移:Range 求光标前渲染文本长,再按光标前的引用芯片
|
|
705
|
+
* (data-composer-chip,DOM 序与 occurrences 的 offset 序一致)修正长度差。
|
|
706
|
+
*/
|
|
707
|
+
function caretDraftOffset(root, occurrences) {
|
|
708
|
+
const sel = window.getSelection();
|
|
709
|
+
if (sel === null || sel.rangeCount === 0 || !sel.isCollapsed) return null;
|
|
710
|
+
const range = sel.getRangeAt(0);
|
|
711
|
+
if (!root.contains(range.startContainer)) return null;
|
|
712
|
+
const pre = document.createRange();
|
|
713
|
+
pre.selectNodeContents(root);
|
|
714
|
+
pre.setEnd(range.startContainer, range.startOffset);
|
|
715
|
+
const rendered = pre.toString().length;
|
|
716
|
+
const corrections = [];
|
|
717
|
+
const chips = root.querySelectorAll("[data-composer-chip]");
|
|
718
|
+
for (let i = 0; i < chips.length; i++) {
|
|
719
|
+
const el = chips[i];
|
|
720
|
+
if (range.comparePoint(el, el.childNodes.length) > 0) continue;
|
|
721
|
+
const occurrence = occurrences[i];
|
|
722
|
+
if (occurrence === void 0) continue;
|
|
723
|
+
corrections.push({
|
|
724
|
+
rendered: el.textContent?.length ?? 0,
|
|
725
|
+
draft: occurrence.length
|
|
726
|
+
});
|
|
727
|
+
}
|
|
728
|
+
return applyChipCorrection(rendered, corrections);
|
|
729
|
+
}
|
|
730
|
+
function toCandidates(list) {
|
|
731
|
+
return [...list.session.map((entry) => ({
|
|
732
|
+
envName: entry.envName,
|
|
733
|
+
name: entry.name,
|
|
734
|
+
description: entry.description,
|
|
735
|
+
scope: "session",
|
|
736
|
+
once: entry.once
|
|
737
|
+
})), ...list.global.map((entry) => ({
|
|
738
|
+
envName: entry.envName,
|
|
739
|
+
name: entry.name,
|
|
740
|
+
description: entry.description,
|
|
741
|
+
scope: "global"
|
|
742
|
+
}))];
|
|
743
|
+
}
|
|
744
|
+
function SecretMenu(props) {
|
|
745
|
+
const { sessionId, t, insertToken } = props;
|
|
746
|
+
const input = (props.useInput ?? ((_selector) => FALLBACK_INPUT))((state) => state);
|
|
747
|
+
const [hit, setHit] = (0, react.useState)(null);
|
|
748
|
+
const [items, setItems] = (0, react.useState)(null);
|
|
749
|
+
const [highlight, setHighlight] = (0, react.useState)(0);
|
|
750
|
+
const wrapRef = (0, react.useRef)(null);
|
|
751
|
+
const composingRef = (0, react.useRef)(false);
|
|
752
|
+
/** Esc 抑制:记录被关闭的令牌,token 变化前不重复弹出。 */
|
|
753
|
+
const suppressedRef = (0, react.useRef)(null);
|
|
754
|
+
/** 候选拉取(ref 稳定;挂载、会话切换与菜单展开沿调用)。 */
|
|
755
|
+
const reloadRef = (0, react.useRef)((_sid) => {});
|
|
756
|
+
reloadRef.current = (sid) => {
|
|
757
|
+
fetchSecrets(sid).then((list) => setItems(toCandidates(list))).catch(() => setItems([]));
|
|
758
|
+
};
|
|
759
|
+
/** 上一次检测的展开态(展开沿判定用)。 */
|
|
760
|
+
const wasOpenRef = (0, react.useRef)(false);
|
|
761
|
+
(0, react.useEffect)(() => {
|
|
762
|
+
reloadRef.current(sessionId);
|
|
763
|
+
}, [sessionId]);
|
|
764
|
+
(0, react.useEffect)(() => {
|
|
765
|
+
const update = () => {
|
|
766
|
+
const root = editorRootOf(wrapRef.current);
|
|
767
|
+
if (root === null || composingRef.current || input.phase !== "plain") {
|
|
768
|
+
setHit((prev) => prev === null ? prev : null);
|
|
769
|
+
wasOpenRef.current = false;
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
772
|
+
const caret = caretDraftOffset(root, input.occurrences);
|
|
773
|
+
const next = caret === null ? null : detectSecretTrigger(input.draft, caret);
|
|
774
|
+
const suppressed = suppressedRef.current;
|
|
775
|
+
const live = next !== null && suppressed !== null && next.start === suppressed.start && next.query === suppressed.query ? null : next;
|
|
776
|
+
if (live !== null && !wasOpenRef.current) reloadRef.current(sessionId);
|
|
777
|
+
wasOpenRef.current = live !== null;
|
|
778
|
+
setHit((prev) => sameHit(prev, live) ? prev : live);
|
|
779
|
+
};
|
|
780
|
+
update();
|
|
781
|
+
document.addEventListener("selectionchange", update);
|
|
782
|
+
return () => document.removeEventListener("selectionchange", update);
|
|
783
|
+
}, [input, sessionId]);
|
|
784
|
+
const candidates = hit === null || items === null ? [] : filterCandidates(items, hit.query);
|
|
785
|
+
const open = hit !== null && candidates.length > 0;
|
|
786
|
+
(0, react.useEffect)(() => {
|
|
787
|
+
const root = editorRootOf(wrapRef.current);
|
|
788
|
+
if (root === null) return;
|
|
789
|
+
const onStart = () => {
|
|
790
|
+
composingRef.current = true;
|
|
791
|
+
};
|
|
792
|
+
const onEnd = () => {
|
|
793
|
+
composingRef.current = false;
|
|
794
|
+
};
|
|
795
|
+
root.addEventListener("compositionstart", onStart);
|
|
796
|
+
root.addEventListener("compositionend", onEnd);
|
|
797
|
+
return () => {
|
|
798
|
+
root.removeEventListener("compositionstart", onStart);
|
|
799
|
+
root.removeEventListener("compositionend", onEnd);
|
|
800
|
+
};
|
|
801
|
+
}, []);
|
|
802
|
+
const pick = (entry) => {
|
|
803
|
+
if (hit === null) return;
|
|
804
|
+
const span = {
|
|
805
|
+
start: hit.start,
|
|
806
|
+
end: hit.end,
|
|
807
|
+
draftRev: input.draftRev
|
|
808
|
+
};
|
|
809
|
+
insertToken(`$${entry.envName} `, span);
|
|
810
|
+
suppressedRef.current = null;
|
|
811
|
+
setHit(null);
|
|
812
|
+
};
|
|
813
|
+
const pickRef = (0, react.useRef)(pick);
|
|
814
|
+
pickRef.current = pick;
|
|
815
|
+
const candidatesRef = (0, react.useRef)(candidates);
|
|
816
|
+
candidatesRef.current = candidates;
|
|
817
|
+
const highlightRef = (0, react.useRef)(highlight);
|
|
818
|
+
highlightRef.current = highlight;
|
|
819
|
+
(0, react.useEffect)(() => {
|
|
820
|
+
if (!open) return;
|
|
821
|
+
const root = editorRootOf(wrapRef.current);
|
|
822
|
+
if (root === null) return;
|
|
823
|
+
const onKey = (event) => {
|
|
824
|
+
const list = candidatesRef.current;
|
|
825
|
+
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
826
|
+
event.preventDefault();
|
|
827
|
+
event.stopPropagation();
|
|
828
|
+
const delta = event.key === "ArrowDown" ? 1 : -1;
|
|
829
|
+
setHighlight((prev) => (prev + delta + list.length) % list.length);
|
|
830
|
+
return;
|
|
831
|
+
}
|
|
832
|
+
if (event.key === "Enter" || event.key === "Tab") {
|
|
833
|
+
const entry = list[highlightRef.current] ?? list[0];
|
|
834
|
+
if (entry !== void 0) {
|
|
835
|
+
event.preventDefault();
|
|
836
|
+
event.stopPropagation();
|
|
837
|
+
pickRef.current(entry);
|
|
838
|
+
}
|
|
839
|
+
return;
|
|
840
|
+
}
|
|
841
|
+
if (event.key === "Escape") {
|
|
842
|
+
event.preventDefault();
|
|
843
|
+
event.stopPropagation();
|
|
844
|
+
suppressedRef.current = hit;
|
|
845
|
+
setHit(null);
|
|
846
|
+
}
|
|
847
|
+
};
|
|
848
|
+
root.addEventListener("keydown", onKey, true);
|
|
849
|
+
return () => root.removeEventListener("keydown", onKey, true);
|
|
850
|
+
}, [open, hit]);
|
|
851
|
+
(0, react.useEffect)(() => {
|
|
852
|
+
if (!open) return;
|
|
853
|
+
const onDown = (event) => {
|
|
854
|
+
if (!(event.target instanceof Node)) return;
|
|
855
|
+
if ((wrapRef.current?.closest("[data-composer-card]"))?.contains(event.target) === true) return;
|
|
856
|
+
setHit(null);
|
|
857
|
+
};
|
|
858
|
+
document.addEventListener("pointerdown", onDown, true);
|
|
859
|
+
return () => document.removeEventListener("pointerdown", onDown, true);
|
|
860
|
+
}, [open]);
|
|
861
|
+
(0, react.useEffect)(() => {
|
|
862
|
+
setHighlight((prev) => candidates.length === 0 ? 0 : Math.min(prev, candidates.length - 1));
|
|
863
|
+
}, [candidates.length]);
|
|
864
|
+
if (!open) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
865
|
+
className: "dse-menuWrap",
|
|
866
|
+
ref: wrapRef
|
|
867
|
+
});
|
|
868
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
869
|
+
className: "dse-menuWrap",
|
|
870
|
+
ref: wrapRef,
|
|
871
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
872
|
+
className: "dse-menu",
|
|
873
|
+
role: "listbox",
|
|
874
|
+
"aria-label": t("menu.aria"),
|
|
875
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
876
|
+
className: "dse-menuTitle",
|
|
877
|
+
children: t("menu.title")
|
|
878
|
+
}), candidates.map((entry, index) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
|
|
879
|
+
type: "button",
|
|
880
|
+
role: "option",
|
|
881
|
+
"aria-selected": index === highlight,
|
|
882
|
+
className: `dse-menuItem${index === highlight ? " dse-menuItemActive" : ""}`,
|
|
883
|
+
onMouseEnter: () => setHighlight(index),
|
|
884
|
+
onMouseDown: (event) => {
|
|
885
|
+
event.preventDefault();
|
|
886
|
+
pick(entry);
|
|
887
|
+
},
|
|
888
|
+
children: [
|
|
889
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
890
|
+
className: "dse-menuName",
|
|
891
|
+
children: ["$", entry.envName]
|
|
892
|
+
}),
|
|
893
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
894
|
+
className: "dse-menuDesc",
|
|
895
|
+
children: entry.description
|
|
896
|
+
}),
|
|
897
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
898
|
+
className: "dse-menuBadges",
|
|
899
|
+
children: [entry.once === true ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
900
|
+
className: "dse-badge",
|
|
901
|
+
children: t("scopeOnce")
|
|
902
|
+
}) : null, /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
903
|
+
className: "dse-badge dse-badgeDim",
|
|
904
|
+
children: entry.scope === "session" ? t("scopeSession") : t("scopeGlobal")
|
|
905
|
+
})]
|
|
906
|
+
})
|
|
907
|
+
]
|
|
908
|
+
}, `${entry.scope}:${entry.name}`))]
|
|
909
|
+
})
|
|
910
|
+
});
|
|
911
|
+
}
|
|
912
|
+
//#endregion
|
|
615
913
|
//#region src/client/section.tsx
|
|
616
914
|
/**
|
|
617
915
|
* 密钥变量设置页(settings.section 官方插槽):全局密钥的列表与增删。
|
|
@@ -934,7 +1232,21 @@ ${extra}
|
|
|
934
1232
|
.dse-popList .dse-row+.dse-row{border-top:1px solid var(--dsw-alias-border-l2)}
|
|
935
1233
|
.dse-popForm{border-top:1px solid var(--dsw-alias-border-l2);margin-top:8px;padding-top:4px}
|
|
936
1234
|
.dse-foot{justify-content:flex-end;gap:8px;display:flex;padding-top:8px}
|
|
937
|
-
/*
|
|
1235
|
+
/* $ 触发补全菜单(仿官方 input-trigger MenuView 的浮层语言) */
|
|
1236
|
+
.dse-menuWrap{position:relative}
|
|
1237
|
+
.dse-menu{position:absolute;bottom:calc(100% + 6px);left:8px;z-index:60;min-width:280px;max-width:min(420px,calc(100vw - 32px));max-height:320px;overflow:auto;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l2);border-radius:12px;box-shadow:0 8px 28px rgba(0,0,0,.18);padding:4px}
|
|
1238
|
+
.dse-menuTitle{color:var(--dsw-alias-label-tertiary);font-size:11px;font-weight:600;padding:6px 10px 4px}
|
|
1239
|
+
.dse-menuItem{appearance:none;border:none;background:0 0;cursor:pointer;align-items:center;gap:8px;width:100%;padding:7px 10px;border-radius:8px;display:flex;text-align:left}
|
|
1240
|
+
.dse-menuItem:hover{background:var(--dsw-alias-interactive-bg-hover)}
|
|
1241
|
+
.dse-menuItemActive{background:var(--dsw-alias-interactive-bg-hover)}
|
|
1242
|
+
.dse-menuName{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--dsw-alias-label-primary);font-size:13px;flex:none}
|
|
1243
|
+
.dse-menuDesc{color:var(--dsw-alias-label-tertiary);font-size:12px;flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
1244
|
+
.dse-menuBadges{align-items:center;gap:4px;flex:none;display:flex}
|
|
1245
|
+
@media (max-width:767px){
|
|
1246
|
+
.dse-menu{left:0;right:0;min-width:0;max-width:none;max-height:45vh}
|
|
1247
|
+
.dse-menuItem{min-height:44px}
|
|
1248
|
+
}
|
|
1249
|
+
|
|
938
1250
|
@media (max-width:767px){
|
|
939
1251
|
.dse-formGrid{grid-template-columns:1fr}
|
|
940
1252
|
.dse-row{flex-wrap:wrap;padding:12px 12px}
|
|
@@ -956,7 +1268,11 @@ ${extra}
|
|
|
956
1268
|
//#region src/client/client.ts
|
|
957
1269
|
const name = "dsh-plus-secret-env";
|
|
958
1270
|
/** 浏览器半需要的 cordis 服务 key(loader 据此注入;package.json 的 dsh.client.inject 管包加载顺序)。 */
|
|
959
|
-
const inject = [
|
|
1271
|
+
const inject = [
|
|
1272
|
+
"slots",
|
|
1273
|
+
"locale",
|
|
1274
|
+
"sessions"
|
|
1275
|
+
];
|
|
960
1276
|
function apply(ctx) {
|
|
961
1277
|
const c = ctx;
|
|
962
1278
|
const styleTag = injectSecretEnvStyle("@dsh-plus/secret-env");
|
|
@@ -982,6 +1298,27 @@ ${extra}
|
|
|
982
1298
|
locale: NS,
|
|
983
1299
|
inject: (sessionId) => ({ sessionId })
|
|
984
1300
|
}, ComposerSecret));
|
|
1301
|
+
c.slots.inject("conversation.input.overlay", () => c.slots.register({
|
|
1302
|
+
name: "conversation.input.overlay",
|
|
1303
|
+
id: "dse-secret-menu",
|
|
1304
|
+
order: 10,
|
|
1305
|
+
locale: NS,
|
|
1306
|
+
inject: (sessionId) => ({
|
|
1307
|
+
sessionId,
|
|
1308
|
+
insertToken: (text, span) => {
|
|
1309
|
+
const actx = c.sessions.scope(sessionId);
|
|
1310
|
+
if (actx === void 0) return false;
|
|
1311
|
+
return actx.bail(actx, "slash/input-insert-text", {
|
|
1312
|
+
text,
|
|
1313
|
+
span: {
|
|
1314
|
+
start: span.start,
|
|
1315
|
+
end: span.end,
|
|
1316
|
+
draftRev: span.draftRev
|
|
1317
|
+
}
|
|
1318
|
+
}) === true;
|
|
1319
|
+
}
|
|
1320
|
+
})
|
|
1321
|
+
}, SecretMenu));
|
|
985
1322
|
}
|
|
986
1323
|
//#endregion
|
|
987
1324
|
exports.apply = apply;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dsh-plus/secret-env",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "dsh-plus service+ui plugin: 密钥环境变量——以 $DSH_SECRET_* 变量名向 agent 暴露密钥(全局持久/会话级/一次性),执行期经 dsh-shell-env 原生注入,值不进消息流,不影响缓存率",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -50,7 +50,9 @@
|
|
|
50
50
|
"inject": [
|
|
51
51
|
"@deepseek-ai/dsh-client-ui-renderer",
|
|
52
52
|
"@deepseek-ai/dsh-client-locale",
|
|
53
|
-
"@deepseek-ai/dsh-api-remotes"
|
|
53
|
+
"@deepseek-ai/dsh-api-remotes",
|
|
54
|
+
"@deepseek-ai/dsh-api-session-controller",
|
|
55
|
+
"@deepseek-ai/dsh-client-ui-conversation"
|
|
54
56
|
],
|
|
55
57
|
"platform": "web"
|
|
56
58
|
}
|
package/src/client/client.ts
CHANGED
|
@@ -10,13 +10,14 @@ import type { Context } from '@deepseek-ai/cordis'
|
|
|
10
10
|
|
|
11
11
|
import { ComposerSecret } from './composer.tsx'
|
|
12
12
|
import { en, NS, zh } from './i18n.ts'
|
|
13
|
+
import { SecretMenu, type TokenSpanLike } from './menu.tsx'
|
|
13
14
|
import { SecretsSection } from './section.tsx'
|
|
14
15
|
import { injectSecretEnvStyle } from './styles.ts'
|
|
15
16
|
|
|
16
17
|
export const name = 'dsh-plus-secret-env'
|
|
17
18
|
|
|
18
19
|
/** 浏览器半需要的 cordis 服务 key(loader 据此注入;package.json 的 dsh.client.inject 管包加载顺序)。 */
|
|
19
|
-
export const inject = ['slots', 'locale'] as const
|
|
20
|
+
export const inject = ['slots', 'locale', 'sessions'] as const
|
|
20
21
|
|
|
21
22
|
interface SlotsLike {
|
|
22
23
|
inject(key: string, callback: () => unknown): unknown
|
|
@@ -28,9 +29,19 @@ interface LocaleLike {
|
|
|
28
29
|
bind(ns: string): (key: string) => string
|
|
29
30
|
}
|
|
30
31
|
|
|
32
|
+
/** 会话作用域句柄的结构子集(dsh-api-session-controller 客户端 sessions 服务)。 */
|
|
33
|
+
interface SessionScopeLike {
|
|
34
|
+
bail(subject: unknown, event: string, payload: Record<string, unknown>): unknown
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface SessionsLike {
|
|
38
|
+
scope(sessionId: string): SessionScopeLike | undefined
|
|
39
|
+
}
|
|
40
|
+
|
|
31
41
|
interface ClientContext {
|
|
32
42
|
slots: SlotsLike
|
|
33
43
|
locale: LocaleLike
|
|
44
|
+
sessions: SessionsLike
|
|
34
45
|
effect(execute: () => () => void, label?: string): unknown
|
|
35
46
|
}
|
|
36
47
|
|
|
@@ -74,4 +85,31 @@ export function apply(ctx: Context): void {
|
|
|
74
85
|
ComposerSecret,
|
|
75
86
|
),
|
|
76
87
|
)
|
|
88
|
+
|
|
89
|
+
// `$` 触发补全菜单(conversation.input.overlay:composer 卡片内浮层)。
|
|
90
|
+
// 插入走官方会话作用域 bail 通道 slash/input-insert-text(span+draftRev CAS)。
|
|
91
|
+
c.slots.inject('conversation.input.overlay', () =>
|
|
92
|
+
c.slots.register(
|
|
93
|
+
{
|
|
94
|
+
name: 'conversation.input.overlay',
|
|
95
|
+
id: 'dse-secret-menu',
|
|
96
|
+
order: 10,
|
|
97
|
+
locale: NS,
|
|
98
|
+
inject: (sessionId: string) => ({
|
|
99
|
+
sessionId,
|
|
100
|
+
insertToken: (text: string, span: TokenSpanLike): boolean => {
|
|
101
|
+
const actx = c.sessions.scope(sessionId)
|
|
102
|
+
if (actx === undefined) return false
|
|
103
|
+
return (
|
|
104
|
+
actx.bail(actx, 'slash/input-insert-text', {
|
|
105
|
+
text,
|
|
106
|
+
span: { start: span.start, end: span.end, draftRev: span.draftRev },
|
|
107
|
+
}) === true
|
|
108
|
+
)
|
|
109
|
+
},
|
|
110
|
+
}),
|
|
111
|
+
},
|
|
112
|
+
SecretMenu,
|
|
113
|
+
),
|
|
114
|
+
)
|
|
77
115
|
}
|
package/src/client/i18n.ts
CHANGED
|
@@ -47,6 +47,9 @@ const ownZh = {
|
|
|
47
47
|
scopeGlobal: '全局',
|
|
48
48
|
scopeSession: '会话',
|
|
49
49
|
scopeOnce: '一次性',
|
|
50
|
+
// `$` 触发补全菜单
|
|
51
|
+
'menu.title': '密钥变量',
|
|
52
|
+
'menu.aria': '密钥变量候选',
|
|
50
53
|
// 错误码
|
|
51
54
|
'error.invalid-name': '变量名不合法:大写字母开头,仅字母/数字/下划线,≤64 字符。',
|
|
52
55
|
'error.empty-value': '值不能为空。',
|
|
@@ -98,6 +101,8 @@ const ownEn = {
|
|
|
98
101
|
scopeGlobal: 'global',
|
|
99
102
|
scopeSession: 'session',
|
|
100
103
|
scopeOnce: 'once',
|
|
104
|
+
'menu.title': 'Secret variables',
|
|
105
|
+
'menu.aria': 'Secret variable suggestions',
|
|
101
106
|
'error.invalid-name':
|
|
102
107
|
'Invalid name: start with an uppercase letter; letters/digits/underscore only, ≤64 chars.',
|
|
103
108
|
'error.empty-value': 'Value must not be empty.',
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `$` 触发补全的纯核心(零 DOM / React / cordis,node 直接单测):
|
|
3
|
+
* - detectSecretTrigger:光标处 `$<query>` 命中检测(边界规则对齐官方
|
|
4
|
+
* input-trigger 的 / 与 @:起草开头、空白后、标点后开闸;词中 $ 不触发);
|
|
5
|
+
* - filterCandidates:按 query 过滤+排序(前缀命中优先);
|
|
6
|
+
* - applyChipCorrection:渲染文本偏移 → 草稿投影偏移的芯片修正。
|
|
7
|
+
* @module secret-env/client/menu-core
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** 一次 `$` 命中:query 为 $ 到光标间的文本,[start, end) 为草稿投影中的令牌区间。 */
|
|
11
|
+
export interface SecretHit {
|
|
12
|
+
readonly query: string
|
|
13
|
+
readonly start: number
|
|
14
|
+
readonly end: number
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** 菜单候选项(全局与会话密钥的统一视图,纯展示数据)。 */
|
|
18
|
+
export interface SecretCandidate {
|
|
19
|
+
readonly envName: string
|
|
20
|
+
readonly name: string
|
|
21
|
+
readonly description: string
|
|
22
|
+
readonly scope: 'global' | 'session'
|
|
23
|
+
readonly once?: boolean
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const WORD_CHAR = /[\p{L}\p{N}_]/u
|
|
27
|
+
const TOKEN_CHAR = /^[A-Za-z0-9_]$/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 检测光标处的 `$` 触发令牌。词法:
|
|
31
|
+
* - 从光标向左扫描,token 字符(字母/数字/下划线)继续,空白直接判负;
|
|
32
|
+
* - 遇到 `$` 时校验词边界:起草开头 / 前字符为空白 / 前字符为非词字符(标点)
|
|
33
|
+
* 才开闸——`foo$BAR` 这类词中 $ 不触发(与官方 URL 内 / 不触发同理);
|
|
34
|
+
* - 其余字符(如 `{`、`(`)终止扫描。
|
|
35
|
+
*/
|
|
36
|
+
export function detectSecretTrigger(draft: string, caret: number): SecretHit | null {
|
|
37
|
+
if (caret <= 0 || caret > draft.length) return null
|
|
38
|
+
for (let i = caret - 1; i >= 0; i--) {
|
|
39
|
+
const ch = draft.charAt(i)
|
|
40
|
+
if (/\s/u.test(ch)) return null
|
|
41
|
+
if (ch !== '$') {
|
|
42
|
+
if (!TOKEN_CHAR.test(ch)) return null
|
|
43
|
+
continue
|
|
44
|
+
}
|
|
45
|
+
if (i > 0) {
|
|
46
|
+
const prev = draft.charAt(i - 1)
|
|
47
|
+
if (!/\s/u.test(prev) && WORD_CHAR.test(prev)) return null
|
|
48
|
+
}
|
|
49
|
+
return { query: draft.slice(i + 1, caret), start: i, end: caret }
|
|
50
|
+
}
|
|
51
|
+
return null
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** 两个命中的等价判定(抑制重复 setState)。 */
|
|
55
|
+
export function sameHit(a: SecretHit | null, b: SecretHit | null): boolean {
|
|
56
|
+
if (a === null || b === null) return a === b
|
|
57
|
+
return a.query === b.query && a.start === b.start && a.end === b.end
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** 按 query 过滤候选(大小写不敏感;变量名前缀命中排前,其余子串命中随后)。 */
|
|
61
|
+
export function filterCandidates(
|
|
62
|
+
entries: readonly SecretCandidate[],
|
|
63
|
+
query: string,
|
|
64
|
+
): SecretCandidate[] {
|
|
65
|
+
const q = query.toUpperCase()
|
|
66
|
+
const prefixed: SecretCandidate[] = []
|
|
67
|
+
const partial: SecretCandidate[] = []
|
|
68
|
+
for (const entry of entries) {
|
|
69
|
+
if (q === '') {
|
|
70
|
+
prefixed.push(entry)
|
|
71
|
+
continue
|
|
72
|
+
}
|
|
73
|
+
if (entry.envName.toUpperCase().includes(q) || entry.name.toUpperCase().includes(q)) {
|
|
74
|
+
if (entry.name.toUpperCase().startsWith(q) || entry.envName.toUpperCase().includes(`_${q}`)) {
|
|
75
|
+
prefixed.push(entry)
|
|
76
|
+
} else {
|
|
77
|
+
partial.push(entry)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return [...prefixed, ...partial]
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** 一处芯片修正对:光标前芯片的渲染文本长与草稿投影长。 */
|
|
85
|
+
export interface ChipCorrection {
|
|
86
|
+
readonly rendered: number
|
|
87
|
+
readonly draft: number
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* 渲染文本偏移 → 草稿投影偏移。contenteditable 中芯片(引用装饰器)的
|
|
92
|
+
* 渲染文本与其草稿投影长度不等,光标渲染偏移需逐芯片修正。
|
|
93
|
+
*/
|
|
94
|
+
export function applyChipCorrection(
|
|
95
|
+
renderedOffset: number,
|
|
96
|
+
chipsBeforeCaret: readonly ChipCorrection[],
|
|
97
|
+
): number {
|
|
98
|
+
let offset = renderedOffset
|
|
99
|
+
for (const chip of chipsBeforeCaret) {
|
|
100
|
+
offset -= chip.rendered - chip.draft
|
|
101
|
+
}
|
|
102
|
+
return Math.max(0, offset)
|
|
103
|
+
}
|
|
@@ -0,0 +1,285 @@
|
|
|
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
|
+
...list.global.map((entry) => ({
|
|
96
|
+
envName: entry.envName,
|
|
97
|
+
name: entry.name,
|
|
98
|
+
description: entry.description,
|
|
99
|
+
scope: 'global' as const,
|
|
100
|
+
})),
|
|
101
|
+
]
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function SecretMenu(props: SecretMenuProps): ReactElement | null {
|
|
105
|
+
const { sessionId, t, insertToken } = props
|
|
106
|
+
// useInput 是标准钩子 prop,必须无条件调用;旧壳缺席时回落常量(菜单永不展开)。
|
|
107
|
+
const useInput =
|
|
108
|
+
props.useInput ?? (<S,>(_selector: (state: InputStateLike) => S): S => FALLBACK_INPUT as S)
|
|
109
|
+
const input = useInput((state: InputStateLike) => state)
|
|
110
|
+
|
|
111
|
+
const [hit, setHit] = useState<SecretHit | null>(null)
|
|
112
|
+
const [items, setItems] = useState<SecretCandidate[] | null>(null)
|
|
113
|
+
const [highlight, setHighlight] = useState(0)
|
|
114
|
+
const wrapRef = useRef<HTMLDivElement | null>(null)
|
|
115
|
+
const composingRef = useRef(false)
|
|
116
|
+
/** Esc 抑制:记录被关闭的令牌,token 变化前不重复弹出。 */
|
|
117
|
+
const suppressedRef = useRef<SecretHit | null>(null)
|
|
118
|
+
|
|
119
|
+
/** 候选拉取(ref 稳定;挂载、会话切换与菜单展开沿调用)。 */
|
|
120
|
+
const reloadRef = useRef((_sid: string): void => {})
|
|
121
|
+
reloadRef.current = (sid: string) => {
|
|
122
|
+
fetchSecrets(sid)
|
|
123
|
+
.then((list) => setItems(toCandidates(list)))
|
|
124
|
+
.catch(() => setItems([]))
|
|
125
|
+
}
|
|
126
|
+
/** 上一次检测的展开态(展开沿判定用)。 */
|
|
127
|
+
const wasOpenRef = useRef(false)
|
|
128
|
+
|
|
129
|
+
// 挂载与会话切换时拉取候选。
|
|
130
|
+
useEffect(() => {
|
|
131
|
+
reloadRef.current(sessionId)
|
|
132
|
+
}, [sessionId])
|
|
133
|
+
|
|
134
|
+
// 检测:草稿变化(每次击键)与光标移动(selectionchange)双通道驱动。
|
|
135
|
+
useEffect(() => {
|
|
136
|
+
const update = (): void => {
|
|
137
|
+
const root = editorRootOf(wrapRef.current)
|
|
138
|
+
if (root === null || composingRef.current || input.phase !== 'plain') {
|
|
139
|
+
setHit((prev) => (prev === null ? prev : null))
|
|
140
|
+
wasOpenRef.current = false
|
|
141
|
+
return
|
|
142
|
+
}
|
|
143
|
+
const caret = caretDraftOffset(root, input.occurrences)
|
|
144
|
+
const next = caret === null ? null : detectSecretTrigger(input.draft, caret)
|
|
145
|
+
const suppressed = suppressedRef.current
|
|
146
|
+
const live =
|
|
147
|
+
next !== null &&
|
|
148
|
+
suppressed !== null &&
|
|
149
|
+
next.start === suppressed.start &&
|
|
150
|
+
next.query === suppressed.query
|
|
151
|
+
? null
|
|
152
|
+
: next
|
|
153
|
+
// 展开沿:闭合 → 命中时刷新候选(密钥可能刚在别处改动)。
|
|
154
|
+
if (live !== null && !wasOpenRef.current) reloadRef.current(sessionId)
|
|
155
|
+
wasOpenRef.current = live !== null
|
|
156
|
+
setHit((prev) => (sameHit(prev, live) ? prev : live))
|
|
157
|
+
}
|
|
158
|
+
update()
|
|
159
|
+
document.addEventListener('selectionchange', update)
|
|
160
|
+
return () => document.removeEventListener('selectionchange', update)
|
|
161
|
+
}, [input, sessionId])
|
|
162
|
+
|
|
163
|
+
const candidates = hit === null || items === null ? [] : filterCandidates(items, hit.query)
|
|
164
|
+
const open = hit !== null && candidates.length > 0
|
|
165
|
+
|
|
166
|
+
// IME 组合期间不介入(输入法键不应被菜单截获)。
|
|
167
|
+
useEffect(() => {
|
|
168
|
+
const root = editorRootOf(wrapRef.current)
|
|
169
|
+
if (root === null) return
|
|
170
|
+
const onStart = (): void => {
|
|
171
|
+
composingRef.current = true
|
|
172
|
+
}
|
|
173
|
+
const onEnd = (): void => {
|
|
174
|
+
composingRef.current = false
|
|
175
|
+
}
|
|
176
|
+
root.addEventListener('compositionstart', onStart)
|
|
177
|
+
root.addEventListener('compositionend', onEnd)
|
|
178
|
+
return () => {
|
|
179
|
+
root.removeEventListener('compositionstart', onStart)
|
|
180
|
+
root.removeEventListener('compositionend', onEnd)
|
|
181
|
+
}
|
|
182
|
+
}, [])
|
|
183
|
+
|
|
184
|
+
const pick = (entry: SecretCandidate): void => {
|
|
185
|
+
if (hit === null) return
|
|
186
|
+
const span = { start: hit.start, end: hit.end, draftRev: input.draftRev }
|
|
187
|
+
insertToken(`$${entry.envName} `, span)
|
|
188
|
+
suppressedRef.current = null
|
|
189
|
+
setHit(null)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const pickRef = useRef(pick)
|
|
193
|
+
pickRef.current = pick
|
|
194
|
+
const candidatesRef = useRef(candidates)
|
|
195
|
+
candidatesRef.current = candidates
|
|
196
|
+
const highlightRef = useRef(highlight)
|
|
197
|
+
highlightRef.current = highlight
|
|
198
|
+
|
|
199
|
+
// 键盘导航:捕获阶段先於 Lexical/官方管线截获;仅在菜单展开时介入。
|
|
200
|
+
useEffect(() => {
|
|
201
|
+
if (!open) return
|
|
202
|
+
const root = editorRootOf(wrapRef.current)
|
|
203
|
+
if (root === null) return
|
|
204
|
+
const onKey = (event: KeyboardEvent): void => {
|
|
205
|
+
const list = candidatesRef.current
|
|
206
|
+
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
|
207
|
+
event.preventDefault()
|
|
208
|
+
event.stopPropagation()
|
|
209
|
+
const delta = event.key === 'ArrowDown' ? 1 : -1
|
|
210
|
+
setHighlight((prev) => (prev + delta + list.length) % list.length)
|
|
211
|
+
return
|
|
212
|
+
}
|
|
213
|
+
if (event.key === 'Enter' || event.key === 'Tab') {
|
|
214
|
+
const entry = list[highlightRef.current] ?? list[0]
|
|
215
|
+
if (entry !== undefined) {
|
|
216
|
+
event.preventDefault()
|
|
217
|
+
event.stopPropagation()
|
|
218
|
+
pickRef.current(entry)
|
|
219
|
+
}
|
|
220
|
+
return
|
|
221
|
+
}
|
|
222
|
+
if (event.key === 'Escape') {
|
|
223
|
+
event.preventDefault()
|
|
224
|
+
event.stopPropagation()
|
|
225
|
+
suppressedRef.current = hit
|
|
226
|
+
setHit(null)
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
root.addEventListener('keydown', onKey, true)
|
|
230
|
+
return () => root.removeEventListener('keydown', onKey, true)
|
|
231
|
+
}, [open, hit])
|
|
232
|
+
|
|
233
|
+
// 点 composer 卡片之外处关闭(对齐官方菜单的外部 dismiss 语义)。
|
|
234
|
+
useEffect(() => {
|
|
235
|
+
if (!open) return
|
|
236
|
+
const onDown = (event: PointerEvent): void => {
|
|
237
|
+
if (!(event.target instanceof Node)) return
|
|
238
|
+
const card = wrapRef.current?.closest('[data-composer-card]')
|
|
239
|
+
if (card?.contains(event.target) === true) return
|
|
240
|
+
setHit(null)
|
|
241
|
+
}
|
|
242
|
+
document.addEventListener('pointerdown', onDown, true)
|
|
243
|
+
return () => document.removeEventListener('pointerdown', onDown, true)
|
|
244
|
+
}, [open])
|
|
245
|
+
|
|
246
|
+
// 高亮随候选集变化收敛到合法范围。
|
|
247
|
+
useEffect(() => {
|
|
248
|
+
setHighlight((prev) => (candidates.length === 0 ? 0 : Math.min(prev, candidates.length - 1)))
|
|
249
|
+
}, [candidates.length])
|
|
250
|
+
|
|
251
|
+
if (!open) {
|
|
252
|
+
// 锚点常驻:闭合态也要挂载容器,检测效果依赖它定位 composer 编辑器根。
|
|
253
|
+
return <div className="dse-menuWrap" ref={wrapRef} />
|
|
254
|
+
}
|
|
255
|
+
return (
|
|
256
|
+
<div className="dse-menuWrap" ref={wrapRef}>
|
|
257
|
+
<div className="dse-menu" role="listbox" aria-label={t('menu.aria')}>
|
|
258
|
+
<div className="dse-menuTitle">{t('menu.title')}</div>
|
|
259
|
+
{candidates.map((entry, index) => (
|
|
260
|
+
<button
|
|
261
|
+
key={`${entry.scope}:${entry.name}`}
|
|
262
|
+
type="button"
|
|
263
|
+
role="option"
|
|
264
|
+
aria-selected={index === highlight}
|
|
265
|
+
className={`dse-menuItem${index === highlight ? ' dse-menuItemActive' : ''}`}
|
|
266
|
+
onMouseEnter={() => setHighlight(index)}
|
|
267
|
+
onMouseDown={(event) => {
|
|
268
|
+
event.preventDefault()
|
|
269
|
+
pick(entry)
|
|
270
|
+
}}
|
|
271
|
+
>
|
|
272
|
+
<span className="dse-menuName">${entry.envName}</span>
|
|
273
|
+
<span className="dse-menuDesc">{entry.description}</span>
|
|
274
|
+
<span className="dse-menuBadges">
|
|
275
|
+
{entry.once === true ? <span className="dse-badge">{t('scopeOnce')}</span> : null}
|
|
276
|
+
<span className="dse-badge dse-badgeDim">
|
|
277
|
+
{entry.scope === 'session' ? t('scopeSession') : t('scopeGlobal')}
|
|
278
|
+
</span>
|
|
279
|
+
</span>
|
|
280
|
+
</button>
|
|
281
|
+
))}
|
|
282
|
+
</div>
|
|
283
|
+
</div>
|
|
284
|
+
)
|
|
285
|
+
}
|
package/src/client/styles.ts
CHANGED
|
@@ -47,7 +47,21 @@ const EXTRA = `
|
|
|
47
47
|
.dse-popList .dse-row+.dse-row{border-top:1px solid var(--dsw-alias-border-l2)}
|
|
48
48
|
.dse-popForm{border-top:1px solid var(--dsw-alias-border-l2);margin-top:8px;padding-top:4px}
|
|
49
49
|
.dse-foot{justify-content:flex-end;gap:8px;display:flex;padding-top:8px}
|
|
50
|
-
/*
|
|
50
|
+
/* $ 触发补全菜单(仿官方 input-trigger MenuView 的浮层语言) */
|
|
51
|
+
.dse-menuWrap{position:relative}
|
|
52
|
+
.dse-menu{position:absolute;bottom:calc(100% + 6px);left:8px;z-index:60;min-width:280px;max-width:min(420px,calc(100vw - 32px));max-height:320px;overflow:auto;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l2);border-radius:12px;box-shadow:0 8px 28px rgba(0,0,0,.18);padding:4px}
|
|
53
|
+
.dse-menuTitle{color:var(--dsw-alias-label-tertiary);font-size:11px;font-weight:600;padding:6px 10px 4px}
|
|
54
|
+
.dse-menuItem{appearance:none;border:none;background:0 0;cursor:pointer;align-items:center;gap:8px;width:100%;padding:7px 10px;border-radius:8px;display:flex;text-align:left}
|
|
55
|
+
.dse-menuItem:hover{background:var(--dsw-alias-interactive-bg-hover)}
|
|
56
|
+
.dse-menuItemActive{background:var(--dsw-alias-interactive-bg-hover)}
|
|
57
|
+
.dse-menuName{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--dsw-alias-label-primary);font-size:13px;flex:none}
|
|
58
|
+
.dse-menuDesc{color:var(--dsw-alias-label-tertiary);font-size:12px;flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
59
|
+
.dse-menuBadges{align-items:center;gap:4px;flex:none;display:flex}
|
|
60
|
+
@media (max-width:767px){
|
|
61
|
+
.dse-menu{left:0;right:0;min-width:0;max-width:none;max-height:45vh}
|
|
62
|
+
.dse-menuItem{min-height:44px}
|
|
63
|
+
}
|
|
64
|
+
|
|
51
65
|
@media (max-width:767px){
|
|
52
66
|
.dse-formGrid{grid-template-columns:1fr}
|
|
53
67
|
.dse-row{flex-wrap:wrap;padding:12px 12px}
|