Files
MyGoNavi/frontend/src/components/QueryEditorResultsPanel.tsx
Syngnat d8c4352b66 🐛 fix(query-editor/data-grid): 修复查询结果预览与保存状态
- 修正保存队列、草稿同步及保存结果校验的生命周期位置
- 查询完成后通过本地请求切回表格数据预览
- 支持 SQLite、ClickHouse、MongoDB 及空结果集重复执行
- 避免共享 DDL 展示状态覆盖新查询结果
- 补充数据预览、DDL 状态恢复和保存状态回归测试
2026-07-23 16:34:09 +08:00

770 lines
39 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { Button, Dropdown, Tabs, Tooltip, message, type MenuProps } from 'antd';
import { BugOutlined, CloseOutlined, CopyOutlined, EyeInvisibleOutlined, RobotOutlined } from '@ant-design/icons';
import type { EditRowLocator } from '../utils/rowLocator';
import type { GridSortInfoItem } from '../utils/dataGridSort';
import type { QueryResultPaginationState } from '../utils/queryResultPagination';
import { filterColumnNamesByGlobalHiddenColumns, useGlobalHiddenColumns } from '../utils/globalHiddenColumns';
import { buildQueryResultColumnPinScope } from '../utils/queryResultColumnPinScope';
import { t as defaultTranslate } from '../i18n';
import { useOptionalI18n } from '../i18n/provider';
import {
resolveNativeDetachPreferredBounds,
shouldDetachAtScreenPoint,
shouldDetachTabByDrag,
type DetachedWindowBounds,
} from '../utils/detachedWindow';
import DetachDragPreview, {
buildDetachDragPreviewState,
type DetachDragPreviewState,
} from './DetachDragPreview';
import DataGrid from './DataGrid';
import LogPanel from './LogPanel';
export type OpenResultInWindowPreferred = Partial<Pick<DetachedWindowBounds, 'x' | 'y' | 'width' | 'height'>>;
export const QUERY_EDITOR_SQL_LOG_TAB_KEY = '__gonavi_sql_execution_log__';
export type QueryEditorResultSet = {
key: string;
sql: string;
exportSql?: string;
sourceStatementIndex?: number;
statementResultIndex?: number;
rows: any[];
columns: string[];
messages?: string[];
resultType?: 'grid' | 'message';
tableName?: string;
/** 列类型/注释元数据所属库(跨库 SELECT 时可能与 currentDb 不同) */
metadataDbName?: string;
/** 列元数据查询用表名PG 等可能为 schema.table */
metadataTableName?: string;
/** DDL 查询目标,与列元数据目标独立,避免多段限定名被误解析。 */
ddlDbName?: string;
ddlTableName?: string;
pkColumns: string[];
editLocator?: EditRowLocator;
readOnly: boolean;
showRowNumberColumn?: boolean;
truncated?: boolean;
pkLoading?: boolean;
sortInfo?: GridSortInfoItem[];
page?: QueryResultPaginationState & { loading?: boolean };
};
export const resolveEffectiveActiveResultKey = (
resultSets: Pick<QueryEditorResultSet, 'key'>[],
activeResultKey: string,
showSqlLogTab: boolean,
): string => {
if (resultSets.some((result) => result.key === activeResultKey)) {
return activeResultKey;
}
if (showSqlLogTab && activeResultKey === QUERY_EDITOR_SQL_LOG_TAB_KEY) {
return QUERY_EDITOR_SQL_LOG_TAB_KEY;
}
return resultSets[0]?.key || (showSqlLogTab ? QUERY_EDITOR_SQL_LOG_TAB_KEY : '');
};
interface QueryEditorResultsPanelProps {
resultSets: QueryEditorResultSet[];
activeResultKey: string;
isActive: boolean;
loading: boolean;
executionError: string;
sqlLogCount: number;
darkMode: boolean;
isV2Ui: boolean;
currentDb: string;
currentConnectionId: string;
dataPreviewRequest?: { resultKey: string; requestId: string } | null;
toggleShortcutLabel: string;
onActiveResultKeyChange: (key: string) => void;
onHide: () => void;
onCloseResult: (key: string) => void;
onCloseOtherResultTabs: (key: string) => void;
onCloseResultTabsToLeft: (key: string) => void;
onCloseResultTabsToRight: (key: string) => void;
onCloseAllResultTabs: () => void;
onOpenResultInWindow?: (key: string, preferred?: OpenResultInWindowPreferred) => void;
onReloadResult: (key: string, sql: string) => void;
onResultPageChange: (key: string, page: number, pageSize: number) => void;
onResultSort: (key: string, field: string, order: string) => void;
onRequestResultTotalCount?: (key: string) => void;
onCancelResultTotalCount?: (key: string) => void;
onDiagnoseExecutionError: () => void;
onCompareResult?: (resultKey: string) => void;
}
const isAffectedRowsResult = (result: QueryEditorResultSet): boolean =>
result.columns.length === 1 && result.columns[0] === 'affectedRows';
const resolveVisibleQueryResultColumns = (columns: string[], globalHiddenColumns: string[]): string[] => {
const visibleColumns = filterColumnNamesByGlobalHiddenColumns(columns, globalHiddenColumns);
return visibleColumns.length > 0 || columns.length === 0 ? visibleColumns : columns;
};
const RESULT_TAB_DETACH_INTERACTIVE_SELECTOR = [
'.query-result-tab-close',
'button',
'a',
'input',
'textarea',
'select',
'[contenteditable="true"]',
'[role="button"]',
'[role="menuitem"]',
'.ant-dropdown-menu',
].join(', ');
export const shouldActivateResultTabDetachPointer = (event: {
button: number;
isPrimary?: boolean;
target: EventTarget | null;
}): boolean => {
if (event.button !== 0 || event.isPrimary === false) return false;
const target = event.target as { closest?: (selector: string) => Element | null } | null;
return typeof target?.closest !== 'function'
|| target.closest(RESULT_TAB_DETACH_INTERACTIVE_SELECTOR) === null;
};
const QueryEditorResultsPanel: React.FC<QueryEditorResultsPanelProps> = ({
resultSets,
activeResultKey,
isActive,
loading,
executionError,
sqlLogCount,
darkMode,
isV2Ui,
currentDb,
currentConnectionId,
dataPreviewRequest,
toggleShortcutLabel,
onActiveResultKeyChange,
onHide,
onCloseResult,
onCloseOtherResultTabs,
onCloseResultTabsToLeft,
onCloseResultTabsToRight,
onCloseAllResultTabs,
onOpenResultInWindow,
onReloadResult,
onResultPageChange,
onResultSort,
onRequestResultTotalCount,
onCancelResultTotalCount,
onDiagnoseExecutionError,
onCompareResult,
}) => {
const i18n = useOptionalI18n();
const t = i18n?.t ?? defaultTranslate;
const globalHiddenColumns = useGlobalHiddenColumns();
const [draggingResultKey, setDraggingResultKey] = useState<string | null>(null);
const [detachDragPreview, setDetachDragPreview] = useState<DetachDragPreviewState | null>(null);
const resultTabDragRef = useRef<{
key: string;
title: string;
startX: number;
startY: number;
startScreenX: number;
startScreenY: number;
pointerId: number;
captureTarget: HTMLElement;
active: boolean;
} | null>(null);
const resultTabDragCleanupRef = useRef<((resetVisualState?: boolean) => void) | null>(null);
useEffect(() => () => {
resultTabDragCleanupRef.current?.(false);
}, []);
const resolveResultTabTitle = useCallback((key: string) => {
const index = resultSets.findIndex((item) => item.key === key);
const rs = index >= 0 ? resultSets[index] : null;
if (!rs) return t('query_editor.results_panel.menu.open_in_window');
if (rs.resultType === 'message') {
return t('query_editor.results_panel.tab.message', { index: index + 1 });
}
return t('query_editor.results_panel.tab.result', { index: index + 1 });
}, [resultSets, t]);
const handleResultTabPointerDown = useCallback((event: React.PointerEvent<HTMLElement>, key: string) => {
if (!onOpenResultInWindow || !shouldActivateResultTabDetachPointer(event)) return;
const openResultInWindow = onOpenResultInWindow;
resultTabDragCleanupRef.current?.();
const title = resolveResultTabTitle(key);
const dragState = {
key,
title,
startX: event.clientX,
startY: event.clientY,
startScreenX: event.screenX,
startScreenY: event.screenY,
pointerId: event.pointerId,
captureTarget: event.currentTarget,
active: false,
};
resultTabDragRef.current = dragState;
const previousUserSelect = document.body.style.userSelect;
const previousWebkitUserSelect = (document.body.style as CSSStyleDeclaration & { webkitUserSelect?: string }).webkitUserSelect || '';
let selectionSuppressed = false;
let cleaned = false;
const clearNativeSelection = () => {
const selection = window.getSelection?.();
if (selection && selection.rangeCount > 0) {
selection.removeAllRanges();
}
};
function preventSelectStart(selectEvent: Event) {
selectEvent.preventDefault();
selectEvent.stopPropagation();
}
function suppressTextSelection() {
if (!selectionSuppressed) {
selectionSuppressed = true;
document.body.style.userSelect = 'none';
(document.body.style as CSSStyleDeclaration & { webkitUserSelect?: string }).webkitUserSelect = 'none';
document.documentElement.classList.add('gn-result-tab-detaching');
window.addEventListener('selectstart', preventSelectStart, true);
window.addEventListener('dragstart', preventSelectStart, true);
}
clearNativeSelection();
}
function clearListeners(resetVisualState = true) {
if (cleaned) return;
cleaned = true;
if (resultTabDragCleanupRef.current === clearListeners) {
resultTabDragCleanupRef.current = null;
}
window.removeEventListener('pointermove', handleMove);
window.removeEventListener('pointerup', handleUp);
window.removeEventListener('pointercancel', handleCancel);
window.removeEventListener('blur', handleWindowBlur);
dragState.captureTarget.removeEventListener('lostpointercapture', handleLostPointerCapture);
window.removeEventListener('selectstart', preventSelectStart, true);
window.removeEventListener('dragstart', preventSelectStart, true);
if (selectionSuppressed) {
document.body.style.userSelect = previousUserSelect;
(document.body.style as CSSStyleDeclaration & { webkitUserSelect?: string }).webkitUserSelect = previousWebkitUserSelect;
document.documentElement.classList.remove('gn-result-tab-detaching');
}
try {
if (dragState.captureTarget.hasPointerCapture?.(dragState.pointerId)) {
dragState.captureTarget.releasePointerCapture(dragState.pointerId);
}
} catch {
// Capture may already be gone after blur, cancellation, or unmount.
}
if (resultTabDragRef.current === dragState) {
resultTabDragRef.current = null;
}
if (resetVisualState) {
setDraggingResultKey(null);
setDetachDragPreview(null);
}
}
function handleMove(moveEvent: PointerEvent) {
if (moveEvent.pointerId !== dragState.pointerId) return;
if (moveEvent.buttons === 0) {
clearListeners();
return;
}
const drag = resultTabDragRef.current;
if (drag !== dragState) return;
const dx = moveEvent.clientX - drag.startX;
const dy = moveEvent.clientY - drag.startY;
if (!drag.active && (Math.abs(dx) > 4 || Math.abs(dy) > 4)) {
drag.active = true;
setDraggingResultKey(key);
suppressTextSelection();
}
if (drag.active) {
// 拖出过程中禁止浏览器默认选中 SQL 编辑器文本
moveEvent.preventDefault();
suppressTextSelection();
setDetachDragPreview(buildDetachDragPreviewState({
title,
clientX: moveEvent.clientX,
clientY: moveEvent.clientY,
deltaY: dy,
}));
}
}
function handleUp(upEvent: PointerEvent) {
if (upEvent.pointerId !== dragState.pointerId) return;
const drag = resultTabDragRef.current;
if (drag !== dragState) {
clearListeners();
return;
}
const dy = upEvent.clientY - drag.startY;
const releaseScreenX = Number.isFinite(upEvent.screenX)
? upEvent.screenX
: drag.startScreenX + (upEvent.clientX - drag.startX);
const releaseScreenY = Number.isFinite(upEvent.screenY)
? upEvent.screenY
: drag.startScreenY + (upEvent.clientY - drag.startY);
const releasedOutsideHost = shouldDetachAtScreenPoint(releaseScreenX, releaseScreenY, {
x: window.screenX,
y: window.screenY,
width: window.outerWidth || window.innerWidth,
height: window.outerHeight || window.innerHeight,
});
const shouldDetach = drag.active && (shouldDetachTabByDrag(dy) || releasedOutsideHost);
if (drag.active) {
upEvent.preventDefault();
clearNativeSelection();
}
// 先清预览再打开真实窗口,避免叠两层
clearListeners();
if (shouldDetach) {
openResultInWindow(key, resolveNativeDetachPreferredBounds(releaseScreenX, releaseScreenY));
}
}
function handleCancel(cancelEvent: PointerEvent) {
if (cancelEvent.pointerId === dragState.pointerId) {
clearListeners();
}
}
function handleWindowBlur() {
clearListeners();
}
function handleLostPointerCapture(lostEvent: Event) {
if ((lostEvent as PointerEvent).pointerId === dragState.pointerId) {
clearListeners();
}
}
resultTabDragCleanupRef.current = clearListeners;
window.addEventListener('pointermove', handleMove, { passive: false });
window.addEventListener('pointerup', handleUp);
window.addEventListener('pointercancel', handleCancel);
window.addEventListener('blur', handleWindowBlur);
dragState.captureTarget.addEventListener('lostpointercapture', handleLostPointerCapture);
try {
dragState.captureTarget.setPointerCapture(dragState.pointerId);
} catch {
// Some embedded WebViews do not expose pointer capture for tab labels.
}
}, [onOpenResultInWindow, resolveResultTabTitle]);
const shouldShowSqlLogTab = isV2Ui;
const logTabCountLabel = sqlLogCount > 999 ? '999+' : String(sqlLogCount);
const hideTooltipTitle = toggleShortcutLabel
? t('query_editor.results_panel.tooltip.hide_with_shortcut', { shortcut: toggleShortcutLabel })
: t('query_editor.results_panel.tooltip.hide');
const resolvedActiveResultKey = resolveEffectiveActiveResultKey(
resultSets,
activeResultKey,
shouldShowSqlLogTab,
);
const handleMessageTextareaKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (!(event.ctrlKey || event.metaKey) || event.altKey || event.shiftKey || event.key.toLowerCase() !== 'a') {
return;
}
event.preventDefault();
event.stopPropagation();
event.currentTarget.focus();
event.currentTarget.select();
};
const handleCopyMessageText = async (text: string) => {
const safeText = String(text || '');
if (!safeText.trim()) return;
try {
if (typeof navigator?.clipboard?.writeText !== 'function') {
throw new Error(t('query_editor.results_panel.message.copy_unsupported'));
}
await navigator.clipboard.writeText(safeText);
message.success(t('data_grid.message.copied_to_clipboard'));
} catch (error: any) {
message.error(t('query_editor.results_panel.message.copy_failed', {
detail: error?.message || t('common.unknown'),
}));
}
};
const renderMessageBlock = ({
text,
title,
fontSize,
fillHeight = false,
compact = false,
maxWidth,
color,
marginTop,
}: {
text: string;
title?: string;
fontSize: string;
fillHeight?: boolean;
compact?: boolean;
maxWidth?: number;
color: string;
marginTop?: number;
}) => (
<div className={`query-result-message-block${compact ? ' is-compact' : ' is-full'}`} style={{
display: 'flex', flexDirection: 'column', gap: compact ? 8 : 12, padding: compact ? 12 : 16,
borderRadius: 8, border: darkMode ? '1px solid rgba(255,255,255,0.12)' : '1px solid rgba(0,0,0,0.08)',
background: darkMode ? 'rgba(255,255,255,0.03)' : '#fff', textAlign: 'left', alignItems: 'stretch', marginTop,
width: maxWidth ? `min(100%, ${maxWidth}px)` : '100%', flex: fillHeight ? 1 : undefined, minHeight: fillHeight ? 0 : undefined,
boxSizing: 'border-box',
}}>
<div className="query-result-message-header" style={{
display: 'flex', alignItems: 'center', justifyContent: title ? 'space-between' : 'flex-end', gap: 12,
flex: '0 0 auto', minHeight: compact ? 28 : 32,
}}>
{title ? <span style={{ fontSize: 14, fontWeight: 600 }}>{title}</span> : <span />}
<Button size="small" icon={<CopyOutlined />} onClick={() => { void handleCopyMessageText(text); }} disabled={!text.trim()}>
{t('query_editor.results_panel.message.action.copy')}
</Button>
</div>
<div className="query-result-message-scroll-body" style={{
flex: fillHeight ? 1 : '0 1 auto', display: 'flex', alignItems: 'stretch', width: '100%', minHeight: compact ? 72 : 0,
maxHeight: compact ? 160 : undefined, overflow: 'hidden', minWidth: 0, borderRadius: 6,
border: darkMode ? '1px solid rgba(255,255,255,0.14)' : '1px solid rgba(0,0,0,0.10)',
background: darkMode ? 'rgba(0,0,0,0.18)' : 'rgba(0,0,0,0.018)',
}}>
<textarea
readOnly
wrap="off"
spellCheck={false}
aria-label={title || t('query_editor.results_panel.message.title')}
data-query-result-message-textarea={compact ? 'compact' : 'full'}
value={text}
onKeyDown={handleMessageTextareaKeyDown}
style={{
display: 'block', flex: '1 1 auto', width: '100%', minWidth: 0, height: '100%', minHeight: compact ? 72 : 0,
padding: compact ? '8px 10px' : '10px 12px', margin: 0, border: 'none', resize: 'none', background: 'transparent',
color, fontFamily: 'var(--gn-font-mono)', fontSize, lineHeight: 1.6, whiteSpace: 'pre', outline: 'none', boxSizing: 'border-box', overflow: 'auto',
}}
/>
</div>
</div>
);
const toolbarHideButton = (
<Tooltip title={hideTooltipTitle}>
<Button
aria-label={t('query_editor.results_panel.aria.hide')}
className={isV2Ui ? 'gn-v2-data-grid-toolbar-action gn-v2-query-result-toolbar-hide' : undefined}
icon={<EyeInvisibleOutlined />}
onClick={onHide}
>
{!isV2Ui && <span>{t('query_editor.results_panel.action.hide')}</span>}
</Button>
</Tooltip>
);
function buildResultTabMenuItems(key: string, index: number): MenuProps['items'] {
const comparableCount = resultSets.filter(
(rs) => rs.resultType !== 'message' && !isAffectedRowsResult(rs) && Array.isArray(rs.columns) && rs.columns.length > 0,
).length;
return [
...(onOpenResultInWindow
? [{
key: 'open-in-window',
label: t('query_editor.results_panel.menu.open_in_window'),
onClick: () => onOpenResultInWindow(key),
}, { type: 'divider' as const }]
: []),
...(onCompareResult
? [{
key: 'compare-results',
label: t('query_editor.results_panel.menu.compare_results'),
disabled: comparableCount < 2,
onClick: () => onCompareResult(key),
}, { type: 'divider' as const }]
: []),
{ key: 'close-other', label: t('query_editor.results_panel.menu.close_other'), disabled: resultSets.length <= 1, onClick: () => onCloseOtherResultTabs(key) },
{ key: 'close-left', label: t('query_editor.results_panel.menu.close_left'), disabled: index <= 0, onClick: () => onCloseResultTabsToLeft(key) },
{ key: 'close-right', label: t('query_editor.results_panel.menu.close_right'), disabled: index >= resultSets.length - 1, onClick: () => onCloseResultTabsToRight(key) },
{ type: 'divider' },
{ key: 'close-all', label: t('query_editor.results_panel.menu.close_all'), disabled: resultSets.length === 0, onClick: onCloseAllResultTabs },
];
}
const resultTabItems = resultSets.map((rs, idx) => ({
key: rs.key,
label: (
<Dropdown menu={{ items: buildResultTabMenuItems(rs.key, idx) }} trigger={['contextMenu']} rootClassName={isV2Ui ? 'gn-v2-tab-context-menu-popup' : undefined}>
<div
className={`query-result-tab-label${onOpenResultInWindow ? ' is-detachable' : ''}${draggingResultKey === rs.key ? ' is-dragging-detach' : ''}`}
title={onOpenResultInWindow ? t('query_editor.results_panel.menu.open_in_window') : undefined}
onContextMenu={(event) => event.preventDefault()}
onPointerDown={(event) => handleResultTabPointerDown(event, rs.key)}
>
<Tooltip title={rs.sql}>
<span className="query-result-tab-text">
{rs.resultType === 'message'
? t('query_editor.results_panel.tab.message', { index: idx + 1 })
: t('query_editor.results_panel.tab.result', { index: idx + 1 })}
</span>
</Tooltip>
{(() => {
if (rs.resultType === 'message') return <span className="query-result-tab-count">i</span>;
if (isAffectedRowsResult(rs)) return <span className="query-result-tab-count"></span>;
if (!Array.isArray(rs.rows)) return null;
return <span className="query-result-tab-count">{rs.rows.length}</span>;
})()}
<Tooltip title={t('query_editor.result.close')}>
<span
className="query-result-tab-close"
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
onCloseResult(rs.key);
}}
>
<CloseOutlined style={{ fontSize: 12 }} />
</span>
</Tooltip>
</div>
</Dropdown>
),
children: (() => {
if (rs.resultType === 'message') {
return (
<div className={isV2Ui ? 'gn-v2-query-success' : undefined} style={{
flex: 1, minHeight: 0, display: 'flex', justifyContent: 'flex-start', flexDirection: 'column', gap: 12,
padding: 24, color: '#666', userSelect: 'text', alignItems: 'stretch', overflow: 'hidden',
}}>
{renderMessageBlock({
text: (rs.messages || []).join('\n'),
title: t('query_editor.results_panel.message.title'),
fontSize: 'var(--gn-font-size-mono, 13px)',
fillHeight: true,
color: darkMode ? '#d4d4d4' : '#333',
})}
</div>
);
}
if (isAffectedRowsResult(rs)) {
const affected = Number(rs.rows[0]?.affectedRows ?? 0);
const messageText = Array.isArray(rs.messages) ? rs.messages.join('\n') : '';
return (
<div className={isV2Ui ? 'gn-v2-query-success' : undefined} 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 }}>{t('query_editor.result.execution_success')}</span>
<span style={{ fontSize: 13, color: '#999' }}>{t('query_editor.result.affected_rows', { count: affected })}</span>
{messageText
? renderMessageBlock({ text: messageText, fontSize: 'var(--gn-font-size-mono, 12px)', compact: true, maxWidth: 720, color: darkMode ? '#d4d4d4' : '#666', marginTop: 8 })
: null}
</div>
);
}
const visibleColumns = resolveVisibleQueryResultColumns(rs.columns, globalHiddenColumns);
const resultTableName = rs.metadataTableName || rs.tableName;
return (
<div style={{ flex: 1, minHeight: 0, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
{Array.isArray(rs.messages) && rs.messages.length > 0 ? (
<div style={{ flex: '0 0 auto', margin: '8px 8px 0' }}>
{renderMessageBlock({
text: rs.messages.join('\n'),
fontSize: 'var(--gn-font-size-mono, 12px)',
compact: true,
color: darkMode ? '#d4d4d4' : '#666',
})}
</div>
) : null}
<DataGrid
data={rs.rows}
columnNames={visibleColumns}
isActive={isActive && resolvedActiveResultKey === rs.key}
loading={loading || rs.page?.loading === true}
tableName={resultTableName}
columnPinScope={resultTableName ? undefined : buildQueryResultColumnPinScope({
sql: rs.exportSql || rs.sql,
sourceStatementIndex: rs.sourceStatementIndex,
statementResultIndex: rs.statementResultIndex,
})}
exportScope="queryResult"
resultSql={rs.exportSql || rs.sql}
resultExportAllSql={rs.page?.exportAllSql}
dbName={rs.metadataDbName || currentDb}
ddlDbName={rs.ddlDbName}
ddlTableName={rs.ddlTableName}
connectionId={currentConnectionId}
initialViewMode={dataPreviewRequest?.resultKey === rs.key ? 'table' : undefined}
initialViewModeRequestId={dataPreviewRequest?.resultKey === rs.key ? dataPreviewRequest.requestId : undefined}
initialViewModeScope={dataPreviewRequest?.resultKey === rs.key ? 'local' : undefined}
pkColumns={rs.pkColumns}
editLocator={rs.editLocator}
onReload={() => {
if (rs.page) {
onResultPageChange(rs.key, rs.page.current, rs.page.pageSize);
return;
}
onReloadResult(rs.key, rs.sql);
}}
pagination={rs.page ? {
current: rs.page.current,
pageSize: rs.page.pageSize,
total: rs.page.total,
totalKnown: rs.page.totalKnown,
totalCountLoading: rs.page.totalCountLoading,
totalCountCancelled: rs.page.totalCountCancelled,
} : undefined}
onPageChange={rs.page ? ((page, size) => onResultPageChange(rs.key, page, size)) : undefined}
onSort={(field, order) => onResultSort(rs.key, field, order)}
sortInfoExternal={rs.sortInfo || []}
onRequestTotalCount={rs.page && onRequestResultTotalCount
? (() => onRequestResultTotalCount(rs.key))
: undefined}
onCancelTotalCount={rs.page && onCancelResultTotalCount
? (() => onCancelResultTotalCount(rs.key))
: undefined}
readOnly={rs.readOnly}
toolbarExtraActions={resolvedActiveResultKey === rs.key ? toolbarHideButton : null}
/>
</div>
);
})(),
}));
const logTabItem = shouldShowSqlLogTab
? {
key: QUERY_EDITOR_SQL_LOG_TAB_KEY,
label: (
<Tooltip title={t('log_panel.title')}>
<div className="query-result-tab-label">
<BugOutlined style={{ fontSize: 12 }} />
<span className="query-result-tab-text">{t('log_panel.short_title')}</span>
<span className="query-result-tab-count">{logTabCountLabel}</span>
</div>
</Tooltip>
),
children: (
<LogPanel
variant="embedded"
executionError={executionError}
onDiagnoseExecutionError={executionError ? onDiagnoseExecutionError : undefined}
/>
),
}
: null;
const tabItems = logTabItem ? [logTabItem, ...resultTabItems] : resultTabItems;
const activeResultSet = resultSets.find((rs) => rs.key === resolvedActiveResultKey) || null;
const activeResultUsesDataGrid = Boolean(activeResultSet && activeResultSet.resultType !== 'message' && !isAffectedRowsResult(activeResultSet));
const hideButton = (
<Tooltip title={hideTooltipTitle}>
<Button className="query-result-panel-hide" type="text" size="small" icon={<EyeInvisibleOutlined />} onClick={onHide}>
{t('query_editor.results_panel.action.hide')}
</Button>
</Tooltip>
);
const tabsHideButton = (
<Tooltip title={hideTooltipTitle}>
<Button aria-label={t('query_editor.results_panel.aria.hide')} className="query-result-panel-hide query-result-panel-hide-compact" type="text" size="small" icon={<EyeInvisibleOutlined />} onClick={onHide} />
</Tooltip>
);
const tabsExtraContent = !activeResultUsesDataGrid
? { right: <div style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>{tabsHideButton}</div> }
: undefined;
return (
<>
<style>{`
.query-result-tabs { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; overflow: hidden; }
.query-result-tabs .ant-tabs-nav { flex: 0 0 auto; margin: 0; min-height: 38px; padding-right: 8px; }
.query-result-tabs .ant-tabs-nav-wrap { flex: 0 1 auto; min-width: 0; }
.query-result-tabs .ant-tabs-extra-content { display: inline-flex; align-items: center; padding-left: 8px; }
.query-result-tabs .ant-tabs-nav-list { align-items: center; width: auto; }
.query-result-tabs .ant-tabs-tab { width: auto !important; min-width: 0 !important; max-width: 148px !important; height: 30px !important; min-height: 30px; margin: 4px 6px 4px 0 !important; padding: 0 9px !important; border-radius: 999px !important; border: 0.5px solid transparent !important; border-right: 0.5px solid transparent !important; align-items: center !important; justify-content: center !important; }
.query-result-tabs .ant-tabs-tab-btn { width: auto !important; height: 100%; max-width: 100%; display: inline-flex !important; align-items: center !important; justify-content: center !important; font-size: 14px !important; line-height: 1 !important; }
.query-result-tabs .ant-tabs-tab.ant-tabs-tab-active::after { display: none; }
.query-result-tabs .ant-tabs-content-holder, .query-result-tabs .ant-tabs-content, .query-result-tabs .ant-tabs-tabpane { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; overflow: hidden; }
.query-result-tabs .ant-tabs-tabpane > div { flex: 1 1 auto; min-height: 0; }
.query-result-tabs .ant-tabs-tabpane-hidden { display: none !important; }
.query-result-tabs .ant-tabs-ink-bar { transition: none !important; }
.query-result-tab-label { display: inline-flex; align-items: center; gap: 5px; min-width: 0; max-width: 126px; height: 100%; line-height: 1; user-select: none; -webkit-user-select: none; }
.query-result-tab-label.is-detachable { cursor: grab; touch-action: none; user-select: none; -webkit-user-select: none; }
.query-result-tab-label.is-dragging-detach { cursor: grabbing; opacity: 0.72; }
html.gn-result-tab-detaching,
html.gn-result-tab-detaching body,
html.gn-result-tab-detaching * {
user-select: none !important;
-webkit-user-select: none !important;
cursor: grabbing !important;
}
.query-result-tab-text { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 14px; font-weight: 700; }
.query-result-tab-count { flex: 0 0 auto; min-width: 17px; height: 17px; padding: 0 5px; border-radius: 999px; display: inline-flex; align-items: center; justify-content: center; background: rgba(148, 163, 184, 0.16); color: inherit; font-size: 11px; font-weight: 700; line-height: 17px; }
.query-result-tab-close { display: inline-flex; align-items: center; justify-content: center; width: 16px; height: 16px; border-radius: 999px; color: #999; cursor: pointer; flex: 0 0 auto; }
.query-result-tab-close:hover { background: rgba(0, 0, 0, 0.06); color: #666; }
.query-result-panel-header { flex: 0 0 auto; min-height: 38px; display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 0 12px; border-bottom: 1px solid rgba(0, 0, 0, 0.06); background: rgba(255, 255, 255, 0.9); }
.query-result-panel-header-title { font-size: 13px; font-weight: 600; color: #666; }
.query-result-panel-hide { display: inline-flex; align-items: center; gap: 4px; }
.query-result-panel-hide-compact { min-width: 28px; padding: 0 6px; justify-content: center; }
`}</style>
<div data-gonavi-close-shortcut-scope="result" className={isV2Ui ? 'gn-v2-query-results' : undefined} style={{ position: 'relative', flex: 1, minHeight: 0, overflow: 'hidden', padding: 0, display: 'flex', flexDirection: 'column' }}>
{tabItems.length > 0 ? (
<Tabs className="query-result-tabs" activeKey={resolvedActiveResultKey} onChange={onActiveResultKeyChange} animated={false} style={{ flex: 1, minHeight: 0 }} tabBarExtraContent={tabsExtraContent} items={tabItems} />
) : executionError ? (
<>
<div className={isV2Ui ? 'query-result-panel-header gn-v2-query-result-panel-header' : 'query-result-panel-header'}>
<span className="query-result-panel-header-title">{t('query_editor.results_panel.panel.title')}</span>
{hideButton}
</div>
<div className={isV2Ui ? 'gn-v2-query-error' : undefined} style={{ flex: 1, minHeight: 0, padding: 24, display: 'flex', flexDirection: 'column', gap: 16, background: darkMode ? '#1e1e1e' : '#fafafa', overflow: 'auto' }}>
<div style={{ color: '#ff4d4f', fontWeight: 'bold', fontSize: 16, display: 'flex', alignItems: 'center', gap: 8 }}>
<CloseOutlined />
<span>{t('query_editor.result.execution_failed')}</span>
</div>
<div className="custom-scrollbar" style={{ padding: 16, background: darkMode ? '#2d1a1a' : '#fff2f0', border: `1px solid ${darkMode ? '#5c2020' : '#ffccc7'}`, borderRadius: 6, color: darkMode ? '#ffa39e' : '#cf1322', fontFamily: 'var(--gn-font-mono)', fontSize: 'var(--gn-font-size-mono, 13px)', whiteSpace: 'pre-wrap', wordBreak: 'break-all', maxHeight: '40vh', overflow: 'auto' }}>
{executionError}
</div>
<div style={{ marginTop: 8 }}>
<Button type="primary" icon={<RobotOutlined />} style={{ background: '#818cf8', borderColor: '#818cf8', boxShadow: '0 2px 0 rgba(129, 140, 248, 0.2)' }} onClick={onDiagnoseExecutionError}>
{t('query_editor.result.ai_diagnose')}
</Button>
</div>
</div>
</>
) : (
<>
<div className={isV2Ui ? 'query-result-panel-header gn-v2-query-result-panel-header' : 'query-result-panel-header'}>
<span className="query-result-panel-header-title">{t('query_editor.results_panel.panel.title')}</span>
{hideButton}
</div>
<div className={isV2Ui ? 'gn-v2-query-empty' : undefined} style={{ flex: 1, minHeight: 0 }}>
{isV2Ui && (
<div>
<strong>{t('query_editor.empty_state.title')}</strong>
<span>{t('query_editor.empty_state.description')}</span>
</div>
)}
</div>
</>
)}
</div>
<DetachDragPreview
preview={detachDragPreview}
darkMode={darkMode}
readyHint={t('query_editor.results_panel.menu.open_in_window')}
/>
</>
);
};
export default QueryEditorResultsPanel;