import { useEffect, useMemo, useState } from "react"; import { execSql, getRedisKey } from "./api"; import { ensureSession, withSessionRetry } from "./ObjectTree"; import { OpsModal } from "./OpsModal"; import { displayRedisValue, formatRedisTtl, redisCmd, redisTypeColor, tryPrettyJson, tsLabel, } from "./redis"; import { useWorkspaceStore } from "./store"; type ValueMode = "table" | "json"; /** 表格首屏渲染行数,超出走「加载更多」 */ const INITIAL_VISIBLE = 100; const PAGE_STEP = 200; /** 单元格单行摘要:JSON 压回单行、空白折叠、超长截断 —— 表格保持紧凑,完整 pretty 留给查看弹层 */ function cellSummary(raw: string, pretty: string | null): { text: string; more: boolean } { const s = pretty ? JSON.stringify(JSON.parse(pretty)) : raw; const one = s.replace(/\s+/g, " ").trim(); if (one.length > 160) return { text: `${one.slice(0, 160)}…`, more: true }; return { text: one, more: false }; } type LineEdit = { field: string; value: string; score: string; isNew: boolean; oldField?: string; index?: number; }; async function copyText(text: string) { await navigator.clipboard.writeText(text); } function cellString(v: unknown): string { if (v == null) return ""; return String(v); } export function RedisKeyPane() { const result = useWorkspaceStore((s) => s.result); const resultMeta = useWorkspaceStore((s) => s.resultMeta); const connections = useWorkspaceStore((s) => s.connections); const activeConnectionId = useWorkspaceStore((s) => s.activeConnectionId); const notify = useWorkspaceStore((s) => s.notify); const notifyError = useWorkspaceStore((s) => s.notifyError); const askConfirm = useWorkspaceStore((s) => s.askConfirm); const set = useWorkspaceStore((s) => s.set); const conn = connections.find((c) => c.id === activeConnectionId); const keyName = resultMeta?.table ?? ""; const dbName = resultMeta?.database ?? conn?.database ?? "0"; const type = resultMeta?.redisType || "string"; const [ttlDraft, setTtlDraft] = useState(String(resultMeta?.redisTtl ?? -1)); const [keyDraft, setKeyDraft] = useState(keyName); const [mode, setMode] = useState(type === "string" ? "json" : "table"); const [filter, setFilter] = useState(""); const [busy, setBusy] = useState(false); const [lineEdit, setLineEdit] = useState(null); /** 行内 JSON 放大查看(pretty 全文) */ const [cellViewer, setCellViewer] = useState(null); /** string 的 JSON 视图可编辑草稿(pretty);保存时校验并压缩回写 */ const [prettyDraft, setPrettyDraft] = useState(null); /** 列排序:null=默认序;dir 1 升 / -1 降 */ const [sort, setSort] = useState<{ col: string; dir: 1 | -1 } | null>(null); /** 双击单元格就地编辑:i=行号(filtered 内)、col=列 */ const [inline, setInline] = useState<{ i: number; col: string; draft: string } | null>(null); /** 表格分批渲染:首屏条数与当前已显示条数(宽/大键不全量铺 DOM) */ const [visibleCount, setVisibleCount] = useState(INITIAL_VISIBLE); const [stringDraft, setStringDraft] = useState(() => cellString(result?.rows?.[0]?.value)); useEffect(() => { setKeyDraft(keyName); setTtlDraft(String(resultMeta?.redisTtl ?? -1)); setStringDraft(cellString(result?.rows?.[0]?.value)); setFilter(""); setMode(type === "string" ? "json" : "table"); setVisibleCount(INITIAL_VISIBLE); setSort(null); setInline(null); }, [keyName, type, resultMeta?.redisTtl, result]); const rows = result?.rows ?? []; const filtered = useMemo(() => { const q = filter.trim().toLowerCase(); if (!q) return rows; return rows.filter((r) => Object.values(r).some((v) => String(v ?? "").toLowerCase().includes(q))); }, [rows, filter]); const sorted = useMemo(() => { if (!sort) return filtered; const { col, dir } = sort; return [...filtered].sort((a, b) => { const av = a[col]; const bv = b[col]; if (col === "score") return ((Number(av) || 0) - (Number(bv) || 0)) * dir; return String(av ?? "").localeCompare(String(bv ?? ""), undefined, { numeric: true }) * dir; }); }, [filtered, sort]); const jsonText = useMemo(() => { if (type === "string") { const raw = cellString(rows[0]?.value); return tryPrettyJson(raw) || raw; } if (type === "hash") { const obj: Record = {}; for (const r of rows) { const f = cellString(r.field); const v = r.value; const parsed = tryPrettyJson(v); obj[f] = parsed ? JSON.parse(parsed) : v; } return JSON.stringify(obj, null, 2); } return JSON.stringify(rows, null, 2); }, [rows, type]); // string 的 JSON 草稿随数据刷新同步(保存/重载后回到干净态) useEffect(() => { if (type === "string") setPrettyDraft(jsonText); }, [jsonText, type]); const reload = async () => { if (!conn || !keyName) return; setBusy(true); try { const res = await withSessionRetry(conn, (sid) => getRedisKey(conn, dbName, keyName, { 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, }; set("result", qr); set("resultMeta", { database: dbName, table: keyName, pkCols: [], redisType: res.type, redisTtl: res.ttl, }); setTtlDraft(String(res.ttl ?? -1)); setKeyDraft(keyName); if (res.type === "string") setStringDraft(cellString(res.rows?.[0]?.value)); set("status", `${keyName} · ${res.type} · TTL ${formatRedisTtl(res.ttl)}`); } catch (e) { notifyError(e, "toast"); } finally { setBusy(false); } }; const runCmd = async (sql: string, okMsg?: string, opts?: { reload?: boolean }) => { if (!conn) return false; setBusy(true); try { const res = await withSessionRetry(conn, (sid) => execSql(conn, sql, { sessionId: sid, database: dbName })); if (res.error) throw new Error(res.error); if (okMsg) notify({ kind: "success", title: okMsg, message: keyName, mode: "toast", durationMs: 1400 }); if (opts?.reload !== false) await reload(); return true; } catch (e) { notifyError(e, "modal"); return false; } finally { setBusy(false); } }; const saveTtl = async () => { const n = Number(ttlDraft); if (!Number.isFinite(n)) { notify({ kind: "warning", title: "TTL", message: "请输入秒数,-1 表示永久", mode: "toast" }); return; } if (n < 0) await runCmd(redisCmd("PERSIST", keyName), "已设为永久"); else await runCmd(redisCmd("EXPIRE", keyName, String(Math.floor(n))), "已更新 TTL"); }; const rename = async () => { const next = keyDraft.trim(); if (!next || next === keyName) return; const ok = await runCmd(redisCmd("RENAME", keyName, next), "已重命名"); if (ok) { set("resultMeta", { database: dbName, table: next, pkCols: [], redisType: type, redisTtl: resultMeta?.redisTtl, }); setKeyDraft(next); } }; /** 服务端游标续载:追加行而不是整键替换(hash/set=SCAN 游标;list/zset=偏移) */ const loadMore = async () => { if (!conn || !keyName) return; const cur = result?.nextCursor ?? 0; if (!cur) return; setBusy(true); try { const res = await withSessionRetry(conn, (sid) => getRedisKey(conn, dbName, keyName, { sessionId: sid, limit: result?.limit || 500, cursor: cur })); if (res.error) throw new Error(res.error); const merged = [...(result?.rows ?? []), ...(res.rows ?? [])]; set("result", { columns: result?.columns ?? res.columns ?? [], rows: merged, rowCount: merged.length, timeMs: result?.timeMs ?? res.timeMs ?? 0, truncated: res.truncated, nextCursor: res.nextCursor, limit: result?.limit ?? res.limit, }); setVisibleCount((v) => v + PAGE_STEP); } catch (e) { notifyError(e, "toast"); } finally { setBusy(false); } }; /** 双击单元格就地编辑提交:按类型/列生成与「编辑」弹窗一致的命令 */ const commitInline = async (row: Record, col: string, draft: string) => { let sql = ""; if (type === "hash") { if (col === "field") { const old = cellString(row.field); if (!draft.trim()) { notify({ kind: "warning", title: "Hash", message: "Field 必填", mode: "toast" }); return; } if (draft !== old) sql = `${redisCmd("HDEL", keyName, old)}\n${redisCmd("HSET", keyName, draft, cellString(row.value))}`; else return; } else { if (draft === cellString(row.value)) return; sql = redisCmd("HSET", keyName, cellString(row.field), draft); } } else if (type === "zset") { if (col === "member") { const old = cellString(row.member); if (draft === old) return; sql = `${redisCmd("ZREM", keyName, old)}\n${redisCmd("ZADD", keyName, cellString(row.score), draft)}`; } else { if (String(draft) === cellString(row.score)) return; if (Number.isNaN(Number(draft))) { notify({ kind: "warning", title: "Score", message: "必须是数字", mode: "toast" }); return; } sql = redisCmd("ZADD", keyName, String(draft), cellString(row.member)); } } else if (type === "set") { const old = cellString(row.member ?? row.value); if (draft === old) return; sql = `${redisCmd("SREM", keyName, old)}\n${redisCmd("SADD", keyName, draft)}`; } else if (type === "list") { if (draft === cellString(row.value)) return; sql = redisCmd("LSET", keyName, cellString(row.index ?? 0), draft); } if (!sql) return; setInline(null); for (const p of sql.split("\n")) { const ok = await runCmd(p, "已更新"); if (!ok) break; } }; const toggleSort = (col: string) => { setSort((s0) => (!s0 || s0.col !== col ? { col, dir: 1 } : s0.dir === 1 ? { col, dir: -1 } : null)); }; const sortTh = (col: string, label: string) => ( ); /** 就地编辑输入框:Enter 提交 / Esc 取消 */ const InlineInput = ({ row, i, col, value }: { row: Record; i: number; col: string; value: string }) => ( setInline({ i, col, draft: e.target.value })} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); void commitInline(row, col, inline?.draft ?? value); } else if (e.key === "Escape") setInline(null); }} onBlur={() => setInline(null)} /> ); const saveString = async () => { let val = stringDraft; if (type === "string" && mode === "json") { // JSON 视图直接编辑:保存前校验并压回单行,避免把 pretty 缩进写进 Redis const draft = prettyDraft ?? jsonText; try { val = JSON.stringify(JSON.parse(draft)); } catch (e) { notify({ kind: "error", title: "JSON 解析失败", message: e instanceof Error ? e.message : String(e), mode: "toast", durationMs: 2600, }); return; } } await runCmd(redisCmd("SET", keyName, val), "已保存"); }; const deleteKey = async () => { const ok = await askConfirm({ title: "删除键", message: `确认删除 Redis 键「${keyName}」?此操作不可恢复。`, okLabel: "删除", danger: true, }); if (!ok) return; const done = await runCmd(redisCmd("DEL", keyName), "已删除", { reload: false }); if (done) { set("result", null); set("resultMeta", { database: dbName, pkCols: [] }); } }; const openAdd = () => { setLineEdit({ field: type === "hash" ? "" : type === "zset" ? "" : "", value: "", score: "0", isNew: true, }); }; const openEditRow = (row: Record, index: number) => { if (type === "hash") { setLineEdit({ field: cellString(row.field), value: cellString(row.value), score: "0", isNew: false, oldField: cellString(row.field), }); return; } if (type === "list") { setLineEdit({ field: "", value: cellString(row.value), score: "0", isNew: false, index }); return; } if (type === "set") { setLineEdit({ field: "", value: cellString(row.member ?? row.value), score: "0", isNew: false, oldField: cellString(row.member ?? row.value), }); return; } if (type === "zset") { setLineEdit({ field: cellString(row.member), value: cellString(row.member), score: cellString(row.score), isNew: false, oldField: cellString(row.member), }); } }; const commitLine = async () => { if (!lineEdit) return; let sql = ""; if (type === "hash") { if (!lineEdit.field.trim()) { notify({ kind: "warning", title: "Hash", message: "Field 必填", mode: "toast" }); return; } if (!lineEdit.isNew && lineEdit.oldField && lineEdit.oldField !== lineEdit.field) { sql = `${redisCmd("HDEL", keyName, lineEdit.oldField)}\n${redisCmd("HSET", keyName, lineEdit.field, lineEdit.value)}`; } else { sql = redisCmd("HSET", keyName, lineEdit.field, lineEdit.value); } } else if (type === "list") { if (lineEdit.isNew) sql = redisCmd("RPUSH", keyName, lineEdit.value); else sql = redisCmd("LSET", keyName, String(lineEdit.index ?? 0), lineEdit.value); } else if (type === "set") { if (lineEdit.isNew) sql = redisCmd("SADD", keyName, lineEdit.value); else if (lineEdit.oldField && lineEdit.oldField !== lineEdit.value) { sql = `${redisCmd("SREM", keyName, lineEdit.oldField)}\n${redisCmd("SADD", keyName, lineEdit.value)}`; } else sql = redisCmd("SADD", keyName, lineEdit.value); } else if (type === "zset") { const member = lineEdit.value || lineEdit.field; if (lineEdit.isNew) sql = redisCmd("ZADD", keyName, lineEdit.score || "0", member); else if (lineEdit.oldField && lineEdit.oldField !== member) { sql = `${redisCmd("ZREM", keyName, lineEdit.oldField)}\n${redisCmd("ZADD", keyName, lineEdit.score || "0", member)}`; } else sql = redisCmd("ZADD", keyName, lineEdit.score || "0", member); } if (!sql) return; const parts = sql.split("\n"); setLineEdit(null); for (const p of parts) { const ok = await runCmd(p, lineEdit.isNew ? "已新增" : "已更新"); if (!ok) break; } }; const deleteLine = async (row: Record, index: number) => { const ok = await askConfirm({ title: "删除元素", message: "确认删除这一行?", okLabel: "删除", danger: true, }); if (!ok) return; if (type === "hash") await runCmd(redisCmd("HDEL", keyName, cellString(row.field)), "已删除字段"); else if (type === "set") await runCmd(redisCmd("SREM", keyName, cellString(row.member ?? row.value)), "已删除成员"); else if (type === "zset") await runCmd(redisCmd("ZREM", keyName, cellString(row.member)), "已删除成员"); else if (type === "list") { const val = cellString(row.value); await runCmd(redisCmd("LREM", keyName, "1", val), "已删除元素"); void index; } }; /** 单行重建命令:复制后可直接在别处回放(zset→ZADD score member 等) */ const rowCommand = (row: Record): string => { switch (type) { case "hash": return redisCmd("HSET", keyName, cellString(row.field), cellString(row.value)); case "zset": return redisCmd("ZADD", keyName, cellString(row.score), cellString(row.member)); case "set": return redisCmd("SADD", keyName, cellString(row.member ?? row.value)); case "list": return redisCmd("RPUSH", keyName, cellString(row.value)); default: return cellString(row.value ?? row.member ?? ""); } }; const dumpClipboard = async () => { const lines: string[] = []; if (type === "string") lines.push(redisCmd("SET", keyName, cellString(rows[0]?.value))); else if (type === "hash") { for (const r of rows) lines.push(redisCmd("HSET", keyName, cellString(r.field), cellString(r.value))); } else if (type === "list") { for (const r of rows) lines.push(redisCmd("RPUSH", keyName, cellString(r.value))); } else if (type === "set") { for (const r of rows) lines.push(redisCmd("SADD", keyName, cellString(r.member ?? r.value))); } else if (type === "zset") { for (const r of rows) lines.push(redisCmd("ZADD", keyName, cellString(r.score), cellString(r.member))); } const ttl = resultMeta?.redisTtl ?? -1; if (ttl >= 0) lines.push(redisCmd("EXPIRE", keyName, String(ttl))); await copyText(lines.join("\n") || redisCmd("GET", keyName)); notify({ kind: "success", title: "已复制命令", message: `${lines.length} 条`, mode: "toast", durationMs: 1400 }); }; const addLabel = type === "hash" ? "新增字段" : type === "list" ? "追加元素" : type === "zset" ? "新增成员" : type === "set" ? "新增成员" : null; const colTitle = type === "hash" ? `ID(共 ${filtered.length})` : type === "list" ? "Index" : type === "zset" ? "#" : "#"; return (
{type} setKeyDraft(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") void rename(); }} />
TTL setTtlDraft(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") void saveTtl(); }} />
{addLabel && ( )} {type === "string" && ( )}
{type !== "string" && ( setFilter(e.target.value)} /> )}
{result?.truncated && (
{(result?.nextCursor ?? 0) > 0 ? `已分页加载前 ${rows.length} 行,底部可继续加载。` : `已截断,最多 ${result.limit || 500} 行。用过滤或命令缩小范围。`}
)}
{type === "string" || mode === "json" ? ( type === "string" && mode === "table" ? (