Files
opsxdb/src/ObjectTree.tsx
T

1949 lines
76 KiB
TypeScript

import { useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type ReactNode } from "react";
import {
deleteRedisKey,
deleteRedisPattern,
execSql,
getRedisKey,
listColumns,
listRedisKeys,
listTables,
loadTree,
openSession,
searchObjects,
type SearchObjectHit,
} from "./api";
import { DdlModal } from "./DdlModal";
import type { ColumnInfo } from "./store";
import { isDriverReady, driverCaps, type TreeGroupKind } from "./catalog";
import { TreeChevron } from "./TreeChevron";
import {
buildRedisKeyTree,
createKeyCommand,
filterRedisTree,
formatRedisTtl,
redisHintForType,
redisLeafLabel,
redisTypeColor,
removeRedisKeyFromTree,
} from "./redis";
import { previewSelectSql, sampleSelectSql } from "./sql";
import { useWorkspaceStore, type SavedConnection, type TreeNode } from "./store";
export { formatRedisTtl };
const GROUPS: { id: string; label: string; kind: TreeNode["kind"] }[] = [
{ id: "tables", label: "表", kind: "group" },
{ id: "views", label: "视图", kind: "group" },
{ id: "procedures", label: "存储过程", kind: "group" },
{ id: "functions", label: "函数", kind: "group" },
];
/** 对象树分类色板:高饱和色,深浅主题下都够亮 */
const TREE_COLORS = {
database: "#10b981", // 库 · 祖母绿
tables: "#3b82f6", // 表 · 蓝
views: "#8b5cf6", // 视图 · 紫
procedures: "#f59e0b", // 存储过程 · 琥珀
functions: "#06b6d4", // 函数 · 青
keys: "#ef4444", // Redis 键 · 红
folder: "#f59e0b", // Redis 文件夹 · 琥珀
columns: "#22c55e", // 字段 · 绿
} as const;
type TreeColorKey = keyof typeof TREE_COLORS;
function groupIdColor(groupId?: string): TreeColorKey {
switch (groupId) {
case "tables":
return "tables";
case "views":
return "views";
case "procedures":
return "procedures";
case "functions":
return "functions";
case "keys":
return "keys";
case "folder":
return "folder";
case "columns":
return "columns";
default:
return "tables";
}
}
/** 分类图标:实底 16×16 线面结合,颜色即分类色 */
function TreeIcon({ kind, size = 13 }: { kind: TreeColorKey; size?: number }) {
const c = TREE_COLORS[kind];
const common = { stroke: c, fill: "none", strokeWidth: 1.7, strokeLinecap: "round" as const, strokeLinejoin: "round" as const };
return (
<svg width={size} height={size} viewBox="0 0 16 16" className="shrink-0" aria-hidden="true">
{kind === "database" && (
<g {...common}>
<ellipse cx="8" cy="3.6" rx="5.2" ry="2.1" fill={c} fillOpacity="0.35" />
<path d="M2.8 3.6v8.8c0 1.16 2.33 2.1 5.2 2.1s5.2-.94 5.2-2.1V3.6" />
<path d="M2.8 8c0 1.16 2.33 2.1 5.2 2.1S13.2 9.16 13.2 8" />
</g>
)}
{kind === "tables" && (
<g {...common}>
<rect x="2" y="2" width="12" height="12" rx="2" fill={c} fillOpacity="0.18" />
<path d="M2 6h12M2 10h12M6 2v12M10 2v12" strokeOpacity="0.85" />
</g>
)}
{kind === "views" && (
<g {...common}>
<path d="M1.8 8s2.2-4 6.2-4 6.2 4 6.2 4-2.2 4-6.2 4-6.2-4-6.2-4Z" fill={c} fillOpacity="0.15" />
<circle cx="8" cy="8" r="1.9" fill={c} stroke="none" />
</g>
)}
{kind === "procedures" && (
<g {...common}>
<rect x="2" y="2.5" width="12" height="11" rx="2" fill={c} fillOpacity="0.15" />
<path d="M6.5 5.8v4.4L10.4 8Z" fill={c} stroke="none" />
</g>
)}
{kind === "functions" && (
<g {...common}>
<rect x="2" y="2" width="12" height="12" rx="6" fill={c} fillOpacity="0.15" />
<path d="M9.4 5.2c-1.2 0-1.2 1-1.2 1.8v2c0 .8 0 1.8-1.3 1.8 1.3 0 1.3 1 1.3 1.8" />
<path d="M6.6 5.2c1.2 0 1.2 1 1.2 1.8" strokeOpacity="0" />
</g>
)}
{kind === "keys" && (
<g {...common}>
<circle cx="5.4" cy="10.6" r="3" fill={c} fillOpacity="0.2" />
<path d="m7.6 8.4 5-5M10 6l1.8 1.8M11.6 4.4l1.8 1.8" />
</g>
)}
{kind === "folder" && (
<g {...common}>
<path d="M2.2 4.2h4.2l1.2 1.4H13.8a1 1 0 0 1 1 1v6.2a1 1 0 0 1-1 1H2.2a1 1 0 0 1-1-1V5.2a1 1 0 0 1 1-1Z" fill={c} fillOpacity="0.2" />
<path d="M1.2 7.2h13.6" />
</g>
)}
{kind === "columns" && (
<g {...common}>
<rect x="2" y="2.5" width="12" height="11" rx="2" fill={c} fillOpacity="0.14" />
<path d="M4.6 5.6h6.8M4.6 8h6.8M4.6 10.4h4.4" />
</g>
)}
</svg>
);
}
/** 分类计数徽章:分类色底 12% 透明 + 分类色文字 */
function CountBadge({ kind, count }: { kind: TreeColorKey; count: number }) {
const c = TREE_COLORS[kind];
return (
<span
className="ml-1.5 rounded px-1 text-[10px] font-semibold leading-4 tabular-nums"
style={{ background: `${c}1f`, color: c }}
>
{count}
</span>
);
}
function isRedisConn(driver?: string) {
return (driver || "").toLowerCase() === "redis";
}
/** 建连在途 promise:并发调用复用同一次 openSession(06-P1#8),避免重复建会话 */
const sessionInflight = new Map<string, Promise<string>>();
/** 可自动重连的错误:会话失效 / 连接类故障(Redis 重启、网络闪断等) */
export function isReconnectableError(e: unknown): boolean {
const msg = (e instanceof Error ? e.message : String(e)).toLowerCase();
return (
msg.includes("会话已失效") ||
msg.includes("session") ||
/connection refused|broken pipe|eof|i\/o timeout|reset by peer|connect: bad|closed|unreachable/.test(msg)
);
}
/**
* 会话自动重连包装:op 失败且属会话/连接类错误时,清本地会话缓存 →
* 重新 openSession → 重试一次。用户无感恢复(对标 ARDM/Navicat 的自动重连)。
*/
export async function withSessionRetry<T>(c: SavedConnection, op: (sid: string) => Promise<T>): Promise<T> {
try {
return await op(await ensureSession(c));
} catch (e) {
if (!isReconnectableError(e)) throw e;
useWorkspaceStore.getState().setSession(c.id, null);
return await op(await ensureSession(c));
}
}
export async function ensureSession(c: SavedConnection): Promise<string> {
const store = useWorkspaceStore.getState();
const existing = store.sessions[c.id];
if (existing) return existing;
const inflight = sessionInflight.get(c.id);
if (inflight) return inflight;
const p = (async () => {
const { sessionId } = await openSession(c);
useWorkspaceStore.getState().setSession(c.id, sessionId);
return sessionId;
})();
sessionInflight.set(c.id, p);
try {
return await p;
} finally {
sessionInflight.delete(c.id);
}
}
function emptyGroups(): TreeNode[] {
// 按驱动能力过滤分组:NoSQL 库不挂「视图/存储过程/函数」等名不副实的空组
const caps = driverCaps(useWorkspaceStore.getState().connections.find(
(c) => c.id === useWorkspaceStore.getState().activeConnectionId,
)?.driver ?? "");
return GROUPS.filter((g) => caps.treeGroups.includes(g.id as TreeGroupKind)).map((g) => ({
name: caps.groupLabel[g.id as TreeGroupKind] ?? g.label,
kind: "group" as const,
groupId: g.id,
count: 0,
children: [],
loaded: false,
}));
}
type CtxMenu = {
x: number;
y: number;
label: string;
/** 对象种类:决定 P0 菜单骨架 */
kind?: "database" | "table" | "view" | "key" | "group" | "folder";
/** Redis 文件夹:按此前缀过滤 */
onFilterPrefix?: () => void;
/** Redis 文件夹 / 键组:新建键 */
onNewKey?: () => void;
/** Redis 文件夹:删除整个目录(前缀批量删除,强确认) */
onDeleteFolder?: () => void;
/** Redis 键:批量删除多选的键 */
onDeleteSelected?: () => void;
onRefresh: () => void;
onSchema?: () => void;
onView?: () => void;
onDelete?: () => void;
/** R2-F3:表/视图 —— 查看 DDL(弹 DdlModal) */
onDdl?: () => void;
/** 表/视图:查看数据 */
onQueryData?: () => void;
/** 表/视图:生成 SQL(P1,菜单暂不展示) */
onGen?: (kind: GenSqlKind) => void;
/** 表:截断/清空 */
onTruncate?: () => void;
/** 表:DROP */
onDropTable?: () => void;
/** 库:新建查询 */
onNewQuery?: () => void;
/** 库:浏览对象(展开) */
onBrowse?: () => void;
/** 库:设为默认库 */
onSetDefaultDb?: () => void;
/** 库:删除库 */
onDropDatabase?: () => void;
/** 刷新按钮文案(Redis 键用「刷新 TTL」) */
refreshLabel?: string;
/** 复制名称按钮文案 */
copyLabel?: string;
};
type Ctx = CtxMenu | null;
export type GenSqlKind = "select" | "insert" | "update" | "delete";
/** 标识符按方言加引号(Oracle 裸名) */
function quoteIdent(driver: string | undefined, ident: string): string {
const d = (driver || "mysql").toLowerCase();
if (d === "postgresql" || d === "postgres" || d === "gaussdb" || d === "kingbase" || d === "opengauss") return `"${ident}"`;
if (d === "sqlserver" || d === "mssql") return `[${ident}]`;
if (d === "sqlite") return `"${ident}"`;
if (d === "oracle") return ident;
return `\`${ident}\``;
}
/** 库.表 全限定(SQLite 为单文件无库级) */
function qualTable(driver: string | undefined, db: string, table: string): string {
const d = (driver || "mysql").toLowerCase();
if (d === "sqlite") return quoteIdent(d, table);
return `${quoteIdent(d, db)}.${quoteIdent(d, table)}`;
}
/** 生成 SQL 模板:? 占位符,主键自动进 WHERE;无列信息时退化为骨架 */
function buildGenSql(
kind: GenSqlKind,
driver: string | undefined,
db: string,
table: string,
cols: ColumnInfo[],
): string {
const qt = qualTable(driver, db, table);
const q = (s: string) => quoteIdent(driver, s);
const names = cols.map((c) => c.name).filter(Boolean);
const pk = cols.find((c) => c.key === "PRI" || c.key === "PK")?.name;
if (kind === "insert") {
if (!names.length) return `INSERT INTO ${qt} (...) VALUES (...);`;
return `INSERT INTO ${qt}\n (${names.map(q).join(", ")})\nVALUES\n (${names.map(() => "?").join(", ")});`;
}
if (kind === "update") {
const sets = (names.length ? names : ["col"]).map((c) => `${q(c)} = ?`);
return `UPDATE ${qt}\nSET\n ${sets.join(",\n ")}\nWHERE ${pk ? `${q(pk)} = ?` : "/* WHERE 条件 */"};`;
}
if (kind === "delete") {
return `DELETE FROM ${qt}\nWHERE ${pk ? `${q(pk)} = ?` : "/* WHERE 条件 */"};`;
}
return sampleSelectSql(driver, db, table);
}
export function ObjectTree() {
const connections = useWorkspaceStore((s) => s.connections);
const activeId = useWorkspaceStore((s) => s.activeConnectionId);
const tree = useWorkspaceStore((s) => s.tree);
const treeLoading = useWorkspaceStore((s) => s.treeLoading);
const set = useWorkspaceStore((s) => s.set);
const updateActiveSql = useWorkspaceStore((s) => s.updateActiveSql);
const newQueryTab = useWorkspaceStore((s) => s.newQueryTab);
const askConfirm = useWorkspaceStore((s) => s.askConfirm);
const notify = useWorkspaceStore((s) => s.notify);
const notifyError = useWorkspaceStore((s) => s.notifyError);
const pushHistory = useWorkspaceStore((s) => s.pushHistory);
const setResultSets = useWorkspaceStore((s) => s.setResultSets);
const tabs = useWorkspaceStore((s) => s.tabs);
const activeTabId = useWorkspaceStore((s) => s.activeTabId);
const [ctx, setCtx] = useState<Ctx>(null);
const keyFilter = useWorkspaceStore((s) => s.sidebarFilter);
const setKeyFilter = (v: string) => useWorkspaceStore.getState().set("sidebarFilter", v);
/** Redis 类型过滤:全部 / string / hash / list / set / zset / stream(前端过滤) */
const redisTypeFilter = useWorkspaceStore((s) => s.sidebarTypeFilter);
const setRedisTypeFilter = (v: string) => useWorkspaceStore.getState().set("sidebarTypeFilter", v);
const [newKey, setNewKey] = useState<{ name: string; type: string; value: string } | null>(null);
/** 库/表过滤(非 Redis 连接);只匹配库名与已加载的对象名 */
const treeFilter = useWorkspaceStore((s) => s.sidebarFilter);
const setTreeFilter = (v: string) => useWorkspaceStore.getState().set("sidebarFilter", v);
/** R2-F4:本地过滤无命中时的服务端搜索状态 */
const [dbSearchKw, setDbSearchKw] = useState("");
const [dbSearching, setDbSearching] = useState(false);
const [dbSearchResults, setDbSearchResults] = useState<SearchObjectHit[] | null>(null);
/** R2-F3:DDL 弹窗目标(库.表) */
const [ddlTarget, setDdlTarget] = useState<{ db: string; table: string } | null>(null);
const ddlRequest = useWorkspaceStore((s) => s.ddlRequest);
const viewDataRequest = useWorkspaceStore((s) => s.viewDataRequest);
/** 修 O1:单击选中的树节点 key(db 或 db/obj) */
const [selectedNode, setSelectedNode] = useState<string | null>(null);
/** 多选键(Cmd/Ctrl+Click 切换),用于批量删除 */
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set());
const toggleKeySelect = (dbName: string, key: string, additive: boolean) => {
const id = `${dbName}/${key}`;
setSelectedKeys((prev) => {
const next = additive ? new Set(prev) : new Set<string>();
if (additive && prev.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const treeScrollRef = useRef<HTMLDivElement>(null);
const conn = connections.find((c) => c.id === activeId);
const refresh = async (id: string) => {
const c = connections.find((x) => x.id === id);
if (!c) return;
if (!isDriverReady(c.driver)) {
set("tree", []);
set("status", "驱动未就绪");
return;
}
set("treeLoading", true);
set("status", `加载 ${c.name}…`);
try {
const sid = await ensureSession(c);
const nodes = await loadTree(c, sid);
set("tree", nodes);
set("status", `${c.name} · ${nodes.length} 库`);
// C8/§3.10:后台预热当前库表清单,喂给 SqlEditor 补全与 AiPanel 上下文
if (c.database && !isRedisConn(c.driver)) {
void listTables(c, c.database, sid).catch(() => {});
}
} catch (e) {
set("tree", []);
const msg = e instanceof Error ? e.message : String(e);
set("status", msg);
if (msg.includes("会话") || msg.toLowerCase().includes("session") || msg.toLowerCase().includes("connect")) {
set("connState", "down");
}
notifyError(e, "modal");
} finally {
set("treeLoading", false);
}
};
useEffect(() => {
if (activeId) void refresh(activeId);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeId]);
useEffect(() => {
if (!ctx) return;
const close = () => setCtx(null);
const onKey = (e: KeyboardEvent) => {
// 修 O2:Esc 关闭右键菜单
if (e.key === "Escape") {
e.preventDefault();
e.stopPropagation();
setCtx(null);
}
};
window.addEventListener("click", close);
window.addEventListener("keydown", onKey, true);
return () => {
window.removeEventListener("click", close);
window.removeEventListener("keydown", onKey, true);
};
}, [ctx]);
// 修 O2:视口边界翻转 —— 菜单渲染后测量,贴右/下缘时自动收回
const ctxRef = useRef<HTMLDivElement>(null);
const [ctxPos, setCtxPos] = useState<{ x: number; y: number } | null>(null);
useEffect(() => {
if (!ctx) {
setCtxPos(null);
return;
}
const el = ctxRef.current;
if (!el) return;
const r = el.getBoundingClientRect();
const x = ctx.x + r.width > window.innerWidth - 8 ? Math.max(8, window.innerWidth - r.width - 8) : ctx.x;
const y = ctx.y + r.height > window.innerHeight - 8 ? Math.max(8, window.innerHeight - r.height - 8) : ctx.y;
setCtxPos({ x, y });
}, [ctx]);
const patchDb = (dbName: string, patch: Partial<TreeNode> | ((n: TreeNode) => TreeNode)) => {
const cur = useWorkspaceStore.getState().tree;
set(
"tree",
cur.map((d) => {
if (d.name !== dbName || d.kind !== "database") return d;
return typeof patch === "function" ? patch(d) : { ...d, ...patch };
}),
);
};
const expandDb = async (dbName: string) => {
if (!conn) return;
const node = useWorkspaceStore.getState().tree.find((n) => n.name === dbName);
if (node?.loaded || node?.loading) return;
const redis = isRedisConn(conn.driver);
patchDb(dbName, {
loading: true,
children: redis ? [] : emptyGroups(),
});
try {
if (redis) {
// 模糊语义:无通配符的输入自动包 *q*(对标 ARDM),避免 "agent" 被当精确 glob 扫不到键
const raw = keyFilter.trim();
const pat = !raw ? undefined : /[*?[\]]/.test(raw) ? raw : `*${raw}*`;
const res = await withSessionRetry(conn, (sid2) => listRedisKeys(conn, dbName, { sessionId: sid2, pattern: pat }));
if (res.error) throw new Error(res.error);
const keys = res.keys ?? [];
const sep = conn.keySeparator || ":";
patchDb(dbName, {
loading: false,
loaded: true,
children: buildRedisKeyTree(keys, sep),
});
if (res.truncated) {
notify({
kind: "info",
title: "键列表已截断",
message: `最多展示 ${keys.length} 个键(SCAN)。可用过滤缩小范围。`,
mode: "toast",
durationMs: 2200,
});
}
return;
}
const sid = await ensureSession(conn);
const { tables } = await withSessionRetry(conn, (sid2) => listTables(conn, dbName, sid2));
// 仅当驱动声明对应分组时才发起元数据查询(ES/ZK/Influx/Mongo 等不查视图/过程/函数)
const caps = driverCaps(conn.driver);
let views: string[] = [];
let procedures: string[] = [];
let functions: string[] = [];
if (caps.treeGroups.includes("views")) {
try {
views = await listSchemaObjects(conn, sid, dbName, "views");
} catch {
/* optional */
}
}
if (caps.treeGroups.includes("procedures")) {
try {
procedures = await listSchemaObjects(conn, sid, dbName, "procedures");
} catch {
/* optional */
}
}
if (caps.treeGroups.includes("functions")) {
try {
functions = await listSchemaObjects(conn, sid, dbName, "functions");
} catch {
/* optional */
}
}
// flat 驱动(ES/Mongo/Influx/ZK):对象直挂库节点,不包分组
if (caps.treeGroups.length === 0) {
patchDb(dbName, {
loading: false,
loaded: true,
children: tables.map((t) => ({ name: t, kind: "table" as const })),
});
return;
}
const label = (k: TreeGroupKind, fb: string) => caps.groupLabel[k] ?? fb;
patchDb(dbName, {
loading: false,
loaded: true,
children: [
{
name: label("tables", "表"),
kind: "group",
groupId: "tables",
count: tables.length,
loaded: true,
children: tables.map((t) => ({ name: t, kind: "table" as const })),
},
...(caps.treeGroups.includes("views")
? [{
name: label("views", "视图"),
kind: "group" as const,
groupId: "views",
count: views.length,
loaded: true,
children: views.map((t) => ({ name: t, kind: "view" as const })),
}]
: []),
...(caps.treeGroups.includes("procedures")
? [{
name: label("procedures", "存储过程"),
kind: "group" as const,
groupId: "procedures",
count: procedures.length,
loaded: true,
children: procedures.map((t) => ({ name: t, kind: "procedure" as const })),
}]
: []),
...(caps.treeGroups.includes("functions")
? [{
name: label("functions", "函数"),
kind: "group" as const,
groupId: "functions",
count: functions.length,
loaded: true,
children: functions.map((t) => ({ name: t, kind: "function" as const })),
}]
: []),
],
});
} catch (e) {
patchDb(dbName, {
loading: false,
loaded: true,
children: isRedisConn(conn.driver) ? [] : emptyGroups(),
});
notifyError(e, "toast");
}
};
const expandTable = async (dbName: string, table: string) => {
if (!conn) return;
const dbNode = useWorkspaceStore.getState().tree.find((n) => n.name === dbName);
const tablesGroup = dbNode?.children?.find((n) => n.groupId === "tables");
const tNode = tablesGroup?.children?.find((n) => n.name === table);
if (tNode?.loaded || tNode?.loading) return;
const mark = (patch: Partial<TreeNode>) => {
patchDb(dbName, (d) => ({
...d,
children: (d.children ?? []).map((g) =>
g.groupId !== "tables"
? g
: {
...g,
children: (g.children ?? []).map((t) => (t.name === table ? { ...t, ...patch } : t)),
},
),
}));
};
mark({ loading: true });
try {
const { columns } = await withSessionRetry(conn, (sid) => listColumns(conn, dbName, table, sid));
mark({
loading: false,
loaded: true,
children: [
{
name: "字段",
kind: "group" as const,
groupId: "columns",
count: columns.length,
loaded: true,
children: columns.map((col) => ({
name: col.name,
kind: "column" as const,
type: col.type,
nullable: col.nullable,
key: col.key,
})),
},
],
});
} catch (e) {
mark({ loading: false });
notifyError(e, "toast");
}
};
const openSchema = async (dbName: string, table: string) => {
if (!conn) return;
set("resultTab", "schema");
set("schemaView", { database: dbName, table, columns: [], loading: true });
try {
const { columns } = await withSessionRetry(conn, (sid) => listColumns(conn, dbName, table, sid));
set("schemaView", { database: dbName, table, columns, loading: false });
const pkCols = columns.filter((c) => c.key === "PRI").map((c) => c.name);
set("resultMeta", { database: dbName, table, pkCols });
set("connState", "ok");
set("status", `结构 ${table} · ${columns.length} 列`);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
set("schemaView", { database: dbName, table, columns: [], loading: false, error: msg });
if (msg.includes("会话") || msg.toLowerCase().includes("session")) set("connState", "down");
notifyError(e, "toast");
}
};
const openRedisKey = async (dbName: string, key: string) => {
if (!conn) return;
set("running", true);
set("status", `读取 ${key}…`);
try {
const res = await withSessionRetry(conn, (sid) => getRedisKey(conn, dbName, key, { sessionId: sid, limit: 500 }));
const qr = {
columns: res.columns ?? [],
rows: res.rows ?? [],
rowCount: res.rowCount ?? 0,
timeMs: res.timeMs ?? 0,
error: res.error,
truncated: res.truncated,
limit: res.limit,
};
const hint = redisHintForType(key, res.type);
set("resultMeta", { database: dbName, table: key, pkCols: [], redisType: res.type, redisTtl: res.ttl });
setResultSets([
{ id: crypto.randomUUID(), label: key, sql: hint, result: qr, database: dbName },
]);
set("schemaView", {
database: dbName,
table: key,
columns: [
{
name: key,
type: res.type || "unknown",
nullable: true,
comment: `ttl=${formatRedisTtl(res.ttl)}`,
},
],
loading: false,
});
set("connState", "ok");
const ttlLabel = formatRedisTtl(res.ttl);
set(
"status",
res.error
? res.error
: `${key} · ${res.type || "?"} · TTL ${ttlLabel} · ${qr.rowCount} 行`,
);
const tab = tabs.find((t) => t.id === activeTabId);
if (!tab?.dirty) updateActiveSql(hint);
pushHistory({
sql: hint,
connectionId: conn.id,
rowCount: qr.rowCount,
timeMs: qr.timeMs,
error: qr.error,
});
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
set("result", { columns: [], rows: [], rowCount: 0, timeMs: 0, error: msg });
set("status", msg);
notifyError(e, "modal");
} finally {
set("running", false);
}
};
const removeRedisKey = async (dbName: string, key: string) => {
if (!conn) return;
const ok = await askConfirm({
title: "删除键",
message: `确认删除 Redis 键「${key}」?此操作不可恢复。`,
okLabel: "删除",
danger: true,
});
if (!ok) return;
try {
const res = await withSessionRetry(conn, (sid) => deleteRedisKey(conn, dbName, key, { sessionId: sid }));
if (res.error) throw new Error(res.error);
notify({ kind: "success", title: "已删除", message: key, mode: "toast", durationMs: 1400 });
patchDb(dbName, (d) => ({
...d,
children: removeRedisKeyFromTree(d.children ?? [], key),
}));
const meta = useWorkspaceStore.getState().resultMeta;
if (meta?.table === key && meta?.database === dbName) {
set("result", null);
set("resultMeta", { database: dbName, pkCols: [] });
}
set("status", `已删除 ${key}`);
} catch (e) {
notifyError(e, "modal");
}
};
/** 删除整个目录(前缀批量删除):强确认 → SCAN+UNLINK → 刷新库 */
const deleteFolderPrefix = async (dbName: string, prefix: string, count: number) => {
if (!conn) return;
const label = prefix.replace(/:+$/, "") || prefix;
const pattern = prefix.endsWith(":") ? `${prefix}*` : `${prefix}:*`;
const ok = await askConfirm({
title: "删除目录",
message: `确认删除目录「${label}」下的全部键?\n模式:${pattern} · 当前已加载 ${count} 个键。此操作不可恢复。`,
okLabel: "删除",
danger: true,
});
if (!ok) return;
try {
const res = await withSessionRetry(conn, (sid) => deleteRedisPattern(conn, dbName, pattern, { sessionId: sid }));
if (res.error) throw new Error(res.error);
notify({
kind: "success",
title: "已删除目录",
message: `${label} · ${res.deleted} 个键${res.truncated ? "(扫描截断,可能仍有残留)" : ""}`,
mode: "toast",
durationMs: 2400,
});
patchDb(dbName, { loaded: false, loading: false, children: undefined });
void expandDb(dbName);
} catch (e) {
notifyError(e, "modal");
}
};
/** 批量删除选中的键(Cmd+Click 多选) */
const deleteSelectedKeys = async (dbName: string) => {
if (!conn) return;
const ids = [...selectedKeys].filter((id) => id.startsWith(`${dbName}/`)).map((id) => id.slice(dbName.length + 1));
if (!ids.length) return;
const ok = await askConfirm({
title: "批量删除",
message: `确认删除选中的 ${ids.length} 个键?此操作不可恢复。`,
okLabel: "删除",
danger: true,
});
if (!ok) return;
try {
const sid = await ensureSession(conn);
let deleted = 0;
for (const key of ids) {
const res = await deleteRedisKey(conn, dbName, key, { sessionId: sid });
if (!res.error) deleted++;
}
notify({
kind: "success",
title: "已批量删除",
message: `${deleted}/${ids.length} 个键`,
mode: "toast",
durationMs: 2000,
});
setSelectedKeys(new Set());
patchDb(dbName, { loaded: false, loading: false, children: undefined });
void expandDb(dbName);
} catch (e) {
notifyError(e, "modal");
}
};
const createRedisKey = async (dbName: string, spec: { name: string; type: string; value: string }) => { if (!conn) return;
const name = spec.name.trim();
if (!name) {
notify({ kind: "warning", title: "新建键", message: "键名必填", mode: "toast" });
return;
}
try {
const sql = createKeyCommand(spec.type, name, spec.value);
const res = await withSessionRetry(conn, (sid) => execSql(conn, sql, { sessionId: sid, database: dbName }));
if (res.error) throw new Error(res.error);
notify({ kind: "success", title: "已新建", message: name, mode: "toast", durationMs: 1400 });
setNewKey(null);
patchDb(dbName, { loaded: false, loading: false, children: undefined });
await expandDb(dbName);
await openRedisKey(dbName, name);
} catch (e) {
notifyError(e, "modal");
}
};
const putSelectSql = async (dbName: string, table: string) => {
if (!conn) return;
set("focusedRelation", { database: dbName, table });
if (isRedisConn(conn.driver)) {
await openRedisKey(dbName, table);
return;
}
// 非 SQL 方言驱动:树点表 = 各驱动 DSL 预览(ZK 读节点 / Mongo find / Influx 按 measurement 过滤)
if (conn.driver === "zookeeper" || conn.driver === "mongodb" || conn.driver === "influxdb") {
const sql =
conn.driver === "zookeeper"
? `GET ${dbName ? `/${dbName}/${table}` : `/${table}`}`
: conn.driver === "mongodb"
? `db.${table}.find()`
: `from(bucket: "${dbName}") |> range(start: -1h) |> filter(fn: (r) => r._measurement == "${table}") |> limit(n: 100)`;
updateActiveSql(sql);
set("running", true);
set("status", `打开 ${table}…`);
try {
const res = await withSessionRetry(conn, (sid) => execSql(conn, sql, { database: dbName, sessionId: sid, limit: 100 }));
setResultSets([{ id: crypto.randomUUID(), label: table, sql, result: res, page: 0, pageSize: 100, database: dbName }]);
set("resultTab", "data");
set("connState", "ok");
set("status", res.error ? res.error : `${table} · ${res.rowCount} 行`);
pushHistory({ sql, connectionId: conn.id, rowCount: res.rowCount, timeMs: res.timeMs, error: res.error });
} catch (e) {
set("result", { columns: [], rows: [], rowCount: 0, timeMs: 0, error: e instanceof Error ? e.message : String(e) });
notifyError(e, "modal");
} finally {
set("running", false);
}
return;
}
// 预览 SQL 不烧 LIMIT:翻页由请求层 offset/limit 承担(带 LIMIT 时第 2 页起恒为空)
const sql = previewSelectSql(conn.driver, dbName, table);
const tab = tabs.find((t) => t.id === activeTabId);
if (tab?.dirty) {
const openNew = await askConfirm({
title: "当前查询未保存",
message: `「${tab.title}」有未保存改动。新开标签写入 SELECT?`,
okLabel: "新开标签",
});
if (!openNew) return;
newQueryTab();
await Promise.resolve();
useWorkspaceStore.getState().updateActiveSql(sql);
// 新 Tab 标题用表名,避免一排「查询 N」找不到哪页是哪张表
useWorkspaceStore.getState().setActiveTabTitle(table);
} else {
updateActiveSql(sql);
}
set("running", true);
set("status", `打开 ${table}…`);
try {
const res = await withSessionRetry(conn, (sid) => execSql(conn, sql, { database: dbName, sessionId: sid, limit: 100 }));
// 挂为结果集(C5):树打开的表预览同样可翻页
setResultSets([
{ id: crypto.randomUUID(), label: table, sql, result: res, page: 0, pageSize: 100, database: dbName },
]);
set("resultTab", "data");
try {
const { columns } = await withSessionRetry(conn, (sid2) => listColumns(conn, dbName, table, sid2));
const pkCols = columns.filter((c: ColumnInfo) => c.key === "PRI").map((c: ColumnInfo) => c.name);
set("resultMeta", { database: dbName, table, pkCols });
} catch {
set("resultMeta", { database: dbName, table, pkCols: [] });
}
set("connState", "ok");
set("status", res.error ? res.error : `${table} · ${res.rowCount} 行`);
pushHistory({ sql, connectionId: conn.id, rowCount: res.rowCount, timeMs: res.timeMs, error: res.error });
void expandTable(dbName, table);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
set("result", { columns: [], rows: [], rowCount: 0, timeMs: 0, error: msg });
set("status", msg);
notifyError(e, "modal");
} finally {
set("running", false);
}
};
const copyName = async (name: string) => {
await navigator.clipboard.writeText(name);
notify({ kind: "success", title: "已复制", message: name, mode: "toast", durationMs: 1200 });
};
const onCtx = (
e: ReactMouseEvent,
label: string,
onRefresh: () => void,
onSchema?: () => void,
onView?: () => void,
onDelete?: () => void,
extra?: Partial<
Pick<
CtxMenu,
| "kind"
| "onQueryData"
| "onGen"
| "onTruncate"
| "onDropTable"
| "onNewQuery"
| "onDdl"
| "onBrowse"
| "onSetDefaultDb"
| "onDropDatabase"
| "onFilterPrefix"
| "onNewKey"
| "onDeleteFolder"
| "onDeleteSelected"
| "refreshLabel"
| "copyLabel"
>
>,
) => {
e.preventDefault();
e.stopPropagation();
setCtx({ x: e.clientX, y: e.clientY, label, onRefresh, onSchema, onView, onDelete, ...extra });
};
const canDropDatabase = (driver?: string) => {
const d = (driver || "").toLowerCase();
if (!d || d === "sqlite" || d === "redis" || d === "mongodb" || d === "elasticsearch" || d === "zookeeper" || d === "influxdb") return false;
return true;
};
const canSetDefaultDb = (driver?: string) => {
const d = (driver || "").toLowerCase();
return !!d && d !== "sqlite" && d !== "redis";
};
const dropDatabase = async (dbName: string) => {
if (!conn) return;
if (!canDropDatabase(conn.driver)) {
notify({ kind: "warning", title: "删除库", message: "当前引擎不支持删除库", mode: "toast" });
return;
}
const ok = await askConfirm({
title: "删除数据库",
message: `确认删除数据库「${dbName}」?库内对象将一并删除,不可恢复。`,
okLabel: "删除库",
danger: true,
});
if (!ok) return;
try {
const d = conn.driver.toLowerCase();
let sql = `DROP DATABASE ${quoteIdent(conn.driver, dbName)}`;
if (d === "postgresql" || d === "gaussdb" || d === "kingbase" || d === "opengauss" || d === "opengauss") {
sql = `DROP DATABASE ${quoteIdent(conn.driver, dbName)}`;
}
const res = await withSessionRetry(conn, (sid) => execSql(conn, sql, { sessionId: sid, confirmDanger: true }));
if (res.error) throw new Error(res.error);
notify({ kind: "success", title: "已删除库", message: dbName, mode: "toast" });
if (activeId) void refresh(activeId);
} catch (e) {
notifyError(e, "modal");
}
};
const setDefaultDatabase = (dbName: string) => {
if (!conn) return;
const next = { ...conn, database: dbName };
useWorkspaceStore.getState().upsertConnection(next);
notify({ kind: "success", title: "已设为默认库", message: dbName, mode: "toast", durationMs: 1500 });
};
/** 生成表 SQL 模板:优先用树上已加载的列,取不到再查库 */
const genTableSql = async (dbName: string, table: string, kind: GenSqlKind) => {
if (!conn) return;
const st = useWorkspaceStore.getState();
const tNode = st.tree
.find((n) => n.name === dbName)
?.children?.find((g) => g.groupId === "tables")
?.children?.find((c) => c.name === table);
let cols = ((tNode?.children ?? []).find((c) => c.groupId === "columns")?.children ?? []) as ColumnInfo[];
if (!cols.length) {
try {
cols = (await withSessionRetry(conn, (sid) => listColumns(conn, dbName, table, sid))).columns;
} catch {
/* 拿不到列也能生成骨架模板 */
}
}
st.updateActiveSql(buildGenSql(kind, conn.driver, dbName, table, cols));
set("status", `已生成 ${kind.toUpperCase()} · ${dbName}.${table}`);
};
/** 截断 / 删除表:二次确认后执行并刷新库 */
useEffect(() => {
if (!ddlRequest) return;
setDdlTarget({ db: ddlRequest.database, table: ddlRequest.table });
set("focusedRelation", { database: ddlRequest.database, table: ddlRequest.table });
set("ddlRequest", null);
}, [ddlRequest, set]);
useEffect(() => {
if (!viewDataRequest) return;
const { database, table } = viewDataRequest;
set("viewDataRequest", null);
void putSelectSql(database, table);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [viewDataRequest]);
const runTableDanger = async (dbName: string, table: string, action: "truncate" | "drop") => {
if (!conn) return;
const ok = await askConfirm({
title: action === "truncate" ? "截断表" : "删除表",
message:
action === "truncate"
? `确认清空表「${dbName}.${table}」的全部数据?结构保留,数据不可恢复。`
: `确认删除表「${dbName}.${table}」?表结构与数据将一并删除,不可恢复。`,
okLabel: action === "truncate" ? "截断" : "删除",
danger: true,
});
if (!ok) return;
try {
const sqlText =
action === "truncate"
? `TRUNCATE TABLE ${qualTable(conn.driver, dbName, table)};`
: `DROP TABLE ${qualTable(conn.driver, dbName, table)};`;
const res = await withSessionRetry(conn, (sid) => execSql(conn, sqlText, { sessionId: sid, database: dbName, confirmDanger: true }));
if (res.error) throw new Error(res.error);
notify({
kind: "success",
title: action === "truncate" ? "已截断" : "已删除",
message: `${dbName}.${table}`,
mode: "toast",
durationMs: 1600,
});
patchDb(dbName, { loaded: false, loading: false, children: undefined });
void expandDb(dbName);
} catch (e) {
notifyError(e, "toast");
}
};
/** 字段行:绿色短横图标 + 类型淡显,主键加 🔑 */
const ColumnRow = ({ col, onRefresh }: { col: TreeNode; onRefresh: () => void }) => (
<div
className="ops-tree-item opacity-85"
title={col.type}
onContextMenu={(e) => onCtx(e, col.name, onRefresh)}
>
<span
className="ml-1 mr-0.5 h-[3px] w-2.5 shrink-0 self-center rounded-full"
style={{ background: TREE_COLORS.columns, opacity: 0.85 }}
/>
<span className="truncate">
{col.key === "PRI" ? "🔑 " : ""}
{col.name}
<span className="ml-1 opacity-50">{col.type}</span>
</span>
</div>
);
/** 过滤视图:库名命中显示整库;否则只留名称命中的子对象(表/视图/键等) */
const f = treeFilter.trim().toLowerCase();
const nameHit = (n: string) => !f || n.toLowerCase().includes(f);
const visibleTree = f
? tree
.map((db) => {
if (nameHit(db.name)) return db;
// flat 驱动:children 即对象,直接按名过滤
if (driverCaps(conn?.driver ?? "").treeGroups.length === 0) {
const objs = (db.children ?? []).filter((o) => nameHit(o.name));
return objs.length ? ({ ...db, children: objs } as TreeNode) : null;
}
const groups = (db.children ?? [])
.map((g) => {
const children = (g.children ?? []).filter((o) => nameHit(o.name));
return children.length ? ({ ...g, children, count: children.length } as TreeNode) : null;
})
.filter((g): g is TreeNode => g !== null);
return groups.length ? ({ ...db, children: groups } as TreeNode) : null;
})
.filter((db): db is TreeNode => db !== null)
: tree;
/** 修 O4:过滤未命中时,一键展开全部库(懒加载)后重搜 */
const expandAllAndResearch = () => {
for (const db of tree) {
if (!db.loaded && !db.loading) void expandDb(db.name);
}
};
/** R2-F4:本地过滤无命中 → 服务端跨库搜索(E6) */
const runDbSearch = async () => {
const kw = treeFilter.trim();
if (!conn || kw.length < 2 || dbSearching) return;
setDbSearching(true);
setDbSearchResults(null);
setDbSearchKw(kw);
try {
const res = await withSessionRetry(conn, (sid) => searchObjects(conn, kw, { sessionId: sid }));
if (res.error) throw new Error(res.error);
setDbSearchResults(res.results ?? []);
} catch (e) {
notifyError(e, "toast");
} finally {
setDbSearching(false);
}
};
/** R2-F4:点击搜索结果 = 走现有「双击打开表」路径(库未加载先加载,再预览+拉列) */
const openSearchHit = async (hit: SearchObjectHit) => {
setDbSearchResults(null);
setTreeFilter("");
if (!conn) return;
const dbNode = useWorkspaceStore.getState().tree.find((n) => n.name === hit.schema && n.kind === "database");
if (dbNode && !dbNode.loaded && !dbNode.loading) await expandDb(hit.schema);
await putSelectSql(hit.schema, hit.name);
};
/**
* 修 K2:树键盘导航 —— ↑↓ 移动、→ 展开 / ← 收起、Enter 打开。
* roving tabindex:所有行 tabIndex=-1,容器是唯一 Tab 停靠点,方向键在行间移动 DOM 焦点。
*/
const onTreeKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>) => {
const root = treeScrollRef.current;
if (!root) return;
const rows = Array.from(root.querySelectorAll<HTMLElement>("[data-tree-row]")).filter(
(el) => el.offsetParent !== null,
);
if (!rows.length) return;
const idx = rows.indexOf(document.activeElement as HTMLElement);
const focusRow = (i: number) => {
const el = rows[Math.max(0, Math.min(rows.length - 1, i))];
if (!el) return;
el.focus();
el.scrollIntoView({ block: "nearest" });
};
if (e.key === "ArrowDown") {
e.preventDefault();
focusRow(idx < 0 ? 0 : idx + 1);
return;
}
if (e.key === "ArrowUp") {
e.preventDefault();
focusRow(idx <= 0 ? 0 : idx - 1);
return;
}
if (idx < 0) return;
const row = rows[idx];
const details = row.closest("details");
if (e.key === "ArrowRight") {
e.preventDefault();
if (details && !details.open) details.open = true; // 触发 onToggle → 懒加载
else focusRow(idx + 1);
return;
}
if (e.key === "ArrowLeft") {
e.preventDefault();
if (details?.open && row.tagName === "SUMMARY") details.open = false;
else {
const parentSummary = details?.parentElement?.closest("details")?.querySelector(":scope > summary");
if (parentSummary instanceof HTMLElement) {
parentSummary.focus();
parentSummary.scrollIntoView({ block: "nearest" });
}
}
return;
}
if (e.key === "Enter") {
e.preventDefault();
const { kind, db, name } = row.dataset;
if ((kind === "table" || kind === "view") && db && name) void putSelectSql(db, name);
else if ((kind === "key" || kind === "folder") && db && name) {
if (kind === "key") void openRedisKey(db, name);
else if (details) details.open = !details.open;
}
else if (row.tagName === "SUMMARY" && details) details.open = !details.open;
}
};
const nodeSelected = (key: string) => selectedNode === key;
const redisSep = conn?.keySeparator || ":";
const renderRedisNodes = (dbName: string, nodes: TreeNode[]): ReactNode =>
nodes.map((obj) =>
obj.kind === "folder" ? (
<details key={obj.fullName || obj.name} className="mb-px">
<summary
className={`ops-tree-item list-none [&::-webkit-details-marker]:hidden ${nodeSelected(`${dbName}/${obj.fullName}`) ? "ops-tree-item-active" : ""}`}
data-tree-row
data-kind="folder"
data-db={dbName}
data-name={obj.fullName}
tabIndex={-1}
onClick={() => setSelectedNode(`${dbName}/${obj.fullName}`)}
onContextMenu={(e) =>
onCtx(e, obj.fullName || obj.name, () => {
patchDb(dbName, { loaded: false, loading: false, children: undefined });
void expandDb(dbName);
}, undefined, undefined, undefined, {
kind: "folder",
copyLabel: "复制前缀",
onFilterPrefix: () => {
setKeyFilter(`${obj.fullName || obj.name}*`);
patchDb(dbName, { loaded: false, loading: false, children: undefined });
void expandDb(dbName);
},
onNewKey: () => setNewKey({ name: obj.fullName || "", type: "string", value: "" }),
onDeleteFolder: () => void deleteFolderPrefix(dbName, obj.fullName || obj.name, obj.count ?? 0),
})
}
>
<TreeChevron size={9} className="opacity-40" />
<TreeIcon kind="folder" />
<span className="min-w-0 flex-1 truncate">
{obj.name}
<CountBadge kind="folder" count={obj.count ?? 0} />
</span>
</summary>
<div className="ml-2 border-l pl-1" style={{ borderColor: "var(--ops-border-soft)" }}>
{renderRedisNodes(dbName, obj.children ?? [])}
</div>
</details>
) : (
<div
key={obj.name}
className={`ops-tree-item ${nodeSelected(`${dbName}/${obj.name}`) || selectedKeys.has(`${dbName}/${obj.name}`) ? "ops-tree-item-active" : ""}`}
data-tree-row
data-kind="key"
data-db={dbName}
data-name={obj.name}
tabIndex={-1}
onClick={(e) => {
if (e.metaKey || e.ctrlKey) {
e.preventDefault();
e.stopPropagation();
toggleKeySelect(dbName, obj.name, true);
return;
}
setSelectedNode(`${dbName}/${obj.name}`);
}}
onContextMenu={(e) =>
onCtx(
e,
obj.name,
() => {
patchDb(dbName, { loaded: false, loading: false, children: undefined });
void expandDb(dbName);
},
undefined,
() => void openRedisKey(dbName, obj.name),
() => void removeRedisKey(dbName, obj.name),
{
kind: "key",
refreshLabel: "刷新 TTL",
copyLabel: "复制键名",
onDeleteSelected: selectedKeys.size > 1 ? () => void deleteSelectedKeys(dbName) : undefined,
},
)
}
>
<span
className="ml-1 mr-0.5 h-2 w-2 shrink-0 self-center rounded-full"
style={{ background: redisTypeColor(obj.type), opacity: 0.9 }}
/>
<button
type="button"
className="min-w-0 flex-1 truncate text-left"
title={obj.name}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
void openRedisKey(dbName, obj.name);
}}
>
<span className="truncate">{redisLeafLabel(obj.name, redisSep)}</span>
<span className="ml-1 font-normal opacity-45">
{obj.type || "?"}
{obj.ttl != null ? ` · ${formatRedisTtl(obj.ttl)}` : ""}
</span>
</button>
</div>
),
);
/** 库下的对象行(表/视图/集合/索引/子节点…):分组内外共用;flat 驱动直挂库节点 */
const renderDbObject = (dbName: string, obj: TreeNode, groupId?: string) =>
obj.kind === "table" || obj.kind === "view" ? (
// 无列概念的对象(ZK 子节点等)渲染为叶子行:单击选中、双击打开,不可展开
!driverCaps(conn?.driver ?? "").columns && obj.kind === "table" ? (
<div
key={obj.name}
className={`ops-tree-item ${nodeSelected(`${dbName}/${obj.name}`) ? "ops-tree-item-active" : ""}`}
data-tree-row
data-kind="table"
data-db={dbName}
data-name={obj.name}
tabIndex={-1}
onClick={() => setSelectedNode(`${dbName}/${obj.name}`)}
onDoubleClick={() => void putSelectSql(dbName, obj.name)}
onContextMenu={(e) =>
onCtx(e, obj.name, () => {
patchDb(dbName, { loaded: false, children: undefined });
void expandDb(dbName);
}, undefined, undefined, undefined, {
kind: "table",
onQueryData: () => void putSelectSql(dbName, obj.name),
copyLabel: "复制名称",
})
}
>
<TreeIcon kind="tables" size={12} />
<span className="truncate">{obj.name}</span>
</div>
) : (
<details
key={obj.name}
onToggle={(e) => {
if ((e.currentTarget as HTMLDetailsElement).open && obj.kind === "table") {
void expandTable(dbName, obj.name);
}
}}
onContextMenu={(e) =>
onCtx(
e,
obj.name,
() => {
if (obj.kind === "table") {
patchDb(dbName, (d) => ({
...d,
children: (d.children ?? []).map((gg) =>
gg.groupId !== "tables"
? gg
: {
...gg,
children: (gg.children ?? []).map((t) =>
t.name === obj.name ? { ...t, loaded: false, children: undefined } : t,
),
},
),
}));
void expandTable(dbName, obj.name);
} else {
patchDb(dbName, { loaded: false, children: undefined });
void expandDb(dbName);
}
},
() => void openSchema(dbName, obj.name),
undefined,
undefined,
{
kind: obj.kind === "view" ? "view" : "table",
onQueryData: () => void putSelectSql(dbName, obj.name),
onGen: (k) => void genTableSql(dbName, obj.name, k),
// DDL/截断/删除按驱动能力开关:ES/Influx/Mongo/ZK 等只读树不提供
onDdl: driverCaps(conn?.driver ?? "").ddl
? () => {
set("focusedRelation", { database: dbName, table: obj.name });
setDdlTarget({ db: dbName, table: obj.name });
}
: undefined,
onTruncate:
obj.kind === "table" && driverCaps(conn?.driver ?? "").truncate
? () => void runTableDanger(dbName, obj.name, "truncate")
: undefined,
onDropTable:
obj.kind === "table" && driverCaps(conn?.driver ?? "").dropObject
? () => void runTableDanger(dbName, obj.name, "drop")
: undefined,
copyLabel: "复制名称",
},
)
}
>
<summary
className={`ops-tree-item list-none [&::-webkit-details-marker]:hidden ${nodeSelected(`${dbName}/${obj.name}`) ? "ops-tree-item-active" : ""}`}
data-tree-row
data-kind={obj.kind}
data-db={dbName}
data-name={obj.name}
tabIndex={-1}
onClick={() => setSelectedNode(`${dbName}/${obj.name}`)}
>
<TreeChevron size={9} className="opacity-40" />
<TreeIcon kind={obj.kind === "view" ? "views" : "tables"} size={12} />
<button
type="button"
className="min-w-0 flex-1 truncate text-left"
title="单击选中 · 双击打开前 100 行"
tabIndex={-1}
onDoubleClick={(e) => {
e.preventDefault();
e.stopPropagation();
void putSelectSql(dbName, obj.name);
}}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setSelectedNode(`${dbName}/${obj.name}`);
}}
>
{obj.name}
</button>
</summary>
<div className="ml-2 border-l pl-1" style={{ borderColor: "var(--ops-border-soft)" }}>
{(obj.children ?? []).map((sub) =>
sub.kind === "group" && sub.groupId === "columns" ? (
<details key={sub.groupId} className="mb-px">
<summary className="ops-tree-item list-none [&::-webkit-details-marker]:hidden">
<TreeChevron size={9} className="opacity-40" />
<TreeIcon kind="columns" size={11} />
<span className="ops-tree-group min-w-0 flex-1 truncate">
{sub.name}
<CountBadge kind="columns" count={sub.count ?? sub.children?.length ?? 0} />
</span>
</summary>
<div className="ml-2 border-l pl-1" style={{ borderColor: "var(--ops-border-soft)" }}>
{(sub.children ?? []).map((col) => (
<ColumnRow key={col.name} col={col} onRefresh={() => void expandTable(dbName, obj.name)} />
))}
</div>
</details>
) : (
<ColumnRow key={sub.name} col={sub} onRefresh={() => void expandTable(dbName, obj.name)} />
),
)}
{obj.loading && (
<div className="px-1.5 py-0.5 text-[10px]" style={{ color: "var(--ops-muted)" }}>
列…
</div>
)}
</div>
</details>
)
) : (
// 存储过程/函数等:叶子行
<div
key={obj.name}
className={`ops-tree-item ${nodeSelected(`${dbName}/${groupId ?? "fn"}/${obj.name}`) ? "ops-tree-item-active" : ""}`}
data-tree-row
data-kind={obj.kind}
data-db={dbName}
data-name={obj.name}
tabIndex={-1}
onClick={() => setSelectedNode(`${dbName}/${groupId ?? "fn"}/${obj.name}`)}
onContextMenu={(e) =>
onCtx(e, obj.name, () => {
patchDb(dbName, { loaded: false, children: undefined });
void expandDb(dbName);
})
}
onDoubleClick={() => void copyName(obj.name)}
>
<TreeIcon kind={groupIdColor(groupId)} size={11} />
<span className="truncate">{obj.name}</span>
</div>
);
return (
<div className="flex flex-col">
<div className="ops-section-label justify-between gap-1.5" style={{ display: "none" }}>
<button
type="button"
className="ops-btn-ghost grid h-5 w-5 place-items-center p-0 text-[12px]"
style={{ color: "var(--ops-accent)" }}
title="刷新对象树"
aria-label="刷新对象树"
onClick={() => activeId && void refresh(activeId)}
>
↻
</button>
</div>
{/* 修 O4:过滤框独立一行(脱离 section-label) */}
{!isRedisConn(conn?.driver) && (
<div style={{ display: "none" }}>
<input
data-ops-tree-filter
className="ops-input w-full py-0.5 text-[11px]"
placeholder="过滤库 / 表…(⌘⇧F)"
aria-label="过滤库 / 表"
value={treeFilter}
onChange={(e) => {
setTreeFilter(e.target.value);
setDbSearchResults(null);
setDbSearchKw("");
}}
onKeyDown={(e) => {
// R2-F4:Escape 关闭服务端搜索并清空关键字
if (e.key === "Escape") {
setTreeFilter("");
setDbSearchResults(null);
setDbSearchKw("");
e.currentTarget.blur();
}
}}
/>
{/* R2-F4:本地过滤无命中且关键字 ≥2 字符 → 提供服务端搜索(E6) */}
{f.length >= 2 && visibleTree.length === 0 && !treeLoading && (
<div className="mt-1 border-t pt-1" style={{ borderColor: "var(--ops-border-soft)" }}>
<button
type="button"
className="w-full truncate text-left hover:underline disabled:opacity-60"
style={{ color: "var(--ops-accent)", fontSize: "var(--ops-text-xs)" }}
disabled={dbSearching}
onClick={() => void runDbSearch()}
>
{dbSearching ? "搜索中…" : `在数据库中搜索「${treeFilter.trim()}」`}
</button>
{dbSearchResults !== null && !dbSearching &&
(dbSearchResults.length === 0 ? (
<div className="px-0.5 py-1" style={{ fontSize: "var(--ops-text-xs)", color: "var(--ops-muted)" }}>
未找到与「{dbSearchKw}」匹配的表/视图
</div>
) : (
<div className="mt-0.5 max-h-40 overflow-auto">
{dbSearchResults.map((r) => (
<button
key={`${r.schema}.${r.name}`}
type="button"
className="flex w-full items-center gap-1 truncate rounded px-1 py-0.5 text-left hover:underline"
style={{ fontSize: "var(--ops-text-xs)" }}
title={`打开 ${r.schema}.${r.name}`}
onClick={() => void openSearchHit(r)}
>
<span className="truncate">
{r.schema}.{r.name}
</span>
<span
className="ml-auto shrink-0 rounded px-1 text-[10px]"
style={{
color: r.type === "view" ? TREE_COLORS.views : TREE_COLORS.tables,
background: `${r.type === "view" ? TREE_COLORS.views : TREE_COLORS.tables}1f`,
}}
>
{r.type === "view" ? "视图" : "表"}
</span>
</button>
))}
</div>
))}
</div>
)}
</div>
)}
{isRedisConn(conn?.driver) && (
<div style={{ display: "none" }}>
<div className="flex items-center gap-1">
<input
data-ops-tree-filter
className="ops-input min-w-0 flex-1 py-0.5 text-[11px]"
placeholder="搜索键名,输入即过滤;回车服务端模糊扫描"
value={keyFilter}
onChange={(e) => setKeyFilter(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
const openDbs = useWorkspaceStore.getState().tree.filter((n) => n.loaded || n.loading);
for (const d of openDbs) {
patchDb(d.name, { loaded: false, loading: false, children: undefined });
void expandDb(d.name);
}
}
}}
/>
<select
className="ops-input shrink-0 py-0.5 text-[11px]"
style={{ width: "auto" }}
value={redisTypeFilter}
onChange={(e) => setRedisTypeFilter(e.target.value)}
title="按类型筛选"
>
<option value="all">全部类型</option>
<option value="string">string</option>
<option value="hash">hash</option>
<option value="list">list</option>
<option value="set">set</option>
<option value="zset">zset</option>
<option value="stream">stream</option>
</select>
<button
type="button"
className="shrink-0 text-[11px] hover:underline"
style={{ color: "var(--ops-accent)" }}
onClick={() => {
const openDbs = useWorkspaceStore.getState().tree.filter((n) => n.loaded || n.loading);
for (const d of openDbs) {
patchDb(d.name, { loaded: false, loading: false, children: undefined });
void expandDb(d.name);
}
}}
>
应用
</button>
<button
type="button"
className="shrink-0 text-[11px] hover:underline"
style={{ color: "var(--ops-accent)" }}
title="新建键"
onClick={() =>
setNewKey({
name: keyFilter.endsWith("*") ? keyFilter.slice(0, -1) : keyFilter,
type: "string",
value: "",
})
}
>
新建
</button>
</div>
{newKey && (
<div className="mt-1 space-y-1 rounded border p-1.5" style={{ borderColor: "var(--ops-border)" }}>
<div className="flex gap-1">
<select
className="ops-input h-6 py-0 text-[11px]"
value={newKey.type}
onChange={(e) => setNewKey({ ...newKey, type: e.target.value })}
>
<option value="string">String</option>
<option value="hash">Hash</option>
<option value="list">List</option>
<option value="set">Set</option>
<option value="zset">ZSet</option>
</select>
<input
className="ops-input h-6 min-w-0 flex-1 py-0 font-mono text-[11px]"
placeholder="键名"
value={newKey.name}
onChange={(e) => setNewKey({ ...newKey, name: e.target.value })}
/>
</div>
<input
className="ops-input h-6 w-full py-0 font-mono text-[11px]"
placeholder={newKey.type === "hash" ? "初始 field 的值" : "初始值"}
value={newKey.value}
onChange={(e) => setNewKey({ ...newKey, value: e.target.value })}
onKeyDown={(e) => {
if (e.key === "Enter" && tree[0]) void createRedisKey(tree[0].name, newKey);
}}
/>
<div className="flex justify-end gap-2">
<button type="button" className="text-[11px] opacity-70" onClick={() => setNewKey(null)}>
取消
</button>
<button
type="button"
className="text-[11px] hover:underline"
style={{ color: "var(--ops-accent)" }}
onClick={() => {
const dbName = tree.find((n) => n.kind === "database")?.name ?? "0";
void createRedisKey(dbName, newKey);
}}
>
创建
</button>
</div>
</div>
)}
</div>
)}
<div
ref={treeScrollRef}
className="p-1 text-[12.5px] outline-none"
tabIndex={0}
role="tree"
onKeyDown={onTreeKeyDown}
>
{treeLoading && (
<div className="px-1.5 py-1 text-[11px]" style={{ color: "var(--ops-muted)" }}>
加载中…
</div>
)}
{!treeLoading && tree.length === 0 && (
<div className="px-1.5 py-1 text-[11px]" style={{ color: "var(--ops-muted)" }}>
{conn ? "点刷新加载库;无权限时会提示错误" : "选择连接后浏览对象"}
</div>
)}
{visibleTree.map((db) => (
<details
key={db.name}
className="mb-px"
open={f ? true : undefined}
onToggle={(e) => {
if ((e.currentTarget as HTMLDetailsElement).open) void expandDb(db.name);
}}
onContextMenu={(e) =>
onCtx(e, db.name, () => {
patchDb(db.name, { loaded: false, loading: false, children: undefined });
void expandDb(db.name);
}, undefined, undefined, undefined, {
kind: "database",
onNewQuery: isRedisConn(conn?.driver)
? undefined
: () => useWorkspaceStore.getState().newQueryTab(),
onNewKey: isRedisConn(conn?.driver)
? () => setNewKey({ name: "", type: "string", value: "" })
: undefined,
onBrowse: () => void expandDb(db.name),
onSetDefaultDb: canSetDefaultDb(conn?.driver) && driverCaps(conn?.driver ?? "").setDefaultDb
? () => setDefaultDatabase(db.name)
: undefined,
onDropDatabase: canDropDatabase(conn?.driver) && driverCaps(conn?.driver ?? "").dropObject
? () => void dropDatabase(db.name)
: undefined,
copyLabel: "复制名称",
})
}
>
<summary
className={`ops-tree-item cursor-pointer list-none [&::-webkit-details-marker]:hidden ${nodeSelected(db.name) ? "ops-tree-item-active" : ""}`}
data-tree-row
data-kind="database"
data-name={db.name}
tabIndex={-1}
onClick={() => setSelectedNode(db.name)}
>
<TreeChevron size={9} className="opacity-50" />
<TreeIcon kind="database" />
<span className="truncate font-medium">{db.name}</span>
{db.loading && <span className="ml-auto text-[10px] opacity-40">…</span>}
</summary>
<div className="ml-1.5 border-l pl-1" style={{ borderColor: "var(--ops-border-soft)" }}>
{isRedisConn(conn?.driver) ? (
renderRedisNodes(db.name, filterRedisTree(db.children ?? [], keyFilter, redisTypeFilter))
) : driverCaps(conn?.driver ?? "").treeGroups.length === 0 ? (
// flat 驱动(ES/Mongo/Influx/ZK):对象直挂库节点,无分组包装
<>
{(db.children ?? []).map((obj) => renderDbObject(db.name, obj))}
{db.loaded && (db.children ?? []).length === 0 && (
<div className="px-1.5 py-0.5 text-[10px]" style={{ color: "var(--ops-muted)" }}>
空
</div>
)}
</>
) : (db.children ?? emptyGroups()).map((g) => (
<details key={g.groupId || g.name} className="mb-px" open={f ? true : undefined}>
<summary
className={`ops-tree-item list-none [&::-webkit-details-marker]:hidden ${nodeSelected(`${db.name}/${g.groupId}`) ? "ops-tree-item-active" : ""}`}
data-tree-row
data-kind="group"
data-db={db.name}
data-name={g.name}
tabIndex={-1}
onClick={() => setSelectedNode(`${db.name}/${g.groupId}`)}
onContextMenu={(e) =>
onCtx(e, g.name, () => {
patchDb(db.name, { loaded: false, loading: false, children: undefined });
void expandDb(db.name);
}, undefined, undefined, undefined, { kind: "group" })
}
>
<TreeChevron size={9} className="opacity-40" />
<TreeIcon kind={groupIdColor(g.groupId)} />
<span className="ops-tree-group min-w-0 flex-1 truncate">
{g.name}
<CountBadge kind={groupIdColor(g.groupId)} count={g.count ?? g.children?.length ?? 0} />
</span>
</summary>
<div className="ml-2 border-l pl-1" style={{ borderColor: "var(--ops-border-soft)" }}>
{(g.children ?? []).length === 0 && g.loaded && (
<div className="px-1.5 py-0.5 text-[10px]" style={{ color: "var(--ops-muted)" }}>
空
</div>
)}
{(g.children ?? []).map((obj) => renderDbObject(db.name, obj, g.groupId))}
</div>
</details>
))}
{db.loaded && (db.children ?? []).length === 0 && (
<div className="px-1.5 py-0.5 text-[10px]" style={{ color: "var(--ops-muted)" }}>
空库或无权限
</div>
)}
</div>
</details>
))}
{f && visibleTree.length === 0 && !treeLoading && (
<div className="px-1.5 py-1 text-[11px]" style={{ color: "var(--ops-muted)" }}>
<div>没有匹配「{treeFilter.trim()}」的对象;未展开的库只匹配库名。</div>
{tree.some((db) => !db.loaded) && (
<button
type="button"
className="mt-1 hover:underline"
style={{ color: "var(--ops-accent)" }}
onClick={expandAllAndResearch}
>
展开全部库后重搜
</button>
)}
</div>
)}
</div>
{ctx && (
<div
ref={ctxRef}
className="ops-ctx"
style={
ctxPos
? { left: ctxPos.x, top: ctxPos.y }
: { left: ctx.x, top: ctx.y, visibility: "hidden" }
}
onClick={(e) => e.stopPropagation()}
>
{/* 库 P0 */}
{ctx.kind === "database" && (
<>
{ctx.onNewQuery && (
<button type="button" onClick={() => { ctx.onNewQuery?.(); setCtx(null); }}>新建查询</button>
)}
{ctx.onBrowse && (
<button type="button" onClick={() => { ctx.onBrowse?.(); setCtx(null); }}>浏览对象</button>
)}
<button type="button" onClick={() => { void copyName(ctx.label); setCtx(null); }}>{ctx.copyLabel ?? "复制名称"}</button>
{ctx.onSetDefaultDb ? (
<button type="button" onClick={() => { ctx.onSetDefaultDb?.(); setCtx(null); }}>设为默认库</button>
) : (
<button type="button" disabled title="当前引擎不支持">设为默认库</button>
)}
<button type="button" onClick={() => { ctx.onRefresh(); setCtx(null); }}>刷新</button>
<div className="ops-ctx-sep" />
{ctx.onDropDatabase ? (
<button type="button" style={{ color: "var(--ops-danger)" }} onClick={() => { ctx.onDropDatabase?.(); setCtx(null); }}>删除库…</button>
) : (
<button type="button" disabled title="当前引擎不支持删除库" style={{ color: "var(--ops-danger)" }}>删除库…</button>
)}
</>
)}
{/* 表/视图 P0 */}
{(ctx.kind === "table" || ctx.kind === "view") && (
<>
{ctx.onQueryData && (
<button type="button" onClick={() => { ctx.onQueryData?.(); setCtx(null); }}>查看数据</button>
)}
{ctx.onDdl && (
<button type="button" onClick={() => { ctx.onDdl?.(); setCtx(null); }}>查看 DDL</button>
)}
<button type="button" onClick={() => { void copyName(ctx.label); setCtx(null); }}>{ctx.copyLabel ?? "复制名称"}</button>
<button type="button" onClick={() => { ctx.onRefresh(); setCtx(null); }}>刷新</button>
{(ctx.onTruncate || ctx.onDropTable) && <div className="ops-ctx-sep" />}
{ctx.onTruncate && (
<button type="button" style={{ color: "var(--ops-danger)" }} onClick={() => { ctx.onTruncate?.(); setCtx(null); }}>清空/截断…</button>
)}
{ctx.onDropTable && (
<button type="button" style={{ color: "var(--ops-danger)" }} onClick={() => { ctx.onDropTable?.(); setCtx(null); }}>删除表…</button>
)}
</>
)}
{/* Redis 键 P0 */}
{ctx.kind === "key" && (
<>
{ctx.onView && (
<button type="button" onClick={() => { ctx.onView?.(); setCtx(null); }}>查看值</button>
)}
<button type="button" onClick={() => { void copyName(ctx.label); setCtx(null); }}>{ctx.copyLabel ?? "复制键名"}</button>
<button type="button" onClick={() => { ctx.onRefresh(); setCtx(null); }}>{ctx.refreshLabel ?? "刷新 TTL"}</button>
{ctx.onDelete && (
<>
<div className="ops-ctx-sep" />
<button type="button" style={{ color: "var(--ops-danger)" }} onClick={() => { ctx.onDelete?.(); setCtx(null); }}>删除键…</button>
</>
)}
</>
)}
{ctx.kind === "folder" && (
<>
{ctx.onFilterPrefix && (
<button type="button" onClick={() => { ctx.onFilterPrefix?.(); setCtx(null); }}>只看此前缀</button>
)}
{ctx.onNewKey && (
<button type="button" onClick={() => { ctx.onNewKey?.(); setCtx(null); }}>在此新建键</button>
)}
<button type="button" onClick={() => { void copyName(ctx.label); setCtx(null); }}>{ctx.copyLabel ?? "复制前缀"}</button>
<button type="button" onClick={() => { ctx.onRefresh(); setCtx(null); }}>刷新</button>
{ctx.onDeleteFolder && (
<button
type="button"
style={{ color: "var(--ops-danger)" }}
onClick={() => { ctx.onDeleteFolder?.(); setCtx(null); }}
>
删除目录…
</button>
)}
</>
)}
{/* 分组等兜底:刷新 + 复制 */}
{(!ctx.kind || ctx.kind === "group") && (
<>
{ctx.onNewKey && (
<button type="button" onClick={() => { ctx.onNewKey?.(); setCtx(null); }}>新建键</button>
)}
<button type="button" onClick={() => { ctx.onRefresh(); setCtx(null); }}>刷新</button>
<button type="button" onClick={() => { void copyName(ctx.label); setCtx(null); }}>复制名称</button>
</>
)}
</div>
)}
{/* R2-F3:DDL 查看弹窗 */}
{ddlTarget && conn && (
<DdlModal
conn={conn}
database={ddlTarget.db}
table={ddlTarget.table}
onClose={() => setDdlTarget(null)}
/>
)}
</div>
);
}
async function listSchemaObjects(
conn: SavedConnection,
sid: string,
db: string,
kind: "views" | "procedures" | "functions",
): Promise<string[]> {
const d = conn.driver;
let sql = "";
if (kind === "views") {
if (d === "postgresql" || d === "gaussdb" || d === "kingbase" || d === "opengauss") {
sql = `SELECT table_name AS n FROM information_schema.views WHERE table_schema = 'public' ORDER BY 1`;
} else if (d === "sqlite") {
sql = `SELECT name AS n FROM sqlite_master WHERE type='view' ORDER BY 1`;
} else {
sql = `SELECT TABLE_NAME AS n FROM information_schema.views WHERE TABLE_SCHEMA = '${db.replace(/'/g, "''")}' ORDER BY 1`;
}
} else if (kind === "procedures") {
if (d === "postgresql" || d === "gaussdb" || d === "kingbase" || d === "opengauss") {
sql = `SELECT routine_name AS n FROM information_schema.routines WHERE routine_schema='public' AND routine_type='PROCEDURE' ORDER BY 1`;
} else if (d === "sqlite") {
return [];
} else {
sql = `SELECT ROUTINE_NAME AS n FROM information_schema.routines WHERE ROUTINE_SCHEMA='${db.replace(/'/g, "''")}' AND ROUTINE_TYPE='PROCEDURE' ORDER BY 1`;
}
} else {
if (d === "postgresql" || d === "gaussdb" || d === "kingbase" || d === "opengauss") {
sql = `SELECT routine_name AS n FROM information_schema.routines WHERE routine_schema='public' AND routine_type='FUNCTION' ORDER BY 1`;
} else if (d === "sqlite") {
return [];
} else {
sql = `SELECT ROUTINE_NAME AS n FROM information_schema.routines WHERE ROUTINE_SCHEMA='${db.replace(/'/g, "''")}' AND ROUTINE_TYPE='FUNCTION' ORDER BY 1`;
}
}
const res = await execSql(conn, sql, { database: db, sessionId: sid, limit: 5000 });
if (res.error) return [];
const key = res.columns[0];
return res.rows.map((r) => String(r[key] ?? "")).filter(Boolean);
}