Files
opsxdb/packages/ui/src/ConnectionSidebar.tsx
T
logan f8740d27f7 feat: OpsxDB 桌面数据库客户端首版入库
含工作台/连库多引擎字段与 SSH·SSL、结果编辑、Redis 键浏览、
Tauri 桌面与 docs/落地页;排除本地构建产物与 sidecar 二进制。
2026-09-19 22:43:45 +08:00

234 lines
9.7 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 { useMemo, useRef, useState } from "react";
import { driverById } from "./catalog";
import { DriverIcon } from "./DriverIcon";
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 [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 = () => {
const name = window.prompt("分组名称", "新分组");
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;
const name = window.prompt("重命名分组", cur);
if (name == null || !name.trim()) return;
renameConnGroup(id, name);
};
const onRemoveGroup = (id: string, name: string) => {
if (id === DEFAULT_CONN_GROUP) return;
if (!window.confirm(`删除分组「${name}」?连接将移回默认。`)) 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 max-h-[42%] min-h-[120px] flex-col border-b" style={{ borderColor: "var(--ops-border)" }}>
<div className="ops-section-label justify-between border-b">
<span>连接</span>
<button type="button" className="normal-case tracking-normal 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)}>
{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">
<span className="mr-1 text-[10px] opacity-50">{driverById(c.driver)?.name}</span>
{c.name}
</span>
</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>
{dropHint?.groupId === g.id && dropHint.index === list.length && i === list.length - 1 && (
<div className="ops-drop-line" />
)}
</div>
))}
</div>
)}
</div>
);
})}
</div>
</div>
);
}