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 ( ); } /** 分类计数徽章:分类色底 12% 透明 + 分类色文字 */ function CountBadge({ kind, count }: { kind: TreeColorKey; count: number }) { const c = TREE_COLORS[kind]; return ( {count} ); } function isRedisConn(driver?: string) { return (driver || "").toLowerCase() === "redis"; } /** 建连在途 promise:并发调用复用同一次 openSession(06-P1#8),避免重复建会话 */ const sessionInflight = new Map>(); /** 可自动重连的错误:会话失效 / 连接类故障(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(c: SavedConnection, op: (sid: string) => Promise): Promise { 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 { 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(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(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(null); /** 多选键(Cmd/Ctrl+Click 切换),用于批量删除 */ const [selectedKeys, setSelectedKeys] = useState>(new Set()); const toggleKeySelect = (dbName: string, key: string, additive: boolean) => { const id = `${dbName}/${key}`; setSelectedKeys((prev) => { const next = additive ? new Set(prev) : new Set(); if (additive && prev.has(id)) next.delete(id); else next.add(id); return next; }); }; const treeScrollRef = useRef(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(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 | ((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) => { 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 }) => (
onCtx(e, col.name, onRefresh)} > {col.key === "PRI" ? "🔑 " : ""} {col.name} {col.type}
); /** 过滤视图:库名命中显示整库;否则只留名称命中的子对象(表/视图/键等) */ 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) => { const root = treeScrollRef.current; if (!root) return; const rows = Array.from(root.querySelectorAll("[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" ? (
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), }) } > {obj.name}
{renderRedisNodes(dbName, obj.children ?? [])}
) : (
{ 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, }, ) } >
), ); /** 库下的对象行(表/视图/集合/索引/子节点…):分组内外共用;flat 驱动直挂库节点 */ const renderDbObject = (dbName: string, obj: TreeNode, groupId?: string) => obj.kind === "table" || obj.kind === "view" ? ( // 无列概念的对象(ZK 子节点等)渲染为叶子行:单击选中、双击打开,不可展开 !driverCaps(conn?.driver ?? "").columns && obj.kind === "table" ? (
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: "复制名称", }) } > {obj.name}
) : (
{ 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: "复制名称", }, ) } > setSelectedNode(`${dbName}/${obj.name}`)} >
{(obj.children ?? []).map((sub) => sub.kind === "group" && sub.groupId === "columns" ? (
{sub.name}
{(sub.children ?? []).map((col) => ( void expandTable(dbName, obj.name)} /> ))}
) : ( void expandTable(dbName, obj.name)} /> ), )} {obj.loading && (
列…
)}
) ) : ( // 存储过程/函数等:叶子行
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)} > {obj.name}
); return (
{/* 修 O4:过滤框独立一行(脱离 section-label) */} {!isRedisConn(conn?.driver) && (
{ 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 && (
{dbSearchResults !== null && !dbSearching && (dbSearchResults.length === 0 ? (
未找到与「{dbSearchKw}」匹配的表/视图
) : (
{dbSearchResults.map((r) => ( ))}
))}
)}
)} {isRedisConn(conn?.driver) && (
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); } } }} />
{newKey && (
setNewKey({ ...newKey, name: e.target.value })} />
setNewKey({ ...newKey, value: e.target.value })} onKeyDown={(e) => { if (e.key === "Enter" && tree[0]) void createRedisKey(tree[0].name, newKey); }} />
)}
)}
{treeLoading && (
加载中…
)} {!treeLoading && tree.length === 0 && (
{conn ? "点刷新加载库;无权限时会提示错误" : "选择连接后浏览对象"}
)} {visibleTree.map((db) => (
{ 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: "复制名称", }) } > setSelectedNode(db.name)} > {db.name} {db.loading && …}
{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 && (
空
)} ) : (db.children ?? emptyGroups()).map((g) => (
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" }) } > {g.name}
{(g.children ?? []).length === 0 && g.loaded && (
空
)} {(g.children ?? []).map((obj) => renderDbObject(db.name, obj, g.groupId))}
))} {db.loaded && (db.children ?? []).length === 0 && (
空库或无权限
)}
))} {f && visibleTree.length === 0 && !treeLoading && (
没有匹配「{treeFilter.trim()}」的对象;未展开的库只匹配库名。
{tree.some((db) => !db.loaded) && ( )}
)}
{ctx && (
e.stopPropagation()} > {/* 库 P0 */} {ctx.kind === "database" && ( <> {ctx.onNewQuery && ( )} {ctx.onBrowse && ( )} {ctx.onSetDefaultDb ? ( ) : ( )}
{ctx.onDropDatabase ? ( ) : ( )} )} {/* 表/视图 P0 */} {(ctx.kind === "table" || ctx.kind === "view") && ( <> {ctx.onQueryData && ( )} {ctx.onDdl && ( )} {(ctx.onTruncate || ctx.onDropTable) &&
} {ctx.onTruncate && ( )} {ctx.onDropTable && ( )} )} {/* Redis 键 P0 */} {ctx.kind === "key" && ( <> {ctx.onView && ( )} {ctx.onDelete && ( <>
)} )} {ctx.kind === "folder" && ( <> {ctx.onFilterPrefix && ( )} {ctx.onNewKey && ( )} {ctx.onDeleteFolder && ( )} )} {/* 分组等兜底:刷新 + 复制 */} {(!ctx.kind || ctx.kind === "group") && ( <> {ctx.onNewKey && ( )} )}
)} {/* R2-F3:DDL 查看弹窗 */} {ddlTarget && conn && ( setDdlTarget(null)} /> )}
); } async function listSchemaObjects( conn: SavedConnection, sid: string, db: string, kind: "views" | "procedures" | "functions", ): Promise { 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); }