- 集成 tauri-plugin-opener:Cargo/lib.rs/capabilities/npm 四处接线,新增 src/openExternal.ts(桌面走 opener,浏览器回退 window.open,相对路径绝对化) - 关于页 4 个链接、活动栏官网菜单、代理错误横幅 2 处健康检查链接全部改走 openExternal,桌面端可正常跳转 - 设置→外观 主题顺序调整为 深色/浅色/跟随系统 - release 二进制重编并验证(浏览器实测关于页链接可开文档页) - 附带工作区中未提交的扁平化重构现状(另一会话进行中) - homepage 落地页与文档终检通过(8787 / 与 /docs 双 200)
180 lines
5.6 KiB
TypeScript
180 lines
5.6 KiB
TypeScript
/**
|
|
* 带环境变量自动补全的输入框:
|
|
* 输入 "{{" 后弹出当前环境变量浮层,↑↓ 选择、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<HTMLInputElement>) => void;
|
|
autoFocus?: boolean;
|
|
spellCheck?: boolean;
|
|
ariaLabel?: string;
|
|
}) {
|
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
|
const [query, setQuery] = useState<{ prefix: string; caret: number } | null>(null);
|
|
const [active, setActive] = useState(0);
|
|
|
|
const candidates = useMemo<Candidate[]>(() => {
|
|
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<HTMLInputElement>) => {
|
|
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 (
|
|
<div className="var-hint-wrap">
|
|
<input
|
|
ref={inputRef}
|
|
className={className}
|
|
placeholder={placeholder}
|
|
value={value}
|
|
aria-label={ariaLabel}
|
|
spellCheck={spellCheck}
|
|
autoFocus={autoFocus}
|
|
onChange={(e) => {
|
|
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 && (
|
|
<div className="var-hint-panel" onMouseDown={(e) => e.preventDefault()}>
|
|
<div className="var-hint-head">
|
|
{envId ? (
|
|
<>
|
|
<EnvBadge envId={envId} name={envName} size={16} />
|
|
<span>{envName || "当前环境"}</span>
|
|
</>
|
|
) : (
|
|
<span>未选择环境</span>
|
|
)}
|
|
<span className="ops-faint">{"输入 {{变量名}} 引用 · Enter 插入"}</span>
|
|
</div>
|
|
{filtered.map((c, i) => (
|
|
<button
|
|
key={c.name}
|
|
className={i === active ? "var-hint-item sel" : "var-hint-item"}
|
|
onMouseEnter={() => setActive(i)}
|
|
onMouseDown={(e) => {
|
|
e.preventDefault();
|
|
insert(c.name);
|
|
}}
|
|
>
|
|
<span className="var-hint-name">{"{{"}{c.name}{"}}"}</span>
|
|
<span className="var-hint-value">{c.value || "(空)"}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|