Release/v0.6.2 (#263)

This commit is contained in:
Syngnat
2026-03-19 21:18:01 +08:00
committed by GitHub
9 changed files with 346 additions and 48 deletions

View File

@@ -1,8 +1,8 @@
import React, { useState, useEffect, useMemo } from 'react';
import React, { useState, useEffect, useMemo, useCallback } 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, WindowGetSize, WindowIsFullscreen, WindowIsMaximised, WindowMaximise, WindowMinimise, WindowSetSize, WindowToggleMaximise, WindowUnfullscreen } from '../wailsjs/runtime';
import { BrowserOpenURL, Environment, EventsOn, Quit, WindowFullscreen, WindowGetPosition, WindowGetSize, WindowIsFullscreen, WindowIsMaximised, WindowMaximise, WindowMinimise, WindowSetPosition, WindowSetSize, WindowToggleMaximise, WindowUnfullscreen } from '../wailsjs/runtime';
import Sidebar from './components/Sidebar';
import TabManager from './components/TabManager';
import ConnectionModal from './components/ConnectionModal';
@@ -89,7 +89,8 @@ function App() {
const [runtimePlatform, setRuntimePlatform] = useState('');
const [isLinuxRuntime, setIsLinuxRuntime] = useState(false);
const [isStoreHydrated, setIsStoreHydrated] = useState(() => useStore.persist.hasHydrated());
const [sidebarWidth, setSidebarWidth] = useState(330);
const sidebarWidth = useStore(state => state.sidebarWidth);
const setSidebarWidth = useStore(state => state.setSidebarWidth);
const globalProxyInvalidHintShownRef = React.useRef(false);
// 同步 macOS 窗口透明度opacity=1.0 且 blur=0 时关闭 NSVisualEffectView
@@ -285,14 +286,43 @@ 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()) {
applyStartupWindowPreference(1);
void restoreWindowState();
}
const unsubscribeHydration = useStore.persist.onFinishHydration(() => {
if (cancelled) {
return;
}
applyStartupWindowPreference(1);
void restoreWindowState();
});
return () => {
@@ -304,6 +334,52 @@ 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;
@@ -910,7 +986,7 @@ function App() {
setAboutLoading(false);
}, []);
const handleNewQuery = () => {
const handleNewQuery = useCallback(() => {
let connId = '';
let db = '';
@@ -930,14 +1006,14 @@ function App() {
}
addTab({
id: `query-${Date.now()}`,
id: `query-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
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();

View File

@@ -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 } from './dataGridLayout';
import { calculateTableBodyBottomPadding, calculateVirtualTableScrollX } from './dataGridLayout';
// --- Error Boundary ---
interface DataGridErrorBoundaryState {
@@ -1374,11 +1374,6 @@ 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 无 classAnt 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;
@@ -1392,6 +1387,17 @@ 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;
@@ -1457,6 +1463,11 @@ 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;
}
@@ -3764,10 +3775,13 @@ 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(() => {
const baseWidth = Math.max(totalWidth, 1000);
if (!isMacLike || tableViewportWidth <= 0) return baseWidth;
// macOS 在“自动隐藏滚动条”模式下容易误判为无横向滚动,预留 2px 触发稳定滚动轨道。
return Math.max(baseWidth, tableViewportWidth + 2);
// rc-table 在 scroll.x 小于容器宽度时会把实际列宽按视口补齐。
// 这里必须与其使用同一套 scroll.x 口径,否则少字段场景下 header/body 会错位。
return calculateVirtualTableScrollX({
totalWidth,
tableViewportWidth,
isMacLike,
});
}, [totalWidth, isMacLike, tableViewportWidth]);
const horizontalScrollVisible = viewMode === 'table' && tableScrollX > tableViewportWidth + 1;
const horizontalScrollWidth = Math.max(externalScrollbarMinWidth, tableScrollX);
@@ -3815,6 +3829,7 @@ 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,
@@ -3987,21 +4002,51 @@ 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;
return !!element.closest('.ant-table-body, .ant-table-content, .ant-table-cell, .ant-table-row, .ant-table-tbody');
if (element.closest('.data-grid-toolbar')) return false;
return true;
};
const handleContainerHorizontalWheel = (event: WheelEvent) => {
// applyVirtualHorizontalOffset 分发的合成 WheelEventisTrusted=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) {
// 虚拟模式:不拦截事件,让 rc-virtual-list 原生处理 wheel。
// rc-virtual-list 会通过内部 setOffsetLeft → re-render → onVirtualScroll
// 自动同步 header scrollLeft。
// 仅需在状态更新后同步外部横向滚动条。
event.preventDefault();
event.stopPropagation();
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;
@@ -4051,7 +4096,7 @@ const DataGrid: React.FC<DataGridProps> = ({
return () => {
container.removeEventListener('wheel', handleContainerHorizontalWheel, { capture: true } as EventListenerOptions);
};
}, [enableVirtual, pickHorizontalScrollTargets, readVirtualHorizontalOffset, viewMode]);
}, [applyVirtualHorizontalOffset, enableVirtual, pickHorizontalScrollTargets, readVirtualHorizontalOffset, viewMode]);
useEffect(() => {
if (viewMode !== 'table') return;
@@ -4059,6 +4104,31 @@ 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;

View File

@@ -170,6 +170,9 @@ 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 ');
@@ -428,6 +431,9 @@ 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) => {
@@ -783,6 +789,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
return { suggestions };
}
});
} // end sqlCompletionRegistered guard
};
const handleFormat = () => {
@@ -2005,7 +2012,11 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
label: (
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
<Tooltip title={rs.sql}>
<span>{`结果 ${idx + 1}${Array.isArray(rs.rows) ? ` (${rs.rows.length}${rs.truncated ? '+' : ''})` : ''}`}</span>
<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>
</Tooltip>
<Tooltip title="关闭结果">
<span
@@ -2021,23 +2032,40 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
</Tooltip>
</div>
),
children: (
<div style={{ flex: 1, minHeight: 0, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
<DataGrid
data={rs.rows}
columnNames={rs.columns}
loading={loading}
tableName={rs.tableName}
exportScope="queryResult"
resultSql={rs.exportSql || rs.sql}
dbName={currentDb}
connectionId={currentConnectionId}
pkColumns={rs.pkColumns}
onReload={handleRun}
readOnly={rs.readOnly}
/>
</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>
);
})()
}))}
/>
) : (

View File

@@ -1462,7 +1462,8 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
type: 'table-overview' as any,
connectionId: id,
dbName,
});
schemaName,
} as any);
return;
}
if (node.type === 'table') {

View File

@@ -194,7 +194,7 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
const openTable = useCallback((tableName: string) => {
if (!connection) return;
addTab({
id: `${connection.id}-${tab.dbName}-${tableName}`,
id: `${connection.id}-${tab.dbName}-table-${tableName}`,
title: tableName,
type: 'table',
connectionId: connection.id,

View File

@@ -1,4 +1,4 @@
import { calculateTableBodyBottomPadding } from './dataGridLayout';
import { calculateTableBodyBottomPadding, calculateVirtualTableScrollX } from './dataGridLayout';
const assertEqual = (actual: unknown, expected: unknown, message: string) => {
if (actual !== expected) {
@@ -36,4 +36,34 @@ 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');

View File

@@ -4,6 +4,12 @@ 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;
@@ -21,3 +27,22 @@ 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;
};

View File

@@ -420,6 +420,9 @@ 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;
@@ -469,6 +472,9 @@ 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[] => {
@@ -599,6 +605,29 @@ 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 {};
@@ -635,6 +664,9 @@ 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) => ({
@@ -875,6 +907,19 @@ 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)
@@ -906,7 +951,10 @@ export const useStore = create<AppState>()(
nextState.enableColumnOrderMemory = state.enableColumnOrderMemory !== false;
const safeHidden = sanitizeTableHiddenColumns(state.tableHiddenColumns);
nextState.tableHiddenColumns = safeHidden;
nextState.enableHiddenColumnMemory = state.enableHiddenColumnMemory !== false;
nextState.enableHiddenColumnMemory = state.enableHiddenColumnMemory !== false;
nextState.windowBounds = sanitizeWindowBounds(state.windowBounds);
nextState.windowState = sanitizeWindowState(state.windowState);
nextState.sidebarWidth = sanitizeSidebarWidth(state.sidebarWidth);
return nextState as AppState;
},
merge: (persistedState, currentState) => {
@@ -928,6 +976,9 @@ 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),
@@ -953,7 +1004,10 @@ export const useStore = create<AppState>()(
tableColumnOrders: state.tableColumnOrders,
enableColumnOrderMemory: state.enableColumnOrderMemory,
tableHiddenColumns: state.tableHiddenColumns,
enableHiddenColumnMemory: state.enableHiddenColumnMemory
enableHiddenColumnMemory: state.enableHiddenColumnMemory,
windowBounds: state.windowBounds,
windowState: state.windowState,
sidebarWidth: state.sidebarWidth,
}), // Don't persist logs
}
)

View File

@@ -525,8 +525,22 @@ 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)
}