250 lines
8.4 KiB
TypeScript
250 lines
8.4 KiB
TypeScript
import { useMemo, useState, type ReactNode } from "react";
|
|
import { driverById } from "./catalog";
|
|
import { DriverIcon } from "./DriverIcon";
|
|
import { useWorkspaceStore, type SavedConnection } from "./store";
|
|
|
|
function hostSummary(c: SavedConnection) {
|
|
const d = driverById(c.driver);
|
|
if (c.driver === "sqlite") {
|
|
return c.database || "本地文件";
|
|
}
|
|
const host = c.host || "127.0.0.1";
|
|
const port = c.port ? `:${c.port}` : "";
|
|
const db = c.database ? ` / ${c.database}` : "";
|
|
return `${host}${port}${db}`;
|
|
}
|
|
|
|
function matchesQuery(c: SavedConnection, needle: string): boolean {
|
|
if (!needle) return true;
|
|
const d = driverById(c.driver);
|
|
const hay = [c.name, c.host || "", c.driver, d?.name ?? "", c.database || "", String(c.port || "")]
|
|
.join(" ")
|
|
.toLowerCase();
|
|
return hay.includes(needle);
|
|
}
|
|
|
|
function ConnRow({
|
|
c,
|
|
onOpen,
|
|
onDelete,
|
|
setDefaultConnection,
|
|
editConnection,
|
|
notify,
|
|
}: {
|
|
c: SavedConnection;
|
|
onOpen: (c: SavedConnection) => void;
|
|
onDelete: (c: SavedConnection) => void;
|
|
setDefaultConnection: (id: string) => void;
|
|
editConnection: (c: SavedConnection) => void;
|
|
notify: ReturnType<typeof useWorkspaceStore.getState>["notify"];
|
|
}) {
|
|
const d = driverById(c.driver);
|
|
return (
|
|
<div className="ops-conn-row group">
|
|
<button type="button" className="flex min-w-0 flex-1 items-center gap-3 text-left" onClick={() => onOpen(c)}>
|
|
<DriverIcon driver={c.driver} size={22} />
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center gap-2 truncate text-[12.5px] font-medium">
|
|
<span className="truncate">{c.name}</span>
|
|
{c.isDefault && (
|
|
<span
|
|
className="shrink-0 rounded px-1 text-[10px] font-semibold"
|
|
style={{
|
|
color: "var(--ops-accent)",
|
|
background: "color-mix(in srgb, var(--ops-accent) 14%, transparent)",
|
|
}}
|
|
>
|
|
默认
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="truncate text-[11px]" style={{ color: "var(--ops-muted)" }}>
|
|
{d?.name ?? c.driver} · {hostSummary(c)}
|
|
</div>
|
|
</div>
|
|
</button>
|
|
<div className="flex shrink-0 items-center gap-1 opacity-0 transition group-hover:opacity-100">
|
|
{!c.isDefault && (
|
|
<button
|
|
type="button"
|
|
className="ops-btn-ghost text-[11px]"
|
|
onClick={() => {
|
|
setDefaultConnection(c.id);
|
|
notify({ kind: "success", title: "默认连接", message: `已设「${c.name}」为默认。`, mode: "toast" });
|
|
}}
|
|
>
|
|
设默认
|
|
</button>
|
|
)}
|
|
<button type="button" className="ops-btn-ghost text-[11px]" onClick={() => editConnection(c)}>
|
|
编辑
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="ops-btn-ghost text-[11px]"
|
|
style={{ color: "var(--ops-danger)" }}
|
|
onClick={() => void onDelete(c)}
|
|
>
|
|
删除
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SectionLabel({ children }: { children: ReactNode }) {
|
|
return (
|
|
<div
|
|
className="sticky top-0 z-[1] px-3 py-1 text-[10px] font-semibold uppercase tracking-wide"
|
|
style={{
|
|
color: "var(--ops-muted)",
|
|
background: "color-mix(in srgb, var(--ops-surface) 92%, transparent)",
|
|
borderBottom: "1px solid var(--ops-border)",
|
|
}}
|
|
>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const RECENT_N = 5;
|
|
|
|
export function ConnectionList() {
|
|
const connections = useWorkspaceStore((s) => s.connections);
|
|
const openPicker = useWorkspaceStore((s) => s.openPicker);
|
|
const editConnection = useWorkspaceStore((s) => s.editConnection);
|
|
const removeConnection = useWorkspaceStore((s) => s.removeConnection);
|
|
const setDefaultConnection = useWorkspaceStore((s) => s.setDefaultConnection);
|
|
const askConfirm = useWorkspaceStore((s) => s.askConfirm);
|
|
const set = useWorkspaceStore((s) => s.set);
|
|
const notify = useWorkspaceStore((s) => s.notify);
|
|
const [search, setSearch] = useState("");
|
|
|
|
const needle = search.trim().toLowerCase();
|
|
|
|
const filtered = useMemo(
|
|
() =>
|
|
[...connections]
|
|
.filter((c) => matchesQuery(c, needle))
|
|
.sort((a, b) => {
|
|
if (a.isDefault && !b.isDefault) return -1;
|
|
if (!a.isDefault && b.isDefault) return 1;
|
|
return (b.lastUsedAt ?? 0) - (a.lastUsedAt ?? 0);
|
|
}),
|
|
[connections, needle],
|
|
);
|
|
|
|
const recent = useMemo(() => {
|
|
const ids = new Set(filtered.map((c) => c.id));
|
|
return [...connections]
|
|
.filter((c) => (c.lastUsedAt ?? 0) > 0 && ids.has(c.id))
|
|
.sort((a, b) => (b.lastUsedAt ?? 0) - (a.lastUsedAt ?? 0))
|
|
.slice(0, RECENT_N);
|
|
}, [connections, filtered]);
|
|
|
|
/** 「全部」list: filtered, with recent still shown (pinned section above). */
|
|
const allList = filtered;
|
|
|
|
const onDelete = async (c: SavedConnection) => {
|
|
const ok = await askConfirm({
|
|
title: "删除连接",
|
|
message: `确认删除「${c.name}」?此操作不可撤销。`,
|
|
okLabel: "删除",
|
|
danger: true,
|
|
});
|
|
if (!ok) return;
|
|
removeConnection(c.id);
|
|
notify({ kind: "success", title: "已删除", message: `「${c.name}」已移除。`, mode: "toast" });
|
|
};
|
|
|
|
const onOpen = (c: SavedConnection) => {
|
|
set("activeConnectionId", c.id);
|
|
set("view", "workbench");
|
|
set("status", `已选择 ${c.name}`);
|
|
};
|
|
|
|
const rowProps = { onOpen, onDelete, setDefaultConnection, editConnection, notify };
|
|
|
|
return (
|
|
<div className="ops-shell flex h-screen flex-col">
|
|
<header className="ops-chrome-header px-3">
|
|
<div className="min-w-0">
|
|
<div className="truncate text-[14px] font-semibold leading-tight">连接</div>
|
|
<div className="text-[11px]" style={{ color: "var(--ops-muted)" }}>
|
|
选择或新建数据库连接
|
|
</div>
|
|
</div>
|
|
<div className="ml-auto flex items-center gap-1.5">
|
|
<button type="button" className="ops-btn-primary" onClick={() => openPicker()}>
|
|
新建连接
|
|
</button>
|
|
{connections.length > 0 && (
|
|
<button type="button" className="ops-btn-secondary" onClick={() => set("view", "workbench")}>
|
|
进入工作台
|
|
</button>
|
|
)}
|
|
</div>
|
|
</header>
|
|
|
|
{connections.length > 0 && (
|
|
<div
|
|
className="flex shrink-0 items-center gap-2 border-b px-3"
|
|
style={{ height: 36, borderColor: "var(--ops-border)", background: "var(--ops-surface)" }}
|
|
>
|
|
<input
|
|
type="search"
|
|
className="h-7 w-full max-w-md rounded border px-2 text-[12px] outline-none"
|
|
style={{
|
|
borderColor: "var(--ops-border)",
|
|
background: "var(--ops-bg, transparent)",
|
|
color: "var(--ops-fg, inherit)",
|
|
}}
|
|
placeholder="搜索名称 / 主机 / 驱动…"
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
aria-label="搜索连接"
|
|
/>
|
|
{search && (
|
|
<button type="button" className="ops-btn-ghost shrink-0 text-[11px]" onClick={() => setSearch("")}>
|
|
清除
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{connections.length === 0 ? (
|
|
<div className="ops-empty">
|
|
<h2>还没有连接</h2>
|
|
<p>新建一个连接,保存后会出现在这里。</p>
|
|
<button type="button" className="ops-btn-primary" onClick={() => openPicker()}>
|
|
新建连接
|
|
</button>
|
|
</div>
|
|
) : filtered.length === 0 ? (
|
|
<div className="ops-empty">
|
|
<h2>没有匹配的连接</h2>
|
|
<p>换个关键词试试,或清除搜索查看全部。</p>
|
|
<button type="button" className="ops-btn-secondary" onClick={() => setSearch("")}>
|
|
清除搜索
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<div className="min-h-0 flex-1 overflow-auto">
|
|
{recent.length > 0 && (
|
|
<>
|
|
<SectionLabel>最近</SectionLabel>
|
|
{recent.map((c) => (
|
|
<ConnRow key={`recent-${c.id}`} c={c} {...rowProps} />
|
|
))}
|
|
</>
|
|
)}
|
|
<SectionLabel>全部{needle ? ` · ${allList.length}` : ""}</SectionLabel>
|
|
{allList.map((c) => (
|
|
<ConnRow key={c.id} c={c} {...rowProps} />
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|