Files
opsxdb/src/ConnectionSidebar.tsx
T

350 lines
14 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 } from "./api";
import { driverById } from "./catalog";
import { askInput } from "./dialogs";
import { DriverIcon } from "./DriverIcon";
import { ObjectTree } from "./ObjectTree";
import { TreeChevron } from "./TreeChevron";
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 editConnection = useWorkspaceStore((s) => s.editConnection);
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}`);
}
};
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 shrink-0 flex-col">
<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 truncate" title={`${driverById(c.driver)?.name ?? c.driver} · ${c.name}`}>
{c.name}
</span>
<span
className="ml-1 h-1.5 w-1.5 shrink-0 rounded-full"
style={{
background: activeId === c.id ? '#22c55e' : 'var(--ops-muted)',
opacity: activeId === c.id ? 1 : 0.4,
}}
title={activeId === c.id ? '已连接' : '未激活'}
/>
</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 && (
<div
className="ops-ctx"
style={{ position: "fixed", left: ctxMenu.x, top: ctxMenu.y, zIndex: 1000 }}
>
{activeId === ctxMenu.conn.id && (
<button
type="button"
onClick={() => {
disconnect(ctxMenu.conn);
setCtxMenu(null);
}}
>
断开连接
</button>
)}
<button
type="button"
onClick={() => {
onSelect(ctxMenu.conn.id);
setCtxMenu(null);
}}
>
连接
</button>
<button
type="button"
onClick={() => {
editConnection(ctxMenu.conn);
setCtxMenu(null);
}}
>
编辑
</button>
<button
type="button"
style={{ color: "var(--ops-danger)" }}
onClick={() => {
onDelete(ctxMenu.conn.id);
setCtxMenu(null);
}}
>
删除
</button>
</div>
)}
</div>
);
}