/** * 带环境变量自动补全的输入框: * 输入 "{{" 后弹出当前环境变量浮层,↑↓ 选择、Enter/Tab 插入 {{变量名}}。 */ import { useWorkspace } from "../workspaceStore"; import { useEffect, useLayoutEffect, useMemo, useRef, useState, type KeyboardEvent } from "react"; import { EnvBadge } from "./EnvBadge"; type Candidate = { name: string; value: string; description?: string }; export function VarHintInput({ value, onChange, placeholder, className, onKeyDown, autoFocus, spellCheck = false, ariaLabel, }: { value: string; onChange: (v: string) => void; placeholder?: string; className?: string; onKeyDown?: (e: KeyboardEvent) => void; autoFocus?: boolean; spellCheck?: boolean; ariaLabel?: string; }) { const inputRef = useRef(null); const [query, setQuery] = useState<{ prefix: string; caret: number } | null>(null); const [active, setActive] = useState(0); const candidates = useMemo(() => { const state = useWorkspace.getState(); const env = state.environments.find((e) => e.id === state.activeEnvironmentId); return (env?.variables ?? []) .filter((v) => v.enabled && v.key.trim()) .map((v) => ({ name: v.key.trim(), value: v.value, description: v.description })); }, [query]); // query 变化时重查(环境可能被改) const filtered = useMemo(() => { if (!query) return []; const q = query.prefix.toLowerCase(); const list = candidates.filter((c) => c.name.toLowerCase().includes(q)); return list.slice(0, 8); }, [candidates, query]); // 光标前未闭合的 {{ 即触发 const detect = (v: string, caret: number) => { const before = v.slice(0, caret); const idx = before.lastIndexOf("{{"); if (idx < 0) return null; const frag = before.slice(idx + 2); if (frag.includes("}") || frag.includes("{") || frag.includes(" ") || frag.length > 40) return null; return { prefix: frag, caret }; }; const sync = () => { const el = inputRef.current; if (!el) return; const q = detect(el.value, el.selectionStart ?? 0); setQuery(q); setActive(0); }; const insert = (name: string) => { const el = inputRef.current; if (!el || !query) return; const idx = el.value.slice(0, query.caret).lastIndexOf("{{"); if (idx < 0) return; const next = `${el.value.slice(0, idx)}{{${name}}}` + el.value.slice(query.caret); onChange(next); setQuery(null); const caretTarget = idx + name.length + 4; requestAnimationFrame(() => { el.focus(); el.setSelectionRange(caretTarget, caretTarget); }); }; // 过滤结果为空时收起 useEffect(() => { if (query && filtered.length === 0) setQuery(null); }, [filtered.length, query]); // 外部清空时重置 useLayoutEffect(() => { if (!value) setQuery(null); }, [value]); const handleKeyDown = (e: KeyboardEvent) => { if (query && filtered.length > 0) { if (e.key === "ArrowDown") { e.preventDefault(); e.stopPropagation(); setActive((a) => Math.min(a + 1, filtered.length - 1)); return; } if (e.key === "ArrowUp") { e.preventDefault(); e.stopPropagation(); setActive((a) => Math.max(a - 1, 0)); return; } if (e.key === "Enter" || e.key === "Tab") { e.preventDefault(); e.stopPropagation(); insert(filtered[active].name); return; } if (e.key === "Escape") { e.stopPropagation(); setQuery(null); return; } } onKeyDown?.(e); }; const envName = useWorkspace( (s) => s.environments.find((e) => e.id === s.activeEnvironmentId)?.name ?? "", ); const envId = useWorkspace((s) => s.activeEnvironmentId); return (
{ const el = e.currentTarget; onChange(el.value); // 同步阶段探测(input 事件时 DOM 已更新,避免 React 并发提交的竞态) setQuery(detect(el.value, el.selectionStart ?? el.value.length)); setActive(0); }} onKeyUp={sync} onClick={sync} onBlur={() => setTimeout(() => setQuery(null), 120)} onKeyDown={handleKeyDown} /> {query && filtered.length > 0 && (
e.preventDefault()}>
{envId ? ( <> {envName || "当前环境"} ) : ( 未选择环境 )} {"输入 {{变量名}} 引用 · Enter 插入"}
{filtered.map((c, i) => ( ))}
)}
); }