mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-05-12 20:29:43 +08:00
Compare commits
19 Commits
release/v0
...
v0.6.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e73f6d8b5 | ||
|
|
2f4e20a34a | ||
|
|
dfabd77615 | ||
|
|
4a2dda8aa2 | ||
|
|
d1d3fa26f1 | ||
|
|
fc8e62b997 | ||
|
|
b0eb93bfa3 | ||
|
|
11b8e0f12a | ||
|
|
8c5fee1c7a | ||
|
|
ec05f518a9 | ||
|
|
2c9aa640fd | ||
|
|
9f7cc58fad | ||
|
|
97bf891df3 | ||
|
|
72a9692200 | ||
|
|
eaa45f17fd | ||
|
|
f101a59d32 | ||
|
|
6ad690cffc | ||
|
|
22bd1c4c28 | ||
|
|
89c81823bc |
@@ -1,8 +1,8 @@
|
||||
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { Layout, Button, ConfigProvider, theme, message, Modal, Spin, Slider, Progress, Switch, Input, InputNumber, Select } from 'antd';
|
||||
import zhCN from 'antd/locale/zh_CN';
|
||||
import { PlusOutlined, ConsoleSqlOutlined, UploadOutlined, DownloadOutlined, CloudDownloadOutlined, BugOutlined, ToolOutlined, GlobalOutlined, InfoCircleOutlined, GithubOutlined, SkinOutlined, CheckOutlined, MinusOutlined, BorderOutlined, CloseOutlined, SettingOutlined, LinkOutlined, BgColorsOutlined, AppstoreOutlined } from '@ant-design/icons';
|
||||
import { BrowserOpenURL, Environment, EventsOn, Quit, WindowFullscreen, WindowGetPosition, WindowGetSize, WindowIsFullscreen, WindowIsMaximised, WindowMaximise, WindowMinimise, WindowSetPosition, WindowSetSize, WindowToggleMaximise, WindowUnfullscreen } from '../wailsjs/runtime';
|
||||
import { BrowserOpenURL, Environment, EventsOn, Quit, WindowFullscreen, WindowGetSize, WindowIsFullscreen, WindowIsMaximised, WindowMaximise, WindowMinimise, WindowSetSize, WindowToggleMaximise, WindowUnfullscreen } from '../wailsjs/runtime';
|
||||
import Sidebar from './components/Sidebar';
|
||||
import TabManager from './components/TabManager';
|
||||
import ConnectionModal from './components/ConnectionModal';
|
||||
@@ -89,8 +89,7 @@ function App() {
|
||||
const [runtimePlatform, setRuntimePlatform] = useState('');
|
||||
const [isLinuxRuntime, setIsLinuxRuntime] = useState(false);
|
||||
const [isStoreHydrated, setIsStoreHydrated] = useState(() => useStore.persist.hasHydrated());
|
||||
const sidebarWidth = useStore(state => state.sidebarWidth);
|
||||
const setSidebarWidth = useStore(state => state.setSidebarWidth);
|
||||
const [sidebarWidth, setSidebarWidth] = useState(330);
|
||||
const globalProxyInvalidHintShownRef = React.useRef(false);
|
||||
|
||||
// 同步 macOS 窗口透明度:opacity=1.0 且 blur=0 时关闭 NSVisualEffectView,
|
||||
@@ -286,43 +285,14 @@ function App() {
|
||||
}, applyRetryDelayMs);
|
||||
};
|
||||
|
||||
const restoreWindowState = async () => {
|
||||
if (cancelled) return;
|
||||
const state = useStore.getState();
|
||||
// startupFullscreen 设置优先
|
||||
if (state.startupFullscreen) {
|
||||
applyStartupWindowPreference(1);
|
||||
return;
|
||||
}
|
||||
// 根据上次保存的窗口状态恢复
|
||||
const savedState = state.windowState;
|
||||
if (savedState === 'fullscreen') {
|
||||
applyStartupWindowPreference(1);
|
||||
return;
|
||||
}
|
||||
if (savedState === 'maximized') {
|
||||
try { await WindowMaximise(); } catch (_) {}
|
||||
return;
|
||||
}
|
||||
// 普通窗口:恢复尺寸和位置
|
||||
const bounds = state.windowBounds;
|
||||
if (!bounds || bounds.width < 400 || bounds.height < 300) return;
|
||||
try {
|
||||
WindowSetSize(bounds.width, bounds.height);
|
||||
WindowSetPosition(bounds.x, bounds.y);
|
||||
} catch (e) {
|
||||
console.warn('Failed to restore window bounds', e);
|
||||
}
|
||||
};
|
||||
|
||||
if (useStore.persist.hasHydrated()) {
|
||||
void restoreWindowState();
|
||||
applyStartupWindowPreference(1);
|
||||
}
|
||||
const unsubscribeHydration = useStore.persist.onFinishHydration(() => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
void restoreWindowState();
|
||||
applyStartupWindowPreference(1);
|
||||
});
|
||||
|
||||
return () => {
|
||||
@@ -334,52 +304,6 @@ function App() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 定时保存窗口状态、尺寸与位置
|
||||
useEffect(() => {
|
||||
const SAVE_INTERVAL_MS = 2000;
|
||||
let lastSaved = '';
|
||||
|
||||
const saveWindowState = async () => {
|
||||
try {
|
||||
const [isFs, isMax] = await Promise.all([
|
||||
WindowIsFullscreen().catch(() => false),
|
||||
WindowIsMaximised().catch(() => false),
|
||||
]);
|
||||
|
||||
// 保存窗口状态
|
||||
const store = useStore.getState();
|
||||
const newState = isFs ? 'fullscreen' : (isMax ? 'maximized' : 'normal');
|
||||
if (store.windowState !== newState) {
|
||||
store.setWindowState(newState);
|
||||
}
|
||||
|
||||
// 只在普通窗口模式下保存尺寸和位置
|
||||
if (isFs || isMax) return;
|
||||
|
||||
const [size, pos] = await Promise.all([
|
||||
WindowGetSize().catch(() => null),
|
||||
WindowGetPosition().catch(() => null),
|
||||
]);
|
||||
if (!size || !pos) return;
|
||||
const w = Math.trunc(Number(size.w || 0));
|
||||
const h = Math.trunc(Number(size.h || 0));
|
||||
const x = Math.trunc(Number(pos.x || 0));
|
||||
const y = Math.trunc(Number(pos.y || 0));
|
||||
if (w < 400 || h < 300) return;
|
||||
|
||||
const key = `${w},${h},${x},${y}`;
|
||||
if (key === lastSaved) return;
|
||||
lastSaved = key;
|
||||
store.setWindowBounds({ width: w, height: h, x, y });
|
||||
} catch (e) {
|
||||
// 静默忽略
|
||||
}
|
||||
};
|
||||
|
||||
const timer = window.setInterval(saveWindowState, SAVE_INTERVAL_MS);
|
||||
return () => window.clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isWindowsPlatform()) {
|
||||
return;
|
||||
@@ -986,7 +910,7 @@ function App() {
|
||||
setAboutLoading(false);
|
||||
}, []);
|
||||
|
||||
const handleNewQuery = useCallback(() => {
|
||||
const handleNewQuery = () => {
|
||||
let connId = '';
|
||||
let db = '';
|
||||
|
||||
@@ -1006,14 +930,14 @@ function App() {
|
||||
}
|
||||
|
||||
addTab({
|
||||
id: `query-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
id: `query-${Date.now()}`,
|
||||
title: '新建查询',
|
||||
type: 'query',
|
||||
connectionId: connId,
|
||||
dbName: db,
|
||||
query: ''
|
||||
});
|
||||
}, [activeTabId, tabs, connections, activeContext, addTab]);
|
||||
};
|
||||
|
||||
const handleImportConnections = async () => {
|
||||
const res = await (window as any).go.app.App.ImportConfigFile();
|
||||
|
||||
@@ -31,7 +31,7 @@ import 'react-resizable/css/styles.css';
|
||||
import { buildOrderBySQL, buildPaginatedSelectSQL, buildWhereSQL, escapeLiteral, quoteIdentPart, quoteQualifiedIdent, withSortBufferTuningSQL, type FilterCondition } from '../utils/sql';
|
||||
import { isMacLikePlatform, normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance';
|
||||
import { getDataSourceCapabilities } from '../utils/dataSourceCapabilities';
|
||||
import { calculateTableBodyBottomPadding, calculateVirtualTableScrollX } from './dataGridLayout';
|
||||
import { calculateTableBodyBottomPadding } from './dataGridLayout';
|
||||
|
||||
// --- Error Boundary ---
|
||||
interface DataGridErrorBoundaryState {
|
||||
@@ -1374,6 +1374,11 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
.${gridId} .ant-table-tbody .ant-table-row > .ant-table-cell { background: transparent !important; border-bottom: 1px solid ${darkMode ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.05)'} !important; border-inline-end: 1px solid transparent !important; }
|
||||
.${gridId} .ant-table-thead > tr > th { background: transparent !important; border-bottom: 1px solid ${darkMode ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.05)'} !important; border-inline-end: 1px solid transparent !important; }
|
||||
/* 选择列对齐:header TH 无 class(Ant Design 虚拟模式),需用 :first-child 匹配 */
|
||||
.${gridId} .ant-table-selection-col,
|
||||
.${gridId} .ant-table-bordered .ant-table-selection-col,
|
||||
.${gridId} .ant-table-selection-col.ant-table-selection-col-with-dropdown {
|
||||
width: ${selectionColumnWidth}px !important;
|
||||
}
|
||||
.${gridId} .ant-table-header th:first-child,
|
||||
.${gridId} .ant-table-thead > tr > th:first-child {
|
||||
text-align: center !important;
|
||||
@@ -1387,17 +1392,6 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
padding-inline-start: 0 !important;
|
||||
padding-inline-end: 0 !important;
|
||||
}
|
||||
/* 窄表场景下 rc-table 会按视口等比放大选择列宽度,不能再额外锁死 header 宽度;
|
||||
这里只统一 header/body 的内边距与对齐方式,避免第一列把后续数据列整体顶偏。 */
|
||||
.${gridId} .ant-table-tbody > tr > td.ant-table-selection-column,
|
||||
.${gridId} .ant-table-tbody .ant-table-row > .ant-table-cell.ant-table-selection-column,
|
||||
.${gridId} .ant-table-tbody-virtual-holder .ant-table-row > .ant-table-cell.ant-table-selection-column {
|
||||
text-align: center !important;
|
||||
padding-inline-start: 0 !important;
|
||||
padding-inline-end: 0 !important;
|
||||
padding-left: 0 !important;
|
||||
padding-right: 0 !important;
|
||||
}
|
||||
.${gridId} .ant-table-thead > tr:first-child > th:first-child,
|
||||
.${gridId} .ant-table-header table > thead > tr:first-child > th:first-child {
|
||||
border-top-left-radius: ${panelRadius}px !important;
|
||||
@@ -1463,11 +1457,6 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
.${gridId} .ant-table-sticky-scroll {
|
||||
display: none !important;
|
||||
}
|
||||
/* 虚拟表列对齐:阻止 header <table> 通过 min-width:100% 拉伸到视口,
|
||||
使 header 列宽与虚拟 body 单元格宽度精确一致 */
|
||||
.${gridId} .ant-table-header > table {
|
||||
min-width: 0 !important;
|
||||
}
|
||||
.${gridId} .ant-table-tbody-virtual-scrollbar.ant-table-tbody-virtual-scrollbar-horizontal {
|
||||
display: none !important;
|
||||
}
|
||||
@@ -3775,13 +3764,10 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
const totalWidth = columns.reduce((sum: number, col: any) => sum + (Number(col.width) || 200), 0) + selectionColumnWidth;
|
||||
const useContextMenuRow = false;
|
||||
const tableScrollX = useMemo(() => {
|
||||
// rc-table 在 scroll.x 小于容器宽度时会把实际列宽按视口补齐。
|
||||
// 这里必须与其使用同一套 scroll.x 口径,否则少字段场景下 header/body 会错位。
|
||||
return calculateVirtualTableScrollX({
|
||||
totalWidth,
|
||||
tableViewportWidth,
|
||||
isMacLike,
|
||||
});
|
||||
const baseWidth = Math.max(totalWidth, 1000);
|
||||
if (!isMacLike || tableViewportWidth <= 0) return baseWidth;
|
||||
// macOS 在“自动隐藏滚动条”模式下容易误判为无横向滚动,预留 2px 触发稳定滚动轨道。
|
||||
return Math.max(baseWidth, tableViewportWidth + 2);
|
||||
}, [totalWidth, isMacLike, tableViewportWidth]);
|
||||
const horizontalScrollVisible = viewMode === 'table' && tableScrollX > tableViewportWidth + 1;
|
||||
const horizontalScrollWidth = Math.max(externalScrollbarMinWidth, tableScrollX);
|
||||
@@ -3829,7 +3815,6 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
// 通过合成 WheelEvent 驱动 rc-virtual-list 内部 offsetLeft state,
|
||||
// 让 rc-table onInternalScroll 自动同步 header scrollLeft。
|
||||
// 不直接操作 DOM marginLeft,避免 React re-render 覆盖。
|
||||
|
||||
holderEl.dispatchEvent(new WheelEvent('wheel', {
|
||||
deltaX: deltaX,
|
||||
deltaY: 0,
|
||||
@@ -4002,51 +3987,21 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
const isTableDataAreaTarget = (target: EventTarget | null) => {
|
||||
const element = target instanceof HTMLElement ? target : null;
|
||||
if (!element) return false;
|
||||
// 排除外部滚动条与工具栏,其余容器内元素一律视为数据区域
|
||||
if (element.closest('.data-grid-external-horizontal-scroll')) return false;
|
||||
if (element.closest('.data-grid-toolbar')) return false;
|
||||
return true;
|
||||
return !!element.closest('.ant-table-body, .ant-table-content, .ant-table-cell, .ant-table-row, .ant-table-tbody');
|
||||
};
|
||||
|
||||
const handleContainerHorizontalWheel = (event: WheelEvent) => {
|
||||
// applyVirtualHorizontalOffset 分发的合成 WheelEvent(isTrusted=false)
|
||||
// 需要传播到 rc-virtual-list 的内部 handler,此处不拦截。
|
||||
if (!event.isTrusted) return;
|
||||
|
||||
const horizontalDelta = resolveHorizontalDelta(event);
|
||||
if (!Number.isFinite(horizontalDelta) || Math.abs(horizontalDelta) < 0.5) return;
|
||||
if (!isTableDataAreaTarget(event.target)) return;
|
||||
|
||||
if (enableVirtual) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
// 虚拟模式:不拦截事件,让 rc-virtual-list 原生处理 wheel。
|
||||
// rc-virtual-list 会通过内部 setOffsetLeft → re-render → onVirtualScroll
|
||||
// 自动同步 header scrollLeft。
|
||||
// 仅需在状态更新后同步外部横向滚动条。
|
||||
horizontalSyncSourceRef.current = 'table';
|
||||
|
||||
// 空数据回退:virtual-holder 不存在时,手动滚动表头
|
||||
const virtualHolder = container.querySelector('.ant-table-tbody-virtual-holder') as HTMLElement | null;
|
||||
if (!virtualHolder) {
|
||||
const headerEl = container.querySelector('.ant-table-header') as HTMLElement | null;
|
||||
const contentEl = container.querySelector('.ant-table-content') as HTMLElement | null;
|
||||
const fallbackTargets = [headerEl, contentEl].filter((el): el is HTMLElement => el instanceof HTMLElement && el.scrollWidth > el.clientWidth + 1);
|
||||
if (fallbackTargets.length > 0) {
|
||||
fallbackTargets.forEach((target) => {
|
||||
const max = Math.max(0, target.scrollWidth - target.clientWidth);
|
||||
target.scrollLeft = Math.max(0, Math.min(max, target.scrollLeft + horizontalDelta));
|
||||
});
|
||||
lastTableScrollLeftRef.current = (fallbackTargets[0]).scrollLeft;
|
||||
const externalScroll = externalHorizontalScrollRef.current;
|
||||
if (externalScroll && Math.abs(externalScroll.scrollLeft - lastTableScrollLeftRef.current) > 1) {
|
||||
externalScroll.scrollLeft = lastTableScrollLeftRef.current;
|
||||
lastExternalScrollLeftRef.current = lastTableScrollLeftRef.current;
|
||||
}
|
||||
}
|
||||
horizontalSyncSourceRef.current = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// 有数据:通过 applyVirtualHorizontalOffset 合成 WheelEvent 驱动 rc-virtual-list
|
||||
const currentOffset = readVirtualHorizontalOffset(container);
|
||||
applyVirtualHorizontalOffset(container, currentOffset + horizontalDelta);
|
||||
requestAnimationFrame(() => {
|
||||
const nextScrollLeft = readVirtualHorizontalOffset(container);
|
||||
lastTableScrollLeftRef.current = nextScrollLeft;
|
||||
@@ -4096,7 +4051,7 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
return () => {
|
||||
container.removeEventListener('wheel', handleContainerHorizontalWheel, { capture: true } as EventListenerOptions);
|
||||
};
|
||||
}, [applyVirtualHorizontalOffset, enableVirtual, pickHorizontalScrollTargets, readVirtualHorizontalOffset, viewMode]);
|
||||
}, [enableVirtual, pickHorizontalScrollTargets, readVirtualHorizontalOffset, viewMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (viewMode !== 'table') return;
|
||||
@@ -4104,31 +4059,6 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
return () => cancelAnimationFrame(rafId);
|
||||
}, [viewMode, totalWidth, mergedDisplayData.length, pagination?.total, pagination?.pageSize, recalculateTableMetrics]);
|
||||
|
||||
// 虚拟表列对齐:antd 虚拟表 body 使用 <div>+<td>(非 <table>),
|
||||
// 不会自动拉伸列宽到视口。而 header <table> 会被 antd 的 CSS 或 JS
|
||||
// 设置为 width:100% 自动拉伸。强制 header table 宽度等于 scroll.x,
|
||||
// 使 header 列宽与 body 单元格宽度精确一致。
|
||||
useEffect(() => {
|
||||
if (viewMode !== 'table') return;
|
||||
const container = tableContainerRef.current;
|
||||
if (!container) return;
|
||||
const syncHeaderWidth = () => {
|
||||
const headerTable = container.querySelector('.ant-table-header > table') as HTMLElement;
|
||||
if (headerTable) {
|
||||
headerTable.style.setProperty('width', `${tableScrollX}px`, 'important');
|
||||
headerTable.style.setProperty('min-width', '0px', 'important');
|
||||
headerTable.style.setProperty('max-width', `${tableScrollX}px`, 'important');
|
||||
}
|
||||
};
|
||||
syncHeaderWidth();
|
||||
const rafId = requestAnimationFrame(syncHeaderWidth);
|
||||
// 监听 antd 可能的重渲染覆盖
|
||||
const observer = new MutationObserver(syncHeaderWidth);
|
||||
const headerEl = container.querySelector('.ant-table-header');
|
||||
if (headerEl) observer.observe(headerEl, { attributes: true, childList: true, subtree: true, attributeFilter: ['style'] });
|
||||
return () => { cancelAnimationFrame(rafId); observer.disconnect(); };
|
||||
}, [viewMode, tableScrollX, mergedDisplayData.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (viewMode !== 'table' || !onScrollSnapshotChange) return;
|
||||
const tableContainer = tableContainerRef.current;
|
||||
|
||||
@@ -170,9 +170,6 @@ const SQL_FUNCTIONS: { name: string; detail: string }[] = [
|
||||
{ name: 'SLEEP', detail: '工具 - 延时' },
|
||||
];
|
||||
|
||||
// 模块级标志:确保 SQL completion provider 全局只注册一次
|
||||
let sqlCompletionRegistered = false;
|
||||
|
||||
const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
const [query, setQuery] = useState(tab.query || 'SELECT * FROM ');
|
||||
|
||||
@@ -431,9 +428,6 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
// 应用透明主题(主题已在 main.tsx 全局注册)
|
||||
monaco.editor.setTheme(darkMode ? 'transparent-dark' : 'transparent-light');
|
||||
|
||||
// 全局只注册一次 SQL completion provider,避免多 tab 重复注册导致补全项重复
|
||||
if (!sqlCompletionRegistered) {
|
||||
sqlCompletionRegistered = true;
|
||||
monaco.languages.registerCompletionItemProvider('sql', {
|
||||
triggerCharacters: ['.'],
|
||||
provideCompletionItems: async (model: any, position: any) => {
|
||||
@@ -789,7 +783,6 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
return { suggestions };
|
||||
}
|
||||
});
|
||||
} // end sqlCompletionRegistered guard
|
||||
};
|
||||
|
||||
const handleFormat = () => {
|
||||
@@ -2012,11 +2005,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
label: (
|
||||
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
||||
<Tooltip title={rs.sql}>
|
||||
<span>{(() => {
|
||||
const isAffected = rs.columns.length === 1 && rs.columns[0] === 'affectedRows';
|
||||
if (isAffected) return `结果 ${idx + 1} ✓`;
|
||||
return `结果 ${idx + 1}${Array.isArray(rs.rows) ? ` (${rs.rows.length}${rs.truncated ? '+' : ''})` : ''}`;
|
||||
})()}</span>
|
||||
<span>{`结果 ${idx + 1}${Array.isArray(rs.rows) ? ` (${rs.rows.length}${rs.truncated ? '+' : ''})` : ''}`}</span>
|
||||
</Tooltip>
|
||||
<Tooltip title="关闭结果">
|
||||
<span
|
||||
@@ -2032,40 +2021,23 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
</Tooltip>
|
||||
</div>
|
||||
),
|
||||
children: (() => {
|
||||
// affectedRows 类型结果集(UPDATE/INSERT/DELETE):简洁提示
|
||||
const isAffectedResult = rs.columns.length === 1 && rs.columns[0] === 'affectedRows';
|
||||
if (isAffectedResult) {
|
||||
const affected = Number(rs.rows[0]?.affectedRows ?? 0);
|
||||
return (
|
||||
<div style={{
|
||||
flex: 1, minHeight: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
flexDirection: 'column', gap: 8, color: '#666', userSelect: 'text',
|
||||
}}>
|
||||
<span style={{ fontSize: 36, color: '#52c41a' }}>✓</span>
|
||||
<span style={{ fontSize: 14, fontWeight: 500 }}>执行成功</span>
|
||||
<span style={{ fontSize: 13, color: '#999' }}>影响行数:{affected}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div style={{ flex: 1, minHeight: 0, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
||||
<DataGrid
|
||||
data={rs.rows}
|
||||
columnNames={rs.columns}
|
||||
loading={loading}
|
||||
tableName={rs.tableName}
|
||||
exportScope="queryResult"
|
||||
resultSql={rs.exportSql || rs.sql}
|
||||
dbName={currentDb}
|
||||
connectionId={currentConnectionId}
|
||||
pkColumns={rs.pkColumns}
|
||||
onReload={handleRun}
|
||||
readOnly={rs.readOnly}
|
||||
/>
|
||||
</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}
|
||||
exportScope="queryResult"
|
||||
resultSql={rs.exportSql || rs.sql}
|
||||
dbName={currentDb}
|
||||
connectionId={currentConnectionId}
|
||||
pkColumns={rs.pkColumns}
|
||||
onReload={handleRun}
|
||||
readOnly={rs.readOnly}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}))}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -1462,8 +1462,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
type: 'table-overview' as any,
|
||||
connectionId: id,
|
||||
dbName,
|
||||
schemaName,
|
||||
} as any);
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (node.type === 'table') {
|
||||
|
||||
@@ -194,7 +194,7 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
|
||||
const openTable = useCallback((tableName: string) => {
|
||||
if (!connection) return;
|
||||
addTab({
|
||||
id: `${connection.id}-${tab.dbName}-table-${tableName}`,
|
||||
id: `${connection.id}-${tab.dbName}-${tableName}`,
|
||||
title: tableName,
|
||||
type: 'table',
|
||||
connectionId: connection.id,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { calculateTableBodyBottomPadding, calculateVirtualTableScrollX } from './dataGridLayout';
|
||||
import { calculateTableBodyBottomPadding } from './dataGridLayout';
|
||||
|
||||
const assertEqual = (actual: unknown, expected: unknown, message: string) => {
|
||||
if (actual !== expected) {
|
||||
@@ -36,34 +36,4 @@ assertEqual(
|
||||
'较粗滚动条场景下应同步放大底部安全区'
|
||||
);
|
||||
|
||||
assertEqual(
|
||||
calculateVirtualTableScrollX({
|
||||
totalWidth: 646,
|
||||
tableViewportWidth: 1200,
|
||||
isMacLike: false,
|
||||
}),
|
||||
1200,
|
||||
'列总宽小于视口时应按视口宽度返回 scroll.x,避免 header/body 走两套宽度'
|
||||
);
|
||||
|
||||
assertEqual(
|
||||
calculateVirtualTableScrollX({
|
||||
totalWidth: 646,
|
||||
tableViewportWidth: 0,
|
||||
isMacLike: false,
|
||||
}),
|
||||
646,
|
||||
'未拿到视口宽度时应退回列宽总和'
|
||||
);
|
||||
|
||||
assertEqual(
|
||||
calculateVirtualTableScrollX({
|
||||
totalWidth: 1200,
|
||||
tableViewportWidth: 800,
|
||||
isMacLike: true,
|
||||
}),
|
||||
1202,
|
||||
'macOS 横向溢出时仍需额外预留 2px 以稳定滚动轨道'
|
||||
);
|
||||
|
||||
console.log('dataGridLayout tests passed');
|
||||
|
||||
@@ -4,12 +4,6 @@ export interface TableBodyBottomPaddingOptions {
|
||||
floatingScrollbarGap: number;
|
||||
}
|
||||
|
||||
export interface VirtualTableScrollXOptions {
|
||||
totalWidth: number;
|
||||
tableViewportWidth: number;
|
||||
isMacLike: boolean;
|
||||
}
|
||||
|
||||
const MIN_SCROLLBAR_CLEARANCE = 8;
|
||||
const FLOATING_SCROLLBAR_VISUAL_EXTRA = 4;
|
||||
|
||||
@@ -27,22 +21,3 @@ export const calculateTableBodyBottomPadding = ({
|
||||
|
||||
return safeScrollbarHeight + FLOATING_SCROLLBAR_VISUAL_EXTRA + safeScrollbarGap + MIN_SCROLLBAR_CLEARANCE;
|
||||
};
|
||||
|
||||
export const calculateVirtualTableScrollX = ({
|
||||
totalWidth,
|
||||
tableViewportWidth,
|
||||
isMacLike,
|
||||
}: VirtualTableScrollXOptions): number => {
|
||||
const safeTotalWidth = Math.max(0, Math.ceil(totalWidth));
|
||||
const safeViewportWidth = Math.max(0, Math.floor(tableViewportWidth));
|
||||
|
||||
if (safeViewportWidth > 0 && safeTotalWidth < safeViewportWidth) {
|
||||
return safeViewportWidth;
|
||||
}
|
||||
|
||||
if (isMacLike && safeViewportWidth > 0 && safeTotalWidth > safeViewportWidth) {
|
||||
return safeTotalWidth + 2;
|
||||
}
|
||||
|
||||
return safeTotalWidth;
|
||||
};
|
||||
|
||||
@@ -420,9 +420,6 @@ interface AppState {
|
||||
enableColumnOrderMemory: boolean;
|
||||
tableHiddenColumns: Record<string, string[]>;
|
||||
enableHiddenColumnMemory: boolean;
|
||||
windowBounds: { width: number; height: number; x: number; y: number } | null;
|
||||
windowState: 'normal' | 'fullscreen' | 'maximized';
|
||||
sidebarWidth: number;
|
||||
|
||||
addConnection: (conn: SavedConnection) => void;
|
||||
updateConnection: (conn: SavedConnection) => void;
|
||||
@@ -472,9 +469,6 @@ interface AppState {
|
||||
setTableHiddenColumns: (connectionId: string, dbName: string, tableName: string, hiddenColumns: string[]) => void;
|
||||
setEnableHiddenColumnMemory: (enabled: boolean) => void;
|
||||
clearTableHiddenColumns: (connectionId: string, dbName: string, tableName: string) => void;
|
||||
setWindowBounds: (bounds: { width: number; height: number; x: number; y: number }) => void;
|
||||
setWindowState: (state: 'normal' | 'fullscreen' | 'maximized') => void;
|
||||
setSidebarWidth: (width: number) => void;
|
||||
}
|
||||
|
||||
const sanitizeSavedQueries = (value: unknown): SavedQuery[] => {
|
||||
@@ -605,29 +599,6 @@ const sanitizeGlobalProxy = (value: unknown): GlobalProxyConfig => {
|
||||
};
|
||||
};
|
||||
|
||||
const sanitizeWindowState = (value: unknown): 'normal' | 'fullscreen' | 'maximized' => {
|
||||
if (value === 'fullscreen' || value === 'maximized') return value;
|
||||
return 'normal';
|
||||
};
|
||||
|
||||
const sanitizeSidebarWidth = (value: unknown): number => {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed)) return 330;
|
||||
return Math.max(200, Math.min(600, Math.trunc(parsed)));
|
||||
};
|
||||
|
||||
const sanitizeWindowBounds = (value: unknown): { width: number; height: number; x: number; y: number } | null => {
|
||||
if (!value || typeof value !== 'object') return null;
|
||||
const raw = value as Record<string, unknown>;
|
||||
const width = Number(raw.width);
|
||||
const height = Number(raw.height);
|
||||
const x = Number(raw.x);
|
||||
const y = Number(raw.y);
|
||||
if (!Number.isFinite(width) || !Number.isFinite(height) || !Number.isFinite(x) || !Number.isFinite(y)) return null;
|
||||
if (width < 400 || height < 300) return null;
|
||||
return { width: Math.trunc(width), height: Math.trunc(height), x: Math.trunc(x), y: Math.trunc(y) };
|
||||
};
|
||||
|
||||
const unwrapPersistedAppState = (persistedState: unknown): Record<string, unknown> => {
|
||||
if (!persistedState || typeof persistedState !== 'object') {
|
||||
return {};
|
||||
@@ -664,9 +635,6 @@ export const useStore = create<AppState>()(
|
||||
enableColumnOrderMemory: true,
|
||||
tableHiddenColumns: {},
|
||||
enableHiddenColumnMemory: true,
|
||||
windowBounds: null,
|
||||
windowState: 'normal' as const,
|
||||
sidebarWidth: 330,
|
||||
|
||||
addConnection: (conn) => set((state) => ({ connections: [...state.connections, conn] })),
|
||||
updateConnection: (conn) => set((state) => ({
|
||||
@@ -907,19 +875,6 @@ export const useStore = create<AppState>()(
|
||||
}),
|
||||
|
||||
setEnableHiddenColumnMemory: (enabled) => set({ enableHiddenColumnMemory: !!enabled }),
|
||||
|
||||
setWindowBounds: (bounds) => set({
|
||||
windowBounds: {
|
||||
width: Math.max(400, Math.trunc(bounds.width)),
|
||||
height: Math.max(300, Math.trunc(bounds.height)),
|
||||
x: Math.trunc(bounds.x),
|
||||
y: Math.trunc(bounds.y),
|
||||
}
|
||||
}),
|
||||
|
||||
setWindowState: (state) => set({ windowState: state }),
|
||||
|
||||
setSidebarWidth: (width) => set({ sidebarWidth: Math.max(200, Math.min(600, Math.trunc(width))) }),
|
||||
}),
|
||||
{
|
||||
name: 'lite-db-storage', // name of the item in the storage (must be unique)
|
||||
@@ -951,10 +906,7 @@ export const useStore = create<AppState>()(
|
||||
nextState.enableColumnOrderMemory = state.enableColumnOrderMemory !== false;
|
||||
const safeHidden = sanitizeTableHiddenColumns(state.tableHiddenColumns);
|
||||
nextState.tableHiddenColumns = safeHidden;
|
||||
nextState.enableHiddenColumnMemory = state.enableHiddenColumnMemory !== false;
|
||||
nextState.windowBounds = sanitizeWindowBounds(state.windowBounds);
|
||||
nextState.windowState = sanitizeWindowState(state.windowState);
|
||||
nextState.sidebarWidth = sanitizeSidebarWidth(state.sidebarWidth);
|
||||
nextState.enableHiddenColumnMemory = state.enableHiddenColumnMemory !== false;
|
||||
return nextState as AppState;
|
||||
},
|
||||
merge: (persistedState, currentState) => {
|
||||
@@ -976,9 +928,6 @@ export const useStore = create<AppState>()(
|
||||
enableColumnOrderMemory: state.enableColumnOrderMemory !== false,
|
||||
tableHiddenColumns: sanitizeTableHiddenColumns(state.tableHiddenColumns),
|
||||
enableHiddenColumnMemory: state.enableHiddenColumnMemory !== false,
|
||||
windowBounds: sanitizeWindowBounds(state.windowBounds),
|
||||
windowState: sanitizeWindowState(state.windowState),
|
||||
sidebarWidth: sanitizeSidebarWidth(state.sidebarWidth),
|
||||
|
||||
sqlFormatOptions: sanitizeSqlFormatOptions(state.sqlFormatOptions),
|
||||
queryOptions: sanitizeQueryOptions(state.queryOptions),
|
||||
@@ -1004,10 +953,7 @@ export const useStore = create<AppState>()(
|
||||
tableColumnOrders: state.tableColumnOrders,
|
||||
enableColumnOrderMemory: state.enableColumnOrderMemory,
|
||||
tableHiddenColumns: state.tableHiddenColumns,
|
||||
enableHiddenColumnMemory: state.enableHiddenColumnMemory,
|
||||
windowBounds: state.windowBounds,
|
||||
windowState: state.windowState,
|
||||
sidebarWidth: state.sidebarWidth,
|
||||
enableHiddenColumnMemory: state.enableHiddenColumnMemory
|
||||
}), // Don't persist logs
|
||||
}
|
||||
)
|
||||
|
||||
@@ -525,22 +525,8 @@ func (a *App) DBQueryMulti(config connection.ConnectionConfig, dbName string, qu
|
||||
a.queryMu.Unlock()
|
||||
}()
|
||||
|
||||
// 尝试使用驱动原生多结果集支持。
|
||||
// 注意:原生 conn.Query() 执行写操作(UPDATE/INSERT/DELETE)时,
|
||||
// sql.Rows 不暴露 RowsAffected,导致影响行数丢失。
|
||||
// 因此仅在全部语句皆为读操作时才使用原生路径。
|
||||
allReadOnly := true
|
||||
for _, stmt := range splitSQLStatements(query) {
|
||||
if strings.TrimSpace(stmt) != "" && !isReadOnlySQLQuery(runConfig.Type, stmt) {
|
||||
allReadOnly = false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 尝试使用驱动原生多结果集支持
|
||||
runMultiQuery := func(inst db.Database) ([]connection.ResultSetData, error) {
|
||||
if !allReadOnly {
|
||||
return nil, nil // 包含写操作,走逐条执行路径
|
||||
}
|
||||
if q, ok := inst.(db.MultiResultQuerierContext); ok {
|
||||
return q.QueryMultiContext(ctx, query)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user