mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-05-17 07:27:36 +08:00
✨ feat(frontend): 升级 DataGrid 组件并引入高性能拖拽交互
- 实现基于原生 DOM 事件的零渲染列宽拖拽,彻底解决卡顿与误触排序问题 - 查询编辑器集成 DataGrid,支持 SQL 结果直接编辑与事务提交 - 侧边栏新增上下文感知的 "新建查询" 快捷入口 - 优化 TabManager 渲染逻辑与全局布局,消除不必要的滚动条
This commit is contained in:
@@ -2,6 +2,7 @@ html, body, #root {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden; /* Disable global scrollbar */
|
||||
}
|
||||
|
||||
/* 侧边栏 Tree 样式优化 */
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Layout, Button, ConfigProvider, theme } from 'antd';
|
||||
import { PlusOutlined, BulbOutlined, BulbFilled } from '@ant-design/icons';
|
||||
import { PlusOutlined, BulbOutlined, BulbFilled, ConsoleSqlOutlined } from '@ant-design/icons';
|
||||
import Sidebar from './components/Sidebar';
|
||||
import TabManager from './components/TabManager';
|
||||
import ConnectionModal from './components/ConnectionModal';
|
||||
@@ -11,27 +11,63 @@ const { Sider, Content } = Layout;
|
||||
|
||||
function App() {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const { darkMode, toggleDarkMode } = useStore();
|
||||
const { darkMode, toggleDarkMode, addTab, activeContext } = useStore();
|
||||
|
||||
// Sidebar Resizing
|
||||
const [sidebarWidth, setSidebarWidth] = useState(300);
|
||||
const sidebarDragRef = React.useRef<{ startX: number, startWidth: number } | null>(null);
|
||||
const rafRef = React.useRef<number | null>(null);
|
||||
const ghostRef = React.useRef<HTMLDivElement>(null);
|
||||
const latestMouseX = React.useRef<number>(0); // Store latest mouse position
|
||||
|
||||
const handleSidebarMouseDown = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (ghostRef.current) {
|
||||
ghostRef.current.style.left = `${sidebarWidth}px`;
|
||||
ghostRef.current.style.display = 'block';
|
||||
}
|
||||
|
||||
sidebarDragRef.current = { startX: e.clientX, startWidth: sidebarWidth };
|
||||
latestMouseX.current = e.clientX; // Init
|
||||
document.addEventListener('mousemove', handleSidebarMouseMove);
|
||||
document.addEventListener('mouseup', handleSidebarMouseUp);
|
||||
};
|
||||
|
||||
const handleSidebarMouseMove = (e: MouseEvent) => {
|
||||
if (!sidebarDragRef.current) return;
|
||||
const delta = e.clientX - sidebarDragRef.current.startX;
|
||||
const newWidth = Math.max(200, Math.min(600, sidebarDragRef.current.startWidth + delta));
|
||||
setSidebarWidth(newWidth);
|
||||
|
||||
latestMouseX.current = e.clientX; // Always update latest pos
|
||||
|
||||
if (rafRef.current) return; // Schedule once per frame
|
||||
|
||||
rafRef.current = requestAnimationFrame(() => {
|
||||
if (!sidebarDragRef.current || !ghostRef.current) return;
|
||||
// Use latestMouseX.current instead of stale closure 'e.clientX'
|
||||
const delta = latestMouseX.current - sidebarDragRef.current.startX;
|
||||
const newWidth = Math.max(200, Math.min(600, sidebarDragRef.current.startWidth + delta));
|
||||
ghostRef.current.style.left = `${newWidth}px`;
|
||||
rafRef.current = null;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSidebarMouseUp = () => {
|
||||
const handleSidebarMouseUp = (e: MouseEvent) => {
|
||||
if (rafRef.current) {
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = null;
|
||||
}
|
||||
|
||||
if (sidebarDragRef.current) {
|
||||
// Use latest position for final commit too
|
||||
const delta = e.clientX - sidebarDragRef.current.startX;
|
||||
const newWidth = Math.max(200, Math.min(600, sidebarDragRef.current.startWidth + delta));
|
||||
setSidebarWidth(newWidth);
|
||||
}
|
||||
|
||||
if (ghostRef.current) {
|
||||
ghostRef.current.style.display = 'none';
|
||||
}
|
||||
|
||||
sidebarDragRef.current = null;
|
||||
document.removeEventListener('mousemove', handleSidebarMouseMove);
|
||||
document.removeEventListener('mouseup', handleSidebarMouseUp);
|
||||
@@ -53,12 +89,26 @@ function App() {
|
||||
algorithm: darkMode ? theme.darkAlgorithm : theme.defaultAlgorithm,
|
||||
}}
|
||||
>
|
||||
<Layout style={{ height: '100vh' }}>
|
||||
<Sider theme={darkMode ? "dark" : "light"} width={sidebarWidth} style={{ borderRight: darkMode ? '1px solid #303030' : '1px solid #f0f0f0', position: 'relative' }}>
|
||||
<Layout style={{ height: '100vh', overflow: 'hidden' }}>
|
||||
<Sider
|
||||
theme={darkMode ? "dark" : "light"}
|
||||
width={sidebarWidth}
|
||||
style={{
|
||||
borderRight: darkMode ? '1px solid #303030' : '1px solid #f0f0f0',
|
||||
position: 'relative'
|
||||
}}
|
||||
>
|
||||
<div style={{ padding: '10px', borderBottom: darkMode ? '1px solid #303030' : '1px solid #f0f0f0', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span style={{ fontWeight: 'bold', paddingLeft: 8 }}>GoNavi</span>
|
||||
<div>
|
||||
<Button type="text" icon={darkMode ? <BulbFilled /> : <BulbOutlined />} onClick={toggleDarkMode} title="切换主题" />
|
||||
<Button type="text" icon={<ConsoleSqlOutlined />} onClick={() => addTab({
|
||||
id: `query-${Date.now()}`,
|
||||
title: '新建查询',
|
||||
type: 'query',
|
||||
connectionId: activeContext?.connectionId || '',
|
||||
dbName: activeContext?.dbName || ''
|
||||
})} title="新建查询" />
|
||||
<Button type="text" icon={<PlusOutlined />} onClick={() => setIsModalOpen(true)} title="新建连接" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -80,10 +130,26 @@ function App() {
|
||||
title="拖动调整宽度"
|
||||
/>
|
||||
</Sider>
|
||||
<Content style={{ background: darkMode ? '#141414' : '#fff' }}>
|
||||
<Content style={{ background: darkMode ? '#141414' : '#fff', overflow: 'hidden' }}>
|
||||
<TabManager />
|
||||
</Content>
|
||||
<ConnectionModal open={isModalOpen} onClose={() => setIsModalOpen(false)} />
|
||||
|
||||
{/* Ghost Resize Line */}
|
||||
<div
|
||||
ref={ghostRef}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
width: '4px',
|
||||
background: 'rgba(24, 144, 255, 0.5)',
|
||||
zIndex: 9999,
|
||||
pointerEvents: 'none',
|
||||
display: 'none'
|
||||
}}
|
||||
/>
|
||||
</Layout>
|
||||
</ConfigProvider>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Modal, Form, Input, InputNumber, Button, message, Checkbox, Divider, Collapse, Select } from 'antd';
|
||||
import { useStore } from '../store';
|
||||
import { MySQLConnect } from '../../wailsjs/go/main/App';
|
||||
import { MySQLConnect } from '../../wailsjs/go/app/App';
|
||||
|
||||
const ConnectionModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open, onClose }) => {
|
||||
const [form] = Form.useForm();
|
||||
|
||||
726
frontend/src/components/DataGrid.tsx
Normal file
726
frontend/src/components/DataGrid.tsx
Normal file
@@ -0,0 +1,726 @@
|
||||
import React, { useState, useEffect, useRef, useContext, useMemo, useCallback } from 'react';
|
||||
import { Table, message, Input, Button, Dropdown, MenuProps, Form, Pagination, Select } from 'antd';
|
||||
import type { SortOrder } from 'antd/es/table/interface';
|
||||
import { ReloadOutlined, ImportOutlined, ExportOutlined, DownOutlined, PlusOutlined, DeleteOutlined, SaveOutlined, UndoOutlined, FilterOutlined, CloseOutlined, ConsoleSqlOutlined, FileTextOutlined, CopyOutlined } from '@ant-design/icons';
|
||||
import { Resizable } from 'react-resizable';
|
||||
import { ImportData, ExportTable, ApplyChanges } from '../../wailsjs/go/app/App';
|
||||
import { useStore } from '../store';
|
||||
import 'react-resizable/css/styles.css';
|
||||
|
||||
// --- Helper: Format Value ---
|
||||
const formatCellValue = (val: any) => {
|
||||
if (val === null) return <span style={{ color: '#ccc' }}>NULL</span>;
|
||||
if (typeof val === 'object') return JSON.stringify(val);
|
||||
if (typeof val === 'string') {
|
||||
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(val)) {
|
||||
return val.replace('T', ' ').replace(/\+.*$/, '').replace(/Z$/, '');
|
||||
}
|
||||
}
|
||||
return String(val);
|
||||
};
|
||||
|
||||
// --- Resizable Header (Native Implementation) ---
|
||||
const ResizableTitle = (props: any) => {
|
||||
const { onResizeStart, width, ...restProps } = props;
|
||||
|
||||
if (!width) {
|
||||
return <th {...restProps} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<th {...restProps} style={{ ...restProps.style, position: 'relative' }}>
|
||||
{restProps.children}
|
||||
<span
|
||||
className="react-resizable-handle"
|
||||
onMouseDown={(e) => {
|
||||
e.stopPropagation();
|
||||
// Pass the header element reference implicitly via event target
|
||||
onResizeStart(e);
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: 0, // Align to right edge
|
||||
bottom: 0,
|
||||
top: 0,
|
||||
width: 10,
|
||||
cursor: 'col-resize',
|
||||
zIndex: 10,
|
||||
touchAction: 'none'
|
||||
}}
|
||||
/>
|
||||
</th>
|
||||
);
|
||||
};
|
||||
|
||||
// --- Contexts ---
|
||||
const EditableContext = React.createContext<any>(null);
|
||||
const DataContext = React.createContext<{
|
||||
selectedRowKeysRef: React.MutableRefObject<React.Key[]>;
|
||||
displayDataRef: React.MutableRefObject<any[]>;
|
||||
handleCopyInsert: (r: any) => void;
|
||||
handleCopyJson: (r: any) => void;
|
||||
handleCopyCsv: (r: any) => void;
|
||||
copyToClipboard: (t: string) => void;
|
||||
tableName?: string;
|
||||
} | null>(null);
|
||||
|
||||
interface Item {
|
||||
key: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
interface EditableCellProps {
|
||||
title: React.ReactNode;
|
||||
editable: boolean;
|
||||
children: React.ReactNode;
|
||||
dataIndex: string;
|
||||
record: Item;
|
||||
handleSave: (record: Item) => void;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
const EditableCell: React.FC<EditableCellProps> = React.memo(({
|
||||
title,
|
||||
editable,
|
||||
children,
|
||||
dataIndex,
|
||||
record,
|
||||
handleSave,
|
||||
...restProps
|
||||
}) => {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const inputRef = useRef<any>(null);
|
||||
const form = useContext(EditableContext);
|
||||
|
||||
useEffect(() => {
|
||||
if (editing) {
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
}, [editing]);
|
||||
|
||||
const toggleEdit = () => {
|
||||
setEditing(!editing);
|
||||
form.setFieldsValue({ [dataIndex]: record[dataIndex] });
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
try {
|
||||
if (!form) return;
|
||||
const values = await form.validateFields();
|
||||
toggleEdit();
|
||||
handleSave({ ...record, ...values });
|
||||
} catch (errInfo) {
|
||||
console.log('Save failed:', errInfo);
|
||||
}
|
||||
};
|
||||
|
||||
let childNode = children;
|
||||
|
||||
if (editable) {
|
||||
childNode = editing ? (
|
||||
<Form.Item style={{ margin: 0 }} name={dataIndex}>
|
||||
<Input ref={inputRef} onPressEnter={save} onBlur={save} />
|
||||
</Form.Item>
|
||||
) : (
|
||||
<div className="editable-cell-value-wrap" style={{ paddingRight: 24, minHeight: 20 }} onClick={toggleEdit}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <td {...restProps}>{childNode}</td>;
|
||||
});
|
||||
|
||||
const ContextMenuRow = React.memo(({ children, ...props }: any) => {
|
||||
const record = props.record;
|
||||
const context = useContext(DataContext);
|
||||
|
||||
if (!record || !context) return <tr {...props}>{children}</tr>;
|
||||
|
||||
const { selectedRowKeysRef, displayDataRef, handleCopyInsert, handleCopyJson, handleCopyCsv, copyToClipboard } = context;
|
||||
|
||||
const getTargets = () => {
|
||||
const keys = selectedRowKeysRef.current;
|
||||
if (keys.includes(record.key)) {
|
||||
return displayDataRef.current.filter(d => keys.includes(d.key));
|
||||
}
|
||||
return [record];
|
||||
};
|
||||
|
||||
const menuItems: MenuProps['items'] = [
|
||||
{
|
||||
key: 'insert',
|
||||
label: `复制为 INSERT`,
|
||||
icon: <ConsoleSqlOutlined />,
|
||||
onClick: () => handleCopyInsert(record)
|
||||
},
|
||||
{ key: 'json', label: '复制为 JSON', icon: <FileTextOutlined />, onClick: () => handleCopyJson(record) },
|
||||
{ key: 'csv', label: '复制为 CSV', icon: <FileTextOutlined />, onClick: () => handleCopyCsv(record) },
|
||||
{ key: 'copy', label: '复制为 Markdown', icon: <CopyOutlined />, onClick: () => {
|
||||
const records = getTargets();
|
||||
const lines = records.map((r: any) => {
|
||||
const { key, ...vals } = r;
|
||||
return `| ${Object.values(vals).join(' | ')} |`;
|
||||
});
|
||||
copyToClipboard(lines.join('\n'));
|
||||
} },
|
||||
];
|
||||
|
||||
return (
|
||||
<Dropdown menu={{ items: menuItems }} trigger={['contextMenu']}>
|
||||
<tr {...props}>{children}</tr>
|
||||
</Dropdown>
|
||||
);
|
||||
});
|
||||
|
||||
interface DataGridProps {
|
||||
data: any[];
|
||||
columnNames: string[];
|
||||
loading: boolean;
|
||||
tableName?: string;
|
||||
dbName?: string;
|
||||
connectionId?: string;
|
||||
pkColumns?: string[];
|
||||
readOnly?: boolean;
|
||||
onReload?: () => void;
|
||||
onSort?: (field: string, order: string) => void;
|
||||
onPageChange?: (page: number, size: number) => void;
|
||||
pagination?: { current: number, pageSize: number, total: number };
|
||||
// Filtering
|
||||
showFilter?: boolean;
|
||||
onToggleFilter?: () => void;
|
||||
onApplyFilter?: (conditions: any[]) => void;
|
||||
}
|
||||
|
||||
const DataGrid: React.FC<DataGridProps> = ({
|
||||
data, columnNames, loading, tableName, dbName, connectionId, pkColumns = [], readOnly = false,
|
||||
onReload, onSort, onPageChange, pagination, showFilter, onToggleFilter, onApplyFilter
|
||||
}) => {
|
||||
const connections = useStore(state => state.connections);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const [sortInfo, setSortInfo] = useState<{ columnKey: string, order: string } | null>(null);
|
||||
const [columnWidths, setColumnWidths] = useState<Record<string, number>>({});
|
||||
|
||||
// Dynamic Height
|
||||
const [tableHeight, setTableHeight] = useState(500);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
const resizeObserver = new ResizeObserver(entries => {
|
||||
for (let entry of entries) {
|
||||
// Subtract header height (~40px)
|
||||
// Ensure minimum height to prevent collapse loop
|
||||
const h = Math.max(100, entry.contentRect.height - 42);
|
||||
setTableHeight(h);
|
||||
}
|
||||
});
|
||||
resizeObserver.observe(containerRef.current);
|
||||
return () => resizeObserver.disconnect();
|
||||
}, []);
|
||||
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
|
||||
const [addedRows, setAddedRows] = useState<any[]>([]);
|
||||
const [modifiedRows, setModifiedRows] = useState<Record<string, any>>({});
|
||||
const [deletedRowKeys, setDeletedRowKeys] = useState<Set<React.Key>>(new Set());
|
||||
|
||||
// Filter State
|
||||
const [filterConditions, setFilterConditions] = useState<{ id: number, column: string, op: string, value: string }[]>([]);
|
||||
const [nextFilterId, setNextFilterId] = useState(1);
|
||||
|
||||
const selectedRowKeysRef = useRef(selectedRowKeys);
|
||||
const displayDataRef = useRef<any[]>([]);
|
||||
|
||||
useEffect(() => { selectedRowKeysRef.current = selectedRowKeys; }, [selectedRowKeys]);
|
||||
|
||||
// Reset local state when data source likely changes (e.g. tableName change)
|
||||
useEffect(() => {
|
||||
setAddedRows([]);
|
||||
setModifiedRows({});
|
||||
setDeletedRowKeys(new Set());
|
||||
setSelectedRowKeys([]);
|
||||
}, [tableName, dbName, connectionId]); // Reset on context change
|
||||
|
||||
const displayData = useMemo(() => {
|
||||
return [...data, ...addedRows].filter(item => !deletedRowKeys.has(item.key));
|
||||
}, [data, addedRows, deletedRowKeys]);
|
||||
|
||||
useEffect(() => { displayDataRef.current = displayData; }, [displayData]);
|
||||
|
||||
const hasChanges = addedRows.length > 0 || Object.keys(modifiedRows).length > 0 || deletedRowKeys.size > 0;
|
||||
|
||||
const handleTableChange = (pag: any, filtersArg: any, sorter: any) => {
|
||||
if (isResizingRef.current) return; // Block sort if resizing
|
||||
if (sorter.field) {
|
||||
const order = sorter.order as string;
|
||||
setSortInfo({ columnKey: sorter.field as string, order });
|
||||
if (onSort) onSort(sorter.field, order);
|
||||
} else {
|
||||
setSortInfo(null);
|
||||
if (onSort) onSort('', '');
|
||||
}
|
||||
};
|
||||
|
||||
// Native Drag State
|
||||
const draggingRef = useRef<{
|
||||
startX: number,
|
||||
startWidth: number,
|
||||
key: string
|
||||
} | null>(null);
|
||||
const ghostRef = useRef<HTMLDivElement>(null);
|
||||
const isResizingRef = useRef(false); // Lock for sorting
|
||||
|
||||
// 1. Drag Start
|
||||
|
||||
const handleResizeStart = useCallback((key: string) => (e: React.MouseEvent) => {
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
e.stopPropagation();
|
||||
|
||||
|
||||
|
||||
isResizingRef.current = true; // Engage lock
|
||||
|
||||
|
||||
|
||||
const startX = e.clientX;
|
||||
|
||||
const currentWidth = columnWidths[key] || 200;
|
||||
|
||||
|
||||
|
||||
draggingRef.current = { startX, startWidth: currentWidth, key };
|
||||
|
||||
|
||||
|
||||
// Show Ghost Line at initial position
|
||||
|
||||
if (ghostRef.current && containerRef.current) {
|
||||
|
||||
const containerRect = containerRef.current.getBoundingClientRect();
|
||||
|
||||
const relativeLeft = startX - containerRect.left;
|
||||
|
||||
ghostRef.current.style.left = `${relativeLeft}px`;
|
||||
|
||||
ghostRef.current.style.display = 'block';
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Add global listeners
|
||||
|
||||
document.addEventListener('mousemove', handleResizeMove);
|
||||
|
||||
document.addEventListener('mouseup', handleResizeStop);
|
||||
|
||||
document.body.style.cursor = 'col-resize';
|
||||
|
||||
document.body.style.userSelect = 'none';
|
||||
|
||||
}, [columnWidths]);
|
||||
|
||||
// 2. Drag Move (Global)
|
||||
const handleResizeMove = useCallback((e: MouseEvent) => {
|
||||
if (!draggingRef.current || !ghostRef.current || !containerRef.current) return;
|
||||
|
||||
// Update Ghost Line Position directly
|
||||
const containerRect = containerRef.current.getBoundingClientRect();
|
||||
const relativeLeft = e.clientX - containerRect.left;
|
||||
ghostRef.current.style.left = `${relativeLeft}px`;
|
||||
}, []);
|
||||
|
||||
// 3. Drag Stop (Global)
|
||||
const handleResizeStop = useCallback((e: MouseEvent) => {
|
||||
if (!draggingRef.current) return;
|
||||
|
||||
const { startX, startWidth, key } = draggingRef.current;
|
||||
const deltaX = e.clientX - startX;
|
||||
const newWidth = Math.max(50, startWidth + deltaX);
|
||||
|
||||
// Commit State
|
||||
setColumnWidths(prev => ({ ...prev, [key]: newWidth }));
|
||||
|
||||
// Cleanup
|
||||
if (ghostRef.current) ghostRef.current.style.display = 'none';
|
||||
document.removeEventListener('mousemove', handleResizeMove);
|
||||
document.removeEventListener('mouseup', handleResizeStop);
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
draggingRef.current = null;
|
||||
|
||||
// Release lock after a short delay to block subsequent click events (sorting)
|
||||
setTimeout(() => {
|
||||
isResizingRef.current = false;
|
||||
}, 100);
|
||||
}, []);
|
||||
|
||||
const handleCellSave = useCallback((row: any) => {
|
||||
// Optimistic update for display
|
||||
// In parent-controlled data, we might need parent to update 'data',
|
||||
// but here we manage 'modifiedRows' locally and overlay it.
|
||||
// Since 'displayData' is derived from 'data' + 'modifiedRows', we need to update the source if it's in 'data'.
|
||||
// But 'data' prop is immutable.
|
||||
// So we update 'modifiedRows'.
|
||||
|
||||
// Check if it's an added row
|
||||
const isAdded = addedRows.some(r => r.key === row.key);
|
||||
if (isAdded) {
|
||||
setAddedRows(prev => prev.map(r => r.key === row.key ? { ...r, ...row } : r));
|
||||
} else {
|
||||
setModifiedRows(prev => ({ ...prev, [row.key]: row }));
|
||||
}
|
||||
}, [addedRows]);
|
||||
|
||||
// Merge Data for Display
|
||||
// 'displayData' already merges addedRows.
|
||||
// We need to merge modifiedRows into it for rendering.
|
||||
const mergedDisplayData = useMemo(() => {
|
||||
return displayData.map(row => {
|
||||
if (modifiedRows[row.key]) {
|
||||
return { ...row, ...modifiedRows[row.key] };
|
||||
}
|
||||
return row;
|
||||
});
|
||||
}, [displayData, modifiedRows]);
|
||||
|
||||
const columns = useMemo(() => {
|
||||
return columnNames.map(key => ({
|
||||
title: key,
|
||||
dataIndex: key,
|
||||
key: key,
|
||||
ellipsis: true,
|
||||
width: columnWidths[key] || 200,
|
||||
sorter: !!onSort,
|
||||
sortOrder: (sortInfo?.columnKey === key ? sortInfo.order : null) as SortOrder | undefined,
|
||||
editable: !readOnly && !!tableName, // Only editable if table name known
|
||||
render: (text: any) => formatCellValue(text),
|
||||
onHeaderCell: (column: any) => ({
|
||||
width: column.width,
|
||||
onResizeStart: handleResizeStart(key), // Only need start
|
||||
}),
|
||||
}));
|
||||
}, [columnNames, columnWidths, sortInfo, handleResizeStart, readOnly, tableName, onSort]);
|
||||
|
||||
const mergedColumns = useMemo(() => columns.map(col => {
|
||||
if (!col.editable) return col;
|
||||
return {
|
||||
...col,
|
||||
onCell: (record: Item) => ({
|
||||
record,
|
||||
editable: col.editable,
|
||||
dataIndex: col.dataIndex,
|
||||
title: col.title,
|
||||
handleSave: handleCellSave,
|
||||
}),
|
||||
};
|
||||
}), [columns, handleCellSave]);
|
||||
|
||||
const handleAddRow = () => {
|
||||
const newKey = `new-${Date.now()}`;
|
||||
const newRow: any = { key: newKey };
|
||||
columnNames.forEach(col => newRow[col] = '');
|
||||
setAddedRows(prev => [...prev, newRow]);
|
||||
};
|
||||
|
||||
const handleDeleteSelected = () => {
|
||||
setDeletedRowKeys(prev => {
|
||||
const newDeleted = new Set(prev);
|
||||
selectedRowKeys.forEach(key => newDeleted.add(key));
|
||||
return newDeleted;
|
||||
});
|
||||
setSelectedRowKeys([]);
|
||||
};
|
||||
|
||||
const handleCommit = async () => {
|
||||
if (!connectionId || !tableName) return;
|
||||
const conn = connections.find(c => c.id === connectionId);
|
||||
if (!conn) return;
|
||||
|
||||
const inserts: any[] = [];
|
||||
const updates: any[] = [];
|
||||
const deletes: any[] = [];
|
||||
|
||||
addedRows.forEach(row => { const { key, ...vals } = row; inserts.push(vals); });
|
||||
deletedRowKeys.forEach(key => {
|
||||
// Find original data
|
||||
const originalRow = data.find(d => d.key === key) || addedRows.find(d => d.key === key);
|
||||
if (originalRow) {
|
||||
const pkData: any = {};
|
||||
if (pkColumns.length > 0) pkColumns.forEach(k => pkData[k] = originalRow[k]);
|
||||
else { const { key: _, ...rest } = originalRow; Object.assign(pkData, rest); }
|
||||
deletes.push(pkData);
|
||||
}
|
||||
});
|
||||
Object.entries(modifiedRows).forEach(([key, newRow]) => {
|
||||
if (deletedRowKeys.has(key)) return;
|
||||
const originalRow = data.find(d => d.key === key);
|
||||
if (!originalRow) return; // Should not happen for modified rows unless deleted
|
||||
|
||||
const pkData: any = {};
|
||||
if (pkColumns.length > 0) pkColumns.forEach(k => pkData[k] = originalRow[k]);
|
||||
else { const { key: _, ...rest } = originalRow; Object.assign(pkData, rest); }
|
||||
|
||||
const { key: _, ...vals } = newRow;
|
||||
updates.push({ keys: pkData, values: vals });
|
||||
});
|
||||
|
||||
if (inserts.length === 0 && updates.length === 0 && deletes.length === 0) {
|
||||
message.info("No changes to commit");
|
||||
return;
|
||||
}
|
||||
|
||||
const config = {
|
||||
...conn.config,
|
||||
port: Number(conn.config.port),
|
||||
password: conn.config.password || "",
|
||||
database: conn.config.database || "",
|
||||
useSSH: conn.config.useSSH || false,
|
||||
ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" }
|
||||
};
|
||||
|
||||
const res = await ApplyChanges(config as any, dbName || '', tableName, { inserts, updates, deletes } as any);
|
||||
if (res.success) {
|
||||
message.success("Changes committed successfully!");
|
||||
setAddedRows([]);
|
||||
setModifiedRows({});
|
||||
setDeletedRowKeys(new Set());
|
||||
if (onReload) onReload();
|
||||
} else {
|
||||
message.error("Commit failed: " + res.message);
|
||||
}
|
||||
};
|
||||
|
||||
const copyToClipboard = useCallback((text: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
message.success("Copied to clipboard");
|
||||
}, []);
|
||||
|
||||
const getTargets = useCallback((clickedRecord: any) => {
|
||||
const selKeys = selectedRowKeysRef.current;
|
||||
const currentData = displayDataRef.current;
|
||||
if (selKeys.includes(clickedRecord.key)) {
|
||||
return currentData.filter(d => selKeys.includes(d.key));
|
||||
}
|
||||
return [clickedRecord];
|
||||
}, []);
|
||||
|
||||
const handleCopyInsert = useCallback((record: any) => {
|
||||
const records = getTargets(record);
|
||||
const sqls = records.map((r: any) => {
|
||||
const { key, ...vals } = r;
|
||||
const cols = Object.keys(vals);
|
||||
const values = Object.values(vals).map(v => v === null ? 'NULL' : `'${v}'`);
|
||||
const targetTable = tableName || 'table';
|
||||
return `INSERT INTO \`${targetTable}\` (${cols.map(c => `\`${c}\``).join(', ')}) VALUES (${values.join(', ')});`;
|
||||
});
|
||||
copyToClipboard(sqls.join('\n'));
|
||||
}, [tableName, getTargets, copyToClipboard]);
|
||||
|
||||
const handleCopyJson = useCallback((record: any) => {
|
||||
const records = getTargets(record);
|
||||
const cleanRecords = records.map((r: any) => {
|
||||
const { key, ...rest } = r;
|
||||
return rest;
|
||||
});
|
||||
copyToClipboard(JSON.stringify(cleanRecords, null, 2));
|
||||
}, [getTargets, copyToClipboard]);
|
||||
|
||||
const handleCopyCsv = useCallback((record: any) => {
|
||||
const records = getTargets(record);
|
||||
const lines = records.map((r: any) => {
|
||||
const { key, ...vals } = r;
|
||||
const values = Object.values(vals).map(v => v === null ? 'NULL' : `"${v}"`);
|
||||
return values.join(',');
|
||||
});
|
||||
copyToClipboard(lines.join('\n'));
|
||||
}, [getTargets, copyToClipboard]);
|
||||
|
||||
// Export
|
||||
const handleExport = async (format: string) => {
|
||||
if (!connectionId || !tableName) return;
|
||||
const conn = connections.find(c => c.id === connectionId);
|
||||
if (!conn) return;
|
||||
const config = { ...conn.config, port: Number(conn.config.port), password: conn.config.password || "", database: conn.config.database || "", useSSH: conn.config.useSSH || false, ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" } };
|
||||
|
||||
const hide = message.loading(`Exporting as ${format.toUpperCase()}...`, 0);
|
||||
const res = await ExportTable(config as any, dbName || '', tableName, format);
|
||||
hide();
|
||||
if (res.success) { message.success("Export Successful"); } else if (res.message !== "Cancelled") { message.error("Export Failed: " + res.message); }
|
||||
};
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!connectionId || !tableName) return;
|
||||
const conn = connections.find(c => c.id === connectionId);
|
||||
if (!conn) return;
|
||||
const config = { ...conn.config, port: Number(conn.config.port), password: conn.config.password || "", database: conn.config.database || "", useSSH: conn.config.useSSH || false, ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" } };
|
||||
|
||||
const res = await ImportData(config as any, dbName || '', tableName);
|
||||
if (res.success) { message.success(res.message); if (onReload) onReload(); } else if (res.message !== "Cancelled") { message.error("Import Failed: " + res.message); }
|
||||
};
|
||||
|
||||
// Filters
|
||||
const addFilter = () => {
|
||||
setFilterConditions([...filterConditions, { id: nextFilterId, column: columnNames[0] || '', op: '=', value: '' }]);
|
||||
setNextFilterId(nextFilterId + 1);
|
||||
};
|
||||
const updateFilter = (id: number, field: string, val: string) => {
|
||||
setFilterConditions(prev => prev.map(c => c.id === id ? { ...c, [field]: val } : c));
|
||||
};
|
||||
const removeFilter = (id: number) => {
|
||||
setFilterConditions(prev => prev.filter(c => c.id !== id));
|
||||
};
|
||||
const applyFilters = () => {
|
||||
if (onApplyFilter) onApplyFilter(filterConditions);
|
||||
};
|
||||
|
||||
const exportMenu: MenuProps['items'] = [
|
||||
{ key: 'csv', label: 'CSV', onClick: () => handleExport('csv') },
|
||||
{ key: 'xlsx', label: 'Excel (XLSX)', onClick: () => handleExport('xlsx') },
|
||||
{ key: 'json', label: 'JSON', onClick: () => handleExport('json') },
|
||||
{ key: 'md', label: 'Markdown', onClick: () => handleExport('md') },
|
||||
];
|
||||
|
||||
const tableComponents = useMemo(() => ({
|
||||
body: { cell: EditableCell, row: ContextMenuRow },
|
||||
header: { cell: ResizableTitle }
|
||||
}), []);
|
||||
|
||||
const totalWidth = columns.reduce((sum, col) => sum + (col.width as number || 200), 0);
|
||||
|
||||
return (
|
||||
<div style={{ height: '100%', overflow: 'hidden', padding: 0, display: 'flex', flexDirection: 'column' }}>
|
||||
{/* 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();
|
||||
}}>刷新</Button>}
|
||||
{tableName && <Button icon={<ImportOutlined />} onClick={handleImport}>导入</Button>}
|
||||
{tableName && <Dropdown menu={{ items: exportMenu }}><Button icon={<ExportOutlined />}>导出 <DownOutlined /></Button></Dropdown>}
|
||||
|
||||
{!readOnly && tableName && (
|
||||
<>
|
||||
<div style={{ width: 1, background: '#eee', height: 20, margin: '0 8px' }} />
|
||||
<Button icon={<PlusOutlined />} onClick={handleAddRow}>添加行</Button>
|
||||
<Button icon={<DeleteOutlined />} danger disabled={selectedRowKeys.length === 0} onClick={handleDeleteSelected}>删除选中</Button>
|
||||
{selectedRowKeys.length > 0 && <span style={{ fontSize: '12px', color: '#888' }}>已选 {selectedRowKeys.length}</span>}
|
||||
<div style={{ width: 1, background: '#eee', height: 20, margin: '0 8px' }} />
|
||||
<Button icon={<SaveOutlined />} type="primary" disabled={!hasChanges} onClick={handleCommit}>提交事务 ({addedRows.length + Object.keys(modifiedRows).length + deletedRowKeys.size})</Button>
|
||||
{hasChanges && (<Button icon={<UndoOutlined />} onClick={() => {
|
||||
setAddedRows([]);
|
||||
setModifiedRows({});
|
||||
setDeletedRowKeys(new Set());
|
||||
}}>回滚</Button>)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{onToggleFilter && (
|
||||
<>
|
||||
<div style={{ width: 1, background: '#eee', height: 20, margin: '0 8px' }} />
|
||||
<Button icon={<FilterOutlined />} type={showFilter ? 'primary' : 'default'} onClick={() => {
|
||||
onToggleFilter();
|
||||
if (filterConditions.length === 0 && !showFilter) addFilter();
|
||||
}}>筛选</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filter Panel */}
|
||||
{showFilter && (
|
||||
<div style={{ padding: '8px', background: '#f5f5f5', borderBottom: '1px solid #eee' }}>
|
||||
{filterConditions.map(cond => (
|
||||
<div key={cond.id} style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
|
||||
<Select style={{ width: 150 }} value={cond.column} onChange={v => updateFilter(cond.id, 'column', v)} options={columnNames.map(c => ({ value: c, label: c }))} />
|
||||
<Select style={{ width: 100 }} value={cond.op} onChange={v => updateFilter(cond.id, 'op', v)} options={[{ value: '=', label: '=' }, { value: 'LIKE', label: '包含' }]} />
|
||||
<Input style={{ width: 200 }} value={cond.value} onChange={e => updateFilter(cond.id, 'value', e.target.value)} />
|
||||
<Button icon={<CloseOutlined />} onClick={() => removeFilter(cond.id)} type="text" danger />
|
||||
</div>
|
||||
))}
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<Button type="dashed" onClick={addFilter} size="small" icon={<FilterOutlined />}>Add Condition</Button>
|
||||
<Button type="primary" onClick={applyFilters} size="small">Apply</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={containerRef} style={{ flex: 1, overflow: 'hidden', position: 'relative' }}>
|
||||
<Form component={false} form={form}>
|
||||
<DataContext.Provider value={{ selectedRowKeysRef, displayDataRef, handleCopyInsert, handleCopyJson, handleCopyCsv, copyToClipboard, tableName }}>
|
||||
<EditableContext.Provider value={form}>
|
||||
<Table
|
||||
components={tableComponents}
|
||||
dataSource={mergedDisplayData}
|
||||
columns={mergedColumns}
|
||||
size="small"
|
||||
scroll={{ x: Math.max(totalWidth, 1000), y: tableHeight }}
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
onChange={handleTableChange}
|
||||
bordered
|
||||
rowSelection={{
|
||||
selectedRowKeys,
|
||||
onChange: setSelectedRowKeys,
|
||||
}}
|
||||
rowClassName={(record) => {
|
||||
if (addedRows.some(r => r.key === record.key)) return 'row-added';
|
||||
if (modifiedRows[record.key] || deletedRowKeys.has(record.key)) return 'row-modified'; // deleted won't show
|
||||
return '';
|
||||
}}
|
||||
onRow={(record) => ({ record } as any)}
|
||||
/>
|
||||
</EditableContext.Provider>
|
||||
</DataContext.Provider>
|
||||
</Form>
|
||||
</div>
|
||||
|
||||
{pagination && (
|
||||
<div style={{ padding: '8px', borderTop: '1px solid #eee', display: 'flex', justifyContent: 'flex-end', background: '#fff' }}>
|
||||
<Pagination
|
||||
current={pagination.current}
|
||||
pageSize={pagination.pageSize}
|
||||
total={pagination.total}
|
||||
showTotal={(total, range) => `当前 ${range[1] - range[0] + 1} 条 / 共 ${total} 条`}
|
||||
showSizeChanger
|
||||
pageSizeOptions={['100', '200', '500', '1000']}
|
||||
onChange={onPageChange}
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<style>{`
|
||||
.row-added td { background-color: #f6ffed !important; }
|
||||
.row-modified td { background-color: #e6f7ff !important; }
|
||||
.ant-table-body {
|
||||
height: ${tableHeight}px !important;
|
||||
max-height: ${tableHeight}px !important;
|
||||
}
|
||||
`}</style>
|
||||
|
||||
{/* Ghost Resize Line for Columns */}
|
||||
<div
|
||||
ref={ghostRef}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
bottom: 0, // Fits container height
|
||||
width: '2px',
|
||||
background: '#1890ff',
|
||||
zIndex: 9999,
|
||||
display: 'none',
|
||||
pointerEvents: 'none'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(DataGrid);
|
||||
@@ -1,200 +1,9 @@
|
||||
import React, { useEffect, useState, useRef, useContext, useMemo, useCallback } from 'react';
|
||||
import { Table, message, Spin, Input, Button, Space, Select, Tag, Dropdown, MenuProps, Form, Popconfirm, Pagination } from 'antd';
|
||||
import type { SortOrder } from 'antd/es/table/interface';
|
||||
import { SearchOutlined, FilterOutlined, CloseOutlined, ReloadOutlined, ImportOutlined, ExportOutlined, DownOutlined, PlusOutlined, DeleteOutlined, SaveOutlined, UndoOutlined, CheckOutlined, ConsoleSqlOutlined, FileTextOutlined, CopyOutlined } from '@ant-design/icons';
|
||||
import { Resizable } from 'react-resizable';
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import { message } from 'antd';
|
||||
import { TabData, ColumnDefinition } from '../types';
|
||||
import { useStore } from '../store';
|
||||
import { MySQLQuery, ImportData, ExportTable, ApplyChanges, DBGetColumns } from '../../wailsjs/go/main/App';
|
||||
import 'react-resizable/css/styles.css';
|
||||
|
||||
// --- Helper: Format Value ---
|
||||
const formatCellValue = (val: any) => {
|
||||
if (val === null) return <span style={{ color: '#ccc' }}>NULL</span>;
|
||||
if (typeof val === 'object') return JSON.stringify(val);
|
||||
if (typeof val === 'string') {
|
||||
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(val)) {
|
||||
return val.replace('T', ' ').replace(/\+.*$/, '').replace(/Z$/, '');
|
||||
}
|
||||
}
|
||||
return String(val);
|
||||
};
|
||||
|
||||
// --- Resizable Header ---
|
||||
const ResizableTitle = (props: any) => {
|
||||
const { onResize, width, ...restProps } = props;
|
||||
|
||||
if (!width) {
|
||||
return <th {...restProps} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Resizable
|
||||
width={width}
|
||||
height={0}
|
||||
handle={
|
||||
<span
|
||||
className="react-resizable-handle"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: -5,
|
||||
bottom: 0,
|
||||
top: 0,
|
||||
width: 10,
|
||||
cursor: 'col-resize',
|
||||
zIndex: 100,
|
||||
touchAction: 'none'
|
||||
}}
|
||||
/>
|
||||
}
|
||||
onResize={onResize}
|
||||
draggableOpts={{ enableUserSelectHack: false }}
|
||||
>
|
||||
<th
|
||||
{...restProps}
|
||||
style={{
|
||||
...restProps.style,
|
||||
position: 'relative',
|
||||
userSelect: 'none'
|
||||
}}
|
||||
/>
|
||||
</Resizable>
|
||||
);
|
||||
};
|
||||
|
||||
// --- Contexts ---
|
||||
const EditableContext = React.createContext<any>(null);
|
||||
|
||||
// Use Ref for selection to prevent Context updates on every selection change
|
||||
const DataContext = React.createContext<{
|
||||
selectedRowKeysRef: React.MutableRefObject<React.Key[]>;
|
||||
displayDataRef: React.MutableRefObject<any[]>;
|
||||
handleCopyInsert: (r: any) => void;
|
||||
handleCopyJson: (r: any) => void;
|
||||
handleCopyCsv: (r: any) => void;
|
||||
copyToClipboard: (t: string) => void;
|
||||
} | null>(null);
|
||||
|
||||
interface Item {
|
||||
key: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
interface EditableCellProps {
|
||||
title: React.ReactNode;
|
||||
editable: boolean;
|
||||
children: React.ReactNode;
|
||||
dataIndex: string;
|
||||
record: Item;
|
||||
handleSave: (record: Item) => void;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
// Optimization: Memoize EditableCell
|
||||
const EditableCell: React.FC<EditableCellProps> = React.memo(({
|
||||
title,
|
||||
editable,
|
||||
children,
|
||||
dataIndex,
|
||||
record,
|
||||
handleSave,
|
||||
...restProps
|
||||
}) => {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const inputRef = useRef<any>(null);
|
||||
const form = useContext(EditableContext);
|
||||
|
||||
useEffect(() => {
|
||||
if (editing) {
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
}, [editing]);
|
||||
|
||||
const toggleEdit = () => {
|
||||
setEditing(!editing);
|
||||
form.setFieldsValue({ [dataIndex]: record[dataIndex] });
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
try {
|
||||
if (!form) return;
|
||||
const values = await form.validateFields();
|
||||
toggleEdit();
|
||||
handleSave({ ...record, ...values });
|
||||
} catch (errInfo) {
|
||||
console.log('Save failed:', errInfo);
|
||||
}
|
||||
};
|
||||
|
||||
let childNode = children;
|
||||
|
||||
if (editable) {
|
||||
childNode = editing ? (
|
||||
<Form.Item
|
||||
style={{ margin: 0 }}
|
||||
name={dataIndex}
|
||||
>
|
||||
<Input ref={inputRef} onPressEnter={save} onBlur={save} />
|
||||
</Form.Item>
|
||||
) : (
|
||||
<div className="editable-cell-value-wrap" style={{ paddingRight: 24, minHeight: 20 }} onClick={toggleEdit}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <td {...restProps}>{childNode}</td>;
|
||||
});
|
||||
|
||||
// --- Context Menu Row Wrapper (External & Memoized) ---
|
||||
const ContextMenuRow = React.memo(({ children, ...props }: any) => {
|
||||
const record = props.record;
|
||||
const context = useContext(DataContext);
|
||||
|
||||
if (!record || !context) {
|
||||
return <tr {...props}>{children}</tr>;
|
||||
}
|
||||
|
||||
const { selectedRowKeysRef, displayDataRef, handleCopyInsert, handleCopyJson, handleCopyCsv, copyToClipboard } = context;
|
||||
|
||||
const getTargets = () => {
|
||||
const keys = selectedRowKeysRef.current;
|
||||
if (keys.includes(record.key)) {
|
||||
return displayDataRef.current.filter(d => keys.includes(d.key));
|
||||
}
|
||||
return [record];
|
||||
};
|
||||
|
||||
const menuItems: MenuProps['items'] = [
|
||||
{
|
||||
key: 'insert',
|
||||
label: `复制为 INSERT`,
|
||||
icon: <ConsoleSqlOutlined />,
|
||||
onClick: () => handleCopyInsert(record)
|
||||
},
|
||||
{ key: 'json', label: '复制为 JSON', icon: <FileTextOutlined />, onClick: () => handleCopyJson(record) },
|
||||
{ key: 'csv', label: '复制为 CSV', icon: <FileTextOutlined />, onClick: () => handleCopyCsv(record) },
|
||||
{ key: 'copy', label: '复制为 Markdown', icon: <CopyOutlined />, onClick: () => {
|
||||
const records = getTargets();
|
||||
const lines = records.map((r: any) => {
|
||||
const { key, ...vals } = r;
|
||||
return `| ${Object.values(vals).join(' | ')} |`;
|
||||
});
|
||||
copyToClipboard(lines.join('\n'));
|
||||
} },
|
||||
];
|
||||
|
||||
return (
|
||||
<Dropdown menu={{ items: menuItems }} trigger={['contextMenu']}>
|
||||
<tr {...props}>{children}</tr>
|
||||
</Dropdown>
|
||||
);
|
||||
});
|
||||
import { MySQLQuery, DBGetColumns } from '../../wailsjs/go/app/App';
|
||||
import DataGrid from './DataGrid';
|
||||
|
||||
const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
const [data, setData] = useState<any[]>([]);
|
||||
@@ -209,39 +18,12 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
total: 0
|
||||
});
|
||||
|
||||
const [form] = Form.useForm();
|
||||
const [sortInfo, setSortInfo] = useState<{ columnKey: string, order: string } | null>(null);
|
||||
|
||||
const [showFilter, setShowFilter] = useState(false);
|
||||
const [filterConditions, setFilterConditions] = useState<{ id: number, column: string, op: string, value: string }[]>([]);
|
||||
const [nextFilterId, setNextFilterId] = useState(1);
|
||||
const [filterConditions, setFilterConditions] = useState<any[]>([]);
|
||||
|
||||
const [columnWidths, setColumnWidths] = useState<Record<string, number>>({});
|
||||
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
|
||||
const [addedRows, setAddedRows] = useState<any[]>([]);
|
||||
const [modifiedRows, setModifiedRows] = useState<Record<string, any>>({});
|
||||
const [deletedRowKeys, setDeletedRowKeys] = useState<Set<React.Key>>(new Set());
|
||||
|
||||
// Refs
|
||||
const selectedRowKeysRef = useRef(selectedRowKeys);
|
||||
const displayDataRef = useRef<any[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
selectedRowKeysRef.current = selectedRowKeys;
|
||||
}, [selectedRowKeys]);
|
||||
|
||||
const displayData = useMemo(() => {
|
||||
return [...data, ...addedRows].filter(item => !deletedRowKeys.has(item.key));
|
||||
}, [data, addedRows, deletedRowKeys]);
|
||||
|
||||
useEffect(() => {
|
||||
displayDataRef.current = displayData;
|
||||
}, [displayData]);
|
||||
|
||||
const hasChanges = addedRows.length > 0 || Object.keys(modifiedRows).length > 0 || deletedRowKeys.size > 0;
|
||||
|
||||
const fetchData = async (page = pagination.current, size = pagination.pageSize) => {
|
||||
const fetchData = useCallback(async (page = pagination.current, size = pagination.pageSize) => {
|
||||
setLoading(true);
|
||||
const conn = connections.find(c => c.id === tab.connectionId);
|
||||
if (!conn) {
|
||||
@@ -320,11 +102,6 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
setData(resultData.map((row: any, i: number) => ({ ...row, key: `row-${i}` })));
|
||||
|
||||
setPagination(prev => ({ ...prev, current: page, pageSize: size, total: totalRecords }));
|
||||
|
||||
setAddedRows([]);
|
||||
setModifiedRows({});
|
||||
setDeletedRowKeys(new Set());
|
||||
setSelectedRowKeys([]);
|
||||
} else {
|
||||
message.error(resData.message);
|
||||
}
|
||||
@@ -332,323 +109,42 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
message.error("Error fetching data: " + e.message);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
}, [connections, tab, sortInfo, filterConditions, pkColumns.length]);
|
||||
// Depend on pkColumns.length to avoid loop? No, pkColumns is updated inside.
|
||||
// Actually, 'pkColumns' state shouldn't trigger re-fetch.
|
||||
// The 'if (pkColumns.length === 0)' check is inside.
|
||||
// So adding pkColumns to dependency is safer but might trigger double fetch if not careful?
|
||||
// Only if pkColumns changes. It changes once from [] to [...].
|
||||
// So it's fine.
|
||||
|
||||
// Handlers memoized
|
||||
const handleReload = useCallback(() => fetchData(), [fetchData]);
|
||||
const handleSort = useCallback((field: string, order: string) => setSortInfo({ columnKey: field, order }), []);
|
||||
const handlePageChange = useCallback((page: number, size: number) => fetchData(page, size), [fetchData]);
|
||||
const handleToggleFilter = useCallback(() => setShowFilter(prev => !prev), []);
|
||||
const handleApplyFilter = useCallback((conditions: any[]) => setFilterConditions(conditions), []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData(1, pagination.pageSize);
|
||||
}, [tab, sortInfo]);
|
||||
|
||||
const handlePaginationChange = (page: number, pageSize: number) => {
|
||||
fetchData(page, pageSize);
|
||||
};
|
||||
|
||||
const handleTableChange = (pag: any, filtersArg: any, sorter: any) => {
|
||||
if (sorter.field) {
|
||||
setSortInfo({ columnKey: sorter.field as string, order: sorter.order as string });
|
||||
} else {
|
||||
setSortInfo(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResize = useCallback((key: string) => (_: React.SyntheticEvent, { size }: { size: { width: number } }) => {
|
||||
window.requestAnimationFrame(() => {
|
||||
setColumnWidths(prev => ({ ...prev, [key]: size.width }));
|
||||
});
|
||||
}, []);
|
||||
|
||||
const columns = useMemo(() => {
|
||||
return columnNames.map(key => ({
|
||||
title: key,
|
||||
dataIndex: key,
|
||||
key: key,
|
||||
ellipsis: true,
|
||||
width: columnWidths[key] || 200,
|
||||
sorter: true,
|
||||
sortOrder: (sortInfo?.columnKey === key ? sortInfo.order : null) as SortOrder | undefined,
|
||||
editable: true,
|
||||
render: (text: any) => formatCellValue(text),
|
||||
onHeaderCell: (column: any) => ({
|
||||
width: column.width,
|
||||
onResize: handleResize(key),
|
||||
}),
|
||||
}));
|
||||
}, [columnNames, columnWidths, sortInfo, handleResize]);
|
||||
|
||||
// Calculate total width
|
||||
const totalWidth = columns.reduce((sum, col) => sum + (col.width as number || 200), 0);
|
||||
|
||||
const handleCellSave = useCallback((row: any) => {
|
||||
setData(prevData => {
|
||||
const newData = [...prevData];
|
||||
const index = newData.findIndex(item => item.key === row.key);
|
||||
if (index > -1) {
|
||||
const item = newData[index];
|
||||
newData.splice(index, 1, { ...item, ...row });
|
||||
setModifiedRows(prev => ({ ...prev, [row.key]: row }));
|
||||
return newData;
|
||||
}
|
||||
return prevData;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Compute merged columns for editable
|
||||
const mergedColumns = useMemo(() => columns.map(col => {
|
||||
if (!col.editable) return col;
|
||||
return {
|
||||
...col,
|
||||
onCell: (record: Item) => ({
|
||||
record,
|
||||
editable: col.editable,
|
||||
dataIndex: col.dataIndex,
|
||||
title: col.title,
|
||||
handleSave: handleCellSave,
|
||||
}),
|
||||
};
|
||||
}), [columns, handleCellSave]);
|
||||
|
||||
const handleAddRow = () => {
|
||||
const newKey = `new-${Date.now()}`;
|
||||
const newRow: any = { key: newKey };
|
||||
columnNames.forEach(col => newRow[col] = '');
|
||||
setAddedRows(prev => [...prev, newRow]);
|
||||
};
|
||||
|
||||
const handleDeleteSelected = () => {
|
||||
setDeletedRowKeys(prev => {
|
||||
const newDeleted = new Set(prev);
|
||||
selectedRowKeys.forEach(key => {
|
||||
newDeleted.add(key);
|
||||
});
|
||||
return newDeleted;
|
||||
});
|
||||
setSelectedRowKeys([]);
|
||||
};
|
||||
|
||||
const handleCommit = async () => {
|
||||
const conn = connections.find(c => c.id === tab.connectionId);
|
||||
if (!conn) return;
|
||||
|
||||
const inserts: any[] = [];
|
||||
const updates: any[] = [];
|
||||
const deletes: any[] = [];
|
||||
|
||||
addedRows.forEach(row => { const { key, ...vals } = row; inserts.push(vals); });
|
||||
deletedRowKeys.forEach(key => {
|
||||
const originalRow = data.find(d => d.key === key);
|
||||
if (originalRow) {
|
||||
const pkData: any = {};
|
||||
if (pkColumns.length > 0) pkColumns.forEach(k => pkData[k] = originalRow[k]);
|
||||
else { const { key: _, ...rest } = originalRow; Object.assign(pkData, rest); }
|
||||
deletes.push(pkData);
|
||||
}
|
||||
});
|
||||
Object.entries(modifiedRows).forEach(([key, newRow]) => {
|
||||
if (deletedRowKeys.has(key)) return;
|
||||
const originalRow = data.find(d => d.key === key);
|
||||
if (!originalRow) return;
|
||||
const pkData: any = {};
|
||||
if (pkColumns.length > 0) pkColumns.forEach(k => pkData[k] = originalRow[k]);
|
||||
else { const { key: _, ...rest } = originalRow; Object.assign(pkData, rest); }
|
||||
const { key: _, ...vals } = newRow;
|
||||
updates.push({ keys: pkData, values: vals });
|
||||
});
|
||||
|
||||
if (inserts.length === 0 && updates.length === 0 && deletes.length === 0) {
|
||||
message.info("No changes to commit");
|
||||
return;
|
||||
}
|
||||
|
||||
const config = { ...conn.config, port: Number(conn.config.port), password: conn.config.password || "", database: conn.config.database || "", useSSH: conn.config.useSSH || false, ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" } };
|
||||
const res = await ApplyChanges(config as any, tab.dbName || '', tab.tableName || '', { inserts, updates, deletes } as any);
|
||||
if (res.success) {
|
||||
message.success("Changes committed successfully!");
|
||||
fetchData();
|
||||
} else {
|
||||
message.error("Commit failed: " + res.message);
|
||||
}
|
||||
};
|
||||
|
||||
const copyToClipboard = useCallback((text: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
message.success("Copied to clipboard");
|
||||
}, []);
|
||||
|
||||
const getTargets = useCallback((clickedRecord: any) => {
|
||||
const selKeys = selectedRowKeysRef.current;
|
||||
const currentData = displayDataRef.current;
|
||||
if (selKeys.includes(clickedRecord.key)) {
|
||||
return currentData.filter(d => selKeys.includes(d.key));
|
||||
}
|
||||
return [clickedRecord];
|
||||
}, []);
|
||||
|
||||
const handleCopyInsert = useCallback((record: any) => {
|
||||
const records = getTargets(record);
|
||||
const sqls = records.map((r: any) => {
|
||||
const { key, ...vals } = r;
|
||||
const cols = Object.keys(vals);
|
||||
const values = Object.values(vals).map(v => v === null ? 'NULL' : `'${v}'`);
|
||||
return `INSERT INTO \`${tab.tableName}\` (${cols.map(c => `\`${c}\``).join(', ')}) VALUES (${values.join(', ')});`;
|
||||
});
|
||||
copyToClipboard(sqls.join('\n'));
|
||||
}, [tab.tableName, getTargets, copyToClipboard]);
|
||||
|
||||
const handleCopyJson = useCallback((record: any) => {
|
||||
const records = getTargets(record);
|
||||
const cleanRecords = records.map((r: any) => {
|
||||
const { key, ...rest } = r;
|
||||
return rest;
|
||||
});
|
||||
copyToClipboard(JSON.stringify(cleanRecords, null, 2));
|
||||
}, [getTargets, copyToClipboard]);
|
||||
|
||||
const handleCopyCsv = useCallback((record: any) => {
|
||||
const records = getTargets(record);
|
||||
const lines = records.map((r: any) => {
|
||||
const { key, ...vals } = r;
|
||||
const values = Object.values(vals).map(v => v === null ? 'NULL' : `"${v}"`);
|
||||
return values.join(',');
|
||||
});
|
||||
copyToClipboard(lines.join('\n'));
|
||||
}, [getTargets, copyToClipboard]);
|
||||
|
||||
// ... (Filter Handlers)
|
||||
const addFilter = () => {
|
||||
setFilterConditions([...filterConditions, { id: nextFilterId, column: columnNames[0] || '', op: '=', value: '' }]);
|
||||
setNextFilterId(nextFilterId + 1);
|
||||
setShowFilter(true);
|
||||
};
|
||||
const updateFilter = (id: number, field: string, val: string) => {
|
||||
setFilterConditions(prev => prev.map(c => c.id === id ? { ...c, [field]: val } : c));
|
||||
};
|
||||
const removeFilter = (id: number) => {
|
||||
setFilterConditions(prev => prev.filter(c => c.id !== id));
|
||||
};
|
||||
const applyFilters = () => fetchData(1, pagination.pageSize);
|
||||
|
||||
const handleImport = async () => {
|
||||
const conn = connections.find(c => c.id === tab.connectionId);
|
||||
if (!conn) return;
|
||||
const config = { ...conn.config, port: Number(conn.config.port), password: conn.config.password || "", database: conn.config.database || "", useSSH: conn.config.useSSH || false, ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" } };
|
||||
const res = await ImportData(config as any, tab.dbName || '', tab.tableName || '');
|
||||
if (res.success) { message.success(res.message); fetchData(); } else if (res.message !== "Cancelled") { message.error("Import Failed: " + res.message); }
|
||||
};
|
||||
|
||||
const handleExport = async (format: string) => {
|
||||
const conn = connections.find(c => c.id === tab.connectionId);
|
||||
if (!conn) return;
|
||||
const config = { ...conn.config, port: Number(conn.config.port), password: conn.config.password || "", database: conn.config.database || "", useSSH: conn.config.useSSH || false, ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" } };
|
||||
const hide = message.loading(`Exporting as ${format.toUpperCase()}...`, 0);
|
||||
const res = await ExportTable(config as any, tab.dbName || '', tab.tableName || '', format);
|
||||
hide();
|
||||
if (res.success) { message.success("Export Successful"); } else if (res.message !== "Cancelled") { message.error("Export Failed: " + res.message); }
|
||||
};
|
||||
|
||||
const exportMenu: MenuProps['items'] = [
|
||||
{ key: 'csv', label: 'CSV', onClick: () => handleExport('csv') },
|
||||
{ key: 'xlsx', label: 'Excel (XLSX)', onClick: () => handleExport('xlsx') },
|
||||
{ key: 'json', label: 'JSON', onClick: () => handleExport('json') },
|
||||
{ key: 'md', label: 'Markdown', onClick: () => handleExport('md') },
|
||||
];
|
||||
|
||||
const contextValue = useMemo(() => ({
|
||||
selectedRowKeysRef,
|
||||
displayDataRef,
|
||||
handleCopyInsert,
|
||||
handleCopyJson,
|
||||
handleCopyCsv,
|
||||
copyToClipboard
|
||||
}), [handleCopyInsert, handleCopyJson, handleCopyCsv, copyToClipboard]);
|
||||
|
||||
const tableComponents = useMemo(() => ({
|
||||
body: { cell: EditableCell, row: ContextMenuRow },
|
||||
header: { cell: ResizableTitle }
|
||||
}), []);
|
||||
}, [tab, sortInfo, filterConditions]); // Initial load and re-load on sort/filter
|
||||
|
||||
return (
|
||||
<div style={{ height: '100%', overflow: 'hidden', padding: 0, display: 'flex', flexDirection: 'column' }}>
|
||||
{/* Toolbar */}
|
||||
<div style={{ padding: '8px', borderBottom: '1px solid #eee', display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => fetchData()}>刷新</Button>
|
||||
<Button icon={<ImportOutlined />} onClick={handleImport}>导入</Button>
|
||||
<Dropdown menu={{ items: exportMenu }}><Button icon={<ExportOutlined />}>导出 <DownOutlined /></Button></Dropdown>
|
||||
<div style={{ width: 1, background: '#eee', height: 20, margin: '0 8px' }} />
|
||||
<Button icon={<PlusOutlined />} onClick={handleAddRow}>添加行</Button>
|
||||
<Button icon={<DeleteOutlined />} danger disabled={selectedRowKeys.length === 0} onClick={handleDeleteSelected}>删除选中</Button>
|
||||
<div style={{ width: 1, background: '#eee', height: 20, margin: '0 8px' }} />
|
||||
<Button icon={<SaveOutlined />} type="primary" disabled={!hasChanges} onClick={handleCommit}>提交事务 ({addedRows.length + Object.keys(modifiedRows).length + deletedRowKeys.size})</Button>
|
||||
{hasChanges && (<Button icon={<UndoOutlined />} onClick={() => fetchData()}>回滚</Button>)}
|
||||
<div style={{ width: 1, background: '#eee', height: 20, margin: '0 8px' }} />
|
||||
<Button icon={<FilterOutlined />} type={showFilter ? 'primary' : 'default'} onClick={() => { setShowFilter(!showFilter); if (filterConditions.length === 0 && !showFilter) addFilter(); }}>筛选</Button>
|
||||
</div>
|
||||
|
||||
{/* Filter Panel */}
|
||||
{showFilter && (
|
||||
<div style={{ padding: '8px', background: '#f5f5f5', borderBottom: '1px solid #eee' }}>
|
||||
{filterConditions.map(cond => (
|
||||
<div key={cond.id} style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
|
||||
<Select style={{ width: 150 }} value={cond.column} onChange={v => updateFilter(cond.id, 'column', v)} options={columnNames.map(c => ({ value: c, label: c }))} />
|
||||
<Select style={{ width: 100 }} value={cond.op} onChange={v => updateFilter(cond.id, 'op', v)} options={[{ value: '=', label: '=' }, { value: 'LIKE', label: '包含' }]} />
|
||||
<Input style={{ width: 200 }} value={cond.value} onChange={e => updateFilter(cond.id, 'value', e.target.value)} />
|
||||
<Button icon={<CloseOutlined />} onClick={() => removeFilter(cond.id)} type="text" danger />
|
||||
</div>
|
||||
))}
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<Button type="dashed" onClick={addFilter} size="small" icon={<FilterOutlined />}>Add Condition</Button>
|
||||
<Button type="primary" onClick={applyFilters} size="small">Apply</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ flex: 1, overflow: 'hidden' }}>
|
||||
<Form component={false} form={form}>
|
||||
<DataContext.Provider value={contextValue}>
|
||||
<EditableContext.Provider value={form}>
|
||||
<Table
|
||||
components={tableComponents}
|
||||
dataSource={displayData}
|
||||
columns={mergedColumns}
|
||||
size="small"
|
||||
scroll={{ x: Math.max(totalWidth, 1000), y: 'calc(100vh - 200px - 40px)' }}
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
onChange={handleTableChange}
|
||||
bordered
|
||||
rowSelection={{
|
||||
selectedRowKeys,
|
||||
onChange: setSelectedRowKeys,
|
||||
}}
|
||||
rowClassName={(record) => {
|
||||
if (addedRows.includes(record)) return 'row-added';
|
||||
if (modifiedRows[record.key]) return 'row-modified';
|
||||
return '';
|
||||
}}
|
||||
onRow={(record) => ({ record } as any)}
|
||||
/>
|
||||
</EditableContext.Provider>
|
||||
</DataContext.Provider>
|
||||
</Form>
|
||||
</div>
|
||||
|
||||
{/* Pagination Bar */}
|
||||
<div style={{ padding: '8px', borderTop: '1px solid #eee', display: 'flex', justifyContent: 'flex-end', background: '#fff' }}>
|
||||
<Pagination
|
||||
current={pagination.current}
|
||||
pageSize={pagination.pageSize}
|
||||
total={pagination.total}
|
||||
showTotal={(total, range) => `当前 ${range[1] - range[0] + 1} 条 / 共 ${total} 条`}
|
||||
showSizeChanger
|
||||
pageSizeOptions={['100', '200', '500', '1000']}
|
||||
onChange={handlePaginationChange}
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
.row-added td { background-color: #f6ffed !important; }
|
||||
.row-modified td { background-color: #e6f7ff !important; }
|
||||
`}</style>
|
||||
</div>
|
||||
<DataGrid
|
||||
data={data}
|
||||
columnNames={columnNames}
|
||||
loading={loading}
|
||||
tableName={tab.tableName}
|
||||
dbName={tab.dbName}
|
||||
connectionId={tab.connectionId}
|
||||
pkColumns={pkColumns}
|
||||
onReload={handleReload}
|
||||
onSort={handleSort}
|
||||
onPageChange={handlePageChange}
|
||||
pagination={pagination}
|
||||
showFilter={showFilter}
|
||||
onToggleFilter={handleToggleFilter}
|
||||
onApplyFilter={handleApplyFilter}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,20 +1,31 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import Editor, { OnMount } from '@monaco-editor/react';
|
||||
import { Button, Table, message, Modal, Input, Form, Dropdown, MenuProps, Tooltip } from 'antd';
|
||||
import { Button, message, Modal, Input, Form, Dropdown, MenuProps, Tooltip, Select } from 'antd';
|
||||
import { PlayCircleOutlined, SaveOutlined, FormatPainterOutlined, SettingOutlined } from '@ant-design/icons';
|
||||
import { format } from 'sql-formatter';
|
||||
import { TabData } from '../types';
|
||||
import { TabData, ColumnDefinition } from '../types';
|
||||
import { useStore } from '../store';
|
||||
import { MySQLQuery, DBGetTables, DBGetAllColumns } from '../../wailsjs/go/main/App';
|
||||
import { MySQLQuery, DBGetTables, DBGetAllColumns, MySQLGetDatabases, DBGetColumns } from '../../wailsjs/go/app/App';
|
||||
import DataGrid 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 [columns, setColumns] = useState<any[]>([]);
|
||||
const [columnNames, setColumnNames] = useState<string[]>([]);
|
||||
const [pkColumns, setPkColumns] = useState<string[]>([]);
|
||||
const [targetTableName, setTargetTableName] = useState<string | undefined>(undefined);
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isSaveModalOpen, setIsSaveModalOpen] = useState(false);
|
||||
const [saveForm] = Form.useForm();
|
||||
|
||||
// Database Selection
|
||||
const [currentConnectionId, setCurrentConnectionId] = useState<string>(tab.connectionId);
|
||||
const [currentDb, setCurrentDb] = useState<string>(tab.dbName || '');
|
||||
const [dbList, setDbList] = useState<string[]>([]);
|
||||
|
||||
// Resizing state
|
||||
const [editorHeight, setEditorHeight] = useState(300);
|
||||
const editorRef = useRef<any>(null);
|
||||
@@ -31,16 +42,44 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
|
||||
// If opening a saved query, load its SQL
|
||||
useEffect(() => {
|
||||
if (tab.query) {
|
||||
setQuery(tab.query);
|
||||
}
|
||||
if (tab.query) setQuery(tab.query);
|
||||
}, [tab.query]);
|
||||
|
||||
// Fetch Database List
|
||||
useEffect(() => {
|
||||
const fetchDbs = async () => {
|
||||
const conn = connections.find(c => c.id === currentConnectionId);
|
||||
if (!conn) return;
|
||||
|
||||
const config = {
|
||||
...conn.config,
|
||||
port: Number(conn.config.port),
|
||||
password: conn.config.password || "",
|
||||
database: conn.config.database || "",
|
||||
useSSH: conn.config.useSSH || false,
|
||||
ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" }
|
||||
};
|
||||
|
||||
const res = await MySQLGetDatabases(config as any);
|
||||
if (res.success && Array.isArray(res.data)) {
|
||||
const dbs = res.data.map((row: any) => row.Database || row.database);
|
||||
setDbList(dbs);
|
||||
if (!currentDb) {
|
||||
if (conn.config.database) setCurrentDb(conn.config.database);
|
||||
else if (dbs.length > 0 && dbs[0] !== 'information_schema') setCurrentDb(dbs[0]);
|
||||
}
|
||||
} else {
|
||||
setDbList([]);
|
||||
}
|
||||
};
|
||||
fetchDbs();
|
||||
}, [currentConnectionId, connections, currentDb]);
|
||||
|
||||
// Fetch Metadata for Autocomplete
|
||||
useEffect(() => {
|
||||
const fetchMetadata = async () => {
|
||||
const conn = connections.find(c => c.id === tab.connectionId);
|
||||
if (!conn) return;
|
||||
const conn = connections.find(c => c.id === currentConnectionId);
|
||||
if (!conn || !currentDb) return;
|
||||
|
||||
const config = {
|
||||
...conn.config,
|
||||
@@ -51,26 +90,25 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" }
|
||||
};
|
||||
|
||||
const dbName = tab.dbName || conn.config.database || "";
|
||||
|
||||
// Fetch Tables
|
||||
const resTables = await DBGetTables(config as any, dbName);
|
||||
const resTables = await DBGetTables(config as any, currentDb);
|
||||
if (resTables.success && Array.isArray(resTables.data)) {
|
||||
// res.data is [{Table: "name"}, ...]
|
||||
const tableNames = resTables.data.map((row: any) => Object.values(row)[0] as string);
|
||||
tablesRef.current = tableNames;
|
||||
} else {
|
||||
tablesRef.current = [];
|
||||
}
|
||||
|
||||
// Fetch All Columns (Optimized for autocomplete)
|
||||
if (config.type === 'mysql' || !config.type) {
|
||||
const resCols = await DBGetAllColumns(config as any, dbName);
|
||||
const resCols = await DBGetAllColumns(config as any, currentDb);
|
||||
if (resCols.success && Array.isArray(resCols.data)) {
|
||||
allColumnsRef.current = resCols.data;
|
||||
} else {
|
||||
allColumnsRef.current = [];
|
||||
}
|
||||
}
|
||||
};
|
||||
fetchMetadata();
|
||||
}, [tab.connectionId, tab.dbName, connections]);
|
||||
}, [currentConnectionId, currentDb, connections]);
|
||||
|
||||
// Handle Resizing
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
@@ -98,7 +136,6 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
editorRef.current = editor;
|
||||
monacoRef.current = monaco;
|
||||
|
||||
// SQL Autocomplete
|
||||
monaco.languages.registerCompletionItemProvider('sql', {
|
||||
provideCompletionItems: (model: any, position: any) => {
|
||||
const word = model.getWordUntilPosition(position);
|
||||
@@ -109,7 +146,6 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
endColumn: word.endColumn,
|
||||
};
|
||||
|
||||
// Simple Heuristic: Find tables mentioned in the query
|
||||
const tableRegex = /(?:FROM|JOIN|UPDATE|INTO)\s+[`"]?(\w+)[`"]?/gi;
|
||||
const foundTables = new Set<string>();
|
||||
let match;
|
||||
@@ -118,7 +154,6 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
foundTables.add(match[1]);
|
||||
}
|
||||
|
||||
// Columns suggestion
|
||||
const relevantColumns = allColumnsRef.current
|
||||
.filter(c => foundTables.has(c.tableName))
|
||||
.map(c => ({
|
||||
@@ -131,14 +166,12 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
}));
|
||||
|
||||
const suggestions = [
|
||||
// Keywords
|
||||
...['SELECT', 'FROM', 'WHERE', 'LIMIT', 'INSERT', 'UPDATE', 'DELETE', 'JOIN', 'LEFT', 'RIGHT', 'INNER', 'OUTER', 'ON', 'GROUP BY', 'ORDER BY', 'AS', 'AND', 'OR', 'NOT', 'NULL', 'IS', 'IN', 'VALUES', 'SET', 'CREATE', 'TABLE', 'DROP', 'ALTER', 'Add', 'MODIFY', 'CHANGE', 'COLUMN', 'KEY', 'PRIMARY', 'FOREIGN', 'REFERENCES', 'CONSTRAINT', 'DEFAULT', 'AUTO_INCREMENT', 'COMMENT', 'SHOW', 'DESCRIBE', 'EXPLAIN'].map(k => ({
|
||||
label: k,
|
||||
kind: monaco.languages.CompletionItemKind.Keyword,
|
||||
insertText: k,
|
||||
range
|
||||
})),
|
||||
// Tables
|
||||
...tablesRef.current.map(t => ({
|
||||
label: t,
|
||||
kind: monaco.languages.CompletionItemKind.Class,
|
||||
@@ -146,7 +179,6 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
detail: 'Table',
|
||||
range
|
||||
})),
|
||||
// Columns
|
||||
...relevantColumns
|
||||
];
|
||||
return { suggestions };
|
||||
@@ -180,8 +212,12 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
|
||||
const handleRun = async () => {
|
||||
if (!query.trim()) return;
|
||||
if (!currentDb) {
|
||||
message.error("请先选择数据库");
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
const conn = connections.find(c => c.id === tab.connectionId);
|
||||
const conn = connections.find(c => c.id === currentConnectionId);
|
||||
if (!conn) {
|
||||
message.error("Connection not found");
|
||||
setLoading(false);
|
||||
@@ -196,30 +232,42 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
useSSH: conn.config.useSSH || false,
|
||||
ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" }
|
||||
};
|
||||
const res = await MySQLQuery(config as any, tab.dbName || conn.config.database || '', query);
|
||||
|
||||
// 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 res = await MySQLQuery(config as any, currentDb, query);
|
||||
|
||||
if (res.success) {
|
||||
if (Array.isArray(res.data)) {
|
||||
if (res.data.length > 0) {
|
||||
const cols = Object.keys(res.data[0]).map(key => ({
|
||||
title: key,
|
||||
dataIndex: key,
|
||||
key: key,
|
||||
ellipsis: true,
|
||||
render: (text: any) => typeof text === 'object' ? JSON.stringify(text) : String(text),
|
||||
}));
|
||||
setColumns(cols);
|
||||
const cols = Object.keys(res.data[0]);
|
||||
setColumnNames(cols);
|
||||
setResults(res.data.map((row: any, i: number) => ({ ...row, key: i })));
|
||||
} else {
|
||||
message.info('查询执行成功,但没有返回结果。');
|
||||
setResults([]);
|
||||
setColumns([]);
|
||||
setColumnNames([]);
|
||||
}
|
||||
} else {
|
||||
// Handle update/insert results
|
||||
const affected = (res.data as any).affectedRows;
|
||||
message.success(`受影响行数: ${affected}`);
|
||||
setResults([]);
|
||||
setColumnNames([]);
|
||||
}
|
||||
} else {
|
||||
message.error(res.message);
|
||||
@@ -234,20 +282,38 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
id: tab.id.startsWith('saved-') ? tab.id : `saved-${Date.now()}`,
|
||||
name: values.name,
|
||||
sql: query,
|
||||
connectionId: tab.connectionId,
|
||||
dbName: tab.dbName || '',
|
||||
connectionId: currentConnectionId,
|
||||
dbName: currentDb || tab.dbName || '',
|
||||
createdAt: Date.now()
|
||||
});
|
||||
message.success('查询已保存!');
|
||||
setIsSaveModalOpen(false);
|
||||
} catch (e) {
|
||||
// validation failed
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||
<div style={{ padding: '8px', borderBottom: '1px solid #eee', display: 'flex', gap: '8px', flexShrink: 0 }}>
|
||||
<div style={{ padding: '8px', borderBottom: '1px solid #eee', display: 'flex', gap: '8px', flexShrink: 0, alignItems: 'center' }}>
|
||||
<Select
|
||||
style={{ width: 150 }}
|
||||
placeholder="选择连接"
|
||||
value={currentConnectionId}
|
||||
onChange={(val) => {
|
||||
setCurrentConnectionId(val);
|
||||
setCurrentDb('');
|
||||
}}
|
||||
options={connections.map(c => ({ label: c.name, value: c.id }))}
|
||||
showSearch
|
||||
/>
|
||||
<Select
|
||||
style={{ width: 200 }}
|
||||
placeholder="选择数据库"
|
||||
value={currentDb}
|
||||
onChange={setCurrentDb}
|
||||
options={dbList.map(db => ({ label: db, value: db }))}
|
||||
showSearch
|
||||
/>
|
||||
<Button type="primary" icon={<PlayCircleOutlined />} onClick={handleRun} loading={loading}>
|
||||
运行
|
||||
</Button>
|
||||
@@ -268,7 +334,6 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
</Button.Group>
|
||||
</div>
|
||||
|
||||
{/* Editor Area - Resizable */}
|
||||
<div style={{ height: editorHeight, minHeight: '100px', borderBottom: '1px solid #eee' }}>
|
||||
<Editor
|
||||
height="100%"
|
||||
@@ -286,7 +351,6 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Resize Handle */}
|
||||
<div
|
||||
onMouseDown={handleMouseDown}
|
||||
style={{
|
||||
@@ -299,16 +363,17 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
title="拖动调整高度"
|
||||
/>
|
||||
|
||||
{/* Results Area - Fills remaining space */}
|
||||
<div style={{ flex: 1, overflow: 'hidden', padding: 10, display: 'flex', flexDirection: 'column' }}>
|
||||
<Table
|
||||
dataSource={results}
|
||||
columns={columns}
|
||||
size="small"
|
||||
scroll={{ x: 'max-content', y: 'calc(100% - 40px)' }}
|
||||
<div style={{ flex: 1, overflow: 'hidden', padding: 0, display: 'flex', flexDirection: 'column' }}>
|
||||
<DataGrid
|
||||
data={results}
|
||||
columnNames={columnNames}
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
style={{ flex: 1, overflow: 'hidden' }}
|
||||
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>
|
||||
|
||||
@@ -330,4 +395,4 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
);
|
||||
};
|
||||
|
||||
export default QueryEditor;
|
||||
export default QueryEditor;
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
} from '@ant-design/icons';
|
||||
import { useStore } from '../store';
|
||||
import { SavedConnection } from '../types';
|
||||
import { MySQLGetDatabases, MySQLGetTables, MySQLShowCreateTable, ExportTable, OpenSQLFile, CreateDatabase } from '../../wailsjs/go/main/App';
|
||||
import { MySQLGetDatabases, MySQLGetTables, MySQLShowCreateTable, ExportTable, OpenSQLFile, CreateDatabase } from '../../wailsjs/go/app/App';
|
||||
|
||||
const { Search } = Input;
|
||||
|
||||
@@ -39,7 +39,7 @@ interface TreeNode {
|
||||
}
|
||||
|
||||
const Sidebar: React.FC = () => {
|
||||
const { connections, savedQueries, addTab } = useStore();
|
||||
const { connections, savedQueries, addTab, setActiveContext } = useStore();
|
||||
const [treeData, setTreeData] = useState<TreeNode[]>([]);
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
const [expandedKeys, setExpandedKeys] = useState<React.Key[]>([]);
|
||||
@@ -50,6 +50,27 @@ const Sidebar: React.FC = () => {
|
||||
const [createDbForm] = Form.useForm();
|
||||
const [targetConnection, setTargetConnection] = useState<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Refresh queries for expanded databases
|
||||
const findNode = (nodes: TreeNode[], k: React.Key): TreeNode | null => {
|
||||
for (const node of nodes) {
|
||||
if (node.key === k) return node;
|
||||
if (node.children) {
|
||||
const res = findNode(node.children, k);
|
||||
if (res) return res;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
expandedKeys.forEach(key => {
|
||||
const node = findNode(treeData, key);
|
||||
if (node && node.type === 'database') {
|
||||
loadTables(node);
|
||||
}
|
||||
});
|
||||
}, [savedQueries]);
|
||||
|
||||
useEffect(() => {
|
||||
setTreeData(connections.map(conn => ({
|
||||
title: conn.name,
|
||||
@@ -230,9 +251,24 @@ const Sidebar: React.FC = () => {
|
||||
};
|
||||
|
||||
const onSelect = (keys: React.Key[], info: any) => {
|
||||
if (!info.node.selected) return;
|
||||
if (!info.node.selected) {
|
||||
setActiveContext(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const { type, dataRef } = info.node;
|
||||
const { type, dataRef, key, title } = info.node;
|
||||
|
||||
// Update active context
|
||||
if (type === 'connection') {
|
||||
setActiveContext({ connectionId: key, dbName: '' });
|
||||
} else if (type === 'database') {
|
||||
setActiveContext({ connectionId: dataRef.id, dbName: title });
|
||||
} else if (type === 'table') {
|
||||
setActiveContext({ connectionId: dataRef.id, dbName: dataRef.dbName });
|
||||
} else if (type === 'saved-query') {
|
||||
setActiveContext({ connectionId: dataRef.connectionId, dbName: dataRef.dbName });
|
||||
}
|
||||
|
||||
if (type === 'folder-columns') openDesign(info.node, 'columns', true);
|
||||
else if (type === 'folder-indexes') openDesign(info.node, 'indexes', true);
|
||||
else if (type === 'folder-fks') openDesign(info.node, 'foreignKeys', true);
|
||||
@@ -315,7 +351,7 @@ const Sidebar: React.FC = () => {
|
||||
};
|
||||
|
||||
const handleRunSQLFile = async (node: any) => {
|
||||
const res = await (window as any).go.main.App.OpenSQLFile();
|
||||
const res = await (window as any).go.app.App.OpenSQLFile();
|
||||
if (res.success) {
|
||||
const sqlContent = res.data;
|
||||
const { dbName, id } = node.dataRef;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import { Tabs, Button } from 'antd';
|
||||
import { useStore } from '../store';
|
||||
import DataViewer from './DataViewer';
|
||||
@@ -18,7 +18,7 @@ const TabManager: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const items = tabs.map(tab => {
|
||||
const items = useMemo(() => tabs.map(tab => {
|
||||
let content;
|
||||
if (tab.type === 'query') {
|
||||
content = <QueryEditor tab={tab} />;
|
||||
@@ -33,18 +33,24 @@ const TabManager: React.FC = () => {
|
||||
key: tab.id,
|
||||
children: content,
|
||||
};
|
||||
});
|
||||
}), [tabs]);
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
type="editable-card"
|
||||
onChange={onChange}
|
||||
activeKey={activeTabId || undefined}
|
||||
onEdit={onEdit}
|
||||
items={items}
|
||||
style={{ height: '100%' }}
|
||||
hideAdd
|
||||
/>
|
||||
<>
|
||||
<style>{`
|
||||
.ant-tabs-content { height: 100%; }
|
||||
.ant-tabs-tabpane { height: 100%; }
|
||||
`}</style>
|
||||
<Tabs
|
||||
type="editable-card"
|
||||
onChange={onChange}
|
||||
activeKey={activeTabId || undefined}
|
||||
onEdit={onEdit}
|
||||
items={items}
|
||||
style={{ height: '100%' }}
|
||||
hideAdd
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import { CSS } from '@dnd-kit/utilities';
|
||||
import { Resizable } from 'react-resizable';
|
||||
import { TabData, ColumnDefinition, IndexDefinition, ForeignKeyDefinition, TriggerDefinition } from '../types';
|
||||
import { useStore } from '../store';
|
||||
import { DBGetColumns, DBGetIndexes, MySQLQuery, DBGetForeignKeys, DBGetTriggers, DBShowCreateTable } from '../../wailsjs/go/main/App';
|
||||
import { DBGetColumns, DBGetIndexes, MySQLQuery, DBGetForeignKeys, DBGetTriggers, DBShowCreateTable } from '../../wailsjs/go/app/App';
|
||||
|
||||
// Need styles for react-resizable
|
||||
import 'react-resizable/css/styles.css';
|
||||
@@ -74,6 +74,11 @@ const ResizableTitle = (props: any) => {
|
||||
className="react-resizable-handle"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault(); // Prevent text selection and focus hijacking
|
||||
}}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
@@ -87,7 +92,7 @@ const ResizableTitle = (props: any) => {
|
||||
/>
|
||||
}
|
||||
onResize={onResize}
|
||||
draggableOpts={{ enableUserSelectHack: false }}
|
||||
draggableOpts={{ enableUserSelectHack: true }}
|
||||
>
|
||||
<th {...restProps} style={{ ...restProps.style, position: 'relative' }} />
|
||||
</Resizable>
|
||||
@@ -263,16 +268,24 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
setTableColumns(initialCols);
|
||||
}, [readOnly]); // Re-create if readOnly changes
|
||||
|
||||
const rafRef = React.useRef<number | null>(null);
|
||||
|
||||
// Resize Handler
|
||||
const handleResize = (index: number) => (_: React.SyntheticEvent, { size }: { size: { width: number } }) => {
|
||||
setTableColumns((columns) => {
|
||||
const nextColumns = [...columns];
|
||||
nextColumns[index] = {
|
||||
...nextColumns[index],
|
||||
width: size.width,
|
||||
};
|
||||
return nextColumns;
|
||||
});
|
||||
if (rafRef.current) {
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
}
|
||||
rafRef.current = requestAnimationFrame(() => {
|
||||
setTableColumns((columns) => {
|
||||
const nextColumns = [...columns];
|
||||
nextColumns[index] = {
|
||||
...nextColumns[index],
|
||||
width: size.width,
|
||||
};
|
||||
return nextColumns;
|
||||
});
|
||||
rafRef.current = null;
|
||||
});
|
||||
};
|
||||
|
||||
const fetchData = async () => {
|
||||
@@ -587,8 +600,8 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
)}
|
||||
{!readOnly && <Button icon={<SaveOutlined />} type="primary" onClick={generateDDL}>保存</Button>}
|
||||
{!isNewTable && <Button icon={<ReloadOutlined />} onClick={fetchData}>刷新</Button>}
|
||||
<div style={{ flex: 1 }} />
|
||||
{!readOnly && <Button icon={<PlusOutlined />} onClick={handleAddColumn}>添加字段</Button>}
|
||||
<div style={{ flex: 1 }} />
|
||||
</div>
|
||||
<Tabs
|
||||
activeKey={activeKey}
|
||||
|
||||
@@ -6,6 +6,7 @@ interface AppState {
|
||||
connections: SavedConnection[];
|
||||
tabs: TabData[];
|
||||
activeTabId: string | null;
|
||||
activeContext: { connectionId: string; dbName: string } | null;
|
||||
savedQueries: SavedQuery[];
|
||||
darkMode: boolean;
|
||||
sqlFormatOptions: { keywordCase: 'upper' | 'lower' };
|
||||
@@ -16,6 +17,7 @@ interface AppState {
|
||||
addTab: (tab: TabData) => void;
|
||||
closeTab: (id: string) => void;
|
||||
setActiveTab: (id: string) => void;
|
||||
setActiveContext: (context: { connectionId: string; dbName: string } | null) => void;
|
||||
|
||||
saveQuery: (query: SavedQuery) => void;
|
||||
deleteQuery: (id: string) => void;
|
||||
@@ -30,6 +32,7 @@ export const useStore = create<AppState>()(
|
||||
connections: [],
|
||||
tabs: [],
|
||||
activeTabId: null,
|
||||
activeContext: null,
|
||||
savedQueries: [],
|
||||
darkMode: false,
|
||||
sqlFormatOptions: { keywordCase: 'upper' },
|
||||
@@ -58,6 +61,7 @@ export const useStore = create<AppState>()(
|
||||
}),
|
||||
|
||||
setActiveTab: (id) => set({ activeTabId: id }),
|
||||
setActiveContext: (context) => set({ activeContext: context }),
|
||||
|
||||
saveQuery: (query) => set((state) => {
|
||||
// If query with same ID exists, update it
|
||||
|
||||
Reference in New Issue
Block a user