import { useEffect, useMemo, useRef, useState, type MouseEvent as ReactMouseEvent, type KeyboardEvent, type ClipboardEvent } from "react"; import { execSql } from "./api"; import { ensureSession } from "./ObjectTree"; import { rowsToCSV, rowsToInsert, rowsToTSV, buildUpdateSql, buildDeleteSql, parseClipboardGrid, isWritableDriver } from "./sql"; import { SchemaPane } from "./SchemaPane"; import { useWorkspaceStore } from "./store"; const DISPLAY_CAP = 2000; function formatCell(v: unknown) { if (v == null) return NULL; if (typeof v === "object") return JSON.stringify(v); const s = String(v); if (s.length > 120) return s.slice(0, 120) + "…"; return s; } async function copyText(text: string) { await navigator.clipboard.writeText(text); } function cmpVal(a: unknown, b: unknown): number { if (a == null && b == null) return 0; if (a == null) return -1; if (b == null) return 1; if (typeof a === "number" && typeof b === "number") return a - b; const na = Number(a); const nb = Number(b); if (!Number.isNaN(na) && !Number.isNaN(nb) && String(a).trim() !== "" && String(b).trim() !== "") { return na - nb; } return String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: "base" }); } export function ResultPane({ tableHint }: { tableHint?: string }) { const result = useWorkspaceStore((s) => s.result); const resultSets = useWorkspaceStore((s) => s.resultSets); const activeResultSetId = useWorkspaceStore((s) => s.activeResultSetId); const resultTab = useWorkspaceStore((s) => s.resultTab); const explainText = useWorkspaceStore((s) => s.explainText); 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 set = useWorkspaceStore((s) => s.set); const newQueryTab = useWorkspaceStore((s) => s.newQueryTab); const askConfirm = useWorkspaceStore((s) => s.askConfirm); const conn = connections.find((c) => c.id === activeConnectionId); const writable = isWritableDriver(conn?.driver); const canEdit = writable && !!resultMeta?.table && (resultMeta.pkCols?.length ?? 0) > 0; const [sortCol, setSortCol] = useState(null); const [sortDir, setSortDir] = useState<"asc" | "desc" | null>(null); const [colWidths, setColWidths] = useState>({}); const [selectedIdx, setSelectedIdx] = useState(null); const [selectedCol, setSelectedCol] = useState(null); const [edit, setEdit] = useState<{ row: number; col: string; value: string; original: string } | null>(null); const dragRef = useRef<{ col: string; startX: number; startW: number } | null>(null); const editRef = useRef(edit); editRef.current = edit; useEffect(() => { setSortCol(null); setSortDir(null); setSelectedIdx(null); setSelectedCol(null); setColWidths({}); setEdit(null); }, [result]); // discard pending edit when switching tabs useEffect(() => { if (resultTab !== "data" && editRef.current) { notify({ kind: "info", title: "已丢弃未提交编辑", message: "切换标签时未保存的单元格改动已丢弃。", mode: "toast", durationMs: 1800 }); setEdit(null); } }, [resultTab, notify]); const sortedRows = useMemo(() => { if (!result || result.error) return []; let rows = result.rows; if (sortCol && sortDir) { rows = [...rows].sort((ra, rb) => { const c = cmpVal(ra[sortCol], rb[sortCol]); return sortDir === "asc" ? c : -c; }); } return rows; }, [result, sortCol, sortDir]); const truncatedLocal = sortedRows.length > DISPLAY_CAP; const visibleRows = truncatedLocal ? sortedRows.slice(0, DISPLAY_CAP) : sortedRows; useEffect(() => { if (!result || result.error) return; const parts: string[] = []; parts.push(`${result.rowCount} 行`); if (result.timeMs != null) parts.push(`${result.timeMs} ms`); if (selectedIdx != null) parts.push(`选中第 ${selectedIdx + 1} 行`); if (result.truncated || truncatedLocal) parts.push("已截断"); set("status", parts.join(" · ")); }, [result, selectedIdx, truncatedLocal, set]); const cycleSort = (col: string) => { if (sortCol !== col) { setSortCol(col); setSortDir("asc"); return; } if (sortDir === "asc") setSortDir("desc"); else if (sortDir === "desc") { setSortCol(null); setSortDir(null); } else setSortDir("asc"); }; const onResizeStart = (col: string, e: ReactMouseEvent) => { e.preventDefault(); e.stopPropagation(); const startW = colWidths[col] ?? 140; dragRef.current = { col, startX: e.clientX, startW }; const move = (ev: MouseEvent) => { const d = dragRef.current; if (!d) return; const w = Math.max(64, Math.min(480, d.startW + (ev.clientX - d.startX))); setColWidths((prev) => ({ ...prev, [d.col]: w })); }; const up = () => { dragRef.current = null; window.removeEventListener("mousemove", move); window.removeEventListener("mouseup", up); }; window.addEventListener("mousemove", move); window.addEventListener("mouseup", up); }; const beginEdit = (rowIdx: number, col: string) => { if (!result || result.error) return; if (!writable) { notify({ kind: "warning", title: "只读", message: "当前引擎不支持结果内联写回。", mode: "toast" }); return; } if (!resultMeta?.table) { notify({ kind: "warning", title: "无法编辑", message: "请从对象树打开表后再编辑(需表上下文)。", mode: "toast" }); return; } if (!(resultMeta.pkCols?.length ?? 0)) { notify({ kind: "warning", title: "无法编辑", message: "未识别主键,暂不支持写回。", mode: "toast" }); return; } if (resultMeta.pkCols!.includes(col)) { notify({ kind: "warning", title: "主键", message: "暂不支持直接改主键列。", mode: "toast" }); return; } const row = visibleRows[rowIdx]; const v = row?.[col]; const s = v == null ? "" : String(v); setEdit({ row: rowIdx, col, value: s, original: s }); }; const commitEdit = async () => { if (!edit || !result || !conn || !resultMeta?.table) { setEdit(null); return; } const row = visibleRows[edit.row]; if (!row) { setEdit(null); return; } const oldVal = row[edit.col]; const newRaw = edit.value; const newVal: unknown = newRaw === "" ? null : newRaw; if (String(oldVal ?? "") === String(newVal ?? "")) { setEdit(null); return; } const sql = buildUpdateSql(conn.driver, resultMeta.database, resultMeta.table, resultMeta.pkCols || [], row, edit.col, newVal); if (!sql) { notify({ kind: "warning", title: "无法写回", message: "缺少主键,无法生成 UPDATE。", mode: "toast" }); setEdit(null); return; } const prev = edit; setEdit(null); // optimistic const nextRows = result.rows.map((r, i) => { // map via visible index carefully — use identity on sorted view if (visibleRows[prev.row] !== r && sortedRows[prev.row] !== r) return r; // find by reference in original return r === row ? { ...r, [prev.col]: newVal } : r; }); // safer: update by matching PK const pk = resultMeta.pkCols || []; const patched = result.rows.map((r) => { const same = pk.every((k) => String(r[k] ?? "") === String(row[k] ?? "")); return same ? { ...r, [prev.col]: newVal } : r; }); set("result", { ...result, rows: patched }); try { const sid = await ensureSession(conn); const res = await execSql(conn, sql, { database: resultMeta.database, sessionId: sid }); if (res.error) throw new Error(res.error); set("connState", "ok"); notify({ kind: "success", title: "已写回", message: `${prev.col} 已更新`, mode: "toast", durationMs: 1400 }); } catch (e) { // rollback set("result", { ...result, rows: result.rows }); const msg = e instanceof Error ? e.message : String(e); if (msg.includes("会话") || msg.toLowerCase().includes("session")) set("connState", "down"); notifyError(e, "modal"); } }; const cancelEdit = () => setEdit(null); const pasteGrid = async (e: ClipboardEvent) => { if (edit) return; // let input handle paste if (!result || result.error) return; if (!canEdit) { e.preventDefault(); notify({ kind: "warning", title: "只读", message: "当前结果不可写回,无法粘贴。", mode: "toast" }); return; } if (!conn || !resultMeta?.table) return; const text = e.clipboardData?.getData("text/plain") ?? ""; if (!text) return; e.preventDefault(); const matrix = parseClipboardGrid(text); if (!matrix.length) return; const pk = resultMeta.pkCols || []; const cols = result.columns; const startRow = selectedIdx ?? 0; let startColName = selectedCol; if (!startColName || !cols.includes(startColName)) { startColName = cols.find((c) => !pk.includes(c)) ?? cols[0] ?? null; } if (!startColName) return; let startColIdx = cols.indexOf(startColName); if (startColIdx < 0) startColIdx = 0; type CellChange = { row: Record; col: string; newVal: unknown; oldVal: unknown }; const changes: CellChange[] = []; for (let ri = 0; ri < matrix.length; ri++) { const rowIdx = startRow + ri; if (rowIdx >= visibleRows.length) break; const row = visibleRows[rowIdx]; if (!row) break; const line = matrix[ri]; let colCursor = startColIdx; for (let ci = 0; ci < line.length; ci++) { while (colCursor < cols.length && pk.includes(cols[colCursor])) colCursor++; if (colCursor >= cols.length) break; const col = cols[colCursor]; const cell = line[ci]; // empty → null (match commitEdit); literal NULL already null from parser const newVal: unknown = cell == null || cell === "" ? null : cell; const oldVal = row[col]; if (String(oldVal ?? "") !== String(newVal ?? "")) { changes.push({ row, col, newVal, oldVal }); } colCursor++; } } if (!changes.length) { notify({ kind: "info", title: "粘贴", message: "没有可写入的单元格(可能全是主键或未越界)。", mode: "toast", durationMs: 1600 }); return; } const snapshotRows = result.rows.map((r) => ({ ...r })); // optimistic batch let patched = result.rows; for (const ch of changes) { patched = patched.map((r) => { const same = pk.every((k) => String(r[k] ?? "") === String(ch.row[k] ?? "")); return same ? { ...r, [ch.col]: ch.newVal } : r; }); } set("result", { ...result, rows: patched }); try { const sid = await ensureSession(conn); for (const ch of changes) { const sql = buildUpdateSql(conn.driver, resultMeta.database, resultMeta.table, pk, ch.row, ch.col, ch.newVal); if (!sql) throw new Error("缺少主键,无法生成 UPDATE"); const res = await execSql(conn, sql, { database: resultMeta.database, sessionId: sid }); if (res.error) throw new Error(res.error); } set("connState", "ok"); notify({ kind: "success", title: "已粘贴", message: `已粘贴 ${changes.length} 格`, mode: "toast", durationMs: 1600 }); } catch (err) { set("result", { ...result, rows: snapshotRows }); const msg = err instanceof Error ? err.message : String(err); if (msg.includes("会话") || msg.toLowerCase().includes("session")) set("connState", "down"); notifyError(err, "modal"); } }; const deleteSelectedRow = async () => { if (!canEdit || selectedIdx == null || !result || result.error || !conn || !resultMeta?.table) return; const row = visibleRows[selectedIdx]; if (!row) return; const n = selectedIdx + 1; const ok = await askConfirm({ title: "删除行", message: `确认删除第 ${n} 行?不可恢复。`, okLabel: "删除", }); if (!ok) return; const pk = resultMeta.pkCols || []; const sql = buildDeleteSql(conn.driver, resultMeta.database, resultMeta.table, pk, row); if (!sql) { notify({ kind: "warning", title: "无法删除", message: "缺少主键,无法生成 DELETE。", mode: "toast" }); return; } const snapshot = result.rows; const snapshotCount = result.rowCount; const filtered = result.rows.filter((r) => !pk.every((k) => String(r[k] ?? "") === String(row[k] ?? ""))); set("result", { ...result, rows: filtered, rowCount: Math.max(0, (result.rowCount ?? filtered.length) - 1) }); setSelectedIdx(null); setSelectedCol(null); try { const sid = await ensureSession(conn); const res = await execSql(conn, sql, { database: resultMeta.database, sessionId: sid }); if (res.error) throw new Error(res.error); set("connState", "ok"); notify({ kind: "success", title: "已删除", message: `第 ${n} 行已删除`, mode: "toast", durationMs: 1400 }); } catch (err) { set("result", { ...result, rows: snapshot, rowCount: snapshotCount }); const msg = err instanceof Error ? err.message : String(err); if (msg.includes("会话") || msg.toLowerCase().includes("session")) set("connState", "down"); notifyError(err, "modal"); } }; const onGridKeyDown = (e: KeyboardEvent) => { if (edit) return; if ((e.key === "Delete" || e.key === "Backspace") && canEdit && selectedIdx != null) { const t = e.target as HTMLElement; if (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable) return; e.preventDefault(); void deleteSelectedRow(); } }; const onEditKey = (e: KeyboardEvent) => { // 未提交前 ⌘/Ctrl+Z:恢复进入编辑时的值(不提交、不关编辑) if ((e.metaKey || e.ctrlKey) && !e.altKey && e.key.toLowerCase() === "z" && !e.shiftKey) { e.preventDefault(); e.stopPropagation(); if (!edit) return; if (edit.value === edit.original) return; setEdit({ ...edit, value: edit.original }); return; } if (e.key === "Enter") { e.preventDefault(); void commitEdit(); } else if (e.key === "Escape") { e.preventDefault(); cancelEdit(); } }; const copy = async (kind: "tsv" | "csv" | "insert") => { if (!result || result.error) return; const text = kind === "csv" ? rowsToCSV(result.columns, result.rows) : kind === "insert" ? rowsToInsert(resultMeta?.table || tableHint || "t", result.columns, result.rows) : rowsToTSV(result.columns, result.rows); await copyText(text); notify({ kind: "success", title: "已复制", message: kind === "insert" ? "INSERT 语句" : kind.toUpperCase(), mode: "toast" }); }; const downloadCsv = () => { if (!result || result.error) return; const blob = new Blob(["\ufeff" + rowsToCSV(result.columns, result.rows)], { type: "text/csv;charset=utf-8" }); const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = `opsxdb-${Date.now()}.csv`; a.click(); URL.revokeObjectURL(a.href); }; const emptyResult = result && !result.error && result.rows.length === 0; return (
{resultSets.length > 1 && ( <> | {resultSets.map((rs) => ( ))} )}
{resultTab === "data" && result && !result.error && ( {result.rowCount} 行 · {result.timeMs} ms {selectedIdx != null ? ` · #${selectedIdx + 1}` : ""} {canEdit ? "" : writable ? "" : " · 只读"} )}
{resultTab === "schema" ? ( ) : resultTab === "explain" ? (
{!explainText ? (

执行计划

点工具栏「执行计划」获取(部分引擎可用)。

) : (
              {explainText}
            
)}
) : ( <> {(result?.truncated || truncatedLocal) && (
已截断:界面最多显示 {result?.limit || DISPLAY_CAP} 行 {truncatedLocal && sortedRows.length > DISPLAY_CAP ? `(共 ${sortedRows.length})` : ""}。加 LIMIT 或导出 CSV。
)}
{result?.error && (
{result.error}
)} {!result && (

还没有结果

写一条 SQL,⌘/Ctrl+Enter 运行;或点左侧表/键预览数据。

)} {emptyResult && (

空结果

查询成功,但没有返回行。

)} {result && !result.error && result.rows.length > 0 && (
void pasteGrid(e)} onKeyDown={onGridKeyDown} > {result.columns.map((c) => { const w = colWidths[c] ?? 140; const mark = sortCol === c ? (sortDir === "asc" ? " ↑" : sortDir === "desc" ? " ↓" : "") : ""; return ( ); })} {visibleRows.map((row, i) => ( setSelectedIdx(i)} > {result.columns.map((c) => { const editing = edit && edit.row === i && edit.col === c; const cellSelected = selectedIdx === i && selectedCol === c; return ( ); })} ))}
# cycleSort(c)} title="点击排序:升 → 降 → 取消" > {c} {mark} onResizeStart(c, e)} />
{i + 1} { e.stopPropagation(); setSelectedIdx(i); setSelectedCol(c); }} onDoubleClick={() => beginEdit(i, c)} > {editing ? ( setEdit({ ...edit!, value: e.target.value })} onBlur={() => void commitEdit()} onKeyDown={onEditKey} onClick={(e) => e.stopPropagation()} /> ) : ( formatCell(row[c]) )}
)}
)}
); }