Compare commits

...

36 Commits

Author SHA1 Message Date
Syngnat
23c895e839 test(update): auto install macOS package after download 2026-07-08 13:43:13 +08:00
Syngnat
7a5960e737 fix(update): auto install macOS package after download 2026-07-08 13:41:45 +08:00
Syngnat
5c6110a57e feat(query-result): support global hidden columns 2026-07-08 12:29:58 +08:00
Syngnat
17843ffd71 chore: remove temporary file 2026-07-08 12:26:21 +08:00
Syngnat
7296ab0425 tmp stop 2026-07-08 12:26:05 +08:00
Syngnat
47664023ee chore: remove temporary file 2026-07-08 12:25:57 +08:00
Syngnat
33918c6837 no 2026-07-08 12:25:42 +08:00
Syngnat
66a6052f88 chore: remove temporary file 2026-07-08 12:25:35 +08:00
Syngnat
31b5b838a2 chore: tmp create pr 2026-07-08 12:25:23 +08:00
Syngnat
575a3d88b9 chore: remove temporary PR trigger file 2026-07-08 12:25:07 +08:00
Syngnat
bef811ce13 tmp 2026-07-08 12:24:53 +08:00
Syngnat
dee5620fc9 test(data-grid): cover global hidden column helpers 2026-07-08 12:24:27 +08:00
Syngnat
b1174861d4 feat(query-result): apply global hidden columns 2026-07-08 12:24:12 +08:00
Syngnat
a1f296ee08 feat(data-grid): expose global hidden column configuration 2026-07-08 12:20:54 +08:00
Syngnat
3e34795ef5 feat(data-grid): add global hidden column storage helpers 2026-07-08 12:19:47 +08:00
Syngnat
f73d8dcb97 fix(oceanbase): tolerate Oracle sequence metadata differences 2026-07-08 12:18:44 +08:00
Syngnat
82ad393a05 perf(oceanbase): use FETCH FIRST for Oracle auto limit 2026-07-08 12:18:32 +08:00
Syngnat
257406120d fix(editor): recover dropped printable Monaco input 2026-07-08 12:18:20 +08:00
Syngnat
9ba3216fa8 test(oceanbase): cover Oracle sequence metadata fallbacks 2026-07-08 12:05:48 +08:00
Syngnat
fca3f0cffb fix(oceanbase): make Oracle sequence metadata queries tolerant 2026-07-08 12:05:14 +08:00
Syngnat
5712b39cf9 test(oceanbase): expect Oracle auto limit FETCH FIRST 2026-07-08 11:52:06 +08:00
Syngnat
07dc1436af perf(oceanbase): use FETCH FIRST for Oracle auto limit 2026-07-08 11:51:05 +08:00
Syngnat
9e52397732 fix(editor): recover dropped printable Monaco input 2026-07-08 11:35:00 +08:00
Syngnat
20ff4f3687 fix(query-result): show capped result counts as exact totals 2026-07-08 11:32:01 +08:00
Syngnat
73ce3039ce fix(oceanbase): improve query limit and Oracle object links 2026-07-08 11:31:29 +08:00
Syngnat
61296bc855 chore: remove OceanBase follow-up note from focused fix 2026-07-08 10:44:43 +08:00
Syngnat
78da0076cf chore: remove temporary OceanBase follow-up note 2026-07-08 10:44:34 +08:00
Syngnat
279b38fec3 docs(oceanbase): note first implementation batch 2026-07-08 10:44:06 +08:00
Syngnat
0e348fcca4 test(query-editor): allow literal 100-row limit label 2026-07-08 10:43:46 +08:00
Syngnat
7d79fae84d fix(query-editor): use literal label for 100-row option 2026-07-08 10:43:02 +08:00
Syngnat
f642e0325d chore: remove temporary max rows i18n note 2026-07-08 10:40:20 +08:00
Syngnat
43a408f1c9 docs(query-editor): note 100-row option i18n key 2026-07-08 10:40:05 +08:00
Syngnat
b97b5cf6e2 docs(oceanbase): track remaining follow-up issues 2026-07-08 10:39:55 +08:00
Syngnat
6c269a99cf fix(oceanbase): support multi-part Oracle table links 2026-07-08 10:39:32 +08:00
Syngnat
24841f2dd9 test(query-editor): require 100-row limit label key 2026-07-08 10:37:10 +08:00
Syngnat
c2e60c1e52 feat(query-editor): add 100-row limit option 2026-07-08 10:36:38 +08:00
13 changed files with 535 additions and 434 deletions

View File

@@ -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;

View File

@@ -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 '';
@@ -126,10 +134,11 @@ const installOceanBaseOracleNavigationFallback = (editor: any) => {
}
const parts = splitSqlIdentifierPath(identifier.text);
if (parts.length !== 2) {
if (parts.length < 2) {
return;
}
const [schemaName, tableName] = parts;
const schemaName = parts[parts.length - 2];
const tableName = parts[parts.length - 1];
if (!schemaName || !tableName) {
return;
}
@@ -193,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;
@@ -290,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]);

View File

@@ -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>

View File

@@ -14,6 +14,7 @@ const legacyLiterals = [
'选择连接',
'选择数据库',
'最大返回行数',
'最大行数100',
'最大行数500',
'最大行数1000',
'最大行数5000',
@@ -64,6 +65,7 @@ describe('QueryEditorToolbar i18n', () => {
expect(source).toContain("import { useOptionalI18n } from '../i18n/provider';");
expect(source).toContain('const i18n = useOptionalI18n();');
expect(source).toContain('const t = i18n?.t ?? defaultTranslate;');
expect(source).toContain("{ label: '100', value: 100 }");
for (const key of requiredKeys) {
expect(source).toContain(key);

View File

@@ -291,6 +291,7 @@ const QueryEditorToolbar: React.FC<QueryEditorToolbarProps> = ({
value={maxRows}
onChange={(val) => onMaxRowsChange(Number(val))}
options={[
{ label: '100', value: 100 },
{ label: t("query_editor.max_rows.option_500"), value: 500 },
{ label: t("query_editor.max_rows.option_1000"), value: 1000 },
{ label: t("query_editor.max_rows.option_5000"), value: 5000 },

View File

@@ -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({

View File

@@ -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,

View 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_atid')).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']);
});
});

View 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)));
};

View File

@@ -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', () => {

View File

@@ -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');

View File

@@ -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
}

View 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)
}
}