️ optimize(core): 查询多语句多结果与大表交互/元数据体验优化

- 支持分号多语句拆分(含引号/注释/PG dollar-quote),多结果集 Tab 展示;
  - 支持选中运行;结果 Tab 支持关闭
  - 修复结果区高度自动收缩/最后一行裁剪;切换结果更顺滑(关闭 ink-bar 动画、修复隐藏面板叠加显示)
  - 补齐 PostgreSQL/SQLite 设计表元数据接口;
  - 修复 Kingbase schema/标识符引用导致打开表失败
  - 标签页右键支持关闭其他/关闭左侧/关闭右侧/关闭所有
This commit is contained in:
杨国锋
2026-02-03 22:44:48 +08:00
parent 178de02783
commit 34c494ce51
10 changed files with 1149 additions and 141 deletions

View File

@@ -285,12 +285,12 @@ function App() {
title="拖动调整宽度"
/>
</Sider>
<Content style={{ background: darkMode ? '#141414' : '#fff', overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
<div style={{ flex: 1, overflow: 'hidden' }}>
<TabManager />
</div>
{isLogPanelOpen && (
<LogPanel
<Content style={{ background: darkMode ? '#141414' : '#fff', overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
<div style={{ flex: 1, minHeight: 0, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
<TabManager />
</div>
{isLogPanelOpen && (
<LogPanel
height={logPanelHeight}
onClose={() => setIsLogPanelOpen(false)}
onResizeStart={handleLogResizeStart}
@@ -343,4 +343,4 @@ function App() {
);
}
export default App;
export default App;

View File

@@ -243,26 +243,38 @@ const DataGrid: React.FC<DataGridProps> = ({
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!containerRef.current) return;
let rafId: number;
const el = containerRef.current;
if (!el) return;
let rafId: number | null = null;
const resizeObserver = new ResizeObserver(entries => {
if (rafId !== null) cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(() => {
for (let entry of entries) {
// Use boundingClientRect for more accurate render size (including padding if any)
const height = entry.contentRect.height;
if (height < 50) return;
// Subtract header (~42px) and a buffer
const h = Math.max(100, height - 42);
setTableHeight(h);
}
const target = (entries[0]?.target as HTMLElement | undefined) || containerRef.current;
if (!target) return;
const height = target.getBoundingClientRect().height;
if (!Number.isFinite(height) || height < 50) return;
const headerEl =
(target.querySelector('.ant-table-header') as HTMLElement | null) ||
(target.querySelector('.ant-table-thead') as HTMLElement | null);
const rawHeaderHeight = headerEl ? headerEl.getBoundingClientRect().height : NaN;
const headerHeight =
Number.isFinite(rawHeaderHeight) && rawHeaderHeight >= 24 && rawHeaderHeight <= 120 ? rawHeaderHeight : 42;
// 留一点余量,避免底部(边框/滚动条)遮挡最后一行
const extraBottom = 16;
const nextHeight = Math.max(100, Math.floor(height - headerHeight - extraBottom));
setTableHeight(nextHeight);
});
});
resizeObserver.observe(containerRef.current);
resizeObserver.observe(el);
return () => {
resizeObserver.disconnect();
cancelAnimationFrame(rafId);
if (rafId !== null) cancelAnimationFrame(rafId);
};
}, []);
@@ -727,12 +739,12 @@ const DataGrid: React.FC<DataGridProps> = ({
const enableVirtual = mergedDisplayData.length >= 200;
return (
<div className={gridId} style={{ height: '100%', overflow: 'hidden', padding: 0, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
{/* Toolbar */}
<div style={{ padding: '8px', borderBottom: '1px solid #eee', display: 'flex', gap: 8, alignItems: 'center' }}>
{onReload && <Button icon={<ReloadOutlined />} onClick={() => {
setAddedRows([]);
setModifiedRows({});
<div className={gridId} style={{ flex: '1 1 auto', height: '100%', overflow: 'hidden', padding: 0, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
{/* Toolbar */}
<div style={{ padding: '8px', borderBottom: '1px solid #eee', display: 'flex', gap: 8, alignItems: 'center' }}>
{onReload && <Button icon={<ReloadOutlined />} onClick={() => {
setAddedRows([]);
setModifiedRows({});
setDeletedRowKeys(new Set());
setSelectedRowKeys([]);
onReload();
@@ -842,14 +854,13 @@ const DataGrid: React.FC<DataGridProps> = ({
</div>
)}
<style>{`
.${gridId} .row-added td { background-color: #f6ffed !important; }
.${gridId} .row-modified td { background-color: #e6f7ff !important; }
.${gridId} .ant-table-body {
height: ${tableHeight}px !important;
max-height: ${tableHeight}px !important;
}
`}</style>
<style>{`
.${gridId} .row-added td { background-color: #f6ffed !important; }
.${gridId} .row-modified td { background-color: #e6f7ff !important; }
.${gridId} .ant-table-body {
max-height: ${tableHeight}px !important;
}
`}</style>
{/* Ghost Resize Line for Columns */}
<div

View File

@@ -46,15 +46,29 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" }
};
const normalizeIdentPart = (ident: string) => {
let raw = (ident || '').trim();
if (!raw) return raw;
const first = raw[0];
const last = raw[raw.length - 1];
if ((first === '"' && last === '"') || (first === '`' && last === '`')) {
raw = raw.slice(1, -1).trim();
}
// 防御:如果传入已包含引号(例如 `"schema"."table"` 的拆分结果),移除残留引号再重新安全转义。
raw = raw.replace(/["`]/g, '').trim();
return raw;
};
const quoteIdentPart = (ident: string) => {
if (!ident) return ident;
if (config.type === 'mysql') return `\`${ident.replace(/`/g, '``')}\``;
return `"${ident.replace(/"/g, '""')}"`;
const raw = normalizeIdentPart(ident);
if (!raw) return raw;
if (config.type === 'mysql') return `\`${raw.replace(/`/g, '``')}\``;
return `"${raw.replace(/"/g, '""')}"`;
};
const quoteQualifiedIdent = (ident: string) => {
const raw = (ident || '').trim();
if (!raw) return raw;
const parts = raw.split('.').filter(Boolean);
const parts = raw.split('.').map(normalizeIdentPart).filter(Boolean);
if (parts.length <= 1) return quoteIdentPart(raw);
return parts.map(quoteIdentPart).join('.');
};
@@ -227,7 +241,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
}, [tab, sortInfo, filterConditions]); // Initial load and re-load on sort/filter
return (
<div style={{ height: '100%', width: '100%', overflow: 'hidden' }}>
<div style={{ flex: '1 1 auto', minHeight: 0, height: '100%', width: '100%', overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
<DataGrid
data={data}
columnNames={columnNames}

View File

@@ -1,7 +1,7 @@
import React, { useState, useEffect, useRef } from 'react';
import Editor, { OnMount } from '@monaco-editor/react';
import { Button, message, Modal, Input, Form, Dropdown, MenuProps, Tooltip, Select } from 'antd';
import { PlayCircleOutlined, SaveOutlined, FormatPainterOutlined, SettingOutlined } from '@ant-design/icons';
import { Button, message, Modal, Input, Form, Dropdown, MenuProps, Tooltip, Select, Tabs } from 'antd';
import { PlayCircleOutlined, SaveOutlined, FormatPainterOutlined, SettingOutlined, CloseOutlined } from '@ant-design/icons';
import { format } from 'sql-formatter';
import { TabData, ColumnDefinition } from '../types';
import { useStore } from '../store';
@@ -11,11 +11,19 @@ import DataGrid, { GONAVI_ROW_KEY } from './DataGrid';
const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
const [query, setQuery] = useState(tab.query || 'SELECT * FROM ');
// DataGrid State
const [results, setResults] = useState<any[]>([]);
const [columnNames, setColumnNames] = useState<string[]>([]);
const [pkColumns, setPkColumns] = useState<string[]>([]);
const [targetTableName, setTargetTableName] = useState<string | undefined>(undefined);
type ResultSet = {
key: string;
sql: string;
rows: any[];
columns: string[];
tableName?: string;
pkColumns: string[];
readOnly: boolean;
};
// Result Sets
const [resultSets, setResultSets] = useState<ResultSet[]>([]);
const [activeResultKey, setActiveResultKey] = useState<string>('');
const [loading, setLoading] = useState(false);
const [isSaveModalOpen, setIsSaveModalOpen] = useState(false);
@@ -210,6 +218,144 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
},
];
const splitSQLStatements = (sql: string): string[] => {
const text = (sql || '').replace(/\r\n/g, '\n');
const statements: string[] = [];
let cur = '';
let inSingle = false;
let inDouble = false;
let inBacktick = false;
let escaped = false;
let inLineComment = false;
let inBlockComment = false;
let dollarTag: string | null = null; // postgres/kingbase: $$...$$ or $tag$...$tag$
const push = () => {
const s = cur.trim();
if (s) statements.push(s);
cur = '';
};
const isWS = (ch: string) => ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r';
for (let i = 0; i < text.length; i++) {
const ch = text[i];
const next = i + 1 < text.length ? text[i + 1] : '';
const prev = i > 0 ? text[i - 1] : '';
const next2 = i + 2 < text.length ? text[i + 2] : '';
if (!inSingle && !inDouble && !inBacktick) {
if (inLineComment) {
cur += ch;
if (ch === '\n') inLineComment = false;
continue;
}
if (inBlockComment) {
cur += ch;
if (ch === '*' && next === '/') {
cur += next;
i++;
inBlockComment = false;
}
continue;
}
// Start comments
if (ch === '/' && next === '*') {
cur += ch + next;
i++;
inBlockComment = true;
continue;
}
if (ch === '#') {
cur += ch;
inLineComment = true;
continue;
}
if (ch === '-' && next === '-' && (i === 0 || isWS(prev)) && (next2 === '' || isWS(next2))) {
cur += ch + next;
i++;
inLineComment = true;
continue;
}
// Dollar-quoted strings (PG/Kingbase)
if (dollarTag) {
if (text.startsWith(dollarTag, i)) {
cur += dollarTag;
i += dollarTag.length - 1;
dollarTag = null;
} else {
cur += ch;
}
continue;
}
if (ch === '$') {
const m = text.slice(i).match(/^\$[A-Za-z0-9_]*\$/);
if (m && m[0]) {
dollarTag = m[0];
cur += dollarTag;
i += dollarTag.length - 1;
continue;
}
}
}
if (escaped) {
cur += ch;
escaped = false;
continue;
}
if ((inSingle || inDouble) && ch === '\\') {
cur += ch;
escaped = true;
continue;
}
if (!inDouble && !inBacktick && ch === '\'') {
inSingle = !inSingle;
cur += ch;
continue;
}
if (!inSingle && !inBacktick && ch === '"') {
inDouble = !inDouble;
cur += ch;
continue;
}
if (!inSingle && !inDouble && ch === '`') {
inBacktick = !inBacktick;
cur += ch;
continue;
}
if (!inSingle && !inDouble && !inBacktick && !dollarTag && (ch === ';' || ch === '')) {
push();
continue;
}
cur += ch;
}
push();
return statements;
};
const getSelectedSQL = (): string => {
const editor = editorRef.current;
if (!editor) return '';
const model = editor.getModel?.();
const selection = editor.getSelection?.();
if (!model || !selection) return '';
const selected = model.getValueInRange?.(selection) || '';
if (typeof selected !== 'string') return '';
if (!selected.trim()) return '';
return selected;
};
const handleRun = async () => {
if (!query.trim()) return;
if (!currentDb) {
@@ -217,6 +363,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
return;
}
setLoading(true);
const runStartTime = Date.now();
const conn = connections.find(c => c.id === currentConnectionId);
if (!conn) {
message.error("Connection not found");
@@ -233,76 +380,114 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" }
};
// Detect Simple Table Query
let simpleTableName: string | undefined = undefined;
let primaryKeys: string[] = [];
// Naive regex to detect SELECT * FROM table
const tableMatch = query.match(/^\s*SELECT\s+\*\s+FROM\s+[`"]?(\w+)[`"]?\s*(?:WHERE.*)?(?:ORDER BY.*)?(?:LIMIT.*)?$/i);
if (tableMatch) {
simpleTableName = tableMatch[1];
// Fetch PKs for editing
const resCols = await DBGetColumns(config as any, currentDb, simpleTableName);
if (resCols.success) {
primaryKeys = (resCols.data as ColumnDefinition[]).filter(c => c.key === 'PRI').map(c => c.name);
}
}
setTargetTableName(simpleTableName);
setPkColumns(primaryKeys);
const startTime = Date.now();
try {
const res = await DBQuery(config as any, currentDb, query);
const duration = Date.now() - startTime;
addSqlLog({
id: `log-${Date.now()}-query`,
timestamp: Date.now(),
sql: query,
status: res.success ? 'success' : 'error',
duration,
message: res.success ? '' : res.message,
affectedRows: (res.success && !Array.isArray(res.data)) ? (res.data as any).affectedRows : (Array.isArray(res.data) ? res.data.length : undefined),
dbName: currentDb
});
const rawSQL = getSelectedSQL() || query;
const statements = splitSQLStatements(rawSQL);
if (statements.length === 0) {
message.info('没有可执行的 SQL。');
setResultSets([]);
setActiveResultKey('');
return;
}
const nextResultSets: ResultSet[] = [];
for (let idx = 0; idx < statements.length; idx++) {
const sql = statements[idx];
const startTime = Date.now();
const res = await DBQuery(config as any, currentDb, sql);
const duration = Date.now() - startTime;
addSqlLog({
id: `log-${Date.now()}-query-${idx + 1}`,
timestamp: Date.now(),
sql,
status: res.success ? 'success' : 'error',
duration,
message: res.success ? '' : res.message,
affectedRows: (res.success && !Array.isArray(res.data)) ? (res.data as any).affectedRows : (Array.isArray(res.data) ? res.data.length : undefined),
dbName: currentDb
});
if (!res.success) {
const prefix = statements.length > 1 ? `${idx + 1} 条语句执行失败:` : '';
message.error(prefix + res.message);
setResultSets([]);
setActiveResultKey('');
return;
}
if (Array.isArray(res.data)) {
const rows = (res.data as any[]) || [];
const cols = (res.fields && res.fields.length > 0)
? (res.fields as string[])
: (rows.length > 0 ? Object.keys(rows[0]) : []);
if (res.success) {
if (Array.isArray(res.data)) {
if (res.data.length > 0) {
const cols = Object.keys(res.data[0]);
setColumnNames(cols);
const rows = res.data as any[];
rows.forEach((row: any, i: number) => {
if (row && typeof row === 'object') row[GONAVI_ROW_KEY] = i;
});
setResults(rows);
let simpleTableName: string | undefined = undefined;
let primaryKeys: string[] = [];
const tableMatch = sql.match(/^\s*SELECT\s+\*\s+FROM\s+[`"]?(\w+)[`"]?\s*(?:WHERE.*)?(?:ORDER BY.*)?(?:LIMIT.*)?$/i);
if (tableMatch) {
simpleTableName = tableMatch[1];
const resCols = await DBGetColumns(config as any, currentDb, simpleTableName);
if (resCols.success) {
primaryKeys = (resCols.data as ColumnDefinition[]).filter(c => c.key === 'PRI').map(c => c.name);
}
}
nextResultSets.push({
key: `result-${idx + 1}`,
sql,
rows,
columns: cols,
tableName: simpleTableName,
pkColumns: primaryKeys,
readOnly: !simpleTableName
});
} else {
message.info('查询执行成功,但没有返回结果。');
setResults([]);
setColumnNames([]);
const affected = Number((res.data as any)?.affectedRows);
if (Number.isFinite(affected)) {
const row = { affectedRows: affected };
(row as any)[GONAVI_ROW_KEY] = 0;
nextResultSets.push({
key: `result-${idx + 1}`,
sql,
rows: [row],
columns: ['affectedRows'],
pkColumns: [],
readOnly: true
});
}
}
} else {
const affected = (res.data as any).affectedRows;
message.success(`受影响行数: ${affected}`);
setResults([]);
setColumnNames([]);
}
} else {
message.error(res.message);
}
setResultSets(nextResultSets);
setActiveResultKey(nextResultSets[0]?.key || '');
if (statements.length > 1) {
message.success(`已执行 ${statements.length} 条语句,生成 ${nextResultSets.length} 个结果集。`);
} else if (nextResultSets.length === 0) {
message.success('执行成功。');
}
} catch (e: any) {
message.error("Error executing query: " + e.message);
addSqlLog({
id: `log-${Date.now()}-error`,
timestamp: Date.now(),
sql: query,
sql: getSelectedSQL() || query,
status: 'error',
duration: Date.now() - startTime,
duration: Date.now() - runStartTime,
message: e.message,
dbName: currentDb
});
setResultSets([]);
setActiveResultKey('');
} finally {
setLoading(false);
}
setLoading(false);
};
const handleSave = async () => {
@@ -322,8 +507,66 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
}
};
const handleCloseResult = (key: string) => {
setResultSets(prev => {
const idx = prev.findIndex(r => r.key === key);
if (idx < 0) return prev;
const next = prev.filter(r => r.key !== key);
setActiveResultKey(prevActive => {
if (prevActive && prevActive !== key) return prevActive;
const nextKey = next[idx]?.key || next[idx - 1]?.key || next[0]?.key || '';
return nextKey;
});
return next;
});
};
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
<div style={{ flex: '1 1 auto', minHeight: 0, display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
<style>{`
.query-result-tabs {
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.query-result-tabs .ant-tabs-nav {
flex: 0 0 auto;
}
.query-result-tabs .ant-tabs-content-holder {
flex: 1 1 auto;
overflow: hidden;
min-height: 0;
display: flex;
flex-direction: column;
}
.query-result-tabs .ant-tabs-content {
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
}
.query-result-tabs .ant-tabs-tabpane {
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.query-result-tabs .ant-tabs-tabpane > div {
flex: 1 1 auto;
min-height: 0;
}
.query-result-tabs .ant-tabs-tabpane-hidden {
display: none !important;
}
.query-result-tabs .ant-tabs-ink-bar {
transition: none !important;
}
`}</style>
<div style={{ padding: '8px', borderBottom: '1px solid #eee', display: 'flex', gap: '8px', flexShrink: 0, alignItems: 'center' }}>
<Select
style={{ width: 150 }}
@@ -393,18 +636,55 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
title="拖动调整高度"
/>
<div style={{ flex: 1, overflow: 'hidden', padding: 0, display: 'flex', flexDirection: 'column' }}>
<DataGrid
data={results}
columnNames={columnNames}
loading={loading}
tableName={targetTableName} // Pass table name only if detection succeeded
dbName={currentDb}
connectionId={currentConnectionId}
pkColumns={pkColumns}
onReload={handleRun}
readOnly={!targetTableName} // Read-only if not a simple table query
/>
<div style={{ flex: 1, minHeight: 0, overflow: 'hidden', padding: 0, display: 'flex', flexDirection: 'column' }}>
{resultSets.length > 0 ? (
<Tabs
className="query-result-tabs"
activeKey={activeResultKey || resultSets[0]?.key}
onChange={setActiveResultKey}
animated={false}
style={{ flex: 1, minHeight: 0 }}
items={resultSets.map((rs, idx) => ({
key: rs.key,
label: (
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
<Tooltip title={rs.sql}>
<span>{`结果 ${idx + 1}${Array.isArray(rs.rows) ? ` (${rs.rows.length})` : ''}`}</span>
</Tooltip>
<Tooltip title="关闭结果">
<span
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleCloseResult(rs.key);
}}
style={{ display: 'inline-flex', alignItems: 'center', color: '#999', cursor: 'pointer' }}
>
<CloseOutlined style={{ fontSize: 12 }} />
</span>
</Tooltip>
</div>
),
children: (
<div style={{ flex: 1, minHeight: 0, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
<DataGrid
data={rs.rows}
columnNames={rs.columns}
loading={loading}
tableName={rs.tableName}
dbName={currentDb}
connectionId={currentConnectionId}
pkColumns={rs.pkColumns}
onReload={handleRun}
readOnly={rs.readOnly}
/>
</div>
)
}))}
/>
) : (
<div style={{ flex: 1, minHeight: 0 }} />
)}
</div>
<Modal

View File

@@ -1,12 +1,13 @@
import React, { useMemo } from 'react';
import { Tabs, Button } from 'antd';
import { Tabs, Dropdown } from 'antd';
import type { MenuProps } from 'antd';
import { useStore } from '../store';
import DataViewer from './DataViewer';
import QueryEditor from './QueryEditor';
import TableDesigner from './TableDesigner';
const TabManager: React.FC = () => {
const { tabs, activeTabId, setActiveTab, closeTab } = useStore();
const { tabs, activeTabId, setActiveTab, closeTab, closeOtherTabs, closeTabsToLeft, closeTabsToRight, closeAllTabs } = useStore();
const onChange = (newActiveKey: string) => {
setActiveTab(newActiveKey);
@@ -18,7 +19,7 @@ const TabManager: React.FC = () => {
}
};
const items = useMemo(() => tabs.map(tab => {
const items = useMemo(() => tabs.map((tab, index) => {
let content;
if (tab.type === 'query') {
content = <QueryEditor tab={tab} />;
@@ -27,27 +28,95 @@ const TabManager: React.FC = () => {
} else if (tab.type === 'design') {
content = <TableDesigner tab={tab} />;
}
const menuItems: MenuProps['items'] = [
{
key: 'close-other',
label: '关闭其他页',
disabled: tabs.length <= 1,
onClick: () => closeOtherTabs(tab.id),
},
{
key: 'close-left',
label: '关闭左侧',
disabled: index === 0,
onClick: () => closeTabsToLeft(tab.id),
},
{
key: 'close-right',
label: '关闭右侧',
disabled: index === tabs.length - 1,
onClick: () => closeTabsToRight(tab.id),
},
{ type: 'divider' },
{
key: 'close-all',
label: '关闭所有',
disabled: tabs.length === 0,
onClick: () => closeAllTabs(),
},
];
return {
label: tab.title,
label: (
<Dropdown menu={{ items: menuItems }} trigger={['contextMenu']}>
<span onContextMenu={(e) => e.preventDefault()}>{tab.title}</span>
</Dropdown>
),
key: tab.id,
children: content,
};
}), [tabs]);
}), [tabs, closeOtherTabs, closeTabsToLeft, closeTabsToRight, closeAllTabs]);
return (
<>
<style>{`
.ant-tabs-content { height: 100%; }
.ant-tabs-tabpane { height: 100%; }
.main-tabs {
height: 100%;
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.main-tabs .ant-tabs-nav {
flex: 0 0 auto;
}
.main-tabs .ant-tabs-content-holder {
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
display: flex;
flex-direction: column;
}
.main-tabs .ant-tabs-content {
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
}
.main-tabs .ant-tabs-tabpane {
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.main-tabs .ant-tabs-tabpane > div {
flex: 1 1 auto;
min-height: 0;
}
.main-tabs .ant-tabs-tabpane-hidden {
display: none !important;
}
`}</style>
<Tabs
className="main-tabs"
type="editable-card"
onChange={onChange}
activeKey={activeTabId || undefined}
onEdit={onEdit}
items={items}
style={{ height: '100%' }}
hideAdd
/>
</>

View File

@@ -550,7 +550,6 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => {
<div ref={containerRef} className="table-designer-wrapper" style={{ height: '100%', overflow: 'hidden', position: 'relative' }}>
<style>{`
.table-designer-wrapper .ant-table-body {
height: ${tableHeight}px !important;
max-height: ${tableHeight}px !important;
}
`}</style>

View File

@@ -29,6 +29,10 @@ interface AppState {
addTab: (tab: TabData) => void;
closeTab: (id: string) => void;
closeOtherTabs: (id: string) => void;
closeTabsToLeft: (id: string) => void;
closeTabsToRight: (id: string) => void;
closeAllTabs: () => void;
setActiveTab: (id: string) => void;
setActiveContext: (context: { connectionId: string; dbName: string } | null) => void;
@@ -79,6 +83,30 @@ export const useStore = create<AppState>()(
}
return { tabs: newTabs, activeTabId: newActiveId };
}),
closeOtherTabs: (id) => set((state) => {
const keep = state.tabs.find(t => t.id === id);
if (!keep) return state;
return { tabs: [keep], activeTabId: id };
}),
closeTabsToLeft: (id) => set((state) => {
const index = state.tabs.findIndex(t => t.id === id);
if (index === -1) return state;
const newTabs = state.tabs.slice(index);
const activeStillExists = state.activeTabId ? newTabs.some(t => t.id === state.activeTabId) : false;
return { tabs: newTabs, activeTabId: activeStillExists ? state.activeTabId : id };
}),
closeTabsToRight: (id) => set((state) => {
const index = state.tabs.findIndex(t => t.id === id);
if (index === -1) return state;
const newTabs = state.tabs.slice(0, index + 1);
const activeStillExists = state.activeTabId ? newTabs.some(t => t.id === state.activeTabId) : false;
return { tabs: newTabs, activeTabId: activeStillExists ? state.activeTabId : id };
}),
closeAllTabs: () => set(() => ({ tabs: [], activeTabId: null })),
setActiveTab: (id) => set({ activeTabId: id }),
setActiveContext: (context) => set({ activeContext: context }),
@@ -105,4 +133,4 @@ export const useStore = create<AppState>()(
partialize: (state) => ({ connections: state.connections, savedQueries: state.savedQueries, darkMode: state.darkMode, sqlFormatOptions: state.sqlFormatOptions }), // Don't persist logs
}
)
);
);

View File

@@ -380,14 +380,13 @@ func (k *KingbaseDB) ApplyChanges(tableName string, changes connection.ChangeSet
}
func (k *KingbaseDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error) {
schema := "public"
if dbName != "" {
schema = dbName
}
query := fmt.Sprintf(`SELECT table_name, column_name, data_type
FROM information_schema.columns
WHERE table_schema = '%s'`, schema)
// dbName 在本项目语义里是“数据库”schema 由 table_schema 决定;这里返回全部用户 schema 的列用于查询提示。
query := `
SELECT table_schema, table_name, column_name, data_type
FROM information_schema.columns
WHERE table_schema NOT IN ('pg_catalog', 'information_schema')
AND table_schema NOT LIKE 'pg_%'
ORDER BY table_schema, table_name, ordinal_position`
data, _, err := k.Query(query)
if err != nil {
@@ -396,8 +395,14 @@ func (k *KingbaseDB) GetAllColumns(dbName string) ([]connection.ColumnDefinition
var cols []connection.ColumnDefinitionWithTable
for _, row := range data {
schema := fmt.Sprintf("%v", row["table_schema"])
table := fmt.Sprintf("%v", row["table_name"])
tableName := table
if strings.TrimSpace(schema) != "" {
tableName = fmt.Sprintf("%s.%s", schema, table)
}
col := connection.ColumnDefinitionWithTable{
TableName: fmt.Sprintf("%v", row["table_name"]),
TableName: tableName,
Name: fmt.Sprintf("%v", row["column_name"]),
Type: fmt.Sprintf("%v", row["data_type"]),
}

View File

@@ -6,6 +6,7 @@ import (
"net"
"net/url"
"strconv"
"strings"
"time"
"GoNavi-Wails/internal/connection"
@@ -167,21 +168,306 @@ func (p *PostgresDB) GetCreateStatement(dbName, tableName string) (string, error
}
func (p *PostgresDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefinition, error) {
return []connection.ColumnDefinition{}, nil
schema := strings.TrimSpace(dbName)
if schema == "" {
schema = "public"
}
table := strings.TrimSpace(tableName)
if table == "" {
return nil, fmt.Errorf("table name required")
}
esc := func(s string) string { return strings.ReplaceAll(s, "'", "''") }
query := fmt.Sprintf(`
SELECT
a.attname AS column_name,
pg_catalog.format_type(a.atttypid, a.atttypmod) AS data_type,
CASE WHEN a.attnotnull THEN 'NO' ELSE 'YES' END AS is_nullable,
pg_get_expr(ad.adbin, ad.adrelid) AS column_default,
col_description(a.attrelid, a.attnum) AS comment,
CASE WHEN pk.attname IS NOT NULL THEN 'PRI' ELSE '' END AS column_key
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
JOIN pg_attribute a ON a.attrelid = c.oid
LEFT JOIN pg_attrdef ad ON ad.adrelid = c.oid AND ad.adnum = a.attnum
LEFT JOIN (
SELECT i.indrelid, a3.attname
FROM pg_index i
JOIN pg_attribute a3 ON a3.attrelid = i.indrelid AND a3.attnum = ANY(i.indkey)
WHERE i.indisprimary
) pk ON pk.indrelid = c.oid AND pk.attname = a.attname
WHERE c.relkind IN ('r', 'p')
AND n.nspname = '%s'
AND c.relname = '%s'
AND a.attnum > 0
AND NOT a.attisdropped
ORDER BY a.attnum`, esc(schema), esc(table))
data, _, err := p.Query(query)
if err != nil {
return nil, err
}
var columns []connection.ColumnDefinition
for _, row := range data {
col := connection.ColumnDefinition{
Name: fmt.Sprintf("%v", row["column_name"]),
Type: fmt.Sprintf("%v", row["data_type"]),
Nullable: fmt.Sprintf("%v", row["is_nullable"]),
Key: fmt.Sprintf("%v", row["column_key"]),
Extra: "",
Comment: "",
}
if v, ok := row["comment"]; ok && v != nil {
col.Comment = fmt.Sprintf("%v", v)
}
if v, ok := row["column_default"]; ok && v != nil {
def := fmt.Sprintf("%v", v)
col.Default = &def
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(def)), "nextval(") {
col.Extra = "auto_increment"
}
}
columns = append(columns, col)
}
return columns, nil
}
func (p *PostgresDB) GetIndexes(dbName, tableName string) ([]connection.IndexDefinition, error) {
return []connection.IndexDefinition{}, nil
schema := strings.TrimSpace(dbName)
if schema == "" {
schema = "public"
}
table := strings.TrimSpace(tableName)
if table == "" {
return nil, fmt.Errorf("table name required")
}
esc := func(s string) string { return strings.ReplaceAll(s, "'", "''") }
query := fmt.Sprintf(`
SELECT
i.relname AS index_name,
a.attname AS column_name,
ix.indisunique AS is_unique,
x.ordinality AS seq_in_index,
am.amname AS index_type
FROM pg_class t
JOIN pg_namespace n ON n.oid = t.relnamespace
JOIN pg_index ix ON t.oid = ix.indrelid
JOIN pg_class i ON i.oid = ix.indexrelid
JOIN pg_am am ON i.relam = am.oid
JOIN unnest(ix.indkey) WITH ORDINALITY AS x(attnum, ordinality) ON TRUE
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = x.attnum
WHERE t.relkind IN ('r', 'p')
AND t.relname = '%s'
AND n.nspname = '%s'
ORDER BY i.relname, x.ordinality`, esc(table), esc(schema))
data, _, err := p.Query(query)
if err != nil {
return nil, err
}
parseBool := func(v interface{}) bool {
switch val := v.(type) {
case bool:
return val
case string:
s := strings.ToLower(strings.TrimSpace(val))
return s == "t" || s == "true" || s == "1" || s == "y" || s == "yes"
default:
s := strings.ToLower(strings.TrimSpace(fmt.Sprintf("%v", v)))
return s == "t" || s == "true" || s == "1" || s == "y" || s == "yes"
}
}
parseInt := func(v interface{}) int {
switch val := v.(type) {
case int:
return val
case int64:
return int(val)
case float64:
return int(val)
case string:
// best effort
var n int
_, _ = fmt.Sscanf(strings.TrimSpace(val), "%d", &n)
return n
default:
var n int
_, _ = fmt.Sscanf(strings.TrimSpace(fmt.Sprintf("%v", v)), "%d", &n)
return n
}
}
var indexes []connection.IndexDefinition
for _, row := range data {
isUnique := false
if v, ok := row["is_unique"]; ok && v != nil {
isUnique = parseBool(v)
}
nonUnique := 1
if isUnique {
nonUnique = 0
}
seq := 0
if v, ok := row["seq_in_index"]; ok && v != nil {
seq = parseInt(v)
}
indexType := ""
if v, ok := row["index_type"]; ok && v != nil {
indexType = strings.ToUpper(fmt.Sprintf("%v", v))
}
if indexType == "" {
indexType = "BTREE"
}
idx := connection.IndexDefinition{
Name: fmt.Sprintf("%v", row["index_name"]),
ColumnName: fmt.Sprintf("%v", row["column_name"]),
NonUnique: nonUnique,
SeqInIndex: seq,
IndexType: indexType,
}
indexes = append(indexes, idx)
}
return indexes, nil
}
func (p *PostgresDB) GetForeignKeys(dbName, tableName string) ([]connection.ForeignKeyDefinition, error) {
return []connection.ForeignKeyDefinition{}, nil
schema := strings.TrimSpace(dbName)
if schema == "" {
schema = "public"
}
table := strings.TrimSpace(tableName)
if table == "" {
return nil, fmt.Errorf("table name required")
}
esc := func(s string) string { return strings.ReplaceAll(s, "'", "''") }
query := fmt.Sprintf(`
SELECT
tc.constraint_name AS constraint_name,
kcu.column_name AS column_name,
ccu.table_schema AS foreign_table_schema,
ccu.table_name AS foreign_table_name,
ccu.column_name AS foreign_column_name
FROM information_schema.table_constraints AS tc
JOIN information_schema.key_column_usage AS kcu
ON tc.constraint_name = kcu.constraint_name
AND tc.table_schema = kcu.table_schema
JOIN information_schema.constraint_column_usage AS ccu
ON ccu.constraint_name = tc.constraint_name
AND ccu.table_schema = tc.table_schema
WHERE tc.constraint_type = 'FOREIGN KEY'
AND tc.table_name = '%s'
AND tc.table_schema = '%s'
ORDER BY tc.constraint_name, kcu.ordinal_position`, esc(table), esc(schema))
data, _, err := p.Query(query)
if err != nil {
return nil, err
}
var fks []connection.ForeignKeyDefinition
for _, row := range data {
refSchema := ""
if v, ok := row["foreign_table_schema"]; ok && v != nil {
refSchema = fmt.Sprintf("%v", v)
}
refTable := fmt.Sprintf("%v", row["foreign_table_name"])
refTableName := refTable
if strings.TrimSpace(refSchema) != "" {
refTableName = fmt.Sprintf("%s.%s", refSchema, refTable)
}
fk := connection.ForeignKeyDefinition{
Name: fmt.Sprintf("%v", row["constraint_name"]),
ColumnName: fmt.Sprintf("%v", row["column_name"]),
RefTableName: refTableName,
RefColumnName: fmt.Sprintf("%v", row["foreign_column_name"]),
ConstraintName: fmt.Sprintf("%v", row["constraint_name"]),
}
fks = append(fks, fk)
}
return fks, nil
}
func (p *PostgresDB) GetTriggers(dbName, tableName string) ([]connection.TriggerDefinition, error) {
return []connection.TriggerDefinition{}, nil
schema := strings.TrimSpace(dbName)
if schema == "" {
schema = "public"
}
table := strings.TrimSpace(tableName)
if table == "" {
return nil, fmt.Errorf("table name required")
}
esc := func(s string) string { return strings.ReplaceAll(s, "'", "''") }
query := fmt.Sprintf(`
SELECT trigger_name, action_timing, event_manipulation, action_statement
FROM information_schema.triggers
WHERE event_object_table = '%s'
AND event_object_schema = '%s'
ORDER BY trigger_name, event_manipulation`, esc(table), esc(schema))
data, _, err := p.Query(query)
if err != nil {
return nil, err
}
var triggers []connection.TriggerDefinition
for _, row := range data {
trig := connection.TriggerDefinition{
Name: fmt.Sprintf("%v", row["trigger_name"]),
Timing: fmt.Sprintf("%v", row["action_timing"]),
Event: fmt.Sprintf("%v", row["event_manipulation"]),
Statement: fmt.Sprintf("%v", row["action_statement"]),
}
triggers = append(triggers, trig)
}
return triggers, nil
}
func (p *PostgresDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error) {
return []connection.ColumnDefinitionWithTable{}, nil
query := `
SELECT table_schema, table_name, column_name, data_type
FROM information_schema.columns
WHERE table_schema NOT IN ('pg_catalog', 'information_schema')
AND table_schema NOT LIKE 'pg_%'
ORDER BY table_schema, table_name, ordinal_position`
data, _, err := p.Query(query)
if err != nil {
return nil, err
}
var cols []connection.ColumnDefinitionWithTable
for _, row := range data {
schema := fmt.Sprintf("%v", row["table_schema"])
table := fmt.Sprintf("%v", row["table_name"])
tableName := table
if strings.TrimSpace(schema) != "" {
tableName = fmt.Sprintf("%s.%s", schema, table)
}
col := connection.ColumnDefinitionWithTable{
TableName: tableName,
Name: fmt.Sprintf("%v", row["column_name"]),
Type: fmt.Sprintf("%v", row["data_type"]),
}
cols = append(cols, col)
}
return cols, nil
}

View File

@@ -3,6 +3,7 @@ package db
import (
"database/sql"
"fmt"
"strings"
"time"
"GoNavi-Wails/internal/connection"
@@ -137,21 +138,336 @@ func (s *SQLiteDB) GetCreateStatement(dbName, tableName string) (string, error)
}
func (s *SQLiteDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefinition, error) {
return []connection.ColumnDefinition{}, nil
table := strings.TrimSpace(tableName)
if table == "" {
return nil, fmt.Errorf("table name required")
}
esc := func(v string) string { return strings.ReplaceAll(v, "'", "''") }
// cid, name, type, notnull, dflt_value, pk
data, _, err := s.Query(fmt.Sprintf("PRAGMA table_info('%s')", esc(table)))
if err != nil {
return nil, err
}
parseInt := func(v interface{}) int {
switch val := v.(type) {
case int:
return val
case int64:
return int(val)
case float64:
return int(val)
case string:
var n int
_, _ = fmt.Sscanf(strings.TrimSpace(val), "%d", &n)
return n
default:
var n int
_, _ = fmt.Sscanf(strings.TrimSpace(fmt.Sprintf("%v", v)), "%d", &n)
return n
}
}
getStr := func(row map[string]interface{}, key string) string {
if v, ok := row[key]; ok && v != nil {
return fmt.Sprintf("%v", v)
}
if v, ok := row[strings.ToUpper(key)]; ok && v != nil {
return fmt.Sprintf("%v", v)
}
return ""
}
var columns []connection.ColumnDefinition
for _, row := range data {
notnull := 0
if v, ok := row["notnull"]; ok && v != nil {
notnull = parseInt(v)
} else if v, ok := row["NOTNULL"]; ok && v != nil {
notnull = parseInt(v)
}
pk := 0
if v, ok := row["pk"]; ok && v != nil {
pk = parseInt(v)
} else if v, ok := row["PK"]; ok && v != nil {
pk = parseInt(v)
}
nullable := "YES"
if notnull == 1 {
nullable = "NO"
}
key := ""
if pk == 1 {
key = "PRI"
}
col := connection.ColumnDefinition{
Name: getStr(row, "name"),
Type: getStr(row, "type"),
Nullable: nullable,
Key: key,
Extra: "",
Comment: "",
}
if v, ok := row["dflt_value"]; ok && v != nil {
def := fmt.Sprintf("%v", v)
col.Default = &def
} else if v, ok := row["DFLT_VALUE"]; ok && v != nil {
def := fmt.Sprintf("%v", v)
col.Default = &def
}
columns = append(columns, col)
}
return columns, nil
}
func (s *SQLiteDB) GetIndexes(dbName, tableName string) ([]connection.IndexDefinition, error) {
return []connection.IndexDefinition{}, nil
table := strings.TrimSpace(tableName)
if table == "" {
return nil, fmt.Errorf("table name required")
}
esc := func(v string) string { return strings.ReplaceAll(v, "'", "''") }
parseInt := func(v interface{}) int {
switch val := v.(type) {
case int:
return val
case int64:
return int(val)
case float64:
return int(val)
case string:
var n int
_, _ = fmt.Sscanf(strings.TrimSpace(val), "%d", &n)
return n
default:
var n int
_, _ = fmt.Sscanf(strings.TrimSpace(fmt.Sprintf("%v", v)), "%d", &n)
return n
}
}
data, _, err := s.Query(fmt.Sprintf("PRAGMA index_list('%s')", esc(table)))
if err != nil {
return nil, err
}
var indexes []connection.IndexDefinition
for _, row := range data {
indexName := ""
if v, ok := row["name"]; ok && v != nil {
indexName = fmt.Sprintf("%v", v)
} else if v, ok := row["NAME"]; ok && v != nil {
indexName = fmt.Sprintf("%v", v)
}
if strings.TrimSpace(indexName) == "" {
continue
}
unique := 0
if v, ok := row["unique"]; ok && v != nil {
unique = parseInt(v)
} else if v, ok := row["UNIQUE"]; ok && v != nil {
unique = parseInt(v)
}
nonUnique := 1
if unique == 1 {
nonUnique = 0
}
cols, _, err := s.Query(fmt.Sprintf("PRAGMA index_info('%s')", esc(indexName)))
if err != nil {
// skip broken index
continue
}
for _, c := range cols {
colName := ""
if v, ok := c["name"]; ok && v != nil {
colName = fmt.Sprintf("%v", v)
} else if v, ok := c["NAME"]; ok && v != nil {
colName = fmt.Sprintf("%v", v)
}
if strings.TrimSpace(colName) == "" {
continue
}
seq := 0
if v, ok := c["seqno"]; ok && v != nil {
seq = parseInt(v) + 1
} else if v, ok := c["SEQNO"]; ok && v != nil {
seq = parseInt(v) + 1
}
indexes = append(indexes, connection.IndexDefinition{
Name: indexName,
ColumnName: colName,
NonUnique: nonUnique,
SeqInIndex: seq,
IndexType: "BTREE",
})
}
}
return indexes, nil
}
func (s *SQLiteDB) GetForeignKeys(dbName, tableName string) ([]connection.ForeignKeyDefinition, error) {
return []connection.ForeignKeyDefinition{}, nil
table := strings.TrimSpace(tableName)
if table == "" {
return nil, fmt.Errorf("table name required")
}
esc := func(v string) string { return strings.ReplaceAll(v, "'", "''") }
data, _, err := s.Query(fmt.Sprintf("PRAGMA foreign_key_list('%s')", esc(table)))
if err != nil {
return nil, err
}
parseInt := func(v interface{}) int {
switch val := v.(type) {
case int:
return val
case int64:
return int(val)
case float64:
return int(val)
case string:
var n int
_, _ = fmt.Sscanf(strings.TrimSpace(val), "%d", &n)
return n
default:
var n int
_, _ = fmt.Sscanf(strings.TrimSpace(fmt.Sprintf("%v", v)), "%d", &n)
return n
}
}
var fks []connection.ForeignKeyDefinition
for _, row := range data {
id := 0
if v, ok := row["id"]; ok && v != nil {
id = parseInt(v)
} else if v, ok := row["ID"]; ok && v != nil {
id = parseInt(v)
}
refTable := ""
if v, ok := row["table"]; ok && v != nil {
refTable = fmt.Sprintf("%v", v)
} else if v, ok := row["TABLE"]; ok && v != nil {
refTable = fmt.Sprintf("%v", v)
}
fromCol := ""
if v, ok := row["from"]; ok && v != nil {
fromCol = fmt.Sprintf("%v", v)
} else if v, ok := row["FROM"]; ok && v != nil {
fromCol = fmt.Sprintf("%v", v)
}
toCol := ""
if v, ok := row["to"]; ok && v != nil {
toCol = fmt.Sprintf("%v", v)
} else if v, ok := row["TO"]; ok && v != nil {
toCol = fmt.Sprintf("%v", v)
}
name := fmt.Sprintf("fk_%s_%d", table, id)
fks = append(fks, connection.ForeignKeyDefinition{
Name: name,
ColumnName: fromCol,
RefTableName: refTable,
RefColumnName: toCol,
ConstraintName: name,
})
}
return fks, nil
}
func (s *SQLiteDB) GetTriggers(dbName, tableName string) ([]connection.TriggerDefinition, error) {
return []connection.TriggerDefinition{}, nil
table := strings.TrimSpace(tableName)
if table == "" {
return nil, fmt.Errorf("table name required")
}
esc := func(v string) string { return strings.ReplaceAll(v, "'", "''") }
data, _, err := s.Query(fmt.Sprintf("SELECT name AS trigger_name, sql AS statement FROM sqlite_master WHERE type='trigger' AND tbl_name='%s' ORDER BY name", esc(table)))
if err != nil {
return nil, err
}
var triggers []connection.TriggerDefinition
for _, row := range data {
name := fmt.Sprintf("%v", row["trigger_name"])
stmt := ""
if v, ok := row["statement"]; ok && v != nil {
stmt = fmt.Sprintf("%v", v)
}
upper := strings.ToUpper(stmt)
timing := ""
switch {
case strings.Contains(upper, " BEFORE "):
timing = "BEFORE"
case strings.Contains(upper, " AFTER "):
timing = "AFTER"
case strings.Contains(upper, " INSTEAD OF "):
timing = "INSTEAD OF"
}
event := ""
switch {
case strings.Contains(upper, " INSERT "):
event = "INSERT"
case strings.Contains(upper, " UPDATE "):
event = "UPDATE"
case strings.Contains(upper, " DELETE "):
event = "DELETE"
}
triggers = append(triggers, connection.TriggerDefinition{
Name: name,
Timing: timing,
Event: event,
Statement: stmt,
})
}
return triggers, nil
}
func (s *SQLiteDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error) {
return []connection.ColumnDefinitionWithTable{}, nil
tables, err := s.GetTables(dbName)
if err != nil {
return nil, err
}
var cols []connection.ColumnDefinitionWithTable
for _, table := range tables {
// Skip internal tables
if strings.HasPrefix(strings.ToLower(table), "sqlite_") {
continue
}
columns, err := s.GetColumns("", table)
if err != nil {
continue
}
for _, col := range columns {
cols = append(cols, connection.ColumnDefinitionWithTable{
TableName: table,
Name: col.Name,
Type: col.Type,
})
}
}
return cols, nil
}