Compare commits

...

72 Commits

Author SHA1 Message Date
Syngnat
dd01d1c3f9 fix(update): auto-install macOS updates after download 2026-07-08 13:49:00 +08:00
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
c27a038fc5 test(query-result): auto limit cap has exact page total 2026-07-08 11:08:03 +08:00
Syngnat
c3c4efc6ec fix(query-result): avoid not-counted summary for auto limit cap 2026-07-08 11:07:31 +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
Syngnat
7a87d43bdc chore: remove temporary macOS CI annotation cleanup workflow 2026-07-08 09:43:21 +08:00
Syngnat
5fa1c90674 chore: trigger macOS CI annotation cleanup workflow 2026-07-08 09:42:18 +08:00
Syngnat
c79225ef27 chore: add temporary macOS CI annotation cleanup workflow 2026-07-08 09:41:44 +08:00
Syngnat
0bd8fdea1f chore: remove temporary macOS CI annotation cleanup workflow 2026-07-08 09:40:30 +08:00
Syngnat
18dbd043ae chore: temporarily apply macOS CI annotation cleanup 2026-07-08 09:39:28 +08:00
Syngnat
a7535dec2b fix(update): repair Go syntax in Windows update cleanup 2026-07-08 08:46:42 +08:00
Syngnat
dbd4b7f763 fix(update): repair Go multiline boolean expression 2026-07-08 08:44:15 +08:00
Syngnat
68eacbbcf7 fix(update): preserve latest Windows portable filename 2026-07-07 22:47:05 +08:00
Syngnat
cecefce84d fix(update): keep script builder test compatibility 2026-07-07 21:35:02 +08:00
Syngnat
2406c1ff71 fix(update): relaunch versioned portable exe with new filename 2026-07-07 21:28:21 +08:00
Syngnat
a4a0ac25cf fix(update): clean Windows update packages and logs 2026-07-07 20:54:13 +08:00
Syngnat
fbb23f0b9a fix(update): import logger for cleanup launcher 2026-07-07 20:50:57 +08:00
Syngnat
016e2bc70e chore(update): remove unused cleanup imports 2026-07-07 20:46:14 +08:00
Syngnat
070c2328e0 test(update): cover Windows cleanup helpers 2026-07-07 20:43:40 +08:00
Syngnat
c117da42e4 fix(update): clean staged Windows packages and logs 2026-07-07 20:42:15 +08:00
Syngnat
950856d86f 合并拉取请求 #627
fix: improve OceanBase Oracle performance, navigation, and count UX
2026-07-07 17:51:42 +08:00
Syngnat
93a3240862 fix(query-editor): fallback ctrl-click for OB Oracle schema tables 2026-07-07 17:47:45 +08:00
Syngnat
1116a3736b fix(data-grid): bridge pagination count action to toolbar 2026-07-07 17:45:37 +08:00
Syngnat
19af57e46b chore: remove temporary patch workflow 2026-07-07 17:36:48 +08:00
Syngnat
401c33c505 chore: apply OB Oracle performance navigation count patches 2026-07-07 17:33:59 +08:00
Syngnat
8c968c1a87 feat(data-grid): expose total count action in pagination 2026-07-07 17:31:53 +08:00
Syngnat
4fee58999d perf(db): keep one idle Oracle-style connection 2026-07-07 17:29:17 +08:00
Syngnat
bd0752df96 test(oceanbase): keep Oracle default timeout unset 2026-07-07 17:28:57 +08:00
Syngnat
47a15a6674 fix(oceanbase): stop inflating Oracle query timeout by default 2026-07-07 17:28:10 +08:00
Syngnat
2a64bad76e fix(oceanbase): reduce Oracle long-query bad connection failures 2026-07-07 16:56:05 +08:00
Syngnat
7ee8698165 test(query-editor): cover bad connection timeout semantics 2026-07-07 16:50:57 +08:00
Syngnat
9ae35e5fec fix(query-editor): classify driver bad connection as timeout 2026-07-07 16:49:39 +08:00
Syngnat
b618927454 test(oceanbase): cover Oracle query timeout default 2026-07-07 16:48:27 +08:00
Syngnat
e8ad34d2f0 fix(oceanbase): extend default Oracle query timeout 2026-07-07 16:47:50 +08:00
Syngnat
007fdc9a5d fix(oceanbase): set current schema for Oracle tenant queries 2026-07-07 15:44:14 +08:00
Syngnat
61b62fe5af test(oceanbase): cover Oracle current schema init 2026-07-07 15:41:22 +08:00
Syngnat
d574e6b396 fix(oceanbase): set Oracle current schema for selected tenant 2026-07-07 15:40:36 +08:00
Syngnat
f798b183e6 fix(query-editor): prevent AI inline completion from trapping cursor 2026-07-07 15:14:30 +08:00
23 changed files with 1406 additions and 439 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

