Files
opsxdb/src/ConnectionSidebar.tsx
T
logan 2c0bfafd8d 同步完整源码至远程:扁平化重构现状 + 未入库源文件
- 补入库:server/internal/db/dump.go、redis 分页/扫描预算测试、opsxdb-mcp main_test、
  packages/opsxdb-mcp package-lock、docs 审计与对比文档等 37 个新文件
- 含结构扁平化进行中的 80 处修改与 155 处旧结构删除(server/ src/ src-tauri/ 为当前有效源)
- 构建产物(bin/target/dist)与签名私钥仍按规则排除
2026-10-16 03:47:33 +08:00

358 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useRef, useState } from "react";
import { closeSession, openSession } from "./api";
import { driverById } from "./catalog";
import { askInput } from "./dialogs";
import { DriverIcon } from "./DriverIcon";
import { ObjectTree } from "./ObjectTree";
import { ContextMenu } from "./ContextMenu";
import { TreeChevron } from "./TreeChevron";
import { connectionEndpoint, connectionUri } from "./contextActions";
import {
DEFAULT_CONN_GROUP,
useWorkspaceStore,
type SavedConnection,
} from "./store";
function cx(...xs: Array<string | false | null | undefined>) {
return xs.filter(Boolean).join(" ");
}
type DropHint = { groupId: string; index: number } | null;
export function ConnectionSidebar({
onSelect,
onDelete,
}: {
onSelect: (id: string) => void;
onDelete: (id: string) => void;
}) {
const connections = useWorkspaceStore((s) => s.connections);
const groups = useWorkspaceStore((s) => s.connGroups);
const expanded = useWorkspaceStore((s) => s.expandedConnGroups);
const activeId = useWorkspaceStore((s) => s.activeConnectionId);
const addConnGroup = useWorkspaceStore((s) => s.addConnGroup);
const renameConnGroup = useWorkspaceStore((s) => s.renameConnGroup);
const removeConnGroup = useWorkspaceStore((s) => s.removeConnGroup);
const moveConnection = useWorkspaceStore((s) => s.moveConnection);
const toggleConnGroup = useWorkspaceStore((s) => s.toggleConnGroup);
const notify = useWorkspaceStore((s) => s.notify);
const notifyError = useWorkspaceStore((s) => s.notifyError);
const editConnection = useWorkspaceStore((s) => s.editConnection);
const connState = useWorkspaceStore((s) => s.connState);
const set = useWorkspaceStore((s) => s.set);
const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number; conn: SavedConnection } | null>(null);
/** 断开连接:关会话 + 清树 + 清激活 */
const disconnect = (c: SavedConnection) => {
const st = useWorkspaceStore.getState();
const sid = st.sessions[c.id];
if (sid) {
void closeSession(sid).catch(() => {});
}
st.setSession(c.id, null);
if (st.activeConnectionId === c.id) {
st.set("activeConnectionId", null);
st.set("tree", []);
st.set("connState", "down");
st.set("status", `已断开 ${c.name}`);
}
notify({ kind: "warning", title: "已断开", message: c.name, mode: "toast", durationMs: 1800 });
};
const reconnect = async (c: SavedConnection) => {
set("connState", "reconnecting");
set("status", "重连中…");
try {
useWorkspaceStore.getState().setSession(c.id, null);
const { sessionId } = await openSession(c);
useWorkspaceStore.getState().setSession(c.id, sessionId);
useWorkspaceStore.getState().selectConnection(c.id);
set("connState", "ok");
set("status", `已重连 ${c.name}` + (c.database ? ` · ${c.database}` : ""));
notify({ kind: "success", title: "重连成功", message: c.name, mode: "toast", durationMs: 1600 });
} catch (e) {
set("connState", "down");
const msg = e instanceof Error ? e.message : String(e);
set("status", `重连失败:${msg}`);
notifyError(e, "modal");
}
};
const [dragId, setDragId] = useState<string | null>(null);
const [dropHint, setDropHint] = useState<DropHint>(null);
const dragIdRef = useRef<string | null>(null);
const byGroup = useMemo(() => {
const map = new Map<string, SavedConnection[]>();
for (const g of groups) map.set(g.id, []);
for (const c of connections) {
const gid = c.groupId || DEFAULT_CONN_GROUP;
if (!map.has(gid)) map.set(gid, []);
map.get(gid)!.push(c);
}
for (const list of map.values()) {
list.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0));
}
return map;
}, [connections, groups]);
const onAddGroup = () => {
void (async () => {
// C6:askInput 替代 window.prompt(WKWebView 下 prompt 返回 null)
const name = await askInput({ title: "分组名称", defaultValue: "新分组" });
if (name == null) return;
const id = addConnGroup(name);
notify({ kind: "info", title: "分组", message: `已创建「${name.trim() || "新分组"}」`, mode: "toast" });
void id;
})();
};
const onRenameGroup = (id: string, cur: string) => {
if (id === DEFAULT_CONN_GROUP) return;
void (async () => {
const name = await askInput({ title: "重命名分组", defaultValue: cur });
if (name == null || !name.trim()) return;
renameConnGroup(id, name);
})();
};
const onRemoveGroup = (id: string, name: string) => {
if (id === DEFAULT_CONN_GROUP) return;
void (async () => {
const ok = await useWorkspaceStore.getState().askConfirm({
title: "删除分组",
message: `删除分组「${name}」?连接将移回默认。`,
okLabel: "删除",
danger: true,
});
if (!ok) return;
removeConnGroup(id);
})();
};
const setDrop = (groupId: string, index: number) => {
const id = dragIdRef.current;
if (!id) return;
setDropHint({ groupId, index });
};
const commitDrop = () => {
const id = dragIdRef.current;
const hint = dropHint;
dragIdRef.current = null;
setDragId(null);
setDropHint(null);
if (!id || !hint) return;
moveConnection(id, hint.groupId, hint.index);
};
return (
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
<div className="flex items-center justify-between px-1.5 py-1 text-[11px]" style={{ color: "var(--ops-muted)" }}>
<span>连接</span>
<button type="button" className="hover:underline" style={{ color: "var(--ops-accent)" }} onClick={onAddGroup} title="新建分组">
+ 分组
</button>
</div>
<div className="min-h-0 flex-1 overflow-auto p-1">
{connections.length === 0 && (
<div className="px-1.5 py-1 text-[11px]" style={{ color: "var(--ops-muted)" }}>
暂无连接
</div>
)}
{groups.map((g) => {
const list = byGroup.get(g.id) ?? [];
const open = expanded.includes(g.id);
return (
<div
key={g.id}
className="mb-0.5"
data-group-id={g.id}
onDragOver={(e) => {
if (!dragIdRef.current) return;
e.preventDefault();
setDrop(g.id, list.length);
}}
onDrop={(e) => {
e.preventDefault();
commitDrop();
}}
>
<div className="ops-tree-item group/g cursor-pointer" onClick={() => toggleConnGroup(g.id)}>
<span className="ops-tree-chevron w-3 shrink-0 text-center text-[10px] opacity-50">{open ? "▾" : "▸"}</span>
<span
className="mr-1 inline-block h-3 w-3 shrink-0 rounded-[2px] border"
style={{ borderColor: "var(--ops-muted)", background: "color-mix(in srgb, var(--ops-muted) 25%, transparent)" }}
aria-hidden
/>
<span className="min-w-0 flex-1 truncate font-medium">{g.name}</span>
<span className="text-[10px] opacity-40">{list.length}</span>
{g.id !== DEFAULT_CONN_GROUP && (
<span className="ml-1 hidden gap-0.5 group-hover/g:inline-flex" onClick={(e) => e.stopPropagation()}>
<button type="button" className="px-0.5 text-[10px] opacity-60 hover:opacity-100" title="重命名" onClick={() => onRenameGroup(g.id, g.name)}>
✎
</button>
<button type="button" className="px-0.5 text-[10px] opacity-60 hover:opacity-100" style={{ color: "var(--ops-danger)" }} title="删除分组" onClick={() => onRemoveGroup(g.id, g.name)}>
×
</button>
</span>
)}
</div>
{open && (
<div className="ml-1 border-l pl-0.5" style={{ borderColor: "var(--ops-border-soft)" }}>
{list.length === 0 && (
<div
className={cx("ops-drop-slot mx-0.5 my-0.5 rounded px-1.5 py-1 text-[10px]", dropHint?.groupId === g.id ? "ops-drop-slot-active" : "")}
style={{ color: "var(--ops-muted)" }}
onDragOver={(e) => {
e.preventDefault();
setDrop(g.id, 0);
}}
>
拖到此处
</div>
)}
{list.map((c, i) => (
<div key={c.id}>
{dropHint?.groupId === g.id && dropHint.index === i && <div className="ops-drop-line" />}
<div
className={cx(
"group flex items-center gap-0.5 rounded",
activeId === c.id ? "ops-tree-item-active" : "",
dragId === c.id ? "opacity-45" : "",
)}
draggable
onDragStart={(e) => {
dragIdRef.current = c.id;
setDragId(c.id);
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("text/plain", c.id);
}}
onDragEnd={() => {
dragIdRef.current = null;
setDragId(null);
setDropHint(null);
}}
onDragOver={(e) => {
if (!dragIdRef.current || dragIdRef.current === c.id) return;
e.preventDefault();
e.stopPropagation();
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const before = e.clientY < rect.top + rect.height / 2;
setDrop(g.id, before ? i : i + 1);
}}
onDrop={(e) => {
e.preventDefault();
e.stopPropagation();
commitDrop();
}}
>
<button
type="button"
className="ops-tree-item min-w-0 flex-1"
onClick={() => onSelect(c.id)}
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
setCtxMenu({ x: e.clientX, y: e.clientY, conn: c });
}}
>
{/* 实例级展开/收起箭头(行首树形 chevron,SVG 保证方向清晰) */}
<span
className="grid w-3.5 shrink-0 cursor-pointer place-items-center opacity-60 hover:opacity-100"
title={activeId === c.id ? "点击收起对象树" : "点击展开对象树"}
onClick={(e) => {
e.stopPropagation();
if (activeId === c.id) {
// 已激活 → 收起(清空树)
useWorkspaceStore.getState().set("tree", []);
useWorkspaceStore.getState().set("activeConnectionId", null);
} else {
// 未激活 → 展开(激活并加载树)
onSelect(c.id);
}
}}
>
<TreeChevron open={activeId === c.id} size={10} />
</span>
{c.color && (
<span className="h-3 w-1 shrink-0 rounded-sm" style={{ background: c.color }} />
)}
<DriverIcon driver={c.driver} size={14} />
<span className="min-w-0 flex-1 truncate" title={`${driverById(c.driver)?.name ?? c.driver} · ${c.name} · ${connectionEndpoint(c)}`}>
<span className="truncate">{c.name}</span>
{(c.host || c.redisNodes || c.database) && (
<span className="ml-1 text-[10px] opacity-45">
{c.host || c.redisNodes ? connectionEndpoint(c) : c.database}
</span>
)}
</span>
<span
className="ml-1 h-1.5 w-1.5 shrink-0 rounded-full"
style={{
background:
activeId === c.id
? connState === "down"
? "var(--ops-danger)"
: connState === "reconnecting"
? "var(--ops-warning)"
: "#22c55e"
: "var(--ops-muted)",
opacity: activeId === c.id ? 1 : 0.4,
}}
title={
activeId === c.id
? connState === "down"
? "已断开"
: connState === "reconnecting"
? "重连中…"
: "已连接"
: "未激活"
}
/>
</button>
<button
type="button"
className="hidden px-0.5 text-[10px] opacity-50 group-hover:inline hover:opacity-100"
title="编辑"
onClick={() => editConnection(c)}
>
✎
</button>
<button
type="button"
className="mr-0.5 hidden px-1 text-[11px] opacity-50 group-hover:inline hover:opacity-100"
style={{ color: "var(--ops-danger)" }}
onClick={() => onDelete(c.id)}
title="删除连接"
>
×
</button>
</div>
{activeId === c.id && <ObjectTree />}
{dropHint?.groupId === g.id && dropHint.index === list.length && i === list.length - 1 && (
<div className="ops-drop-line" />
)}
</div>
))}
</div>
)}
</div>
);
})}
</div>
{ctxMenu && <ContextMenu x={ctxMenu.x} y={ctxMenu.y} title={ctxMenu.conn.name} onClose={() => setCtxMenu(null)} items={[
{ label: "连接", hidden: activeId === ctxMenu.conn.id, action: () => onSelect(ctxMenu.conn.id) },
{ label: "断开连接", hidden: activeId !== ctxMenu.conn.id, action: () => disconnect(ctxMenu.conn) },
{ label: "重新连接", action: () => void reconnect(ctxMenu.conn) },
{ label: "编辑连接…", action: () => editConnection(ctxMenu.conn) },
{ label: "复制连接地址(无凭据)", separator: true, action: () => { void navigator.clipboard.writeText(connectionUri(ctxMenu.conn)).catch((e) => notifyError(e, "toast")); } },
{ label: "复制主机端口", action: () => { void navigator.clipboard.writeText(connectionEndpoint(ctxMenu.conn)).catch((e) => notifyError(e, "toast")); } },
{ label: "删除连接…", separator: true, danger: true, action: () => onDelete(ctxMenu.conn.id) },
]} />}
</div>
);
}