Files
opsxdb/src/ObjectTree.tsx
T
logan 2c0bfafd8d 同步完整源码至远程:扁平化重构现状 + 未入库源文件
- 补入库:server/internal/db/dump.go、redis 分页/扫描预算测试、opsxdb-mcp main_test、
  packages/opsxdb-mcp package-lock、docs 审计与对比文档等 37 个新文件
- 含结构扁平化进行中的 80 处修改与 155 处旧结构删除(server/ src/ src-tauri/ 为当前有效源)
- 构建产物(bin/target/dist)与签名私钥仍按规则排除
2026-10-16 03:47:33 +08:00

2162 lines
90 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type ReactNode } from "react";
import {
deleteRedisKey,
deleteRedisPattern,
downloadBlob,
dumpTable,
exportData,
execSql,
getRedisKey,
getDdl,
listColumns,
listRedisKeys,
listTables,
loadTree,
openSession,
searchObjects,
type DumpMode,
type RedisKeyInfo,
type SearchObjectHit,
} from "./api";
import { TableMetadataTree } from "./TableMetadataTree";
import { DdlModal } from "./DdlModal";
import { TableDesigner } from "./TableDesigner";
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, quoteIdent as quoteSqlIdent, relationParts } from "./sql";
import { connectionEndpoint } from "./contextActions";
import { askInput } from "./dialogs";
import { useWorkspaceStore, type SavedConnection, type TreeNode } from "./store";
import { editorDatabase, isRedisCluster } from "./redisContext";
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;
onCopyDdl?: () => void;
/** 表/视图:查看数据 */
onQueryData?: () => void;
onQueryLimit?: () => void;
qualifiedName?: string;
/** 表/视图:生成 SQL 到独立查询标签 */
onGen?: (kind: GenSqlKind) => void;
/** 表:设计表(设计器 v1:字段网格 + CREATE/ALTER 预览) */
onDesign?: () => void;
/** 表:转储 SQL 文件(仅结构/仅数据/结构+数据) */
onDump?: (mode: DumpMode) => void;
onExport?: (format: "csv" | "json") => void;
/** 库:新建表(设计器空表) */
onNewTable?: () => 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" | "ddl";
/** 标识符按方言加引号(Oracle 裸名) */
function quoteIdent(driver: string | undefined, ident: string): string {
return quoteSqlIdent(driver, ident);
}
/** 库.表 全限定(SQLite 为单文件无库级) */
function qualTable(driver: string | undefined, db: string, table: string): string {
return relationParts(driver, db, table).map((part) => quoteIdent(driver, part)).join(".");
}
/** 生成 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 条件 */"};`;
}
const columns = names.length ? names.map(q).join(", ") : "*";
const d = (driver ?? "").toLowerCase();
if (d === "sqlserver" || d === "mssql") return `SELECT TOP 100 ${columns} FROM ${qt};`;
if (d === "oracle") return `SELECT ${columns} FROM ${qt} FETCH FIRST 100 ROWS ONLY;`;
return `SELECT ${columns} FROM ${qt} LIMIT 100;`;
}
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 [redisDbDraft, setRedisDbDraft] = useState<Record<string, string>>({});
const [redisDbSearch, setRedisDbSearch] = useState<Record<string, string>>({});
const [redisKeyCursors, setRedisKeyCursors] = useState<Record<string, string>>({});
const redisLoadedKeys = useRef<Record<string, RedisKeyInfo[]>>({});
const redisRequests = useRef<Record<string, number>>({});
const setKeyFilter = (v: string) => useWorkspaceStore.getState().set("sidebarFilter", v);
/** Redis 类型过滤:全部 / string / hash / list / set / zset / stream(前端过滤) */
const redisTypeFilter = useWorkspaceStore((s) => s.sidebarTypeFilter);
const [newKey, setNewKey] = useState<{ name: string; type: string; value: string; db?: 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);
/** 表设计器目标(table 缺省 = 新建表) */
const [designerTarget, setDesignerTarget] = 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 selectedRedisDb = editorDatabase(conn, tabs.find(t => t.id === activeTabId));
useEffect(() => {
setRedisDbDraft({});
setRedisDbSearch({});
setRedisKeyCursors({});
redisLoadedKeys.current = {};
}, [activeId]);
useEffect(() => {
// 底部全局搜索发起新查询时,清除当前库独立搜索的覆盖。
setRedisDbSearch({});
setRedisDbDraft({});
}, [keyFilter]);
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);
}
};
const treeEpoch = useWorkspaceStore((s) => s.treeEpoch);
useEffect(() => {
if (activeId) void refresh(activeId);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeId, treeEpoch]);
useEffect(() => {
// 底部搜索/刷新可重复提交相同关键词;每次都以该关键词为准。
setRedisDbSearch({});
setRedisDbDraft({});
}, [treeEpoch]);
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, requestedPattern?: string, nextCursor?: string) => {
if (!conn) return;
const node = useWorkspaceStore.getState().tree.find((n) => n.name === dbName);
if ((node?.loaded && !nextCursor) || node?.loading) return;
const redis = isRedisConn(conn.driver);
const request = (redisRequests.current[dbName] ?? 0) + 1;
redisRequests.current[dbName] = request;
patchDb(dbName, {
loading: true,
children: redis && nextCursor ? node?.children : redis ? [] : emptyGroups(),
});
try {
if (redis) {
// 无通配符由服务端按 *关键词* 扫描,精确键单独置顶;有通配符按 glob 扫。
const raw = (requestedPattern ?? redisDbSearch[dbName] ?? keyFilter).trim();
const pat = raw || undefined;
const res = await withSessionRetry(conn, (sid2) => listRedisKeys(conn, dbName, { sessionId: sid2, pattern: pat, keyCursor: nextCursor }));
if (useWorkspaceStore.getState().activeConnectionId !== conn.id || redisRequests.current[dbName] !== request) return;
if (res.error) throw new Error(res.error);
const page = (res.keys ?? []).map((k) => ({
name: k.name,
type: k.type,
ttl: k.ttl,
exact: k.exact || (res.exactHit && k.name === raw) || undefined,
}));
const merged = new Map<string, RedisKeyInfo>();
if (nextCursor) for (const key of redisLoadedKeys.current[dbName] ?? []) merged.set(key.name, key);
for (const key of page) merged.set(key.name, key);
const keys = [...merged.values()];
redisLoadedKeys.current[dbName] = keys;
setRedisKeyCursors(prev => ({ ...prev, [dbName]: res.nextCursor ?? "" }));
const sep = conn.keySeparator || ":";
patchDb(dbName, {
loading: false,
loaded: true,
children: buildRedisKeyTree(keys, sep),
});
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) {
if (redis && (redisRequests.current[dbName] !== request || useWorkspaceStore.getState().activeConnectionId !== conn.id)) return;
patchDb(dbName, {
loading: false,
loaded: !!nextCursor,
children: nextCursor ? node?.children : isRedisConn(conn.driver) ? [] : emptyGroups(),
});
notifyError(e, "toast");
}
};
const searchRedisDb = (dbName: string) => {
const raw = (redisDbDraft[dbName] ?? "").trim();
setRedisDbSearch(prev => ({ ...prev, [dbName]: raw }));
patchDb(dbName, { loaded: false, loading: false, children: undefined });
void expandDb(dbName, raw);
};
// Redis:刷新后自动展开库以灌入 SCAN/精确结果(details open 属性不保证触发 onToggle)
useEffect(() => {
if (!conn || !isRedisConn(conn.driver) || treeLoading) return;
const shouldExpand = keyFilter.trim().length > 0 || tree.length === 1;
if (!shouldExpand) return;
const currentDb = selectedRedisDb;
for (const db of tree) {
if (db.name !== currentDb) continue;
if (db.kind === "database" && !db.loaded && !db.loading) void expandDb(db.name);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [conn?.id, tree, treeLoading, keyFilter, selectedRedisDb]);
const expandTable = async (dbName: string, table: string, groupId = "tables") => {
if (!conn) return;
const dbNode = useWorkspaceStore.getState().tree.find((n) => n.name === dbName);
const tablesGroup = dbNode?.children?.find((n) => n.groupId === groupId);
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 !== groupId
? 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,
nextCursor: res.nextCursor,
};
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, limit = 100) => {
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);
const samePreview = tab?.connectionId === conn.id && tab.database === dbName && tab.sql === sql &&
(tab.title === table || tab.title === `${table}@${dbName}`);
if (!samePreview && (tab?.dirty || !!tab?.sql.trim())) {
// 当前标签有内容时新开,避免覆盖其他表预览或 SQL 草稿。
newQueryTab(dbName);
}
useWorkspaceStore.getState().updateActiveSql(sql);
useWorkspaceStore.getState().setActiveTabTitle(`${table}@${dbName}`);
useWorkspaceStore.getState().setTabDatabase(dbName);
set("running", true);
set("status", `打开 ${table}…`);
try {
const res = await withSessionRetry(conn, (sid) => execSql(conn, sql, { database: dbName, sessionId: sid, limit }));
// 挂为结果集(C5):树打开的表预览同样可翻页
setResultSets([
{ id: crypto.randomUUID(), label: table, sql, result: res, page: 0, pageSize: limit, database: dbName,
source: { connectionId: conn.id, driver: conn.driver, database: dbName, table } },
]);
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) => {
try {
await navigator.clipboard.writeText(name);
} catch (e) { notifyError(e, "toast"); }
};
const onCtx = (
e: ReactMouseEvent,
label: string,
onRefresh: () => void,
onSchema?: () => void,
onView?: () => void,
onDelete?: () => void,
extra?: Partial<
Pick<
CtxMenu,
| "kind"
| "onQueryData"
| "onQueryLimit"
| "qualifiedName"
| "onGen"
| "onDesign"
| "onDump"
| "onExport"
| "onNewTable"
| "onTruncate"
| "onDropTable"
| "onNewQuery"
| "onDdl"
| "onCopyDdl"
| "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;
if (kind === "ddl") {
try {
const res = await withSessionRetry(conn, sid => getDdl(conn, table, { database: dbName, sessionId: sid }));
if (res.error || !res.ddl) throw new Error(res.error || "未返回 DDL");
if (useWorkspaceStore.getState().activeConnectionId !== conn.id) return;
const st = useWorkspaceStore.getState();
st.newQueryTab(dbName);
st.updateActiveSql(res.ddl);
st.setActiveTabTitle(`DDL · ${table}`);
} catch (error) { notifyError(error, "toast"); }
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 {
/* 拿不到列也能生成骨架模板 */
}
}
// 模板进入独立标签,避免覆盖现有草稿或异步加载期间切换到的查询。
if (useWorkspaceStore.getState().activeConnectionId !== conn.id) return;
st.newQueryTab(dbName);
st.updateActiveSql(buildGenSql(kind, conn.driver, dbName, table, cols));
st.setActiveTabTitle(`${kind.toUpperCase()} · ${table}`);
set("status", `已生成 ${kind.toUpperCase()} · ${dbName}.${table}`);
};
/** 截断 / 删除表:二次确认后执行并刷新库 */
/** 表右键「转储 SQL 文件」:服务端流式生成 .sql 后浏览器下载 */
const dumpTableSql = async (dbName: string, table: string, mode: DumpMode) => {
if (!conn) return;
try {
const { blob, filename } = await withSessionRetry(conn, (sid) =>
dumpTable(conn, { table, database: dbName, mode, sessionId: sid }),
);
downloadBlob(blob, filename || `dump-${table}.sql`);
const label = mode === "structure" ? "仅结构" : mode === "data" ? "仅数据" : "结构和数据";
notify({ kind: "success", title: "转储已下载", message: `${label} · ${dbName}.${table}`, mode: "toast", durationMs: 1800, feedback: "visible" });
} catch (e) {
notifyError(e, "toast");
}
};
const exportTableData = async (dbName: string, table: string, format: "csv" | "json") => {
if (!conn) return;
const input = await askInput({
title: `导出 ${dbName}.${table} · ${format.toUpperCase()}`,
label: "最多导出行数(1–500000;达到上限时文件可能不完整)",
defaultValue: "100000",
placeholder: "100000",
});
if (input == null) return;
const limit = Number(input.trim());
if (!/^\d+$/.test(input.trim()) || limit < 1 || limit > 500000) {
notify({ kind: "warning", title: "行数无效", message: "请输入 1–500000 之间的整数", mode: "toast" });
return;
}
try {
const { blob, filename } = await withSessionRetry(conn, sid => exportData(conn, { database: dbName, table, format, limit, sessionId: sid }));
downloadBlob(blob, filename || `${table}.${format}`);
notify({ kind: "success", title: "导出已下载", message: `上限 ${limit} 行;若表超过上限,文件仅包含前 ${limit} 行`, mode: "toast", feedback: "visible" });
} catch (error) { notifyError(error, "modal"); }
};
/** 设计器应用后刷新该库对象(结构与表清单都可能变化) */
const refreshDbAfterDesign = (dbName: string) => {
patchDb(dbName, { loaded: false, loading: false, children: undefined });
void expandDb(dbName);
};
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);
// Redis:侧栏关键字由 listRedisKeys + filterRedisTree 负责;勿按库名/顶层 folder 名剪空(否则精确键永远不显示)
const visibleTree = f && !isRedisConn(conn?.driver)
? 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: () => {
const pattern = `${obj.fullName || obj.name}*`;
setKeyFilter(pattern);
useWorkspaceStore.getState().requestTreeRefresh();
},
onNewKey: () => setNewKey({ name: obj.fullName || "", type: "string", value: "", db: dbName }),
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>
{obj.exact && (
<span
className="ml-1 shrink-0 rounded px-1 text-[10px] font-medium"
style={{ color: "var(--ops-accent)", background: "color-mix(in srgb, var(--ops-accent) 18%, transparent)" }}
>
精确
</span>
)}
<span className="ml-1 font-normal opacity-45">
{obj.type || "?"}
{obj.ttl != null && obj.ttl >= 0 ? ` · ${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.target === e.currentTarget && (e.currentTarget as HTMLDetailsElement).open) {
void expandTable(dbName, obj.name, obj.kind === "view" ? "views" : "tables");
}
}}
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),
onQueryLimit: () => { void (async () => {
const value = await askInput({ title: "打开前 N 行", label: "行数(1–10000)", defaultValue: "100", placeholder: "100" });
if (value == null) return;
if (!/^\d+$/.test(value.trim()) || Number(value) < 1 || Number(value) > 10000) {
notify({ kind: "warning", title: "行数无效", message: "请输入 1–10000 之间的整数", mode: "toast" });
return;
}
if (useWorkspaceStore.getState().activeConnectionId === conn?.id) await putSelectSql(dbName, obj.name, Number(value));
})(); },
qualifiedName: qualTable(conn?.driver, dbName, obj.name),
onGen: (k) => void genTableSql(dbName, obj.name, k),
onDesign:
obj.kind === "table" && driverCaps(conn?.driver ?? "").ddl
? () => setDesignerTarget({ db: dbName, table: obj.name })
: undefined,
onDump:
obj.kind === "table" && driverCaps(conn?.driver ?? "").ddl
? (mode) => void dumpTableSql(dbName, obj.name, mode)
: undefined,
onExport: obj.kind === "table" && driverCaps(conn?.driver ?? "").ddl
? format => void exportTableData(dbName, obj.name, format)
: undefined,
// DDL/截断/删除按驱动能力开关:ES/Influx/Mongo/ZK 等只读树不提供
onDdl: driverCaps(conn?.driver ?? "").ddl
? () => {
set("focusedRelation", { database: dbName, table: obj.name });
setDdlTarget({ db: dbName, table: obj.name });
}
: undefined,
onCopyDdl: driverCaps(conn?.driver ?? "").ddl && conn ? () => {
void (async () => {
try {
const response = await withSessionRetry(conn, (sessionId) => getDdl(conn, obj.name, { database: dbName, sessionId }));
if (response.error) throw new Error(response.error);
if (!response.ddl) throw new Error("未返回表结构定义");
await copyName(response.ddl);
} catch (error) { notifyError(error, "toast"); }
})();
} : 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.loaded && conn && obj.kind === "table" && driverCaps(conn.driver).ddl && <TableMetadataTree conn={conn} database={dbName} table={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>
)}
{/* P1: Redis 顶栏已迁到 SidebarFilterBar(单搜索+↻);新建键表单由库/文件夹右键唤起 */}
{isRedisConn(conn?.driver) && newKey && (
<div className="mx-1 mb-1 space-y-1 rounded border p-1.5" style={{ borderColor: "var(--ops-border)" }}>
<div className="text-[10px]" style={{ color: "var(--ops-muted)" }}>
新建键{newKey.db != null && newKey.db !== "" ? ` · DB${newKey.db}` : ""}
</div>
<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 })}
autoFocus
/>
</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") {
const dbName = newKey.db || tree.find((n) => n.kind === "database")?.name || "0";
void createRedisKey(dbName, 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 = newKey.db || tree.find((n) => n.kind === "database")?.name || "0";
void createRedisKey(dbName, newKey);
}}
>
创建
</button>
</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) => conn && isRedisConn(conn.driver) && isRedisCluster(conn) ? (
<div key={db.name}>
<div className="flex items-center gap-1 px-1 py-1">
<input className="ops-input h-6 min-w-0 flex-1 text-[11px]" aria-label="搜索集群 Key"
placeholder="搜索集群 Key(自动包含匹配)"
value={redisDbDraft[db.name] ?? ""}
onChange={e => setRedisDbDraft(prev => ({ ...prev, [db.name]: e.target.value }))}
onKeyDown={e => { if (e.key === "Enter" && !e.nativeEvent.isComposing) searchRedisDb(db.name); }} />
<button type="button" className="ops-btn-ghost text-[11px]" onClick={() => searchRedisDb(db.name)}>搜索</button>
</div>
{db.loading && <div className="ops-tree-meta-note">加载 Key…</div>}
{renderRedisNodes(db.name, filterRedisTree(db.children ?? [], /[*?[\]]/.test((redisDbSearch[db.name] ?? keyFilter).trim()) ? "" : redisDbSearch[db.name] ?? keyFilter, redisTypeFilter))}
{redisKeyCursors[db.name] && <button type="button" className="ops-btn-ghost mx-2 my-1 text-[11px]" disabled={db.loading}
title="继续从当前游标扫描,不会一次读取全部 Key"
onClick={() => void expandDb(db.name, redisDbSearch[db.name] ?? keyFilter, redisKeyCursors[db.name])}>
{db.loading ? "加载中…" : "加载更多"}
</button>}
{db.loaded && !db.children?.length && <div className="ops-tree-meta-note">未找到 Key;搜索有扫描上限,可输入更明确的名称</div>}
</div>
) : (
<details
key={db.name}
className="mb-px"
open={isRedisConn(conn?.driver) ? db.name === selectedRedisDb || !!redisDbSearch[db.name] : f ? true : undefined}
onToggle={(e) => {
if (e.target === e.currentTarget && (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(db.name),
onNewTable: !isRedisConn(conn?.driver) && driverCaps(conn?.driver ?? "").ddl
? () => setDesignerTarget({ db: db.name })
: undefined,
onNewKey: isRedisConn(conn?.driver)
? () => setNewKey({ name: "", type: "string", value: "", db: db.name })
: 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) && <div className="flex items-center gap-1 px-1 py-1">
<input className="ops-input h-6 min-w-0 flex-1 text-[11px]" aria-label={`搜索 DB${db.name} 的 Key`}
placeholder={`搜索 DB${db.name} Key(自动包含匹配)`}
value={redisDbDraft[db.name] ?? ""}
onChange={e => setRedisDbDraft(prev => ({ ...prev, [db.name]: e.target.value }))}
onKeyDown={e => { if (e.key === "Enter" && !e.nativeEvent.isComposing) searchRedisDb(db.name); }} />
<button type="button" className="ops-btn-ghost text-[11px]" onClick={() => searchRedisDb(db.name)}>搜索</button>
</div>}
{isRedisConn(conn?.driver) ? (<>
{/* 服务端已按 pattern SCAN/精确命中;通配符查询不再做字面子串过滤。 */}
{renderRedisNodes(
db.name,
filterRedisTree(
db.children ?? [],
/[*?[\]]/.test((redisDbSearch[db.name] ?? keyFilter).trim()) ? "" : redisDbSearch[db.name] ?? keyFilter,
redisTypeFilter,
),
)}
{redisKeyCursors[db.name] && <button type="button" className="ops-btn-ghost mx-1 my-1 text-[11px]" disabled={db.loading} title="继续从当前游标扫描,不会一次读取全部 Key"
onClick={() => void expandDb(db.name, redisDbSearch[db.name] ?? keyFilter, redisKeyCursors[db.name])}>
{db.loading ? "加载中…" : "加载更多"}
</button>}
</>
) : 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 && !isRedisConn(conn?.driver) && 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>
)}
{f && isRedisConn(conn?.driver) && !treeLoading && tree.length > 0 &&
tree.every((db) => db.loaded && filterRedisTree(db.children ?? [], /[*?[\]]/.test(keyFilter.trim()) ? "" : keyFilter, redisTypeFilter).length === 0) && (
<div className="px-1.5 py-1 text-[11px]" style={{ color: "var(--ops-muted)" }}>
没有匹配「{treeFilter.trim()}」的键
</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.onNewKey && (
<button type="button" onClick={() => { ctx.onNewKey?.(); setCtx(null); }}>新建键</button>
)}
{ctx.onNewQuery && (
<button type="button" onClick={() => { ctx.onNewQuery?.(); setCtx(null); }}>新建查询</button>
)}
{ctx.onNewTable && (
<button type="button" onClick={() => { ctx.onNewTable?.(); 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>
{conn && <button type="button" onClick={() => { void copyName(connectionEndpoint(conn)); setCtx(null); }}>复制主机端口</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.onQueryLimit && <button type="button" onClick={() => { ctx.onQueryLimit?.(); setCtx(null); }}>打开前 N 行…</button>}
{ctx.onSchema && <button type="button" onClick={() => { ctx.onSchema?.(); setCtx(null); }}>查看结构</button>}
{ctx.onDesign && (
<button type="button" onClick={() => { ctx.onDesign?.(); 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>
{ctx.qualifiedName && <button type="button" onClick={() => { void copyName(ctx.qualifiedName!); setCtx(null); }}>复制限定表名(库 / Schema 前缀)</button>}
{ctx.onCopyDdl && <button type="button" onClick={() => { ctx.onCopyDdl?.(); setCtx(null); }}>复制建表 / 视图 SQL</button>}
{ctx.onGen && <>
<div className="ops-ctx-sep" />
<details className="ops-ctx-group"><summary>生成 SQL <span>›</span></summary>
<button type="button" onClick={() => { ctx.onGen?.("select"); setCtx(null); }}>SELECT</button>
{ctx.kind === "table" && <button type="button" onClick={() => { ctx.onGen?.("insert"); setCtx(null); }}>INSERT</button>}
{ctx.kind === "table" && <button type="button" onClick={() => { ctx.onGen?.("update"); setCtx(null); }}>UPDATE</button>}
{ctx.kind === "table" && <button type="button" onClick={() => { ctx.onGen?.("delete"); setCtx(null); }}>DELETE</button>}
{ctx.onDdl && <button type="button" onClick={() => { ctx.onGen?.("ddl"); setCtx(null); }}>DDL</button>}
</details>
</>}
{ctx.onExport && <>
<details className="ops-ctx-group"><summary>导出数据 <span>›</span></summary>
<button type="button" onClick={() => { ctx.onExport?.("csv"); setCtx(null); }}>CSV…</button>
<button type="button" onClick={() => { ctx.onExport?.("json"); setCtx(null); }}>JSON…</button>
</details>
</>}
{ctx.onDump && <>
<details className="ops-ctx-group"><summary>转储 SQL <span>›</span></summary>
<button type="button" onClick={() => { ctx.onDump?.("structure"); setCtx(null); }}>仅结构…</button>
<button type="button" onClick={() => { ctx.onDump?.("data"); setCtx(null); }}>仅数据…</button>
<button type="button" onClick={() => { ctx.onDump?.("structure_data"); setCtx(null); }}>结构和数据…</button>
</details>
</>}
<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)}
/>
)}
{/* 表设计器 v1:新建表 / 设计既有表(字段网格 + DDL 预览) */}
{designerTarget && conn && (
<TableDesigner
conn={conn}
database={designerTarget.db}
table={designerTarget.table}
onClose={() => setDesignerTarget(null)}
onApplied={() => refreshDbAfterDesign(designerTarget.db)}
/>
)}
</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);
}