@@ -1,6 +1,6 @@
import React from 'react';
import { Button, InputNumber, Pagination, Select } from 'antd';
import { LeftOutlined, RightOutlined } from '@ant-design/icons';
import { CloseOutlined, LeftOutlined, RightOutlined, VerticalAlignBottomOutlined } from '@ant-design/icons';
import { t as defaultTranslate, type I18nParams } from '../i18n';
interface DataGridPaginationState {
@@ -26,12 +26,31 @@ export interface DataGridPaginationBarProps {
paginationPageText: string;
paginationPageSizeOptions: string[];
showKnownPageCount: boolean;
manualTotalCountAvailable?: boolean;
totalCountLoading?: boolean;
onPageChange?: (page: number, size: number) => void;
onPageSizeChange: (value: string) => void;
onV2PageStep: (direction: 'previous' | 'next') => void;
onToggleTotalCount?: () => void;
translate?: DataGridPaginationTranslate;
}
const findToolbarTotalCountButton = (
trigger: HTMLElement,
labels: string[],
): HTMLButtonElement | null => {
const root = trigger.closest('.data-grid-root') || trigger.ownerDocument?.body;
if (!root) return null;
const normalizedLabels = labels.map((label) => String(label || '').trim()).filter(Boolean);
const buttons = Array.from(root.querySelectorAll('button')) as HTMLButtonElement[];
return buttons.find((button) => {
if (button === trigger) return false;
if (button.disabled) return false;
const text = String(button.textContent || '').replace(/\s+/g, ' ').trim();
return normalizedLabels.some((label) => text === label || text.includes(label));
}) || null;
};
const DataGridPaginationBar: React.FC<DataGridPaginationBarProps> = ({
isV2Ui,
pagination,
@@ -42,9 +61,12 @@ const DataGridPaginationBar: React.FC<DataGridPaginationBarProps> = ({
paginationPageText,
paginationPageSizeOptions,
showKnownPageCount,
manualTotalCountAvailable = false,
totalCountLoading = false,
onPageChange,
onPageSizeChange,
onV2PageStep,
onToggleTotalCount,
translate = defaultTranslate,
}) => {
const [jumpPage, setJumpPage] = React.useState<number | null>(pagination?.current ?? null);
@@ -58,6 +80,37 @@ const DataGridPaginationBar: React.FC<DataGridPaginationBarProps> = ({
return null;
}
const countTotalLabel = translate('data_grid.toolbar.count_total');
const cancelCountLabel = translate('data_grid.toolbar.cancel_count');
const effectiveTotalCountLoading = totalCountLoading || Boolean(pagination.totalCountLoading);
const shouldShowTotalCountButton = Boolean(
onToggleTotalCount
|| manualTotalCountAvailable
|| pagination.totalCountLoading
|| pagination.totalKnown === false,
);
const handleToggleTotalCount = (event: React.MouseEvent<HTMLButtonElement>) => {
if (onToggleTotalCount) {
onToggleTotalCount();
return;
}
// Backward-compatible bridge for existing DataGridShell callers: the top toolbar already owns
// the total-count handler, but it can be horizontally scrolled out of view on large toolbars.
// Trigger that existing button so the pagination bar can expose the action without duplicating data-flow state.
const toolbarButton = findToolbarTotalCountButton(event.currentTarget, [countTotalLabel, cancelCountLabel]);
toolbarButton?.click();
};
const totalCountButton = shouldShowTotalCountButton ? (
<Button
data-grid-pagination-total-count="true"
size="small"
icon={effectiveTotalCountLoading ? <CloseOutlined /> : <VerticalAlignBottomOutlined />}
onClick={handleToggleTotalCount}
>
{effectiveTotalCountLoading ? cancelCountLabel : countTotalLabel}
</Button>
) : null;
const maxJumpPage = showKnownPageCount ? Math.max(1, paginationTotalPages) : null;
const normalizedJumpPage = Number.isFinite(Number(jumpPage)) && Number(jumpPage) > 0
? (maxJumpPage !== null
@@ -132,6 +185,7 @@ const DataGridPaginationBar: React.FC<DataGridPaginationBarProps> = ({
<div className="data-grid-pagination-summary" aria-live="polite">
<span className="data-grid-pagination-summary-value">{paginationV2SummaryText}</span>
</div>
{totalCountButton}
<Button
data-grid-v2-pagination-prev="true"
size="small"
@@ -174,6 +228,7 @@ const DataGridPaginationBar: React.FC<DataGridPaginationBarProps> = ({
<span className="data-grid-pagination-kicker">{translate('data_grid.pagination.result_set')}</span>
<span className="data-grid-pagination-summary-value">{paginationSummaryText}</span>
</div>
{totalCountButton}
{showSequentialPagination ? sequentialPaginationControl : (
<Pagination
current={pagination.current}

View File

@@ -24,6 +24,149 @@ 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 '';
if ((text.startsWith('`') && text.endsWith('`'))
|| (text.startsWith('"') && text.endsWith('"'))
|| (text.startsWith('[') && text.endsWith(']'))) {
return text.slice(1, -1).trim();
}
return text;
};
const splitSqlIdentifierPath = (raw: string): string[] => (
String(raw || '')
.split('.')
.map(stripSqlIdentifierQuotes)
.map((part) => part.trim())
.filter(Boolean)
);
const resolveIdentifierWindowAtColumn = (
lineContent: string,
column: number,
): { start: number; end: number; text: string } | null => {
const text = String(lineContent || '');
if (!text) return null;
const isIdentChar = (ch: string) => /[A-Za-z0-9_$`"\[\].]/.test(ch || '');
let offset = Math.max(0, Math.min(text.length - 1, Number(column || 1) - 2));
if (!isIdentChar(text[offset] || '')) {
if (offset > 0 && isIdentChar(text[offset - 1] || '')) {
offset -= 1;
} else if (offset + 1 < text.length && isIdentChar(text[offset + 1] || '')) {
offset += 1;
} else {
return null;
}
}
let start = offset;
while (start > 0 && isIdentChar(text[start - 1] || '')) start -= 1;
let end = offset + 1;
while (end < text.length && isIdentChar(text[end] || '')) end += 1;
return start < end ? { start, end, text: text.slice(start, end).trim() } : null;
};
const isLikelyTableReferenceIdentifier = (
lineContent: string,
identifierStart: number,
): boolean => {
const beforeIdentifier = String(lineContent || '').slice(0, Math.max(0, identifierStart));
return /\b(?:from|join|update|into|delete\s+from|alter\s+table|drop\s+table|truncate\s+table)\s*$/i.test(beforeIdentifier);
};
const isOceanBaseOracleConnection = (connection: any): boolean => {
const config = connection?.config || {};
return String(config.type || '').trim().toLowerCase() === 'oceanbase'
&& String(config.oceanBaseProtocol || '').trim().toLowerCase() === 'oracle';
};
const installOceanBaseOracleNavigationFallback = (editor: any) => {
const editorDomNode = editor?.getDomNode?.();
if (!editorDomNode || editor.__gonaviObOracleNavigationFallbackInstalled) {
return;
}
Object.defineProperty(editor, '__gonaviObOracleNavigationFallbackInstalled', {
value: true,
configurable: true,
});
const handleMouseDownCapture = (event: MouseEvent) => {
if (event.button !== 0 || !(event.ctrlKey || event.metaKey) || event.altKey) {
return;
}
const store = useStore.getState();
const activeTab = (store.tabs || []).find((tab: any) => tab.id === store.activeTabId);
if (!activeTab || activeTab.type !== 'query') {
return;
}
const connectionId = String(activeTab.connectionId || store.activeContext?.connectionId || '').trim();
if (!connectionId) {
return;
}
const connection = (store.connections || []).find((item: any) => item.id === connectionId);
if (!isOceanBaseOracleConnection(connection)) {
return;
}
const target = editor.getTargetAtClientPoint?.(event.clientX, event.clientY);
const position = target?.position;
if (!position) {
return;
}
const model = editor.getModel?.();
const lineContent = String(model?.getLineContent?.(position.lineNumber) || '');
const identifier = resolveIdentifierWindowAtColumn(lineContent, position.column);
if (!identifier || !identifier.text.includes('.')) {
return;
}
if (!isLikelyTableReferenceIdentifier(lineContent, identifier.start)) {
return;
}
const parts = splitSqlIdentifierPath(identifier.text);
if (parts.length < 2) {
return;
}
const schemaName = parts[parts.length - 2];
const tableName = parts[parts.length - 1];
if (!schemaName || !tableName) {
return;
}
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation?.();
store.setActiveContext?.({ connectionId, dbName: schemaName });
store.addTab?.({
id: `${connectionId}-${schemaName}-table-${tableName}`,
title: tableName,
type: 'table',
connectionId,
dbName: schemaName,
tableName,
initialViewMode: 'fields',
initialViewModeRequestId: String(Date.now()),
objectType: 'table',
returnToTabId: activeTab.id || undefined,
});
};
editorDomNode.addEventListener('mousedown', handleMouseDownCapture, true);
editor.onDidDispose?.(() => {
editorDomNode.removeEventListener('mousedown', handleMouseDownCapture, true);
});
};
const patchQueryEditorAiInlineRightArrowFallback = (editor: any, monaco: any) => {
const originalAddCommand = editor?.addCommand?.bind?.(editor);
if (!originalAddCommand || !monaco?.KeyCode?.RightArrow) {
@@ -59,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;
@@ -154,7 +363,9 @@ const MonacoEditor: React.FC<MonacoEditorProps> = ({
}, [beforeMount]);
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

@@ -26,6 +26,25 @@ describe('queryResultPagination', () => {
});
});
it('treats query-editor injected LIMIT as a capped page rather than an uncounted total', () => {
const page = createInitialQueryResultPagination({
executedSql: 'SELECT id, name FROM users LIMIT 500',
exportSql: 'SELECT id, name FROM users',
dbType: 'mysql',
returnedRowCount: 500,
fallbackPageSize: 500,
});
expect(page).toMatchObject({
current: 1,
pageSize: 500,
total: 500,
totalKnown: true,
baseSql: 'SELECT id, name FROM users',
exportAllSql: 'SELECT id, name FROM users',
});
});
it('builds the next page SQL with one lookahead row', () => {
expect(buildQueryResultPageSql({
baseSql: 'SELECT id FROM users',

View File

@@ -22,6 +22,14 @@ const normalizePositiveInteger = (value: unknown): number => {
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
};
const normalizeSqlForComparison = (sql: string): string => (
String(sql || '')
.replace(/\s+/g, ' ')
.replace(/;+\s*$/g, '')
.trim()
.toLowerCase()
);
const parseTopLevelLimit = (sql: string): LimitInfo | null => {
const { main } = splitSqlTail(sql);
const limitPos = findTopLevelKeyword(main, 'limit');
@@ -60,6 +68,14 @@ const stripExplicitLimitForExport = (sql: string): string => {
return splitSqlTail(sql).main.trim();
};
const wasLimitAppliedByQueryEditorCap = (executedSql: string, exportSql: string): boolean => {
const executed = String(executedSql || '').trim();
const exportable = String(exportSql || '').trim();
if (!executed || !exportable) return false;
if (normalizeSqlForComparison(executed) === normalizeSqlForComparison(exportable)) return false;
return normalizeSqlForComparison(stripExplicitLimitForExport(executed)) === normalizeSqlForComparison(stripExplicitLimitForExport(exportable));
};
const resolveWrappedBaseSql = (dbType: string, baseSql: string): string => {
const normalizedType = String(dbType || '').trim().toLowerCase();
const base = baseSql.trim();
@@ -146,11 +162,14 @@ export const createInitialQueryResultPagination = (params: {
const exportAllSql = exportSql && getLeadingKeyword(exportSql) === 'select'
? stripExplicitLimitForExport(exportSql)
: stripExplicitLimitForExport(executedSql);
const totalState = resolveQueryResultPaginationTotal({
current,
pageSize,
rowCount: returnedRowCount,
});
const autoLimitCap = current === 1 && wasLimitAppliedByQueryEditorCap(executedSql, exportSql);
const totalState = autoLimitCap
? { total: returnedRowCount, totalKnown: true }
: resolveQueryResultPaginationTotal({
current,
pageSize,
rowCount: returnedRowCount,
});
return {
current,

View File

@@ -37,6 +37,13 @@ describe('formatSqlExecutionError', () => {
expect(formatted).toContain('Raw error: driver returned unexpected status 123');
});
it('recognizes driver bad connection during SQL execution as timeout semantics', () => {
const formatted = formatSqlExecutionError('第 1 条语句执行失败driver: bad connection');
expect(formatted).toContain('Semantic meaning: Query timed out or was canceled');
expect(formatted).toContain('Raw error: 第 1 条语句执行失败driver: bad connection');
});
it('recognizes localized connection-timeout wrappers as timeout semantics', () => {
const translate = (key: string, params?: Record<string, unknown>) => {
if (key === 'query_editor.sql_error.wrapper.semantic_line') {

View File

@@ -135,6 +135,7 @@ const SQL_ERROR_RULES: SqlErrorSemanticRule[] = [
/context cancelled/i,
/timeout/i,
/timed out/i,
/driver:\s*bad connection/i,
],
},
{

View File

@@ -1,6 +1,7 @@
package app
import (
"net/url"
"strconv"
"strings"
@@ -23,7 +24,9 @@ func normalizeRunConfig(config connection.ConnectionConfig, dbName string) conne
case "kafka", "apache-kafka", "apache_kafka":
// Kafka 的 Database 字段表示默认 Topic不能被树上的 synthetic database(topics) 覆盖。
case "oceanbase":
if !isOceanBaseOracleProtocol(config) {
if isOceanBaseOracleProtocol(config) {
runConfig = applyOceanBaseOracleCurrentSchemaInit(runConfig, name)
} else {
runConfig.Database = name
}
case "mysql", "mariadb", "goldendb", "greatdb", "gdb", "diros", "starrocks", "sphinx", "postgres", "kingbase", "highgo", "vastbase", "opengauss", "gaussdb", "sqlserver", "iris", "intersystems", "intersystemsiris", "inter-systems", "inter-systems-iris", "mongodb", "tdengine", "iotdb", "clickhouse", "trino", "rabbitmq", "rabbit-mq", "rabbit_mq":
@@ -46,6 +49,59 @@ func normalizeRunConfig(config connection.ConnectionConfig, dbName string) conne
return runConfig
}
func applyOceanBaseOracleCurrentSchemaInit(config connection.ConnectionConfig, schema string) connection.ConnectionConfig {
normalizedSchema := strings.TrimSpace(schema)
if normalizedSchema == "" {
return config
}
values, err := url.ParseQuery(strings.TrimSpace(config.ConnectionParams))
if err != nil {
return config
}
statement := "ALTER SESSION SET CURRENT_SCHEMA = " + quoteOracleCurrentSchemaIdentifier(normalizedSchema)
for _, existing := range values["init"] {
if strings.EqualFold(strings.TrimSpace(existing), statement) {
return config
}
}
values.Add("init", statement)
config.ConnectionParams = values.Encode()
return config
}
func quoteOracleCurrentSchemaIdentifier(schema string) string {
normalized := strings.TrimSpace(schema)
if normalized == "" {
return normalized
}
if isSimpleOracleIdentifier(normalized) {
return strings.ToUpper(normalized)
}
return `"` + strings.ReplaceAll(normalized, `"`, `""`) + `"`
}
func isSimpleOracleIdentifier(value string) bool {
text := strings.TrimSpace(value)
if text == "" {
return false
}
for index, r := range text {
isLetter := (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z')
isDigit := r >= '0' && r <= '9'
isSpecial := r == '_' || r == '$' || r == '#'
if index == 0 {
if !isLetter && r != '_' {
return false
}
continue
}
if !isLetter && !isDigit && !isSpecial {
return false
}
}
return true
}
func normalizeSchemaAndTable(config connection.ConnectionConfig, dbName string, tableName string) (string, string) {
rawTable := strings.TrimSpace(tableName)
rawDB := strings.TrimSpace(dbName)

View File

@@ -0,0 +1,92 @@
package app
import (
"net/url"
"testing"
"GoNavi-Wails/internal/connection"
)
func TestNormalizeRunConfig_OceanBaseOracleAddsCurrentSchemaInit(t *testing.T) {
t.Parallel()
config := connection.ConnectionConfig{
Type: "oceanbase",
Database: "OBORCL",
OceanBaseProtocol: "oracle",
}
runConfig := normalizeRunConfig(config, "sbdev")
if runConfig.Database != "OBORCL" {
t.Fatalf("expected OceanBase Oracle service name to stay OBORCL, got %q", runConfig.Database)
}
if runConfig.Timeout != 0 {
t.Fatalf("expected default OceanBase Oracle timeout to stay unset, got %d", runConfig.Timeout)
}
values, err := url.ParseQuery(runConfig.ConnectionParams)
if err != nil {
t.Fatalf("unexpected connection params parse error: %v", err)
}
initValues := values["init"]
if len(initValues) != 1 || initValues[0] != "ALTER SESSION SET CURRENT_SCHEMA = SBDEV" {
t.Fatalf("expected current schema init for selected schema, got %#v", initValues)
}
}
func TestNormalizeRunConfig_OceanBaseOraclePreservesExplicitTimeoutAndExistingInit(t *testing.T) {
t.Parallel()
config := connection.ConnectionConfig{
Type: "oceanbase",
Database: "OBORCL",
OceanBaseProtocol: "oracle",
Timeout: 45,
ConnectionParams: "init=ALTER+SESSION+SET+NLS_DATE_FORMAT%3D%27YYYY-MM-DD%27&timeout=10s",
}
runConfig := normalizeRunConfig(config, "sbdev")
if runConfig.Timeout != 45 {
t.Fatalf("expected explicit timeout to be preserved, got %d", runConfig.Timeout)
}
values, err := url.ParseQuery(runConfig.ConnectionParams)
if err != nil {
t.Fatalf("unexpected connection params parse error: %v", err)
}
initValues := values["init"]
if len(initValues) != 2 {
t.Fatalf("expected existing init plus current schema init, got %#v", initValues)
}
if initValues[0] != "ALTER SESSION SET NLS_DATE_FORMAT='YYYY-MM-DD'" {
t.Fatalf("expected existing init to stay first, got %#v", initValues)
}
if initValues[1] != "ALTER SESSION SET CURRENT_SCHEMA = SBDEV" {
t.Fatalf("expected current schema init appended, got %#v", initValues)
}
if values.Get("timeout") != "10s" {
t.Fatalf("expected non-init connection param preserved, got %q", values.Get("timeout"))
}
}
func TestQuoteOracleCurrentSchemaIdentifier(t *testing.T) {
t.Parallel()
cases := []struct {
name string
raw string
want string
}{
{name: "lower simple", raw: "sbdev", want: "SBDEV"},
{name: "normal uppercase", raw: "SBDEV", want: "SBDEV"},
{name: "quoted mixed case required", raw: "Sb Dev", want: `"Sb Dev"`},
{name: "quote escaping", raw: `A"B`, want: `"A""B"`},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := quoteOracleCurrentSchemaIdentifier(tc.raw); got != tc.want {
t.Fatalf("expected %q, got %q", tc.want, got)
}
})
}
}

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

View File

@@ -0,0 +1,363 @@
package app
import (
"io"
"os"
"path/filepath"
stdRuntime "runtime"
"strconv"
"strings"
"GoNavi-Wails/internal/logger"
)
func init() {
updateLaunchInstallScript = launchUpdateScriptWithCleanup
}
func launchUpdateScriptWithCleanup(staged *stagedUpdate) error {
exePath, err := os.Executable()
if err != nil {
return err
}
exePath, _ = filepath.EvalSymlinks(exePath)
pid := os.Getpid()
if stdRuntime.GOOS == "windows" {
return launchWindowsUpdateWithCleanup(staged, exePath, pid)
}
return launchUpdateScript(staged)
}
func launchWindowsUpdateWithCleanup(staged *stagedUpdate, targetExe string, pid int) error {
if staged == nil {
return localizedUpdateError{key: "app.update.backend.message.no_downloaded_package"}
}
if err := os.MkdirAll(staged.StagedDir, 0o755); err != nil {
return err
}
currentTargetExe := strings.TrimSpace(targetExe)
originalSourceDir := strings.TrimSpace(filepath.Dir(staged.FilePath))
preparedSource, err := prepareWindowsStagedUpdateAsset(staged.FilePath, staged.StagedDir)
if err != nil {
return err
}
staged.FilePath = preparedSource
staged.InstallLogPath = buildUpdateInstallLogPath(staged.StagedDir)
finalTargetExe := resolveWindowsUpdateFinalTargetPath(currentTargetExe, staged.FilePath)
cleanupWindowsUpdateArtifacts([]string{
originalSourceDir,
strings.TrimSpace(filepath.Dir(currentTargetExe)),
strings.TrimSpace(filepath.Dir(finalTargetExe)),
}, map[string]struct{}{
cleanComparablePath(currentTargetExe): {},
cleanComparablePath(finalTargetExe): {},
cleanComparablePath(staged.FilePath): {},
cleanComparablePath(staged.StagedDir): {},
})
scriptPath := filepath.Join(staged.StagedDir, "update.cmd")
content := buildWindowsScriptWithCurrentTargetCleanup(staged.FilePath, finalTargetExe, currentTargetExe, staged.StagedDir, staged.InstallLogPath, pid)
if err := os.WriteFile(scriptPath, []byte(content), 0o644); err != nil {
return err
}
logger.Infof("启动 Windows 更新脚本current=%s target=%s script=%s log=%s", currentTargetExe, finalTargetExe, scriptPath, staged.InstallLogPath)
cmd := buildWindowsLaunchCommand(scriptPath)
if err := cmd.Start(); err != nil {
return err
}
if cmd.Process != nil {
if err := cmd.Process.Release(); err != nil {
logger.Warnf("释放 Windows 更新脚本进程句柄失败:%v", err)
}
}
return nil
}
func resolveWindowsUpdateFinalTargetPath(currentTarget string, sourcePath string) string {
currentTarget = strings.TrimSpace(currentTarget)
if currentTarget == "" {
return currentTarget
}
currentName := filepath.Base(currentTarget)
sourceName := filepath.Base(strings.TrimSpace(sourcePath))
if isVersionedWindowsUpdatePackageName(currentName) && isVersionedWindowsUpdatePackageName(sourceName) {
return filepath.Join(filepath.Dir(currentTarget), sourceName)
}
return currentTarget
}
func isVersionedWindowsUpdatePackageName(name string) bool {
trimmed := strings.TrimSpace(name)
lower := strings.ToLower(trimmed)
return strings.HasPrefix(trimmed, "GoNavi-") &&
strings.Contains(trimmed, "-Windows-") &&
strings.HasSuffix(lower, ".exe")
}
func prepareWindowsStagedUpdateAsset(sourcePath string, stagedDir string) (string, error) {
sourcePath = strings.TrimSpace(sourcePath)
stagedDir = strings.TrimSpace(stagedDir)
if sourcePath == "" || stagedDir == "" {
return sourcePath, nil
}
if isUpdateAssetPathInsideStagedDir(sourcePath, stagedDir) {
return sourcePath, nil
}
if err := os.MkdirAll(stagedDir, 0o755); err != nil {
return "", err
}
targetPath := filepath.Join(stagedDir, filepath.Base(sourcePath))
if cleanComparablePath(sourcePath) == cleanComparablePath(targetPath) {
return sourcePath, nil
}
_ = os.Remove(targetPath)
if err := os.Rename(sourcePath, targetPath); err == nil {
return targetPath, nil
}
if err := copyFileForWindowsUpdate(sourcePath, targetPath); err != nil {
return "", err
}
_ = os.Remove(sourcePath)
return targetPath, nil
}
func copyFileForWindowsUpdate(sourcePath string, targetPath string) error {
in, err := os.Open(sourcePath)
if err != nil {
return err
}
defer in.Close()
out, err := os.OpenFile(targetPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
if err != nil {
return err
}
_, copyErr := io.Copy(out, in)
closeErr := out.Close()
if copyErr != nil {
return copyErr
}
return closeErr
}
func cleanupWindowsUpdateArtifacts(dirs []string, keep map[string]struct{}) {
seen := map[string]struct{}{}
for _, dir := range dirs {
dir = strings.TrimSpace(dir)
if dir == "" || dir == "." {
continue
}
cleanDir := cleanComparablePath(dir)
if cleanDir == "" {
continue
}
if _, ok := seen[cleanDir]; ok {
continue
}
seen[cleanDir] = struct{}{}
entries, err := os.ReadDir(dir)
if err != nil {
continue
}
for _, entry := range entries {
path := filepath.Join(dir, entry.Name())
cleanPath := cleanComparablePath(path)
if cleanPath == "" {
continue
}
if _, ok := keep[cleanPath]; ok {
continue
}
if shouldRemoveWindowsUpdateArtifact(entry.Name(), entry.IsDir()) {
if entry.IsDir() {
_ = os.RemoveAll(path)
} else {
_ = os.Remove(path)
}
}
}
}
}
func shouldRemoveWindowsUpdateArtifact(name string, isDir bool) bool {
trimmed := strings.TrimSpace(name)
lower := strings.ToLower(trimmed)
if trimmed == "" {
return false
}
if isDir {
return strings.HasPrefix(lower, ".gonavi-update-")
}
if strings.HasPrefix(lower, "gonavi-update-") && strings.HasSuffix(lower, ".log") {
return true
}
if !strings.HasPrefix(trimmed, "GoNavi-") {
return false
}
if !strings.Contains(trimmed, "-Windows-") {
return false
}
return strings.HasSuffix(lower, ".exe") || strings.HasSuffix(lower, ".zip")
}
func cleanComparablePath(path string) string {
path = strings.TrimSpace(path)
if path == "" {
return ""
}
cleaned := filepath.Clean(path)
if cleaned == "." {
return ""
}
if stdRuntime.GOOS == "windows" {
return strings.ToLower(cleaned)
}
return cleaned
}
func buildWindowsScriptWithCleanup(source, target, stagedDir, logPath string, pid int) string {
return buildWindowsScriptWithCurrentTargetCleanup(source, target, target, stagedDir, logPath, pid)
}
func buildWindowsScriptWithCurrentTargetCleanup(source, target, currentTarget, stagedDir, logPath string, pid int) string {
script := `@echo off
setlocal EnableExtensions EnableDelayedExpansion
set "SOURCE=__GONAVI_UPDATE_SOURCE__"
set "TARGET=__GONAVI_UPDATE_TARGET__"
set "CURRENT_TARGET=__GONAVI_CURRENT_TARGET__"
set "TARGET_OLD=%TARGET%.old"
set "STAGED=__GONAVI_UPDATE_STAGED__"
set "LOG_FILE=__GONAVI_UPDATE_LOG__"
set PID=__GONAVI_UPDATE_PID__
set /a WAIT_PID_SECONDS=0
call :log updater started
if not exist "%SOURCE%" (
call :log source file not found: %SOURCE%
exit /b 1
)
for %%I in ("%TARGET%") do set "TARGET_NAME=%%~nxI"
for %%I in ("%TARGET%") do set "TARGET_DIR=%%~dpI"
for %%I in ("%SOURCE%") do set "SOURCE_EXT=%%~xI"
set "SOURCE_EXE="
if /I "%SOURCE_EXT%"==".zip" (
set "EXTRACT_DIR=%STAGED%\_extract"
if exist "%EXTRACT_DIR%" (
rmdir /S /Q "%EXTRACT_DIR%" >> "%LOG_FILE%" 2>&1
)
mkdir "%EXTRACT_DIR%" >> "%LOG_FILE%" 2>&1
powershell -NoProfile -ExecutionPolicy Bypass -Command "$src=$env:SOURCE; $dst=$env:EXTRACT_DIR; Expand-Archive -LiteralPath $src -DestinationPath $dst -Force" >> "%LOG_FILE%" 2>&1
if !ERRORLEVEL! NEQ 0 (
call :log expand zip failed: %SOURCE%
exit /b 1
)
if exist "%EXTRACT_DIR%\%TARGET_NAME%" (
set "SOURCE_EXE=%EXTRACT_DIR%\%TARGET_NAME%"
) else (
for /R "%EXTRACT_DIR%" %%F in (*.exe) do (
if not defined SOURCE_EXE (
set "SOURCE_EXE=%%~fF"
)
)
)
if not defined SOURCE_EXE (
call :log no executable found in portable zip: %SOURCE%
exit /b 1
)
) else (
set "SOURCE_EXE=%SOURCE%"
)
:waitloop
tasklist /FI "PID eq %PID%" | find "%PID%" >nul
if %ERRORLEVEL%==0 (
if !WAIT_PID_SECONDS! GEQ 90 (
call :log host process still running after !WAIT_PID_SECONDS! seconds, aborting update
exit /b 1
)
timeout /t 1 /nobreak >nul
set /a WAIT_PID_SECONDS+=1
goto waitloop
)
call :log host process exited
timeout /t 3 /nobreak >nul
call :log cooldown finished, starting file replace
:replace_binary
if /I "%SOURCE_EXE%"=="%TARGET%" (
call :log downloaded executable already at target path, skip replace
goto move_done
)
set /a RETRY=0
:move_retry
call :log attempt !RETRY!: trying rename-then-copy strategy
move /Y "%TARGET%" "%TARGET_OLD%" >> "%LOG_FILE%" 2>&1
if !ERRORLEVEL!==0 (
copy /Y "%SOURCE_EXE%" "%TARGET%" >> "%LOG_FILE%" 2>&1
if !ERRORLEVEL!==0 (
del /F /Q "%TARGET_OLD%" >> "%LOG_FILE%" 2>&1
goto move_done
)
call :log copy after rename failed, restoring old file
move /Y "%TARGET_OLD%" "%TARGET%" >> "%LOG_FILE%" 2>&1
)
call :log rename strategy failed, trying direct move
move /Y "%SOURCE_EXE%" "%TARGET%" >> "%LOG_FILE%" 2>&1
if %ERRORLEVEL%==0 goto move_done
copy /Y "%SOURCE_EXE%" "%TARGET%" >> "%LOG_FILE%" 2>&1
if %ERRORLEVEL%==0 goto move_done
set /a RETRY+=1
if !RETRY! LSS 15 (
set /a WAIT=1
if !RETRY! GEQ 3 set /a WAIT=2
if !RETRY! GEQ 6 set /a WAIT=3
if !RETRY! GEQ 9 set /a WAIT=5
call :log waiting !WAIT! seconds before retry
timeout /t !WAIT! /nobreak >nul
goto move_retry
)
call :log replace failed after retries (portable mode, no elevation): check directory write permission or file lock
exit /b 1
:move_done
del /F /Q "%TARGET_OLD%" >> "%LOG_FILE%" 2>&1
if /I not "%CURRENT_TARGET%"=="%TARGET%" (
if exist "%CURRENT_TARGET%" del /F /Q "%CURRENT_TARGET%" >> "%LOG_FILE%" 2>&1
)
if exist "%SOURCE%" del /F /Q "%SOURCE%" >> "%LOG_FILE%" 2>&1
start "" /D "%TARGET_DIR%" "%TARGET%" >> "%LOG_FILE%" 2>&1
if %ERRORLEVEL% NEQ 0 (
call :log cmd start failed, trying powershell Start-Process
powershell -NoProfile -ExecutionPolicy Bypass -Command "Start-Process -FilePath '%TARGET%' -WorkingDirectory '%TARGET_DIR%'" >> "%LOG_FILE%" 2>&1
if !ERRORLEVEL! NEQ 0 (
call :log relaunch failed
exit /b 1
)
)
call :log update finished
start "" /MIN cmd.exe /D /C "timeout /t 2 /nobreak >nul & del /F /Q ""%LOG_FILE%"" >nul 2>&1 & rmdir /S /Q ""%STAGED%"" >nul 2>&1"
exit /b 0
:log
echo [%date% %time%] %*>>"%LOG_FILE%"
exit /b 0
`
return strings.NewReplacer(
"__GONAVI_UPDATE_SOURCE__", source,
"__GONAVI_UPDATE_TARGET__", target,
"__GONAVI_CURRENT_TARGET__", currentTarget,
"__GONAVI_UPDATE_STAGED__", stagedDir,
"__GONAVI_UPDATE_LOG__", logPath,
"__GONAVI_UPDATE_PID__", strconv.Itoa(pid),
).Replace(strings.ReplaceAll(script, "\n", "\r\n"))
}

View File

@@ -0,0 +1,113 @@
package app
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestShouldRemoveWindowsUpdateArtifact(t *testing.T) {
cases := []struct {
name string
isDir bool
want bool
}{
{name: "GoNavi-dev-abc-Windows-Amd64.exe", want: true},
{name: "GoNavi-0.8.4-Windows-Amd64.zip", want: true},
{name: "gonavi-update-windows-123.log", want: true},
{name: ".gonavi-update-windows-dev-dev-abc", isDir: true, want: true},
{name: "GoNavi-dev-abc-MacOS-Arm64.dmg", want: false},
{name: "GoNavi.exe", want: false},
{name: "notes.log", want: false},
}
for _, tc := range cases {
if got := shouldRemoveWindowsUpdateArtifact(tc.name, tc.isDir); got != tc.want {
t.Fatalf("shouldRemoveWindowsUpdateArtifact(%q, %v) = %v, want %v", tc.name, tc.isDir, got, tc.want)
}
}
}
func TestCleanupWindowsUpdateArtifactsKeepsCurrentTargetAndRemovesStalePackages(t *testing.T) {
dir := t.TempDir()
currentTarget := filepath.Join(dir, "GoNavi-dev-current-Windows-Amd64.exe")
currentPackage := filepath.Join(dir, "GoNavi-dev-new-Windows-Amd64.exe")
stalePackage := filepath.Join(dir, "GoNavi-dev-old-Windows-Amd64.exe")
staleLog := filepath.Join(dir, "gonavi-update-windows-123.log")
staleStage := filepath.Join(dir, ".gonavi-update-windows-dev-old")
otherFile := filepath.Join(dir, "notes.txt")
for _, path := range []string{currentTarget, currentPackage, stalePackage, staleLog, otherFile} {
if err := os.WriteFile(path, []byte("x"), 0o644); err != nil {
t.Fatalf("WriteFile(%q) returned error: %v", path, err)
}
}
if err := os.MkdirAll(staleStage, 0o755); err != nil {
t.Fatalf("MkdirAll returned error: %v", err)
}
cleanupWindowsUpdateArtifacts([]string{dir}, map[string]struct{}{
cleanComparablePath(currentTarget): {},
cleanComparablePath(currentPackage): {},
})
for _, path := range []string{currentTarget, currentPackage, otherFile} {
if _, err := os.Stat(path); err != nil {
t.Fatalf("expected %q to remain: %v", path, err)
}
}
for _, path := range []string{stalePackage, staleLog, staleStage} {
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatalf("expected %q to be removed, stat err=%v", path, err)
}
}
}
func TestPrepareWindowsStagedUpdateAssetMovesPackageIntoStagedDir(t *testing.T) {
dir := t.TempDir()
stagedDir := filepath.Join(dir, ".gonavi-update-windows-dev-new")
source := filepath.Join(dir, "GoNavi-dev-new-Windows-Amd64.exe")
if err := os.WriteFile(source, []byte("payload"), 0o644); err != nil {
t.Fatalf("WriteFile returned error: %v", err)
}
prepared, err := prepareWindowsStagedUpdateAsset(source, stagedDir)
if err != nil {
t.Fatalf("prepareWindowsStagedUpdateAsset returned error: %v", err)
}
want := filepath.Join(stagedDir, filepath.Base(source))
if prepared != want {
t.Fatalf("expected prepared path %q, got %q", want, prepared)
}
if _, err := os.Stat(source); !os.IsNotExist(err) {
t.Fatalf("expected source to be moved away, stat err=%v", err)
}
if data, err := os.ReadFile(prepared); err != nil || string(data) != "payload" {
t.Fatalf("expected payload in staged file, data=%q err=%v", string(data), err)
}
}
func TestBuildWindowsScriptWithCleanupRemovesLogAndStagedDirectoryAfterSuccess(t *testing.T) {
script := buildWindowsScriptWithCleanup(
`C:\Users\tester\AppData\Local\Temp\gonavi-updates\.gonavi-update-windows-dev-new\GoNavi-dev-new-Windows-Amd64.exe`,
`C:\Users\tester\Desktop\GoNavi-dev-current-Windows-Amd64.exe`,
`C:\Users\tester\AppData\Local\Temp\gonavi-updates\.gonavi-update-windows-dev-new`,
`C:\Users\tester\AppData\Local\Temp\gonavi-updates\.gonavi-update-windows-dev-new\gonavi-update-windows-123.log`,
12345,
)
mustContain := []string{
`if exist "%SOURCE%" del /F /Q "%SOURCE%"`,
`del /F /Q ""%LOG_FILE%""`,
`rmdir /S /Q ""%STAGED%""`,
}
for _, token := range mustContain {
if !strings.Contains(script, token) {
t.Fatalf("expected script to contain %q\n%s", token, script)
}
}
if strings.Contains(script, `rmdir /S /Q "%STAGED%" >> "%LOG_FILE%"`) {
t.Fatalf("script should not synchronously remove staged dir while logging to it\n%s", script)
}
}

View File

@@ -19,6 +19,12 @@ func configureSQLConnectionPool(db *sql.DB, dbType string) {
switch strings.ToLower(strings.TrimSpace(dbType)) {
case "sqlite", "duckdb":
return
case "oracle", "oceanbase":
db.SetMaxOpenConns(defaultSQLMaxOpenConns)
db.SetMaxIdleConns(1)
db.SetConnMaxIdleTime(defaultSQLConnMaxIdleTime)
db.SetConnMaxLifetime(defaultSQLConnMaxLifetime)
return
}
db.SetMaxOpenConns(defaultSQLMaxOpenConns)
db.SetMaxIdleConns(0)