mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-07-08 22:12:29 +08:00
Compare commits
24 Commits
fix/query-
...
fix/sideba
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd01d1c3f9 | ||
|
|
23c895e839 | ||
|
|
7a5960e737 | ||
|
|
5c6110a57e | ||
|
|
17843ffd71 | ||
|
|
7296ab0425 | ||
|
|
47664023ee | ||
|
|
33918c6837 | ||
|
|
66a6052f88 | ||
|
|
31b5b838a2 | ||
|
|
575a3d88b9 | ||
|
|
bef811ce13 | ||
|
|
dee5620fc9 | ||
|
|
b1174861d4 | ||
|
|
a1f296ee08 | ||
|
|
3e34795ef5 | ||
|
|
f73d8dcb97 | ||
|
|
82ad393a05 | ||
|
|
257406120d | ||
|
|
9ba3216fa8 | ||
|
|
fca3f0cffb | ||
|
|
5712b39cf9 | ||
|
|
07dc1436af | ||
|
|
9e52397732 |
@@ -1,6 +1,13 @@
|
||||
import React from 'react';
|
||||
import { Button, Checkbox, Input } from 'antd';
|
||||
import { t as defaultTranslate, type I18nParams } from '../i18n';
|
||||
import {
|
||||
loadGlobalHiddenColumns,
|
||||
parseGlobalHiddenColumnsText,
|
||||
saveGlobalHiddenColumns,
|
||||
serializeGlobalHiddenColumns,
|
||||
subscribeGlobalHiddenColumns,
|
||||
} from '../utils/globalHiddenColumns';
|
||||
|
||||
export type DataGridColumnInfoTranslate = (key: string, params?: I18nParams) => string;
|
||||
|
||||
@@ -27,6 +34,13 @@ export interface DataGridColumnInfoPopoverContentProps {
|
||||
onResetHidden: () => void;
|
||||
}
|
||||
|
||||
const GLOBAL_HIDDEN_LABEL = 'Global hidden columns';
|
||||
const GLOBAL_HIDDEN_HELP = 'Column names listed here are hidden in query results wherever they appear. Separate names with comma or newline.';
|
||||
const GLOBAL_HIDDEN_PLACEHOLDER = 'id\ncreated_by\nupdated_at';
|
||||
const GLOBAL_HIDDEN_SAVE = 'Apply global';
|
||||
const GLOBAL_HIDDEN_ADD_LOCAL = 'Add current hidden';
|
||||
const GLOBAL_HIDDEN_CLEAR = 'Clear global';
|
||||
|
||||
const DataGridColumnInfoPopoverContent: React.FC<DataGridColumnInfoPopoverContentProps> = ({
|
||||
darkMode,
|
||||
showColumnComment,
|
||||
@@ -48,68 +62,107 @@ const DataGridColumnInfoPopoverContent: React.FC<DataGridColumnInfoPopoverConten
|
||||
onEnableHiddenColumnMemoryChange,
|
||||
onResetOrder,
|
||||
onResetHidden,
|
||||
}) => (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, minWidth: 200, maxWidth: 300 }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 13, color: darkMode ? '#ddd' : '#666' }}>
|
||||
{translate('data_grid.column_settings.display_settings')}
|
||||
</div>
|
||||
<Checkbox checked={showColumnComment} onChange={(e) => onShowColumnCommentChange(e.target.checked)}>
|
||||
{translate('data_grid.column_settings.show_comments')}
|
||||
</Checkbox>
|
||||
<Checkbox checked={showColumnType} onChange={(e) => onShowColumnTypeChange(e.target.checked)}>
|
||||
{translate('data_grid.column_settings.show_types')}
|
||||
</Checkbox>
|
||||
<div style={{ height: 1, backgroundColor: darkMode ? '#424242' : '#f0f0f0', margin: '4px 0' }} />
|
||||
}) => {
|
||||
const [globalHiddenText, setGlobalHiddenText] = React.useState(() => serializeGlobalHiddenColumns(loadGlobalHiddenColumns()));
|
||||
|
||||
<div style={{ fontWeight: 600, fontSize: 13, color: darkMode ? '#ddd' : '#666', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span>{translate('data_grid.column_settings.column_visibility')}</span>
|
||||
React.useEffect(() => subscribeGlobalHiddenColumns((columns) => {
|
||||
setGlobalHiddenText(serializeGlobalHiddenColumns(columns));
|
||||
}), []);
|
||||
|
||||
const saveGlobalHiddenText = React.useCallback(() => {
|
||||
setGlobalHiddenText(serializeGlobalHiddenColumns(saveGlobalHiddenColumns(parseGlobalHiddenColumnsText(globalHiddenText))));
|
||||
}, [globalHiddenText]);
|
||||
|
||||
const addCurrentHiddenColumnsToGlobal = React.useCallback(() => {
|
||||
const next = [
|
||||
...parseGlobalHiddenColumnsText(globalHiddenText),
|
||||
...localHiddenColumns,
|
||||
];
|
||||
setGlobalHiddenText(serializeGlobalHiddenColumns(saveGlobalHiddenColumns(next)));
|
||||
}, [globalHiddenText, localHiddenColumns]);
|
||||
|
||||
const clearGlobalHiddenColumns = React.useCallback(() => {
|
||||
setGlobalHiddenText(serializeGlobalHiddenColumns(saveGlobalHiddenColumns([])));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, minWidth: 220, maxWidth: 320 }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 13, color: darkMode ? '#ddd' : '#666' }}>
|
||||
{translate('data_grid.column_settings.display_settings')}
|
||||
</div>
|
||||
<Checkbox checked={showColumnComment} onChange={(e) => onShowColumnCommentChange(e.target.checked)}>
|
||||
{translate('data_grid.column_settings.show_comments')}
|
||||
</Checkbox>
|
||||
<Checkbox checked={showColumnType} onChange={(e) => onShowColumnTypeChange(e.target.checked)}>
|
||||
{translate('data_grid.column_settings.show_types')}
|
||||
</Checkbox>
|
||||
<div style={{ height: 1, backgroundColor: darkMode ? '#424242' : '#f0f0f0', margin: '4px 0' }} />
|
||||
|
||||
<div style={{ fontWeight: 600, fontSize: 13, color: darkMode ? '#ddd' : '#666', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span>{translate('data_grid.column_settings.column_visibility')}</span>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<a style={{ fontSize: 12 }} onClick={() => onToggleAllColumnsVisibility(true)}>
|
||||
{translate('data_grid.column_settings.show_all')}
|
||||
</a>
|
||||
<a style={{ fontSize: 12 }} onClick={() => onToggleAllColumnsVisibility(false)}>
|
||||
{translate('data_grid.column_settings.hide_all')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<Input
|
||||
placeholder={translate('data_grid.column_settings.search_columns_placeholder')}
|
||||
size="small"
|
||||
value={columnSearchText}
|
||||
onChange={(e) => onColumnSearchTextChange(e.target.value)}
|
||||
allowClear
|
||||
/>
|
||||
<div className="custom-scrollbar" style={{ maxHeight: 220, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{allOrderedColumnNames
|
||||
.filter((col) => !columnSearchText || col.toLowerCase().includes(columnSearchText.toLowerCase()))
|
||||
.map((col) => (
|
||||
<Checkbox
|
||||
key={col}
|
||||
checked={!localHiddenColumns.includes(col)}
|
||||
onChange={(e) => onToggleColumnVisibility(col, e.target.checked)}
|
||||
style={{ marginLeft: 0 }}
|
||||
>
|
||||
{col}
|
||||
</Checkbox>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ height: 1, backgroundColor: darkMode ? '#424242' : '#f0f0f0', margin: '4px 0' }} />
|
||||
<div style={{ fontWeight: 600, fontSize: 13, color: darkMode ? '#ddd' : '#666' }}>{GLOBAL_HIDDEN_LABEL}</div>
|
||||
<div style={{ fontSize: 12, lineHeight: 1.5, color: darkMode ? '#aaa' : '#888' }}>{GLOBAL_HIDDEN_HELP}</div>
|
||||
<Input.TextArea
|
||||
autoSize={{ minRows: 2, maxRows: 4 }}
|
||||
placeholder={GLOBAL_HIDDEN_PLACEHOLDER}
|
||||
value={globalHiddenText}
|
||||
onChange={(event) => setGlobalHiddenText(event.target.value)}
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<a style={{ fontSize: 12 }} onClick={() => onToggleAllColumnsVisibility(true)}>
|
||||
{translate('data_grid.column_settings.show_all')}
|
||||
</a>
|
||||
<a style={{ fontSize: 12 }} onClick={() => onToggleAllColumnsVisibility(false)}>
|
||||
{translate('data_grid.column_settings.hide_all')}
|
||||
</a>
|
||||
<Button size="small" style={{ flex: 1 }} onClick={saveGlobalHiddenText}>{GLOBAL_HIDDEN_SAVE}</Button>
|
||||
<Button size="small" style={{ flex: 1 }} disabled={localHiddenColumns.length === 0} onClick={addCurrentHiddenColumnsToGlobal}>{GLOBAL_HIDDEN_ADD_LOCAL}</Button>
|
||||
</div>
|
||||
<Button size="small" danger disabled={!globalHiddenText.trim()} onClick={clearGlobalHiddenColumns}>{GLOBAL_HIDDEN_CLEAR}</Button>
|
||||
|
||||
<div style={{ height: 1, backgroundColor: darkMode ? '#424242' : '#f0f0f0', margin: '4px 0' }} />
|
||||
<Checkbox checked={enableColumnOrderMemory} onChange={(e) => onEnableColumnOrderMemoryChange(e.target.checked)}>
|
||||
{translate('data_grid.column_settings.remember_column_order')}
|
||||
</Checkbox>
|
||||
<Checkbox checked={enableHiddenColumnMemory} onChange={(e) => onEnableHiddenColumnMemoryChange(e.target.checked)}>
|
||||
{translate('data_grid.column_settings.remember_hidden_columns')}
|
||||
</Checkbox>
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 4 }}>
|
||||
<Button size="small" danger style={{ flex: 1 }} disabled={!canResetOrder} onClick={onResetOrder}>
|
||||
{translate('data_grid.column_settings.reset_order')}
|
||||
</Button>
|
||||
<Button size="small" danger style={{ flex: 1 }} disabled={!canResetHidden} onClick={onResetHidden}>
|
||||
{translate('data_grid.column_settings.reset_hidden')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Input
|
||||
placeholder={translate('data_grid.column_settings.search_columns_placeholder')}
|
||||
size="small"
|
||||
value={columnSearchText}
|
||||
onChange={(e) => onColumnSearchTextChange(e.target.value)}
|
||||
allowClear
|
||||
/>
|
||||
<div className="custom-scrollbar" style={{ maxHeight: 240, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{allOrderedColumnNames
|
||||
.filter((col) => !columnSearchText || col.toLowerCase().includes(columnSearchText.toLowerCase()))
|
||||
.map((col) => (
|
||||
<Checkbox
|
||||
key={col}
|
||||
checked={!localHiddenColumns.includes(col)}
|
||||
onChange={(e) => onToggleColumnVisibility(col, e.target.checked)}
|
||||
style={{ marginLeft: 0 }}
|
||||
>
|
||||
{col}
|
||||
</Checkbox>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ height: 1, backgroundColor: darkMode ? '#424242' : '#f0f0f0', margin: '4px 0' }} />
|
||||
<Checkbox checked={enableColumnOrderMemory} onChange={(e) => onEnableColumnOrderMemoryChange(e.target.checked)}>
|
||||
{translate('data_grid.column_settings.remember_column_order')}
|
||||
</Checkbox>
|
||||
<Checkbox checked={enableHiddenColumnMemory} onChange={(e) => onEnableHiddenColumnMemoryChange(e.target.checked)}>
|
||||
{translate('data_grid.column_settings.remember_hidden_columns')}
|
||||
</Checkbox>
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 4 }}>
|
||||
<Button size="small" danger style={{ flex: 1 }} disabled={!canResetOrder} onClick={onResetOrder}>
|
||||
{translate('data_grid.column_settings.reset_order')}
|
||||
</Button>
|
||||
<Button size="small" danger style={{ flex: 1 }} disabled={!canResetHidden} onClick={onResetHidden}>
|
||||
{translate('data_grid.column_settings.reset_hidden')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
export default DataGridColumnInfoPopoverContent;
|
||||
|
||||
@@ -24,6 +24,14 @@ const sameEditorPosition = (left: any, right: any): boolean => (
|
||||
&& Number(left?.column) === Number(right?.column)
|
||||
);
|
||||
|
||||
const isSelectionEmpty = (selection: any): boolean => (
|
||||
!selection
|
||||
|| (
|
||||
Number(selection.startLineNumber) === Number(selection.endLineNumber)
|
||||
&& Number(selection.startColumn) === Number(selection.endColumn)
|
||||
)
|
||||
);
|
||||
|
||||
const stripSqlIdentifierQuotes = (value: string): string => {
|
||||
const text = String(value || '').trim();
|
||||
if (!text) return '';
|
||||
@@ -194,6 +202,72 @@ const patchQueryEditorAiInlineRightArrowFallback = (editor: any, monaco: any) =>
|
||||
};
|
||||
};
|
||||
|
||||
const installPrintableInputFallback = (editor: any, monaco: any) => {
|
||||
const editorDomNode = editor?.getDomNode?.();
|
||||
if (!editorDomNode || editor.__gonaviPrintableInputFallbackInstalled) {
|
||||
return;
|
||||
}
|
||||
const input = editorDomNode.querySelector?.('textarea.inputarea, .inputarea textarea, textarea') as HTMLTextAreaElement | null;
|
||||
if (!(input instanceof HTMLTextAreaElement)) {
|
||||
return;
|
||||
}
|
||||
Object.defineProperty(editor, '__gonaviPrintableInputFallbackInstalled', {
|
||||
value: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const isReadOnly = (): boolean => {
|
||||
try {
|
||||
const optionId = monaco?.editor?.EditorOption?.readOnly;
|
||||
return optionId !== undefined ? editor.getOption?.(optionId) === true : false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleBeforeInput = (event: InputEvent) => {
|
||||
const text = String(event.data || '');
|
||||
if (
|
||||
event.defaultPrevented
|
||||
|| event.isComposing
|
||||
|| event.inputType !== 'insertText'
|
||||
|| !text
|
||||
|| text.length > 8
|
||||
|| isReadOnly()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selectionBefore = editor.getSelection?.();
|
||||
if (!isSelectionEmpty(selectionBefore)) {
|
||||
return;
|
||||
}
|
||||
const beforeValue = String(editor.getValue?.() ?? '');
|
||||
const beforePosition = editor.getPosition?.();
|
||||
|
||||
window.setTimeout(() => {
|
||||
const domNode = editor.getDomNode?.();
|
||||
if (!(domNode instanceof HTMLElement) || !domNode.isConnected || isReadOnly()) {
|
||||
return;
|
||||
}
|
||||
if (document.activeElement && !domNode.contains(document.activeElement)) {
|
||||
return;
|
||||
}
|
||||
const afterValue = String(editor.getValue?.() ?? '');
|
||||
const afterPosition = editor.getPosition?.();
|
||||
if (afterValue !== beforeValue || !sameEditorPosition(beforePosition, afterPosition)) {
|
||||
return;
|
||||
}
|
||||
editor.trigger?.('gonavi-printable-input-fallback', 'type', { text });
|
||||
}, 16);
|
||||
};
|
||||
|
||||
input.addEventListener('beforeinput', handleBeforeInput);
|
||||
editor.onDidDispose?.(() => {
|
||||
input.removeEventListener('beforeinput', handleBeforeInput);
|
||||
});
|
||||
};
|
||||
|
||||
export const registerGonaviMonacoThemes: BeforeMount = (monaco) => {
|
||||
if (transparentThemesRegistered) {
|
||||
return;
|
||||
@@ -291,6 +365,7 @@ const MonacoEditor: React.FC<MonacoEditorProps> = ({
|
||||
const handleMount: OnMount = useCallback((editor, monaco) => {
|
||||
installOceanBaseOracleNavigationFallback(editor);
|
||||
patchQueryEditorAiInlineRightArrowFallback(editor, monaco);
|
||||
installPrintableInputFallback(editor, monaco);
|
||||
onMount?.(editor, monaco);
|
||||
}, [onMount]);
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { BugOutlined, CloseOutlined, CopyOutlined, EyeInvisibleOutlined, RobotOu
|
||||
|
||||
import type { EditRowLocator } from '../utils/rowLocator';
|
||||
import type { QueryResultPaginationState } from '../utils/queryResultPagination';
|
||||
import { filterColumnNamesByGlobalHiddenColumns, useGlobalHiddenColumns } from '../utils/globalHiddenColumns';
|
||||
import { t as defaultTranslate } from '../i18n';
|
||||
import { useOptionalI18n } from '../i18n/provider';
|
||||
import DataGrid from './DataGrid';
|
||||
@@ -57,6 +58,11 @@ interface QueryEditorResultsPanelProps {
|
||||
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 QueryEditorResultsPanel: React.FC<QueryEditorResultsPanelProps> = ({
|
||||
resultSets,
|
||||
activeResultKey,
|
||||
@@ -81,14 +87,13 @@ const QueryEditorResultsPanel: React.FC<QueryEditorResultsPanelProps> = ({
|
||||
}) => {
|
||||
const i18n = useOptionalI18n();
|
||||
const t = i18n?.t ?? defaultTranslate;
|
||||
const globalHiddenColumns = useGlobalHiddenColumns();
|
||||
const shouldShowSqlLogTab = isV2Ui && (sqlLogCount > 0 || activeResultKey === QUERY_EDITOR_SQL_LOG_TAB_KEY);
|
||||
const logTabCountLabel = sqlLogCount > 999 ? '999+' : String(sqlLogCount);
|
||||
const resolvedResultSetKey = activeResultKey && resultSets.some((rs) => rs.key === activeResultKey)
|
||||
? activeResultKey
|
||||
: (resultSets[0]?.key || '');
|
||||
const hideTooltipTitle = toggleShortcutLabel
|
||||
? t('query_editor.results_panel.tooltip.hide_with_shortcut', { shortcut: toggleShortcutLabel })
|
||||
: t('query_editor.results_panel.tooltip.hide');
|
||||
|
||||
const handleMessageTextareaKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (!(event.ctrlKey || event.metaKey) || event.altKey || event.shiftKey || event.key.toLowerCase() !== 'a') {
|
||||
return;
|
||||
@@ -98,11 +103,10 @@ const QueryEditorResultsPanel: React.FC<QueryEditorResultsPanelProps> = ({
|
||||
event.currentTarget.focus();
|
||||
event.currentTarget.select();
|
||||
};
|
||||
|
||||
const handleCopyMessageText = async (text: string) => {
|
||||
const safeText = String(text || '');
|
||||
if (!safeText.trim()) {
|
||||
return;
|
||||
}
|
||||
if (!safeText.trim()) return;
|
||||
try {
|
||||
if (typeof navigator?.clipboard?.writeText !== 'function') {
|
||||
throw new Error(t('query_editor.results_panel.message.copy_unsupported'));
|
||||
@@ -115,6 +119,7 @@ const QueryEditorResultsPanel: React.FC<QueryEditorResultsPanelProps> = ({
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const renderMessageBlock = ({
|
||||
text,
|
||||
title,
|
||||
@@ -135,55 +140,27 @@ const QueryEditorResultsPanel: React.FC<QueryEditorResultsPanelProps> = ({
|
||||
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,
|
||||
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,
|
||||
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()}
|
||||
>
|
||||
<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)',
|
||||
}}
|
||||
>
|
||||
<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"
|
||||
@@ -193,89 +170,39 @@ const QueryEditorResultsPanel: React.FC<QueryEditorResultsPanelProps> = ({
|
||||
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',
|
||||
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
|
||||
className={isV2Ui ? 'gn-v2-query-result-toolbar-hide' : undefined}
|
||||
icon={<EyeInvisibleOutlined />}
|
||||
onClick={onHide}
|
||||
>
|
||||
<Button className={isV2Ui ? 'gn-v2-query-result-toolbar-hide' : undefined} icon={<EyeInvisibleOutlined />} onClick={onHide}>
|
||||
<span>{t('query_editor.results_panel.action.hide')}</span>
|
||||
{isV2Ui && toggleShortcutLabel && (
|
||||
<span className="gn-v2-toolbar-kbd">{toggleShortcutLabel}</span>
|
||||
)}
|
||||
{isV2Ui && toggleShortcutLabel && <span className="gn-v2-toolbar-kbd">{toggleShortcutLabel}</span>}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
function buildResultTabMenuItems(key: string, index: number): MenuProps['items'] {
|
||||
return [
|
||||
{
|
||||
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),
|
||||
},
|
||||
{ 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,
|
||||
},
|
||||
{ key: 'close-all', label: t('query_editor.results_panel.menu.close_all'), disabled: resultSets.length === 0, onClick: onCloseAllResultTabs },
|
||||
];
|
||||
}
|
||||
|
||||
const buildResultTabItems = () => resultSets.map((rs, idx) => ({
|
||||
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"
|
||||
onContextMenu={(event) => {
|
||||
event.preventDefault();
|
||||
}}
|
||||
>
|
||||
<Dropdown menu={{ items: buildResultTabMenuItems(rs.key, idx) }} trigger={['contextMenu']} rootClassName={isV2Ui ? 'gn-v2-tab-context-menu-popup' : undefined}>
|
||||
<div className="query-result-tab-label" onContextMenu={(event) => event.preventDefault()}>
|
||||
<Tooltip title={rs.sql}>
|
||||
<span className="query-result-tab-text">
|
||||
{rs.resultType === 'message'
|
||||
@@ -284,23 +211,17 @@ const QueryEditorResultsPanel: React.FC<QueryEditorResultsPanelProps> = ({
|
||||
</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;
|
||||
}
|
||||
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"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onCloseResult(rs.key);
|
||||
}}
|
||||
>
|
||||
@@ -312,16 +233,13 @@ const QueryEditorResultsPanel: React.FC<QueryEditorResultsPanelProps> = ({
|
||||
),
|
||||
children: (() => {
|
||||
if (rs.resultType === 'message') {
|
||||
const messageText = (rs.messages || []).join('\n');
|
||||
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',
|
||||
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: messageText,
|
||||
text: (rs.messages || []).join('\n'),
|
||||
title: t('query_editor.results_panel.message.title'),
|
||||
fontSize: 'var(--gn-font-size-mono, 13px)',
|
||||
fillHeight: true,
|
||||
@@ -335,42 +253,34 @@ const QueryEditorResultsPanel: React.FC<QueryEditorResultsPanelProps> = ({
|
||||
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',
|
||||
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,
|
||||
})
|
||||
? 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);
|
||||
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}
|
||||
{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={rs.columns}
|
||||
columnNames={visibleColumns}
|
||||
loading={loading || rs.page?.loading === true}
|
||||
tableName={rs.tableName}
|
||||
exportScope="queryResult"
|
||||
@@ -396,14 +306,13 @@ const QueryEditorResultsPanel: React.FC<QueryEditorResultsPanelProps> = ({
|
||||
} : undefined}
|
||||
onPageChange={rs.page ? ((page, size) => onResultPageChange(rs.key, page, size)) : undefined}
|
||||
readOnly={rs.readOnly}
|
||||
toolbarExtraActions={resolvedResultSetKey === rs.key ? toolbarHideButton : null}
|
||||
toolbarExtraActions={resolvedActiveResultKey === rs.key ? toolbarHideButton : null}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})(),
|
||||
}));
|
||||
|
||||
const resultTabItems = buildResultTabItems();
|
||||
const logTabItem = shouldShowSqlLogTab
|
||||
? {
|
||||
key: QUERY_EDITOR_SQL_LOG_TAB_KEY,
|
||||
@@ -428,30 +337,16 @@ const QueryEditorResultsPanel: React.FC<QueryEditorResultsPanelProps> = ({
|
||||
const tabItems = logTabItem ? [logTabItem, ...resultTabItems] : resultTabItems;
|
||||
|
||||
const resolvedActiveResultKey = (() => {
|
||||
if (activeResultKey && tabItems.some((item) => item.key === activeResultKey)) {
|
||||
return activeResultKey;
|
||||
}
|
||||
if (resultSets[0]?.key) {
|
||||
return resultSets[0].key;
|
||||
}
|
||||
if (activeResultKey && tabItems.some((item) => item.key === activeResultKey)) return activeResultKey;
|
||||
if (resultSets[0]?.key) return resultSets[0].key;
|
||||
return shouldShowSqlLogTab ? QUERY_EDITOR_SQL_LOG_TAB_KEY : '';
|
||||
})();
|
||||
const activeResultSet = resultSets.find((rs) => rs.key === resolvedActiveResultKey) || null;
|
||||
const activeResultUsesDataGrid = Boolean(
|
||||
activeResultSet &&
|
||||
activeResultSet.resultType !== 'message' &&
|
||||
!isAffectedRowsResult(activeResultSet),
|
||||
);
|
||||
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}
|
||||
>
|
||||
<Button className="query-result-panel-hide" type="text" size="small" icon={<EyeInvisibleOutlined />} onClick={onHide}>
|
||||
{t('query_editor.results_panel.action.hide')}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
@@ -459,202 +354,41 @@ const QueryEditorResultsPanel: React.FC<QueryEditorResultsPanelProps> = ({
|
||||
|
||||
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}
|
||||
/>
|
||||
<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>
|
||||
),
|
||||
}
|
||||
? { 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 {
|
||||
flex: 1 1 auto;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.query-result-tabs .ant-tabs-content {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.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-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;
|
||||
}
|
||||
.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-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
|
||||
className={isV2Ui ? 'gn-v2-query-results' : undefined}
|
||||
style={{ position: 'relative', flex: 1, minHeight: 0, overflow: 'hidden', padding: 0, display: 'flex', flexDirection: 'column' }}
|
||||
>
|
||||
<div 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}
|
||||
/>
|
||||
<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'}>
|
||||
@@ -670,12 +404,7 @@ const QueryEditorResultsPanel: React.FC<QueryEditorResultsPanelProps> = ({
|
||||
{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}
|
||||
>
|
||||
<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>
|
||||
|
||||
@@ -157,9 +157,46 @@ describe('useAppUpdateManager', () => {
|
||||
|
||||
expect(backendApp.DownloadUpdate).toHaveBeenCalledTimes(1);
|
||||
expect(backendApp.OpenDownloadedUpdateDirectory).not.toHaveBeenCalled();
|
||||
expect(backendApp.InstallUpdateAndRestart).not.toHaveBeenCalled();
|
||||
expect(hook?.lastUpdateInfo?.downloaded).toBe(true);
|
||||
});
|
||||
|
||||
it('auto-installs a macOS update immediately after download when the backend reports auto relaunch support', async () => {
|
||||
backendApp.CheckForUpdates.mockResolvedValue({
|
||||
success: true,
|
||||
data: {
|
||||
hasUpdate: true,
|
||||
currentVersion: '0.8.1',
|
||||
latestVersion: '0.8.2',
|
||||
downloaded: false,
|
||||
assetSize: 2048,
|
||||
},
|
||||
});
|
||||
backendApp.DownloadUpdate.mockResolvedValue({
|
||||
success: true,
|
||||
data: {
|
||||
platform: 'darwin',
|
||||
autoRelaunch: true,
|
||||
downloadPath: '/Users/test/Desktop/GoNavi-0.8.2/GoNavi-0.8.2-MacOS-Arm64.dmg',
|
||||
},
|
||||
});
|
||||
backendApp.InstallUpdateAndRestart.mockResolvedValue({ success: true });
|
||||
|
||||
renderHook();
|
||||
|
||||
await act(async () => {
|
||||
await hook?.checkForUpdates(false);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await hook?.downloadUpdate(hook?.lastUpdateInfo!, false);
|
||||
});
|
||||
|
||||
expect(backendApp.DownloadUpdate).toHaveBeenCalledTimes(1);
|
||||
expect(backendApp.InstallUpdateAndRestart).toHaveBeenCalledTimes(1);
|
||||
expect(backendApp.OpenDownloadedUpdateDirectory).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('switches update channel and re-checks against the selected channel', async () => {
|
||||
backendApp.SetUpdateChannel.mockResolvedValue({ success: true, data: { channel: 'dev' } });
|
||||
backendApp.CheckForUpdates.mockResolvedValue({
|
||||
|
||||
@@ -63,6 +63,11 @@ const buildUpdateKey = (info: Pick<UpdateInfo, 'channel' | 'latestVersion'> | nu
|
||||
? `${normalizeUpdateChannel(info.channel)}:${String(info.latestVersion || '').trim()}`
|
||||
: '';
|
||||
|
||||
const shouldAutoInstallDownloadedUpdate = (resultData: UpdateDownloadResultData | null | undefined): boolean => {
|
||||
const platform = String(resultData?.platform || '').trim().toLowerCase();
|
||||
return platform === 'darwin' && resultData?.autoRelaunch !== false;
|
||||
};
|
||||
|
||||
export const useAppUpdateManager = ({
|
||||
runtimeBuildType,
|
||||
t,
|
||||
@@ -192,6 +197,21 @@ export const useAppUpdateManager = ({
|
||||
void message.success({ content: t('app.about.message.download_completed'), duration: 2 });
|
||||
}
|
||||
setAboutUpdateStatus(formatAboutUpdateStatus({ ...info, channel: normalizeUpdateChannel(info.channel), downloaded: true }));
|
||||
|
||||
if (shouldAutoInstallDownloadedUpdate(resultData)) {
|
||||
let installRes: any = null;
|
||||
try {
|
||||
installRes = await (window as any).go?.app?.App?.InstallUpdateAndRestart?.();
|
||||
} catch (error: any) {
|
||||
installRes = { success: false, message: error?.message || t('common.unknown') };
|
||||
}
|
||||
if (!installRes?.success) {
|
||||
void message.error(t('app.about.message.install_failed_with_error', { error: installRes?.message || t('common.unknown') }));
|
||||
return;
|
||||
}
|
||||
updateInstallTriggeredVersionRef.current = targetKey || null;
|
||||
setUpdateDownloadProgress((prev) => ({ ...prev, open: false }));
|
||||
}
|
||||
} else {
|
||||
setUpdateDownloadProgress((prev) => ({
|
||||
...prev,
|
||||
|
||||
27
frontend/src/utils/globalHiddenColumns.test.ts
Normal file
27
frontend/src/utils/globalHiddenColumns.test.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
filterColumnNamesByGlobalHiddenColumns,
|
||||
normalizeGlobalHiddenColumns,
|
||||
parseGlobalHiddenColumnsText,
|
||||
} from './globalHiddenColumns';
|
||||
|
||||
describe('globalHiddenColumns', () => {
|
||||
it('parses comma, semicolon, and newline separated column names with dedupe', () => {
|
||||
expect(parseGlobalHiddenColumnsText('ID, created_by\nupdated_at;id')).toEqual([
|
||||
'ID',
|
||||
'created_by',
|
||||
'updated_at',
|
||||
]);
|
||||
});
|
||||
|
||||
it('normalizes persisted arrays and text input consistently', () => {
|
||||
expect(normalizeGlobalHiddenColumns([' ID ', 'id', 'NAME'])).toEqual(['ID', 'NAME']);
|
||||
expect(normalizeGlobalHiddenColumns(' ID\nNAME')).toEqual(['ID', 'NAME']);
|
||||
});
|
||||
|
||||
it('filters query result columns case-insensitively', () => {
|
||||
expect(filterColumnNamesByGlobalHiddenColumns(['ID', 'Name', 'updated_at'], ['id', 'UPDATED_AT']))
|
||||
.toEqual(['Name']);
|
||||
});
|
||||
});
|
||||
105
frontend/src/utils/globalHiddenColumns.ts
Normal file
105
frontend/src/utils/globalHiddenColumns.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export const GLOBAL_HIDDEN_COLUMNS_STORAGE_KEY = 'gonavi.globalHiddenColumns.v1';
|
||||
export const GLOBAL_HIDDEN_COLUMNS_CHANGED_EVENT = 'gonavi:global-hidden-columns-changed';
|
||||
|
||||
export const normalizeGlobalHiddenColumnName = (value: unknown): string => (
|
||||
String(value ?? '').trim().toLowerCase()
|
||||
);
|
||||
|
||||
export const parseGlobalHiddenColumnsText = (value: unknown): string[] => {
|
||||
const seen = new Set<string>();
|
||||
const result: string[] = [];
|
||||
String(value ?? '')
|
||||
.split(/[\n\r,,;;\t]+/)
|
||||
.map((item) => String(item || '').trim())
|
||||
.filter(Boolean)
|
||||
.forEach((item) => {
|
||||
const normalized = normalizeGlobalHiddenColumnName(item);
|
||||
if (!normalized || seen.has(normalized)) return;
|
||||
seen.add(normalized);
|
||||
result.push(item);
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
export const normalizeGlobalHiddenColumns = (value: unknown): string[] => {
|
||||
if (Array.isArray(value)) {
|
||||
return parseGlobalHiddenColumnsText(value.join('\n'));
|
||||
}
|
||||
return parseGlobalHiddenColumnsText(value);
|
||||
};
|
||||
|
||||
export const serializeGlobalHiddenColumns = (columns: unknown): string => (
|
||||
normalizeGlobalHiddenColumns(columns).join('\n')
|
||||
);
|
||||
|
||||
export const loadGlobalHiddenColumns = (): string[] => {
|
||||
if (typeof localStorage === 'undefined') return [];
|
||||
try {
|
||||
const raw = localStorage.getItem(GLOBAL_HIDDEN_COLUMNS_STORAGE_KEY);
|
||||
if (!raw) return [];
|
||||
const parsed = JSON.parse(raw);
|
||||
return normalizeGlobalHiddenColumns(parsed);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
export const saveGlobalHiddenColumns = (columns: unknown): string[] => {
|
||||
const normalized = normalizeGlobalHiddenColumns(columns);
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
try {
|
||||
localStorage.setItem(GLOBAL_HIDDEN_COLUMNS_STORAGE_KEY, JSON.stringify(normalized));
|
||||
} catch {
|
||||
// Ignore storage failures; still notify in-memory listeners.
|
||||
}
|
||||
}
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new CustomEvent(GLOBAL_HIDDEN_COLUMNS_CHANGED_EVENT, {
|
||||
detail: { columns: normalized },
|
||||
}));
|
||||
}
|
||||
return normalized;
|
||||
};
|
||||
|
||||
export const subscribeGlobalHiddenColumns = (callback: (columns: string[]) => void): (() => void) => {
|
||||
if (typeof window === 'undefined') {
|
||||
return () => {};
|
||||
}
|
||||
const handleCustomEvent = (event: Event) => {
|
||||
const detail = (event as CustomEvent<{ columns?: unknown }>).detail;
|
||||
callback(normalizeGlobalHiddenColumns(detail?.columns));
|
||||
};
|
||||
const handleStorageEvent = (event: StorageEvent) => {
|
||||
if (event.key !== GLOBAL_HIDDEN_COLUMNS_STORAGE_KEY) return;
|
||||
callback(loadGlobalHiddenColumns());
|
||||
};
|
||||
window.addEventListener(GLOBAL_HIDDEN_COLUMNS_CHANGED_EVENT, handleCustomEvent as EventListener);
|
||||
window.addEventListener('storage', handleStorageEvent);
|
||||
return () => {
|
||||
window.removeEventListener(GLOBAL_HIDDEN_COLUMNS_CHANGED_EVENT, handleCustomEvent as EventListener);
|
||||
window.removeEventListener('storage', handleStorageEvent);
|
||||
};
|
||||
};
|
||||
|
||||
export const useGlobalHiddenColumns = (): string[] => {
|
||||
const [columns, setColumns] = useState<string[]>(() => loadGlobalHiddenColumns());
|
||||
|
||||
useEffect(() => subscribeGlobalHiddenColumns(setColumns), []);
|
||||
|
||||
return columns;
|
||||
};
|
||||
|
||||
export const filterColumnNamesByGlobalHiddenColumns = (
|
||||
columnNames: string[],
|
||||
hiddenColumns: unknown,
|
||||
): string[] => {
|
||||
const hiddenSet = new Set(
|
||||
normalizeGlobalHiddenColumns(hiddenColumns).map(normalizeGlobalHiddenColumnName),
|
||||
);
|
||||
if (hiddenSet.size === 0) {
|
||||
return columnNames;
|
||||
}
|
||||
return (columnNames || []).filter((columnName) => !hiddenSet.has(normalizeGlobalHiddenColumnName(columnName)));
|
||||
};
|
||||
@@ -40,9 +40,9 @@ describe('applyQueryAutoLimit', () => {
|
||||
['dameng'],
|
||||
['dm'],
|
||||
['dm8'],
|
||||
])('adds ROWNUM limit for %s connections', (dbType) => {
|
||||
])('adds FETCH FIRST limit for %s connections', (dbType) => {
|
||||
expect(applyQueryAutoLimit('SELECT * FROM MYCIMLED.EDC_LOG', dbType, 500).sql)
|
||||
.toBe('SELECT * FROM (SELECT * FROM MYCIMLED.EDC_LOG) WHERE ROWNUM <= 500');
|
||||
.toBe('SELECT * FROM MYCIMLED.EDC_LOG FETCH FIRST 500 ROWS ONLY');
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -61,8 +61,8 @@ describe('applyQueryAutoLimit', () => {
|
||||
});
|
||||
|
||||
it.each([
|
||||
['oracle', 'SELECT * FROM (SELECT * FROM users) WHERE ROWNUM <= 500'],
|
||||
['dm8', 'SELECT * FROM (SELECT * FROM users) WHERE ROWNUM <= 500'],
|
||||
['oracle', 'SELECT * FROM users FETCH FIRST 500 ROWS ONLY'],
|
||||
['dm8', 'SELECT * FROM users FETCH FIRST 500 ROWS ONLY'],
|
||||
['mssql', 'SELECT TOP 500 * FROM users'],
|
||||
['postgresql', 'SELECT * FROM users LIMIT 500'],
|
||||
['gauss-db', 'SELECT * FROM users LIMIT 500'],
|
||||
@@ -76,12 +76,17 @@ describe('applyQueryAutoLimit', () => {
|
||||
|
||||
it('keeps trailing semicolon and comments after injected Oracle limit', () => {
|
||||
expect(applyQueryAutoLimit('SELECT * FROM MYCIMLED.EDC_LOG; -- preview', 'oracle', 500).sql)
|
||||
.toBe('SELECT * FROM (SELECT * FROM MYCIMLED.EDC_LOG) WHERE ROWNUM <= 500; -- preview');
|
||||
.toBe('SELECT * FROM MYCIMLED.EDC_LOG FETCH FIRST 500 ROWS ONLY; -- preview');
|
||||
});
|
||||
|
||||
it('uses Oracle 11g compatible ROWNUM limit for simple table queries', () => {
|
||||
it('uses Oracle FETCH FIRST limit for simple table queries', () => {
|
||||
expect(applyQueryAutoLimit('select 1 from xxx', 'oracle', 500).sql)
|
||||
.toBe('SELECT * FROM (select 1 from xxx) WHERE ROWNUM <= 500');
|
||||
.toBe('select 1 from xxx FETCH FIRST 500 ROWS ONLY');
|
||||
});
|
||||
|
||||
it('keeps ORDER BY semantics with Oracle FETCH FIRST', () => {
|
||||
expect(applyQueryAutoLimit('SELECT * FROM users ORDER BY created_at DESC', 'oracle', 100).sql)
|
||||
.toBe('SELECT * FROM users ORDER BY created_at DESC FETCH FIRST 100 ROWS ONLY');
|
||||
});
|
||||
|
||||
it('does not add another generic limit when SQL already limits rows', () => {
|
||||
|
||||
@@ -322,7 +322,7 @@ export const applyQueryAutoLimit = (
|
||||
if (offsetPos >= 0 && (fromPos < 0 || offsetPos > fromPos)) return { sql, applied: false, maxRows };
|
||||
const forPos = findTopLevelKeyword(main, 'for');
|
||||
if (forPos >= 0 && (fromPos < 0 || forPos > fromPos)) return { sql, applied: false, maxRows };
|
||||
return { sql: `SELECT * FROM (${main.trimEnd()}) WHERE ROWNUM <= ${maxRows}${tail}`, applied: true, maxRows };
|
||||
return { sql: `${main.trimEnd()} FETCH FIRST ${maxRows} ROWS ONLY${tail}`, applied: true, maxRows };
|
||||
}
|
||||
|
||||
const offsetPos = findTopLevelKeyword(main, 'offset');
|
||||
|
||||
@@ -417,9 +417,16 @@ func buildObjectSequenceMetadataQueries(dbType string, dbName string) []objectMe
|
||||
switch dbType {
|
||||
case "oracle", "dameng":
|
||||
if strings.TrimSpace(dbName) == "" {
|
||||
return []objectMetadataQuerySpec{{sql: "SELECT SEQUENCE_NAME AS sequence_name FROM USER_SEQUENCES ORDER BY SEQUENCE_NAME"}}
|
||||
return []objectMetadataQuerySpec{
|
||||
{sql: "SELECT * FROM USER_SEQUENCES ORDER BY SEQUENCE_NAME"},
|
||||
{sql: "SELECT OBJECT_NAME AS sequence_name FROM USER_OBJECTS WHERE OBJECT_TYPE = 'SEQUENCE' ORDER BY OBJECT_NAME"},
|
||||
}
|
||||
}
|
||||
owner := strings.ToUpper(safeDbName)
|
||||
return []objectMetadataQuerySpec{
|
||||
{sql: fmt.Sprintf("SELECT * FROM ALL_SEQUENCES WHERE SEQUENCE_OWNER = '%s' ORDER BY SEQUENCE_NAME", owner)},
|
||||
{sql: fmt.Sprintf("SELECT OWNER AS schema_name, OBJECT_NAME AS sequence_name FROM ALL_OBJECTS WHERE OWNER = '%s' AND OBJECT_TYPE = 'SEQUENCE' ORDER BY OBJECT_NAME", owner)},
|
||||
}
|
||||
return []objectMetadataQuerySpec{{sql: fmt.Sprintf("SELECT SEQUENCE_OWNER AS schema_name, SEQUENCE_NAME AS sequence_name FROM ALL_SEQUENCES WHERE SEQUENCE_OWNER = '%s' ORDER BY SEQUENCE_NAME", strings.ToUpper(safeDbName))}}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
39
internal/app/methods_db_objects_sequence_test.go
Normal file
39
internal/app/methods_db_objects_sequence_test.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBuildObjectSequenceMetadataQueriesOracleUsesWildcardAndObjectFallback(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
specs := buildObjectSequenceMetadataQueries("oracle", "sbdev")
|
||||
if len(specs) != 2 {
|
||||
t.Fatalf("expected 2 Oracle sequence metadata queries, got %d", len(specs))
|
||||
}
|
||||
if !strings.Contains(specs[0].sql, "SELECT * FROM ALL_SEQUENCES") {
|
||||
t.Fatalf("expected ALL_SEQUENCES wildcard query, got %q", specs[0].sql)
|
||||
}
|
||||
if strings.Contains(specs[0].sql, "LAST_NUMBER") || strings.Contains(specs[0].sql, "ORDER_FLAG") {
|
||||
t.Fatalf("query should not enumerate optional sequence columns: %q", specs[0].sql)
|
||||
}
|
||||
if !strings.Contains(specs[1].sql, "ALL_OBJECTS") || !strings.Contains(specs[1].sql, "OBJECT_TYPE = 'SEQUENCE'") {
|
||||
t.Fatalf("expected ALL_OBJECTS fallback query, got %q", specs[1].sql)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildObjectSequenceMetadataQueriesOracleUserFallbacks(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
specs := buildObjectSequenceMetadataQueries("oracle", "")
|
||||
if len(specs) != 2 {
|
||||
t.Fatalf("expected 2 Oracle user sequence metadata queries, got %d", len(specs))
|
||||
}
|
||||
if specs[0].sql != "SELECT * FROM USER_SEQUENCES ORDER BY SEQUENCE_NAME" {
|
||||
t.Fatalf("unexpected USER_SEQUENCES query: %q", specs[0].sql)
|
||||
}
|
||||
if !strings.Contains(specs[1].sql, "USER_OBJECTS") || !strings.Contains(specs[1].sql, "OBJECT_TYPE = 'SEQUENCE'") {
|
||||
t.Fatalf("expected USER_OBJECTS fallback query, got %q", specs[1].sql)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user