️ perf(ui): 优化数据页滚动与编辑响应

- 优化 DataGrid 虚拟滚动横向同步与外部滚动条宽度计算
- 降低 v2 数据表内容容器的重绘与持久化写入开销
- 拆分 Tab 内容渲染并收敛 QueryEditor 对活跃标签的订阅
- 修复虚拟编辑态与单元格右键菜单的共享渲染路径
- 调整 v2 数据表编辑态样式并补齐性能复现 harness 对照能力
- 补充 DataGrid 布局与滚动相关回归测试
This commit is contained in:
Syngnat
2026-05-27 19:56:14 +08:00
parent 17695c361d
commit ccd12742d3
10 changed files with 721 additions and 125 deletions

View File

@@ -237,13 +237,17 @@ vi.mock('antd', () => {
</div>
);
return {
Table: (props: any) => {
const MockTable = React.forwardRef((_props: any, _ref) => {
const props = _props;
const { columns } = props;
testRenderState.latestColumns = Array.isArray(columns) ? columns : [];
testRenderState.latestTableProps = props;
return <table />;
},
});
MockTable.displayName = 'MockTable';
return {
Table: MockTable,
message: messageApi,
Input,
Button,
@@ -719,12 +723,25 @@ describe('DataGrid DDL interactions', () => {
const idColumn = testRenderState.latestColumns.find((column) => column.key === 'id');
const cellProps = idColumn.onCell({ __gonavi_row_key__: 'row-1', id: 1 });
const contextTarget = {
closest: (selector: string) => selector === '[data-row-key][data-col-name]'
? {
getAttribute: (name: string) => {
if (name === 'data-row-key') return 'row-1';
if (name === 'data-col-name') return 'id';
return null;
},
}
: null,
} as unknown as HTMLElement;
await act(async () => {
cellProps.onContextMenu({
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
clientX: 160,
clientY: 120,
currentTarget: contextTarget,
target: contextTarget,
});
});
@@ -803,12 +820,25 @@ describe('DataGrid DDL interactions', () => {
const nameColumn = testRenderState.latestColumns.find((column) => column.key === 'name');
const cellProps = nameColumn.onCell({ __gonavi_row_key__: 'row-1', id: 1, name: 'alpha' });
const contextTarget = {
closest: (selector: string) => selector === '[data-row-key][data-col-name]'
? {
getAttribute: (name: string) => {
if (name === 'data-row-key') return 'row-1';
if (name === 'data-col-name') return 'name';
return null;
},
}
: null,
} as unknown as HTMLElement;
await act(async () => {
cellProps.onContextMenu({
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
clientX: 160,
clientY: 120,
currentTarget: contextTarget,
target: contextTarget,
});
});
@@ -827,6 +857,8 @@ describe('DataGrid DDL interactions', () => {
stopPropagation: vi.fn(),
clientX: 160,
clientY: 120,
currentTarget: contextTarget,
target: contextTarget,
});
});
await act(async () => {

View File

@@ -369,10 +369,13 @@ describe('DataGrid layout', () => {
const source = readFileSync(new URL('./DataGrid.tsx', import.meta.url), 'utf8');
expect(source).toContain('virtualHorizontalElementsRef');
expect(source).toContain('type VirtualTableScrollReference = TableReference & {');
expect(source).toContain('const tableRef = useRef<VirtualTableScrollReference | null>(null);');
expect(source).toContain('resolveDataGridHorizontalWheelDelta({');
expect(source).toContain('const scheduleVirtualHorizontalWheel = useCallback');
expect(source).toContain('pendingTableHorizontalDeltaRef.current += delta;');
expect(source).toContain('tableHorizontalWheelRafRef.current = requestAnimationFrame');
expect(source).toContain('tableInstance.scrollTo({ left: clampedOffset, top: holderEl.scrollTop });');
expect(source).toContain('if (externalSyncRafRef.current !== null)');
expect(source).toContain('externalSyncRafRef.current = requestAnimationFrame');
expect(source).toContain('const scheduleSyncExternalScrollFromTargets = useCallback');
@@ -388,11 +391,33 @@ describe('DataGrid layout', () => {
expect(source).toContain('const attachDataGridVirtualEditRenderVersion = <T extends Item>(');
expect(source).toContain('hasDataGridVirtualEditRenderVersionChanged(record, prevRecord)');
expect(source).not.toContain('if (enableVirtual && enableInlineEditableCell) {\n return (\n <EditableCell');
expect(source).toContain("content-visibility: ${useAggressiveVirtualPaintHints ? 'auto' : 'visible'};");
expect(source).toContain("contain-intrinsic-size: ${useAggressiveVirtualPaintHints ? '24px 160px' : 'auto'};");
expect(source).toContain("contain: ${useAggressiveVirtualPaintHints ? 'layout paint style' : 'layout style'};");
expect(source).toContain("content-visibility: ${useVirtualHolderPaintHints ? 'auto' : 'visible'};");
expect(source).toContain("content-visibility: ${useVirtualEditableVisibilityHints ? 'auto' : 'visible'};");
expect(source).toContain("contain-intrinsic-size: ${useVirtualEditableVisibilityHints ? '24px 160px' : 'auto'};");
expect(source).toContain("const useVirtualHolderPaintHints = !isMacLike && !isV2Ui;");
expect(source).toContain("const useVirtualCellContentContain = false;");
expect(source).toContain("const useVirtualEditableVisibilityHints = !isMacLike && !isV2Ui;");
expect(source).toContain("contain: ${useVirtualRowCellContain ? 'layout paint style' : 'none'};");
expect(source).toContain('const handleSharedCellContextMenu = useCallback');
expect(source).toContain('const shouldUsePlainVirtualContent = isV2Ui && !modifiedStyle;');
expect(source).toContain('if (shouldUsePlainVirtualContent) {');
expect(source).toContain('return originalRenderContent;');
expect(source).toContain('if (scrollSnapshotRafRef.current !== null) return;');
expect(source).toContain('scrollSnapshotRafRef.current = requestAnimationFrame');
expect(source).toContain("const dataGridBackdropFilter = isMacLike ? 'none' : (opacity < 0.999 ? 'blur(14px)' : 'none');");
expect(source).toContain("const dataGridBackdropFilter = isV2Ui || isMacLike ? 'none' : (opacity < 0.999 ? 'blur(14px)' : 'none');");
expect(source).toContain('rowHoverable={!enableVirtual}');
});
it('keeps the DataGrid performance harness aligned with legacy and v2 comparison controls', () => {
const harnessSource = readFileSync(new URL('../dev/PerfDataGridHarness.tsx', import.meta.url), 'utf8');
expect(harnessSource).toContain("options={[");
expect(harnessSource).toContain("{ label: '旧版 UI', value: 'legacy' }");
expect(harnessSource).toContain("{ label: '新版 UI', value: 'v2' }");
expect(harnessSource).toContain("{ value: 'comfortable', label: '标准' }");
expect(harnessSource).toContain("{ value: 'standard', label: '紧凑' }");
expect(harnessSource).toContain("{ value: 'compact', label: '极紧凑' }");
expect(harnessSource).toContain("document.body.setAttribute('data-ui-version', uiVersion);");
expect(harnessSource).toContain("if (value === null || value === undefined || value === '') {");
expect(harnessSource).toContain("const currentState = useStore.getState();");
});
});

View File

@@ -4,6 +4,7 @@ import { createPortal } from 'react-dom';
import { Table, message, Input, Button, Dropdown, MenuProps, Form, Pagination, Select, Modal, Checkbox, Segmented, Tooltip, Popover, DatePicker, TimePicker } from 'antd';
import dayjs from 'dayjs';
import type { SortOrder, ColumnType } from 'antd/es/table/interface';
import type { Reference as TableReference } from 'rc-table';
import { CloseOutlined, ConsoleSqlOutlined, CopyOutlined, EditOutlined, ExportOutlined, FileTextOutlined, LeftOutlined, RightOutlined, SearchOutlined, VerticalAlignBottomOutlined } from '@ant-design/icons';
import {
DndContext,
@@ -38,6 +39,7 @@ import {
import { resolvePaginationPageText, resolvePaginationSummaryText, resolvePaginationTotalForControl } from '../utils/dataGridPagination';
import { resolveGridSortInfoFromTableSorter } from '../utils/dataGridSort';
import {
calculateExternalHorizontalScrollInnerWidth,
calculateTableBodyBottomPadding,
calculateVirtualTableScrollX,
resolveDataGridHorizontalWheelDelta,
@@ -178,6 +180,7 @@ const CELL_KEY_SEP = '\u0001';
const CELL_SELECTION_DRAG_THRESHOLD_PX = 4;
const DATE_TIME_CACHE_LIMIT = 2000;
const TABLE_CELL_PREVIEW_MAX_CHARS = 240;
const DATA_GRID_DISPLAY_RENDER_VERSION = Symbol('DATA_GRID_DISPLAY_RENDER_VERSION');
const DATA_GRID_VIRTUAL_EDIT_RENDER_VERSION = Symbol('DATA_GRID_VIRTUAL_EDIT_RENDER_VERSION');
const normalizedDateTimeCache = new Map<string, string>();
const objectCellPreviewCache = new WeakMap<object, string>();
@@ -392,6 +395,33 @@ export const attachDataGridVirtualEditRenderVersion = <T extends Item>(
});
};
export const attachDataGridDisplayRenderVersion = <T extends Item>(
rows: T[],
renderVersion: string,
): T[] => {
if (!renderVersion) return rows;
return rows.map((row) => {
if (!row || typeof row !== 'object') return row;
const nextRow = { ...(row as object) } as T;
Object.defineProperty(nextRow, DATA_GRID_DISPLAY_RENDER_VERSION, {
value: renderVersion,
enumerable: false,
});
return nextRow;
});
};
export const hasDataGridDisplayRenderVersionChanged = (nextRecord: unknown, previousRecord: unknown): boolean => {
const nextVersion = nextRecord && typeof nextRecord === 'object'
? (nextRecord as Record<symbol, unknown>)[DATA_GRID_DISPLAY_RENDER_VERSION]
: undefined;
const previousVersion = previousRecord && typeof previousRecord === 'object'
? (previousRecord as Record<symbol, unknown>)[DATA_GRID_DISPLAY_RENDER_VERSION]
: undefined;
return nextVersion !== previousVersion;
};
export const hasDataGridVirtualEditRenderVersionChanged = (nextRecord: unknown, previousRecord: unknown): boolean => {
const nextVersion = nextRecord && typeof nextRecord === 'object'
? (nextRecord as Record<symbol, unknown>)[DATA_GRID_VIRTUAL_EDIT_RENDER_VERSION]
@@ -1214,6 +1244,10 @@ type ForeignKeyTarget = {
constraintName: string;
};
type VirtualTableScrollReference = TableReference & {
scrollTo: (config: { left?: number; top?: number; index?: number; key?: React.Key }) => void;
};
const EXACT_GRID_FILTER_OPERATOR = '=';
const CONTAINS_GRID_FILTER_OPERATOR = 'CONTAINS';
const FILTER_FIELD_SELECT_STYLE: React.CSSProperties = {
@@ -1419,6 +1453,20 @@ const VIRTUAL_CELL_TEXT_STYLE: React.CSSProperties = {
width: '100%',
};
const READONLY_CELL_WRAP_STYLE: React.CSSProperties = { minHeight: 20, display: 'flex', alignItems: 'center', width: '100%', minWidth: 0 };
const VIRTUAL_EDITING_CELL_STYLE: React.CSSProperties = {
margin: 0,
padding: 0,
display: 'flex',
flex: '1 1 auto',
alignItems: 'center',
width: '100%',
minWidth: 0,
minHeight: 'calc(28px * var(--gn-ui-scale, 1))',
height: 'calc(28px * var(--gn-ui-scale, 1))',
overflow: 'visible',
whiteSpace: 'nowrap',
boxSizing: 'border-box',
};
const DataGrid: React.FC<DataGridProps> = ({
data, columnNames, loading, tableName, exportScope = 'table', dbName, connectionId, pkColumns = [], editLocator, readOnly = false,
@@ -1432,6 +1480,7 @@ const DataGrid: React.FC<DataGridProps> = ({
const addSqlLog = useStore(state => state.addSqlLog);
const theme = useStore(state => state.theme);
const appearance = useStore(state => state.appearance);
const uiScale = useStore(state => state.uiScale);
const queryOptions = useStore(state => state.queryOptions);
const setQueryOptions = useStore(state => state.setQueryOptions);
const tableColumnOrders = useStore(state => state.tableColumnOrders);
@@ -1449,12 +1498,17 @@ const DataGrid: React.FC<DataGridProps> = ({
const isMacLike = useMemo(() => isMacLikePlatform(), []);
const isV2Ui = appearance?.uiVersion === 'v2';
const effectiveUiScale = Math.min(1.25, Math.max(0.8, Number(uiScale) || 1));
const activeShortcutPlatform = useMemo(() => getShortcutPlatform(isMacLike), [isMacLike]);
const darkMode = theme === 'dark';
const resolvedAppearance = resolveAppearanceValues(appearance);
const opacity = normalizeOpacityForPlatform(resolvedAppearance.opacity);
const useAggressiveVirtualPaintHints = !isMacLike;
const dataGridBackdropFilter = isMacLike ? 'none' : (opacity < 0.999 ? 'blur(14px)' : 'none');
const useVirtualHolderPaintHints = !isMacLike && !isV2Ui;
const useVirtualRowCellContain = !isMacLike && !isV2Ui;
const useVirtualCellContentContain = false;
const useVirtualEditablePaintContain = !isMacLike && !isV2Ui;
const useVirtualEditableVisibilityHints = !isMacLike && !isV2Ui;
const dataGridBackdropFilter = isV2Ui || isMacLike ? 'none' : (opacity < 0.999 ? 'blur(14px)' : 'none');
const showDataTableVerticalBorders = appearance.showDataTableVerticalBorders === true;
const dataTableDensity = appearance.dataTableDensity;
const densityParams = useMemo(() => getDensityParams(dataTableDensity), [dataTableDensity]);
@@ -1467,13 +1521,17 @@ const DataGrid: React.FC<DataGridProps> = ({
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}), [densityParams]);
contain: useVirtualCellContentContain ? 'layout style' : undefined,
}), [densityParams, useVirtualCellContentContain]);
const headerCellMinHeight = densityParams.headerMinHeight;
const inputCellPadding: React.CSSProperties = { padding: densityParams.inputCellPadding };
const dataTableVerticalBorderColor = resolveDataTableVerticalBorderColor({
darkMode,
visible: showDataTableVerticalBorders,
});
const dataTableVerticalBorderRule = showDataTableVerticalBorders
? `1px solid ${dataTableVerticalBorderColor}`
: 'none';
const effectiveEditLocator = useMemo<EditRowLocator | undefined>(() => {
if (editLocator) return editLocator;
if (pkColumns.length === 0) return undefined;
@@ -1664,7 +1722,7 @@ const DataGrid: React.FC<DataGridProps> = ({
panelFrameColor: darkMode ? 'rgba(0, 0, 0, 0.42)' : 'rgba(0, 0, 0, 0.18)',
floatingScrollbarThumbBg: darkMode ? 'rgba(255,255,255,0.68)' : 'rgba(0,0,0,0.44)',
floatingScrollbarThumbBorderColor: darkMode ? 'rgba(255,255,255,0.26)' : 'rgba(255,255,255,0.52)',
floatingScrollbarThumbShadow: isMacLike ? 'none' : (darkMode ? '0 4px 14px rgba(0,0,0,0.42)' : '0 4px 10px rgba(0,0,0,0.20)'),
floatingScrollbarThumbShadow: (isMacLike || isV2Ui) ? 'none' : (darkMode ? '0 4px 14px rgba(0,0,0,0.42)' : '0 4px 10px rgba(0,0,0,0.20)'),
verticalScrollbarTrackBg: darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)',
horizontalScrollbarThumbBg: darkMode ? 'rgba(255,255,255,0.20)' : 'rgba(0,0,0,0.14)',
toolbarDividerColor: darkMode ? 'rgba(255, 255, 255, 0.12)' : 'rgba(0, 0, 0, 0.10)',
@@ -1696,7 +1754,7 @@ const DataGrid: React.FC<DataGridProps> = ({
paginationActiveItemBorderColor: darkMode ? 'rgba(255,214,102,0.46)' : 'rgba(24,144,255,0.28)',
paginationActiveItemTextColor: darkMode ? '#fff7d6' : '#0958d9',
};
}, [darkMode, opacity, resolvedAppearance.blur, isMacLike]);
}, [darkMode, opacity, resolvedAppearance.blur, isMacLike, isV2Ui]);
// 解构常用变量以保持后续代码引用不变
const {
@@ -1819,6 +1877,7 @@ const DataGrid: React.FC<DataGridProps> = ({
const rootRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const tableContainerRef = useRef<HTMLDivElement | null>(null);
const tableRef = useRef<VirtualTableScrollReference | null>(null);
const tableScrollTargetsRef = useRef<HTMLElement[]>([]);
const externalHorizontalScrollRef = useRef<HTMLDivElement | null>(null);
const virtualHorizontalElementsRef = useRef<{
@@ -2359,8 +2418,8 @@ const DataGrid: React.FC<DataGridProps> = ({
}
.${gridId} .ant-table-tbody > tr > td,
.${gridId} .ant-table-tbody .ant-table-row > .ant-table-cell,
.${gridId} .ant-table-tbody-virtual-holder .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 ${dataTableVerticalBorderColor} !important; font-size: ${densityParams.dataFontSize}px !important; vertical-align: middle !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 ${dataTableVerticalBorderColor} !important; font-size: ${densityParams.dataFontSize}px !important; }
.${gridId} .ant-table-tbody-virtual-holder .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: ${dataTableVerticalBorderRule} !important; font-size: ${densityParams.dataFontSize}px !important; vertical-align: middle !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: ${dataTableVerticalBorderRule} !important; font-size: ${densityParams.dataFontSize}px !important; }
.${gridId} .ant-table-tbody > tr > td:last-child,
.${gridId} .ant-table-tbody .ant-table-row > .ant-table-cell:last-child,
.${gridId} .ant-table-tbody-virtual-holder .ant-table-row > .ant-table-cell:last-child,
@@ -2472,17 +2531,33 @@ const DataGrid: React.FC<DataGridProps> = ({
padding-bottom: ${tableBodyBottomPadding}px;
box-sizing: border-box;
scroll-padding-bottom: ${tableBodyBottomPadding}px;
contain: ${useAggressiveVirtualPaintHints ? 'layout paint style' : 'layout style'};
content-visibility: ${useAggressiveVirtualPaintHints ? 'auto' : 'visible'};
contain: ${useVirtualHolderPaintHints ? 'layout paint style' : 'layout style'};
content-visibility: ${useVirtualHolderPaintHints ? 'auto' : 'visible'};
}
.${gridId} .ant-table-tbody-virtual-holder-inner {
padding-bottom: ${tableBodyBottomPadding}px;
box-sizing: border-box;
contain: ${useAggressiveVirtualPaintHints ? 'layout paint style' : 'layout style'};
contain: ${useVirtualHolderPaintHints ? 'layout paint style' : 'layout style'};
}
.${gridId} .ant-table-tbody-virtual-holder .ant-table-row,
.${gridId} .ant-table-tbody-virtual-holder .ant-table-row > .ant-table-cell {
contain: ${useAggressiveVirtualPaintHints ? 'layout paint style' : 'none'};
contain: ${useVirtualRowCellContain ? 'layout paint style' : 'none'};
}
.${gridId}.gn-v2-data-grid .ant-table-tbody-virtual-holder .ant-table-row > .ant-table-cell {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.${gridId}.gn-v2-data-grid .ant-table-tbody > tr > td,
.${gridId}.gn-v2-data-grid .ant-table-tbody .ant-table-row > .ant-table-cell,
.${gridId}.gn-v2-data-grid .ant-table-tbody-virtual-holder .ant-table-row > .ant-table-cell {
vertical-align: middle !important;
}
.${gridId}.gn-v2-data-grid .ant-table-tbody-virtual-holder .ant-table-row > .ant-table-cell.ant-table-cell-row-hover,
.${gridId}.gn-v2-data-grid .ant-table-tbody-virtual-holder .ant-table-row > .ant-table-cell.data-grid-virtual-inline-editing {
overflow: visible;
text-overflow: clip;
white-space: normal;
}
.${gridId} .data-grid-table-wrap {
width: 100%;
@@ -2505,14 +2580,14 @@ const DataGrid: React.FC<DataGridProps> = ({
min-height: 20px;
padding-right: 0;
position: relative;
contain: ${useAggressiveVirtualPaintHints ? 'layout paint style' : 'layout style'};
contain: ${useVirtualEditablePaintContain ? 'layout paint style' : 'layout style'};
}
.${gridId} .editable-cell-value-wrap > * {
min-width: 0;
}
.${gridId} .ant-table-tbody-virtual-holder .editable-cell-value-wrap {
content-visibility: ${useAggressiveVirtualPaintHints ? 'auto' : 'visible'};
contain-intrinsic-size: ${useAggressiveVirtualPaintHints ? '24px 160px' : 'auto'};
content-visibility: ${useVirtualEditableVisibilityHints ? 'auto' : 'visible'};
contain-intrinsic-size: ${useVirtualEditableVisibilityHints ? '24px 160px' : 'auto'};
}
/* 虚拟表列对齐:阻止 header <table> 通过 min-width:100% 拉伸到视口,
使 header 列宽与虚拟 body 单元格宽度精确一致 */
@@ -4202,12 +4277,17 @@ const DataGrid: React.FC<DataGridProps> = ({
}, [mergedDisplayData, rowKeyStr]);
const resolveRenderedCellInfoFromElement = useCallback((target: EventTarget | null) => {
const element = target instanceof HTMLElement ? target.closest('[data-row-key][data-col-name]') as HTMLElement | null : null;
const closestSource = target && typeof target === 'object' && 'closest' in target
? target as { closest?: (selector: string) => { getAttribute?: (name: string) => string | null } | null }
: null;
const element = typeof closestSource?.closest === 'function'
? closestSource.closest('[data-row-key][data-col-name]')
: null;
if (!element) {
return null;
}
const rowKey = String(element.getAttribute('data-row-key') || '').trim();
const dataIndex = String(element.getAttribute('data-col-name') || '').trim();
const rowKey = String(element.getAttribute?.('data-row-key') || '').trim();
const dataIndex = String(element.getAttribute?.('data-col-name') || '').trim();
if (!rowKey || !dataIndex) {
return null;
}
@@ -4218,6 +4298,15 @@ const DataGrid: React.FC<DataGridProps> = ({
return { rowKey, dataIndex, record };
}, []);
const handleSharedCellContextMenu = useCallback((event: React.MouseEvent<HTMLElement>) => {
const eventTarget = (event.currentTarget as EventTarget | null) ?? event.target;
const cellInfo = resolveRenderedCellInfoFromElement(eventTarget);
if (!cellInfo) return;
event.preventDefault();
event.stopPropagation();
showCellContextMenu(event, cellInfo.record, cellInfo.dataIndex, cellInfo.dataIndex);
}, [resolveRenderedCellInfoFromElement, showCellContextMenu]);
const handleVirtualTableClickCapture = useCallback((event: React.MouseEvent<HTMLDivElement>) => {
if (!dataPanelOpenRef.current) return;
const cellInfo = resolveRenderedCellInfoFromElement(event.target);
@@ -4310,12 +4399,19 @@ const DataGrid: React.FC<DataGridProps> = ({
? activePageFindMatchIndex + 1
: 0;
const displayRenderVersion = useMemo(() => (
`${isV2Ui ? 'v2' : 'legacy'}|${theme}|${dataTableDensity}|${effectiveUiScale}`
), [dataTableDensity, effectiveUiScale, isV2Ui, theme]);
const tableRenderData = useMemo(
() => attachDataGridVirtualEditRenderVersion(
attachDataGridFindRenderVersion(mergedDisplayData, normalizedPageFindText),
attachDataGridDisplayRenderVersion(
attachDataGridFindRenderVersion(mergedDisplayData, normalizedPageFindText),
displayRenderVersion,
),
virtualEditingCell,
),
[mergedDisplayData, normalizedPageFindText, virtualEditingCell]
[displayRenderVersion, mergedDisplayData, normalizedPageFindText, virtualEditingCell]
);
useEffect(() => {
@@ -4655,6 +4751,7 @@ const DataGrid: React.FC<DataGridProps> = ({
shouldCellUpdate: (record: Item, prevRecord: Item) => {
const rowKeyChanged = record?.[GONAVI_ROW_KEY] !== prevRecord?.[GONAVI_ROW_KEY];
if (rowKeyChanged) return true;
if (hasDataGridDisplayRenderVersionChanged(record, prevRecord)) return true;
if (hasDataGridFindRenderVersionChanged(record, prevRecord)) return true;
if (hasDataGridVirtualEditRenderVersionChanged(record, prevRecord)) return true;
return !isCellValueEqualForRender(record?.[key], prevRecord?.[key]);
@@ -4728,17 +4825,12 @@ const DataGrid: React.FC<DataGridProps> = ({
cellProps.deletedRowKeys = deletedRowKeys;
cellProps.darkMode = darkMode;
} else if (enableVirtual) {
// 虚拟表格主要走 table 容器级事件委托;这里保留一个共享右键入口,兼容测试桩和非标准事件分发场景。
cellProps.onContextMenu = (e: React.MouseEvent) => {
handleVirtualCellContextMenu(e, record, dataIndex);
};
// 虚拟表格主要走容器级事件委托;这里保留共享 handler
// 兼容测试桩与非标准事件分发,同时避免为每个单元格创建闭包。
cellProps.onContextMenu = handleSharedCellContextMenu;
} else {
// 不可编辑(只读查询结果):只绑定右键菜单
cellProps.onContextMenu = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
showCellContextMenu(e, record, dataIndex, dataIndex);
};
// 不可编辑(只读查询结果):共享右键菜单 handler减少单元格闭包。
cellProps.onContextMenu = handleSharedCellContextMenu;
}
return cellProps;
},
@@ -4755,6 +4847,7 @@ const DataGrid: React.FC<DataGridProps> = ({
const modifiedStyle: React.CSSProperties | undefined = isModifiedCell
? { backgroundColor: darkMode ? 'rgba(255, 214, 102, 0.16)' : '#FFF3B0' }
: undefined;
const shouldUsePlainVirtualContent = isV2Ui && !modifiedStyle;
if (enableVirtual && enableInlineEditableCell) {
const pickerType = getTemporalPickerType(columnType);
const isDateTimeField = !!pickerType && !(/^0{4}-0{2}-0{2}/.test(String(record?.[dataIndex] || '')));
@@ -4763,7 +4856,8 @@ const DataGrid: React.FC<DataGridProps> = ({
if (isVirtualInlineEditingCell && virtualEditable) {
return (
<div
style={virtualCellStyle}
style={modifiedStyle ? { ...VIRTUAL_EDITING_CELL_STYLE, ...modifiedStyle } : VIRTUAL_EDITING_CELL_STYLE}
className="data-grid-virtual-inline-editing"
onContextMenu={(e) => handleVirtualCellContextMenu(e, record, dataIndex)}
>
<Form.Item style={{ margin: 0, width: '100%' }} name={getCellFieldName(record, dataIndex)}>
@@ -4829,7 +4923,7 @@ const DataGrid: React.FC<DataGridProps> = ({
) : (
<Input
ref={virtualInlineInputRef}
style={inputCellPadding}
style={{ width: '100%', ...inputCellPadding }}
onPressEnter={() => { void saveVirtualInlineEditor(); }}
onBlur={() => { void saveVirtualInlineEditor(); }}
onFocus={(e) => {
@@ -4853,15 +4947,21 @@ const DataGrid: React.FC<DataGridProps> = ({
</div>
);
}
if (shouldUsePlainVirtualContent) {
return originalRenderContent;
}
return <div style={virtualCellStyle}>{originalRenderContent}</div>;
}
if (enableVirtual) {
if (shouldUsePlainVirtualContent) {
return originalRenderContent;
}
return <div style={virtualCellWrapperStyle}>{originalRenderContent}</div>;
}
return originalRenderContent;
}
};
}), [columns, useInlineEditableBodyCell, enableInlineEditableCell, enableVirtual, handleCellSave, openCellEditor, handleVirtualCellActivate, handleVirtualCellContextMenu, displayColumnTypeMap, inputCellPadding, virtualCellWrapperStyle, modifiedColumns, rowKeyStr, deletedRowKeys, darkMode, virtualEditingCell, form, saveVirtualInlineEditor, lockVirtualInlineTableScroll, closeVirtualInlineEditor, updateFocusedCell]);
}), [columns, useInlineEditableBodyCell, enableInlineEditableCell, enableVirtual, handleCellSave, openCellEditor, handleVirtualCellActivate, handleSharedCellContextMenu, displayColumnTypeMap, inputCellPadding, virtualCellWrapperStyle, modifiedColumns, rowKeyStr, deletedRowKeys, darkMode, virtualEditingCell, form, saveVirtualInlineEditor, lockVirtualInlineTableScroll, closeVirtualInlineEditor, updateFocusedCell]);
const handleAddRow = () => {
const newKey = `new-${Date.now()}`;
@@ -5901,12 +6001,14 @@ const DataGrid: React.FC<DataGridProps> = ({
selectedRowKeys,
onChange: setSelectedRowKeys,
columnWidth: selectionColumnWidth,
renderCell: (_checked: boolean, _record: any, _index: number, originNode: React.ReactNode) => (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: '100%', height: '100%' }}>
{originNode}
</div>
),
}), [selectedRowKeys, selectionColumnWidth]);
...(isV2Ui ? {} : {
renderCell: (_checked: boolean, _record: any, _index: number, originNode: React.ReactNode) => (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: '100%', height: '100%' }}>
{originNode}
</div>
),
}),
}), [isV2Ui, selectedRowKeys, selectionColumnWidth]);
const rowPropsFactory = useCallback((record: any) => ({ record } as any), []);
@@ -5922,8 +6024,14 @@ const DataGrid: React.FC<DataGridProps> = ({
});
}, [totalWidth, isMacLike, tableViewportWidth]);
const horizontalScrollVisible = isTableSurfaceActive && tableScrollX > tableViewportWidth + 1;
const horizontalScrollWidth = Math.max(externalScrollbarMinWidth, tableScrollX);
const horizontalScrollWidth = useMemo(() => calculateExternalHorizontalScrollInnerWidth({
tableScrollWidth: tableScrollX,
trackInset: floatingScrollbarInset,
}), [tableScrollX, floatingScrollbarInset]);
const tableScrollConfig = useMemo(() => ({ x: tableScrollX, y: tableHeight }), [tableScrollX, tableHeight]);
const virtualListItemHeight = useMemo(() => (
isV2Ui ? Math.max(24, Math.round(28 * effectiveUiScale)) : undefined
), [effectiveUiScale, isV2Ui]);
const tableComponents = useMemo(() => {
const body: Record<string, any> = {};
// 虚拟表模式下 render() 已返回 EditableCell这里再挂 body.cell 会形成双层包装,
@@ -5961,27 +6069,51 @@ const DataGrid: React.FC<DataGridProps> = ({
const readVirtualHorizontalOffset = useCallback((tableContainer: HTMLElement): number => {
const { innerEl, headerEl } = resolveVirtualHorizontalElements(tableContainer);
const marginLeft = innerEl ? Math.abs(parseFloat(innerEl.style.marginLeft) || 0) : 0;
const headerLeft = headerEl ? Math.max(0, headerEl.scrollLeft) : 0;
return Math.max(marginLeft, headerLeft);
if (innerEl instanceof HTMLElement) {
return Math.max(0, Math.abs(parseFloat(innerEl.style.marginLeft) || 0));
}
return headerEl ? Math.max(0, headerEl.scrollLeft) : 0;
}, [resolveVirtualHorizontalElements]);
const applyVirtualHorizontalOffset = useCallback((tableContainer: HTMLElement, nextOffset: number) => {
const { holderEl, innerEl } = resolveVirtualHorizontalElements(tableContainer);
const syncVirtualHorizontalVisualOffset = useCallback((tableContainer: HTMLElement, nextOffset: number) => {
const { holderEl, innerEl, headerEl } = resolveVirtualHorizontalElements(tableContainer);
if (!(holderEl instanceof HTMLElement) || !(innerEl instanceof HTMLElement)) {
return false;
return null;
}
const maxScroll = Math.max(0, tableScrollX - holderEl.clientWidth);
const clampedOffset = Math.max(0, Math.min(maxScroll, nextOffset));
const currentOffset = Math.abs(parseFloat(innerEl.style.marginLeft) || 0);
const currentOffset = Math.max(0, Math.abs(parseFloat(innerEl.style.marginLeft) || 0));
const nextMarginLeft = `${-clampedOffset}px`;
if (innerEl.style.marginLeft !== nextMarginLeft) {
innerEl.style.marginLeft = nextMarginLeft;
}
if (headerEl instanceof HTMLElement && Math.abs(headerEl.scrollLeft - clampedOffset) > 1) {
headerEl.scrollLeft = clampedOffset;
}
return { holderEl, clampedOffset, currentOffset };
}, [resolveVirtualHorizontalElements, tableScrollX]);
const applyVirtualHorizontalOffset = useCallback((tableContainer: HTMLElement, nextOffset: number) => {
const synced = syncVirtualHorizontalVisualOffset(tableContainer, nextOffset);
if (!synced) {
return false;
}
const { holderEl, clampedOffset, currentOffset } = synced;
const deltaX = clampedOffset - currentOffset;
if (Math.abs(deltaX) < 0.5) return true;
// 通过合成 WheelEvent 驱动 rc-virtual-list 内部 offsetLeft state
// 让 rc-table onInternalScroll 自动同步 header scrollLeft。
// 不直接操作 DOM marginLeft避免 React re-render 覆盖。
const tableInstance = tableRef.current;
if (tableInstance && typeof tableInstance.scrollTo === 'function') {
tableInstance.scrollTo({ left: clampedOffset, top: holderEl.scrollTop });
return true;
}
// 回退:通过合成 WheelEvent 驱动 rc-virtual-list 内部 offsetLeft state
// 让 rc-table onInternalScroll 自动同步 header scrollLeft。
holderEl.dispatchEvent(new WheelEvent('wheel', {
deltaX: deltaX,
deltaY: 0,
@@ -5989,7 +6121,7 @@ const DataGrid: React.FC<DataGridProps> = ({
cancelable: true,
}));
return true;
}, [resolveVirtualHorizontalElements, tableScrollX]);
}, [syncVirtualHorizontalVisualOffset]);
const flushVirtualHorizontalWheel = useCallback((tableContainer: HTMLElement) => {
tableHorizontalWheelRafRef.current = null;
@@ -6002,18 +6134,16 @@ const DataGrid: React.FC<DataGridProps> = ({
const currentOffset = readVirtualHorizontalOffset(tableContainer);
applyVirtualHorizontalOffset(tableContainer, currentOffset + delta);
requestAnimationFrame(() => {
const nextScrollLeft = readVirtualHorizontalOffset(tableContainer);
lastTableScrollLeftRef.current = nextScrollLeft;
const externalScroll = externalHorizontalScrollRef.current;
if (externalScroll && Math.abs(externalScroll.scrollLeft - nextScrollLeft) > 1) {
externalScroll.scrollLeft = nextScrollLeft;
lastExternalScrollLeftRef.current = nextScrollLeft;
}
if (pendingTableHorizontalDeltaRef.current === 0 && tableHorizontalWheelRafRef.current === null) {
horizontalSyncSourceRef.current = '';
}
});
const nextScrollLeft = readVirtualHorizontalOffset(tableContainer);
lastTableScrollLeftRef.current = nextScrollLeft;
const externalScroll = externalHorizontalScrollRef.current;
if (externalScroll && Math.abs(externalScroll.scrollLeft - nextScrollLeft) > 1) {
externalScroll.scrollLeft = nextScrollLeft;
lastExternalScrollLeftRef.current = nextScrollLeft;
}
if (pendingTableHorizontalDeltaRef.current === 0 && tableHorizontalWheelRafRef.current === null) {
horizontalSyncSourceRef.current = '';
}
}, [applyVirtualHorizontalOffset, readVirtualHorizontalOffset]);
const scheduleVirtualHorizontalWheel = useCallback((tableContainer: HTMLElement, delta: number) => {
@@ -6180,10 +6310,23 @@ const DataGrid: React.FC<DataGridProps> = ({
return;
}
if (Math.abs(lastExternalScrollLeftRef.current - externalScroll.scrollLeft) < 1) {
const tableContainer = tableContainerRef.current;
let nextExternalScrollLeft = externalScroll.scrollLeft;
if (enableVirtual && tableContainer instanceof HTMLElement) {
const synced = syncVirtualHorizontalVisualOffset(tableContainer, externalScroll.scrollLeft);
if (synced) {
nextExternalScrollLeft = synced.clampedOffset;
lastTableScrollLeftRef.current = synced.clampedOffset;
if (Math.abs(externalScroll.scrollLeft - synced.clampedOffset) > 1) {
externalScroll.scrollLeft = synced.clampedOffset;
}
}
}
if (Math.abs(lastExternalScrollLeftRef.current - nextExternalScrollLeft) < 1) {
return;
}
lastExternalScrollLeftRef.current = externalScroll.scrollLeft;
lastExternalScrollLeftRef.current = nextExternalScrollLeft;
if (externalSyncRafRef.current !== null) {
return;
}
@@ -6205,7 +6348,12 @@ const DataGrid: React.FC<DataGridProps> = ({
if (applied) {
// WheelEvent 经 rc-virtual-list 处理后状态异步更新,延迟同步 ref
requestAnimationFrame(() => {
lastTableScrollLeftRef.current = readVirtualHorizontalOffset(tableContainer);
const resolvedScrollLeft = readVirtualHorizontalOffset(tableContainer);
lastTableScrollLeftRef.current = resolvedScrollLeft;
if (Math.abs(latestExternalScroll.scrollLeft - resolvedScrollLeft) > 1) {
latestExternalScroll.scrollLeft = resolvedScrollLeft;
}
lastExternalScrollLeftRef.current = resolvedScrollLeft;
horizontalSyncSourceRef.current = '';
});
return;
@@ -6242,7 +6390,7 @@ const DataGrid: React.FC<DataGridProps> = ({
lastTableScrollLeftRef.current = latestExternalScroll.scrollLeft;
horizontalSyncSourceRef.current = '';
});
}, [applyVirtualHorizontalOffset, enableVirtual, readVirtualHorizontalOffset]);
}, [applyVirtualHorizontalOffset, enableVirtual, readVirtualHorizontalOffset, syncVirtualHorizontalVisualOffset]);
// 外部水平滚动条的 wheel 处理(通过原生事件绑定,确保 preventDefault 生效)
useEffect(() => {
@@ -6661,9 +6809,13 @@ const DataGrid: React.FC<DataGridProps> = ({
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={displayColumnNames} strategy={horizontalListSortingStrategy}>
<Table
ref={tableRef}
components={tableComponents}
dataSource={tableRenderData}
columns={mergedColumns}
{...(enableVirtual && typeof virtualListItemHeight === 'number'
? { listItemHeight: virtualListItemHeight }
: {})}
showSorterTooltip={{ target: 'sorter-icon' }}
size="small"
tableLayout="fixed"
@@ -6674,6 +6826,7 @@ const DataGrid: React.FC<DataGridProps> = ({
rowKey={GONAVI_ROW_KEY}
pagination={false}
onChange={handleTableChange}
rowHoverable={!enableVirtual}
bordered
rowSelection={rowSelectionConfig}
rowClassName={rowClassName}

View File

@@ -862,7 +862,6 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
() => resolveShortcutBinding(shortcutOptions, 'selectCurrentStatement', activeShortcutPlatform),
[activeShortcutPlatform, shortcutOptions],
);
const activeTabId = useStore(state => state.activeTabId);
const autoFetchVisible = useAutoFetchVisibility();
const currentSavedQuery = useMemo(() => {
@@ -916,7 +915,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
// 当此 Tab 成为活跃 Tab 时,将本实例的状态同步到模块级共享变量
// 确保 completion provider 始终使用当前活跃 Tab 的上下文
useEffect(() => {
if (activeTabId !== tab.id) return;
if (!isActive) return;
sharedCurrentDb = currentDb;
sharedCurrentConnectionId = currentConnectionId;
sharedConnections = connections;
@@ -924,7 +923,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
sharedAllColumnsData = allColumnsRef.current;
sharedVisibleDbs = visibleDbsRef.current;
sharedColumnsCacheData = columnsCacheRef.current;
}, [activeTabId, tab.id, currentDb, currentConnectionId, connections]);
}, [isActive, currentDb, currentConnectionId, connections]);
useEffect(() => {
connectionsRef.current = connections;
@@ -1011,7 +1010,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
// 存储可见数据库列表用于跨库智能提示
visibleDbsRef.current = dbs;
if (activeTabId === tab.id) {
if (isActive) {
sharedVisibleDbs = dbs;
}
@@ -1022,7 +1021,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
}
} else {
visibleDbsRef.current = [];
if (activeTabId === tab.id) {
if (isActive) {
sharedVisibleDbs = [];
}
setDbList([]);
@@ -1110,13 +1109,13 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
tablesRef.current = allTables;
allColumnsRef.current = allColumns;
// 如果当前 Tab 是活跃 Tab同步更新共享变量
if (activeTabId === tab.id) {
if (isActive) {
sharedTablesData = allTables;
sharedAllColumnsData = allColumns;
}
};
void fetchMetadata();
}, [autoFetchVisible, currentConnectionId, connections, dbList]); // dbList 变化时触发重新加载
}, [autoFetchVisible, currentConnectionId, connections, dbList, isActive]); // dbList 变化时触发重新加载
// Query ID management helpers
const setQueryId = (id: string) => {
@@ -2504,7 +2503,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
useEffect(() => {
const handleSelectAllInEditor = (event: KeyboardEvent) => {
if (activeTabId !== tab.id) {
if (!isActive) {
return;
}
if (!(event.ctrlKey || event.metaKey) || event.altKey || event.shiftKey || event.key.toLowerCase() !== 'a') {
@@ -2540,7 +2539,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
return () => {
window.removeEventListener('keydown', handleSelectAllInEditor, true);
};
}, [activeTabId, tab.id]);
}, [isActive]);
useEffect(() => {
const binding = runQueryShortcutBinding;
@@ -2549,7 +2548,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
}
const handleRunShortcut = (event: KeyboardEvent) => {
if (activeTabId !== tab.id) {
if (!isActive) {
return;
}
if (!isShortcutMatch(event, binding.combo)) {
@@ -2568,7 +2567,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
return () => {
window.removeEventListener('keydown', handleRunShortcut, true);
};
}, [activeTabId, tab.id, runQueryShortcutBinding, handleRun]);
}, [isActive, runQueryShortcutBinding, handleRun]);
// Re-register Monaco internal keybinding when runQuery shortcut changes
useEffect(() => {
@@ -2637,7 +2636,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
useEffect(() => {
const handleRunActiveQuery = () => {
if (activeTabId !== tab.id) {
if (!isActive) {
return;
}
void handleRun();
@@ -2647,7 +2646,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
return () => {
window.removeEventListener('gonavi:run-active-query', handleRunActiveQuery as EventListener);
};
}, [activeTabId, tab.id, handleRun]);
}, [isActive, handleRun]);
// 监听由 TabManager 分发的专用注入事件
useEffect(() => {
@@ -3130,4 +3129,4 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
);
};
export default QueryEditor;
export default React.memo(QueryEditor);

View File

@@ -275,6 +275,52 @@ const DraggableTabNode: React.FC<DraggableTabNodeProps> = ({ node }) => {
});
};
const TabContent: React.FC<{ tab: TabData; isActive: boolean }> = React.memo(({ tab, isActive }) => {
if (tab.type === 'query') {
return <QueryEditor tab={tab} isActive={isActive} />;
}
if (tab.type === 'table') {
return <DataViewer tab={tab} isActive={isActive} />;
}
if (tab.type === 'design') {
return <TableDesigner tab={tab} />;
}
if (tab.type === 'redis-keys') {
return <RedisViewer connectionId={tab.connectionId} redisDB={tab.redisDB ?? 0} />;
}
if (tab.type === 'redis-command') {
return <RedisCommandEditor connectionId={tab.connectionId} redisDB={tab.redisDB ?? 0} />;
}
if (tab.type === 'redis-monitor') {
return <RedisMonitor connectionId={tab.connectionId} redisDB={tab.redisDB ?? 0} />;
}
if (tab.type === 'trigger') {
return <TriggerViewer tab={tab} />;
}
if (tab.type === 'view-def' || tab.type === 'event-def' || tab.type === 'routine-def') {
return <DefinitionViewer tab={tab} />;
}
if (tab.type === 'table-overview') {
return <TableOverview tab={tab} />;
}
if (tab.type === 'jvm-overview') {
return <JVMOverview tab={tab} />;
}
if (tab.type === 'jvm-resource') {
return <JVMResourceBrowser tab={tab} />;
}
if (tab.type === 'jvm-audit') {
return <JVMAuditViewer tab={tab} />;
}
if (tab.type === 'jvm-diagnostic') {
return <JVMDiagnosticConsole tab={tab} />;
}
if (tab.type === 'jvm-monitoring') {
return <JVMMonitoringDashboard tab={tab} />;
}
return null;
});
const TabManager: React.FC = React.memo(() => {
const tabs = useStore(state => state.tabs);
const connections = useStore(state => state.connections);
@@ -398,36 +444,6 @@ const TabManager: React.FC = React.memo(() => {
const accentColor = connection ? resolveConnectionAccentColor(connection) : undefined;
const hostSummary = resolveConnectionHostSummary(connection?.config);
const tabIsActive = tab.id === activeTabId;
let content;
if (tab.type === 'query') {
content = <QueryEditor tab={tab} isActive={tabIsActive} />;
} else if (tab.type === 'table') {
content = <DataViewer tab={tab} isActive={tabIsActive} />;
} else if (tab.type === 'design') {
content = <TableDesigner tab={tab} />;
} else if (tab.type === 'redis-keys') {
content = <RedisViewer connectionId={tab.connectionId} redisDB={tab.redisDB ?? 0} />;
} else if (tab.type === 'redis-command') {
content = <RedisCommandEditor connectionId={tab.connectionId} redisDB={tab.redisDB ?? 0} />;
} else if (tab.type === 'redis-monitor') {
content = <RedisMonitor connectionId={tab.connectionId} redisDB={tab.redisDB ?? 0} />;
} else if (tab.type === 'trigger') {
content = <TriggerViewer tab={tab} />;
} else if (tab.type === 'view-def' || tab.type === 'event-def' || tab.type === 'routine-def') {
content = <DefinitionViewer tab={tab} />;
} else if (tab.type === 'table-overview') {
content = <TableOverview tab={tab} />;
} else if (tab.type === 'jvm-overview') {
content = <JVMOverview tab={tab} />;
} else if (tab.type === 'jvm-resource') {
content = <JVMResourceBrowser tab={tab} />;
} else if (tab.type === 'jvm-audit') {
content = <JVMAuditViewer tab={tab} />;
} else if (tab.type === 'jvm-diagnostic') {
content = <JVMDiagnosticConsole tab={tab} />;
} else if (tab.type === 'jvm-monitoring') {
content = <JVMMonitoringDashboard tab={tab} />;
}
const menuItems: MenuProps['items'] = [
{
@@ -472,7 +488,7 @@ const TabManager: React.FC = React.memo(() => {
),
key: tab.id,
closable: !isV2Ui,
children: content,
children: <TabContent tab={tab} isActive={tabIsActive} />,
};
}), [tabs, connections, activeTabId, closeOtherTabs, closeTabsToLeft, closeTabsToRight, closeAllTabs, closeTab, isV2Ui]);

View File

@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest';
import {
calculateExternalHorizontalScrollInnerWidth,
calculateTableBodyBottomPadding,
calculateVirtualTableScrollX,
resolveDataGridHorizontalWheelDelta,
@@ -34,6 +35,18 @@ describe('dataGridLayout helpers', () => {
expect(calculateVirtualTableScrollX({ totalWidth: 1200, tableViewportWidth: 800, isMacLike: true })).toBe(1202);
});
it('keeps external horizontal scrollbar range aligned with table content range', () => {
expect(calculateExternalHorizontalScrollInnerWidth({
tableScrollWidth: 4563,
trackInset: 10,
})).toBe(4543);
expect(calculateExternalHorizontalScrollInnerWidth({
tableScrollWidth: 18,
trackInset: 10,
})).toBe(1);
});
it('only treats wheel gestures as horizontal when the horizontal intent is strong enough', () => {
expect(resolveDataGridHorizontalWheelDelta({
deltaX: 18,

View File

@@ -16,6 +16,11 @@ export interface DataGridHorizontalWheelIntentOptions {
shiftKey: boolean;
}
export interface ExternalHorizontalScrollInnerWidthOptions {
tableScrollWidth: number;
trackInset: number;
}
const MIN_SCROLLBAR_CLEARANCE = 8;
const FLOATING_SCROLLBAR_VISUAL_EXTRA = 4;
const HORIZONTAL_WHEEL_MIN_DELTA = 0.5;
@@ -55,6 +60,16 @@ export const calculateVirtualTableScrollX = ({
return safeTotalWidth;
};
export const calculateExternalHorizontalScrollInnerWidth = ({
tableScrollWidth,
trackInset,
}: ExternalHorizontalScrollInnerWidthOptions): number => {
const safeTableScrollWidth = Math.max(0, Math.ceil(tableScrollWidth));
const safeTrackInset = Math.max(0, Math.ceil(trackInset));
return Math.max(1, safeTableScrollWidth - safeTrackInset * 2);
};
export const resolveDataGridHorizontalWheelDelta = ({
deltaX,
deltaY,

View File

@@ -1,15 +1,111 @@
import React, { useMemo, useState } from 'react';
import { Alert, Button, Card, InputNumber, Select, Space, Typography } from 'antd';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { Alert, Button, Card, InputNumber, Segmented, Select, Space, Typography } from 'antd';
import DataGrid, { GONAVI_ROW_KEY } from '../components/DataGrid';
import { useStore } from '../store';
import type { EditRowLocator } from '../utils/rowLocator';
import type { DataTableDensity } from '../utils/dataGridDisplay';
const { Text } = Typography;
type HarnessUiVersion = 'legacy' | 'v2';
type HarnessTheme = 'light' | 'dark';
type HarnessRow = Record<string, any> & {
[GONAVI_ROW_KEY]: string;
};
type HarnessRuntimeConfig = {
uiVersion: HarnessUiVersion;
density: DataTableDensity;
theme: HarnessTheme;
uiScale: number;
fontSize: number;
};
type HarnessRestoreSnapshot = {
appearance: ReturnType<typeof useStore.getState>['appearance'];
theme: ReturnType<typeof useStore.getState>['theme'];
uiScale: number;
fontSize: number;
bodyUiVersion: string | null;
bodyTheme: string | null;
bodyFontSize: string;
rootVars: Record<string, string>;
};
const hasHarnessAppearanceDrift = (
appearance: ReturnType<typeof useStore.getState>['appearance'],
uiVersion: HarnessUiVersion,
density: DataTableDensity,
): boolean => (
appearance.uiVersion !== uiVersion
|| appearance.dataTableDensity !== density
|| appearance.dataTableFontSize !== null
|| appearance.dataTableFontSizeFollowGlobal !== true
);
const DEFAULT_HARNESS_CONFIG: HarnessRuntimeConfig = {
uiVersion: 'legacy',
density: 'comfortable',
theme: 'light',
uiScale: 1,
fontSize: 14,
};
const clampHarnessUiScale = (value: unknown): number => {
if (value === null || value === undefined || value === '') {
return DEFAULT_HARNESS_CONFIG.uiScale;
}
const numeric = Number(value);
if (!Number.isFinite(numeric)) return DEFAULT_HARNESS_CONFIG.uiScale;
return Math.min(1.25, Math.max(0.8, numeric));
};
const clampHarnessFontSize = (value: unknown): number => {
if (value === null || value === undefined || value === '') {
return DEFAULT_HARNESS_CONFIG.fontSize;
}
const numeric = Number(value);
if (!Number.isFinite(numeric)) return DEFAULT_HARNESS_CONFIG.fontSize;
return Math.min(20, Math.max(12, Math.round(numeric)));
};
const readHarnessRuntimeConfig = (): HarnessRuntimeConfig => {
if (typeof window === 'undefined') {
return { ...DEFAULT_HARNESS_CONFIG };
}
try {
const searchParams = new URLSearchParams(window.location.search);
const uiVersion = searchParams.get('uiVersion') === 'v2' ? 'v2' : DEFAULT_HARNESS_CONFIG.uiVersion;
const densityRaw = searchParams.get('density');
const density: DataTableDensity = densityRaw === 'compact' || densityRaw === 'standard'
? densityRaw
: DEFAULT_HARNESS_CONFIG.density;
const theme = searchParams.get('theme') === 'dark' ? 'dark' : DEFAULT_HARNESS_CONFIG.theme;
return {
uiVersion,
density,
theme,
uiScale: clampHarnessUiScale(searchParams.get('uiScale')),
fontSize: clampHarnessFontSize(searchParams.get('fontSize')),
};
} catch {
return { ...DEFAULT_HARNESS_CONFIG };
}
};
const DOCUMENT_ROOT_VAR_KEYS = [
'--gonavi-font-size',
'--gn-ui-scale',
'--gn-font-size',
'--gn-font-size-sm',
'--gn-font-size-xs',
'--gn-font-size-mono',
'--gn-data-table-font-size',
'--gn-sidebar-tree-font-size',
] as const;
const buildHarnessColumns = (count: number): string[] => {
const safeCount = Math.max(8, Math.min(64, Math.trunc(count || 0)));
return Array.from({ length: safeCount }, (_, index) => {
@@ -62,12 +158,113 @@ const HARNESS_EDIT_LOCATOR: EditRowLocator = {
};
const PerfDataGridHarness: React.FC = () => {
const initialConfig = useMemo(() => readHarnessRuntimeConfig(), []);
const setAppearance = useStore((state) => state.setAppearance);
const setTheme = useStore((state) => state.setTheme);
const setUiScale = useStore((state) => state.setUiScale);
const setFontSize = useStore((state) => state.setFontSize);
const [rowCount, setRowCount] = useState(10000);
const [columnCount, setColumnCount] = useState(24);
const [density, setDensity] = useState<'compact' | 'comfortable' | 'spacious'>('comfortable');
const [uiVersion, setUiVersion] = useState<HarnessUiVersion>(initialConfig.uiVersion);
const [density, setDensity] = useState<DataTableDensity>(initialConfig.density);
const restoreSnapshotRef = useRef<HarnessRestoreSnapshot | null>(null);
const columnNames = useMemo(() => buildHarnessColumns(columnCount), [columnCount]);
const data = useMemo(() => buildHarnessData(rowCount, columnNames), [rowCount, columnNames]);
const effectiveUiScale = clampHarnessUiScale(initialConfig.uiScale);
const effectiveFontSize = clampHarnessFontSize(initialConfig.fontSize);
const effectiveDataTableFontSize = effectiveFontSize;
useEffect(() => {
if (restoreSnapshotRef.current) return;
const currentState = useStore.getState();
restoreSnapshotRef.current = {
appearance: { ...currentState.appearance },
theme: currentState.theme,
uiScale: currentState.uiScale,
fontSize: currentState.fontSize,
bodyUiVersion: document.body.getAttribute('data-ui-version'),
bodyTheme: document.body.getAttribute('data-theme'),
bodyFontSize: document.body.style.fontSize,
rootVars: Object.fromEntries(
DOCUMENT_ROOT_VAR_KEYS.map((key) => [key, document.documentElement.style.getPropertyValue(key)])
),
};
return () => {
const snapshot = restoreSnapshotRef.current;
if (!snapshot) return;
useStore.getState().setAppearance(snapshot.appearance);
useStore.getState().setTheme(snapshot.theme);
useStore.getState().setUiScale(snapshot.uiScale);
useStore.getState().setFontSize(snapshot.fontSize);
if (snapshot.bodyUiVersion) {
document.body.setAttribute('data-ui-version', snapshot.bodyUiVersion);
} else {
document.body.removeAttribute('data-ui-version');
}
if (snapshot.bodyTheme) {
document.body.setAttribute('data-theme', snapshot.bodyTheme);
} else {
document.body.removeAttribute('data-theme');
}
document.body.style.fontSize = snapshot.bodyFontSize;
DOCUMENT_ROOT_VAR_KEYS.forEach((key) => {
const value = snapshot.rootVars[key];
if (value) {
document.documentElement.style.setProperty(key, value);
return;
}
document.documentElement.style.removeProperty(key);
});
restoreSnapshotRef.current = null;
};
}, []);
useEffect(() => {
const currentState = useStore.getState();
if (hasHarnessAppearanceDrift(currentState.appearance, uiVersion, density)) {
setAppearance({
uiVersion,
dataTableDensity: density,
dataTableFontSize: null,
dataTableFontSizeFollowGlobal: true,
});
}
if (currentState.theme !== initialConfig.theme) {
setTheme(initialConfig.theme);
}
if (Math.abs(currentState.uiScale - initialConfig.uiScale) > 0.0001) {
setUiScale(initialConfig.uiScale);
}
if (currentState.fontSize !== initialConfig.fontSize) {
setFontSize(initialConfig.fontSize);
}
}, [
density,
initialConfig.fontSize,
initialConfig.theme,
initialConfig.uiScale,
setAppearance,
setFontSize,
setTheme,
setUiScale,
uiVersion,
]);
useEffect(() => {
document.body.setAttribute('data-theme', initialConfig.theme);
document.body.setAttribute('data-ui-version', uiVersion);
document.body.style.fontSize = `${effectiveFontSize}px`;
document.documentElement.style.setProperty('--gonavi-font-size', `${effectiveFontSize}px`);
document.documentElement.style.setProperty('--gn-ui-scale', `${effectiveUiScale}`);
document.documentElement.style.setProperty('--gn-font-size', `${effectiveFontSize}px`);
document.documentElement.style.setProperty('--gn-font-size-sm', `${Math.max(10, Math.round(effectiveFontSize * 0.86))}px`);
document.documentElement.style.setProperty('--gn-font-size-xs', `${Math.max(9, Math.round(effectiveFontSize * 0.76))}px`);
document.documentElement.style.setProperty('--gn-font-size-mono', `${Math.max(10, Math.round(effectiveDataTableFontSize * 0.92))}px`);
document.documentElement.style.setProperty('--gn-data-table-font-size', `${effectiveDataTableFontSize}px`);
document.documentElement.style.setProperty('--gn-sidebar-tree-font-size', `${effectiveFontSize}px`);
}, [effectiveDataTableFontSize, effectiveFontSize, effectiveUiScale, initialConfig.theme, uiVersion]);
return (
<div style={{ height: '100vh', overflow: 'hidden', background: '#0b1220', padding: 16, boxSizing: 'border-box' }}>
@@ -90,6 +287,14 @@ const PerfDataGridHarness: React.FC = () => {
>
<Space wrap align="center" size={12}>
<Text strong>DataGrid </Text>
<Segmented
value={uiVersion}
onChange={(value) => setUiVersion(value as HarnessUiVersion)}
options={[
{ label: '旧版 UI', value: 'legacy' },
{ label: '新版 UI', value: 'v2' },
]}
/>
<InputNumber
min={200}
max={50000}
@@ -111,9 +316,9 @@ const PerfDataGridHarness: React.FC = () => {
style={{ width: 140 }}
onChange={(value) => setDensity(value)}
options={[
{ value: 'compact', label: '紧凑' },
{ value: 'comfortable', label: '标准' },
{ value: 'spacious', label: '宽松' },
{ value: 'standard', label: '紧凑' },
{ value: 'compact', label: '极紧凑' },
]}
/>
<Button
@@ -128,7 +333,7 @@ const PerfDataGridHarness: React.FC = () => {
type="info"
showIcon
message="这个页面只用于开发态滚动性能采样"
description={`当前 ${data.length} 行 / ${columnNames.length} 列。直接在表格区域做纵向、横向、Shift+滚轮滚动采样。`}
description={`当前 ${uiVersion === 'v2' ? '新版' : '旧版'} UI${data.length} 行 / ${columnNames.length} 列。直接在表格区域做纵向、横向、Shift+滚轮滚动采样。`}
/>
<div style={{ flex: '1 1 auto', minHeight: 0, display: 'flex', flexDirection: 'column' }}>
<DataGrid

View File

@@ -1,5 +1,10 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
import {
createJSONStorage,
persist,
type PersistStorage,
type StateStorage,
} from "zustand/middleware";
import {
ConnectionConfig,
ProxyConfig,
@@ -76,6 +81,7 @@ const DEFAULT_DIAGNOSTIC_TIMEOUT_SECONDS = 15;
const MAX_DIAGNOSTIC_TIMEOUT_SECONDS = 300;
const PERSIST_VERSION = 9;
const PERSIST_STORAGE_KEY = "lite-db-storage";
const PERSIST_WRITE_DEBOUNCE_MS = 160;
const MAX_PERSISTED_QUERY_TABS = 20;
const MAX_PERSISTED_QUERY_LENGTH = 1024 * 1024;
const MAX_SQL_LOGS = 1000;
@@ -93,6 +99,100 @@ const DEFAULT_GLOBAL_PROXY: GlobalProxyConfig = {
password: "",
hasPassword: false,
};
const isFrontendTestRuntime = (): boolean => {
const env = (import.meta as unknown as { env?: Record<string, unknown> }).env || {};
return env.MODE === "test" || env.VITEST === true || env.VITEST === "true";
};
const createDebouncedPersistStorage = <S>(
getStorage: () => StateStorage,
debounceMs = PERSIST_WRITE_DEBOUNCE_MS,
): PersistStorage<S> | undefined => {
const baseStorage = createJSONStorage<S>(getStorage);
if (!baseStorage || isFrontendTestRuntime()) {
return baseStorage;
}
type PersistedValue = Parameters<PersistStorage<S>["setItem"]>[1];
let pendingWrite: { name: string; value: PersistedValue } | null = null;
let pendingTimer: number | null = null;
let listenersBound = false;
let pendingResolves: Array<() => void> = [];
let pendingRejects: Array<(error: unknown) => void> = [];
const settlePending = (error?: unknown) => {
const resolves = pendingResolves;
const rejects = pendingRejects;
pendingResolves = [];
pendingRejects = [];
if (error !== undefined) {
rejects.forEach((reject) => reject(error));
return;
}
resolves.forEach((resolve) => resolve());
};
const flushPendingWrite = async (): Promise<void> => {
if (pendingTimer !== null) {
window.clearTimeout(pendingTimer);
pendingTimer = null;
}
const nextWrite = pendingWrite;
pendingWrite = null;
if (!nextWrite) {
settlePending();
return;
}
try {
await baseStorage.setItem(nextWrite.name, nextWrite.value);
settlePending();
} catch (error) {
settlePending(error);
throw error;
}
};
const bindFlushListeners = () => {
if (listenersBound || typeof window === "undefined") {
return;
}
listenersBound = true;
const handleFlush = () => {
void flushPendingWrite();
};
window.addEventListener("pagehide", handleFlush, { capture: true });
window.addEventListener("beforeunload", handleFlush, { capture: true });
};
return {
getItem: baseStorage.getItem,
setItem: (name, value) => {
bindFlushListeners();
pendingWrite = { name, value };
if (pendingTimer !== null) {
window.clearTimeout(pendingTimer);
}
return new Promise<void>((resolve, reject) => {
pendingResolves.push(resolve);
pendingRejects.push(reject);
pendingTimer = window.setTimeout(() => {
void flushPendingWrite();
}, debounceMs);
});
},
removeItem: async (name) => {
pendingWrite = null;
if (pendingTimer !== null) {
window.clearTimeout(pendingTimer);
pendingTimer = null;
}
settlePending();
await baseStorage.removeItem(name);
},
};
};
const resolveOceanBaseProtocol = (
raw: Record<string, unknown>,
normalizedConnectionParams: string,
@@ -2384,6 +2484,7 @@ export const useStore = create<AppState>()(
}),
{
name: PERSIST_STORAGE_KEY, // name of the item in the storage (must be unique)
storage: createDebouncedPersistStorage(() => localStorage),
version: PERSIST_VERSION,
migrate: (persistedState: unknown, version: number) => {
const state = unwrapPersistedAppState(

View File

@@ -3049,6 +3049,43 @@ body[data-ui-version="v2"] .gn-v2-data-grid .ant-table-tbody .ant-table-row > .a
padding: 0 calc(10px * var(--gn-ui-scale, 1)) !important;
font-family: var(--gn-font-mono) !important;
font-size: var(--gn-data-table-font-size, var(--gn-font-size-mono, 12px)) !important;
line-height: calc(28px * var(--gn-ui-scale, 1)) !important;
vertical-align: middle !important;
}
body[data-ui-version="v2"] .gn-v2-data-grid .ant-table-tbody > tr > td .editable-cell-value-wrap,
body[data-ui-version="v2"] .gn-v2-data-grid .ant-table-tbody .ant-table-row > .ant-table-cell .editable-cell-value-wrap {
min-height: calc(28px * var(--gn-ui-scale, 1));
line-height: calc(28px * var(--gn-ui-scale, 1));
}
body[data-ui-version="v2"] .gn-v2-data-grid .data-grid-virtual-inline-editing .ant-form-item,
body[data-ui-version="v2"] .gn-v2-data-grid .data-grid-virtual-inline-editing .ant-form-item-row,
body[data-ui-version="v2"] .gn-v2-data-grid .data-grid-virtual-inline-editing .ant-form-item-control,
body[data-ui-version="v2"] .gn-v2-data-grid .data-grid-virtual-inline-editing .ant-form-item-control-input,
body[data-ui-version="v2"] .gn-v2-data-grid .data-grid-virtual-inline-editing .ant-form-item-control-input-content {
display: flex !important;
align-items: center !important;
height: 24px !important;
min-height: 24px !important;
}
body[data-ui-version="v2"] .gn-v2-data-grid .data-grid-virtual-inline-editing .ant-form-item {
margin: 0 !important;
}
body[data-ui-version="v2"] .gn-v2-data-grid .data-grid-virtual-inline-editing .ant-input,
body[data-ui-version="v2"] .gn-v2-data-grid .data-grid-virtual-inline-editing .ant-input-affix-wrapper,
body[data-ui-version="v2"] .gn-v2-data-grid .data-grid-virtual-inline-editing .ant-picker {
display: flex !important;
align-items: center !important;
width: 100% !important;
height: 24px !important;
min-height: 24px !important;
line-height: 24px !important;
margin: 0 !important;
box-sizing: border-box !important;
position: static !important;
}
body[data-ui-version="v2"] .gn-v2-data-grid-statusbar {