mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-07-09 22:42:55 +08:00
Compare commits
72 Commits
cursor-fix
...
fix/sideba
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd01d1c3f9 | ||
|
|
23c895e839 | ||
|
|
7a5960e737 | ||
|
|
5c6110a57e | ||
|
|
17843ffd71 | ||
|
|
7296ab0425 | ||
|
|
47664023ee | ||
|
|
33918c6837 | ||
|
|
66a6052f88 | ||
|
|
31b5b838a2 | ||
|
|
575a3d88b9 | ||
|
|
bef811ce13 | ||
|
|
dee5620fc9 | ||
|
|
b1174861d4 | ||
|
|
a1f296ee08 | ||
|
|
3e34795ef5 | ||
|
|
f73d8dcb97 | ||
|
|
82ad393a05 | ||
|
|
257406120d | ||
|
|
9ba3216fa8 | ||
|
|
fca3f0cffb | ||
|
|
5712b39cf9 | ||
|
|
07dc1436af | ||
|
|
9e52397732 | ||
|
|
20ff4f3687 | ||
|
|
73ce3039ce | ||
|
|
c27a038fc5 | ||
|
|
c3c4efc6ec | ||
|
|
61296bc855 | ||
|
|
78da0076cf | ||
|
|
279b38fec3 | ||
|
|
0e348fcca4 | ||
|
|
7d79fae84d | ||
|
|
f642e0325d | ||
|
|
43a408f1c9 | ||
|
|
b97b5cf6e2 | ||
|
|
6c269a99cf | ||
|
|
24841f2dd9 | ||
|
|
c2e60c1e52 | ||
|
|
7a87d43bdc | ||
|
|
5fa1c90674 | ||
|
|
c79225ef27 | ||
|
|
0bd8fdea1f | ||
|
|
18dbd043ae | ||
|
|
a7535dec2b | ||
|
|
dbd4b7f763 | ||
|
|
68eacbbcf7 | ||
|
|
cecefce84d | ||
|
|
2406c1ff71 | ||
|
|
a4a0ac25cf | ||
|
|
fbb23f0b9a | ||
|
|
016e2bc70e | ||
|
|
070c2328e0 | ||
|
|
c117da42e4 | ||
|
|
950856d86f | ||
|
|
93a3240862 | ||
|
|
1116a3736b | ||
|
|
19af57e46b | ||
|
|
401c33c505 | ||
|
|
8c968c1a87 | ||
|
|
4fee58999d | ||
|
|
bd0752df96 | ||
|
|
47a15a6674 | ||
|
|
2a64bad76e | ||
|
|
7ee8698165 | ||
|
|
9ae35e5fec | ||
|
|
b618927454 | ||
|
|
e8ad34d2f0 | ||
|
|
007fdc9a5d | ||
|
|
61b62fe5af | ||
|
|
d574e6b396 | ||
|
|
f798b183e6 |
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -157,9 +157,46 @@ describe('useAppUpdateManager', () => {
|
||||
|
||||
expect(backendApp.DownloadUpdate).toHaveBeenCalledTimes(1);
|
||||
expect(backendApp.OpenDownloadedUpdateDirectory).not.toHaveBeenCalled();
|
||||
expect(backendApp.InstallUpdateAndRestart).not.toHaveBeenCalled();
|
||||
expect(hook?.lastUpdateInfo?.downloaded).toBe(true);
|
||||
});
|
||||
|
||||
it('auto-installs a macOS update immediately after download when the backend reports auto relaunch support', async () => {
|
||||
backendApp.CheckForUpdates.mockResolvedValue({
|
||||
success: true,
|
||||
data: {
|
||||
hasUpdate: true,
|
||||
currentVersion: '0.8.1',
|
||||
latestVersion: '0.8.2',
|
||||
downloaded: false,
|
||||
assetSize: 2048,
|
||||
},
|
||||
});
|
||||
backendApp.DownloadUpdate.mockResolvedValue({
|
||||
success: true,
|
||||
data: {
|
||||
platform: 'darwin',
|
||||
autoRelaunch: true,
|
||||
downloadPath: '/Users/test/Desktop/GoNavi-0.8.2/GoNavi-0.8.2-MacOS-Arm64.dmg',
|
||||
},
|
||||
});
|
||||
backendApp.InstallUpdateAndRestart.mockResolvedValue({ success: true });
|
||||
|
||||
renderHook();
|
||||
|
||||
await act(async () => {
|
||||
await hook?.checkForUpdates(false);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await hook?.downloadUpdate(hook?.lastUpdateInfo!, false);
|
||||
});
|
||||
|
||||
expect(backendApp.DownloadUpdate).toHaveBeenCalledTimes(1);
|
||||
expect(backendApp.InstallUpdateAndRestart).toHaveBeenCalledTimes(1);
|
||||
expect(backendApp.OpenDownloadedUpdateDirectory).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('switches update channel and re-checks against the selected channel', async () => {
|
||||
backendApp.SetUpdateChannel.mockResolvedValue({ success: true, data: { channel: 'dev' } });
|
||||
backendApp.CheckForUpdates.mockResolvedValue({
|
||||
|
||||
@@ -63,6 +63,11 @@ const buildUpdateKey = (info: Pick<UpdateInfo, 'channel' | 'latestVersion'> | nu
|
||||
? `${normalizeUpdateChannel(info.channel)}:${String(info.latestVersion || '').trim()}`
|
||||
: '';
|
||||
|
||||
const shouldAutoInstallDownloadedUpdate = (resultData: UpdateDownloadResultData | null | undefined): boolean => {
|
||||
const platform = String(resultData?.platform || '').trim().toLowerCase();
|
||||
return platform === 'darwin' && resultData?.autoRelaunch !== false;
|
||||
};
|
||||
|
||||
export const useAppUpdateManager = ({
|
||||
runtimeBuildType,
|
||||
t,
|
||||
@@ -192,6 +197,21 @@ export const useAppUpdateManager = ({
|
||||
void message.success({ content: t('app.about.message.download_completed'), duration: 2 });
|
||||
}
|
||||
setAboutUpdateStatus(formatAboutUpdateStatus({ ...info, channel: normalizeUpdateChannel(info.channel), downloaded: true }));
|
||||
|
||||
if (shouldAutoInstallDownloadedUpdate(resultData)) {
|
||||
let installRes: any = null;
|
||||
try {
|
||||
installRes = await (window as any).go?.app?.App?.InstallUpdateAndRestart?.();
|
||||
} catch (error: any) {
|
||||
installRes = { success: false, message: error?.message || t('common.unknown') };
|
||||
}
|
||||
if (!installRes?.success) {
|
||||
void message.error(t('app.about.message.install_failed_with_error', { error: installRes?.message || t('common.unknown') }));
|
||||
return;
|
||||
}
|
||||
updateInstallTriggeredVersionRef.current = targetKey || null;
|
||||
setUpdateDownloadProgress((prev) => ({ ...prev, open: false }));
|
||||
}
|
||||
} else {
|
||||
setUpdateDownloadProgress((prev) => ({
|
||||
...prev,
|
||||
|
||||
27
frontend/src/utils/globalHiddenColumns.test.ts
Normal file
27
frontend/src/utils/globalHiddenColumns.test.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
filterColumnNamesByGlobalHiddenColumns,
|
||||
normalizeGlobalHiddenColumns,
|
||||
parseGlobalHiddenColumnsText,
|
||||
} from './globalHiddenColumns';
|
||||
|
||||
describe('globalHiddenColumns', () => {
|
||||
it('parses comma, semicolon, and newline separated column names with dedupe', () => {
|
||||
expect(parseGlobalHiddenColumnsText('ID, created_by\nupdated_at;id')).toEqual([
|
||||
'ID',
|
||||
'created_by',
|
||||
'updated_at',
|
||||
]);
|
||||
});
|
||||
|
||||
it('normalizes persisted arrays and text input consistently', () => {
|
||||
expect(normalizeGlobalHiddenColumns([' ID ', 'id', 'NAME'])).toEqual(['ID', 'NAME']);
|
||||
expect(normalizeGlobalHiddenColumns(' ID\nNAME')).toEqual(['ID', 'NAME']);
|
||||
});
|
||||
|
||||
it('filters query result columns case-insensitively', () => {
|
||||
expect(filterColumnNamesByGlobalHiddenColumns(['ID', 'Name', 'updated_at'], ['id', 'UPDATED_AT']))
|
||||
.toEqual(['Name']);
|
||||
});
|
||||
});
|
||||
105
frontend/src/utils/globalHiddenColumns.ts
Normal file
105
frontend/src/utils/globalHiddenColumns.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export const GLOBAL_HIDDEN_COLUMNS_STORAGE_KEY = 'gonavi.globalHiddenColumns.v1';
|
||||
export const GLOBAL_HIDDEN_COLUMNS_CHANGED_EVENT = 'gonavi:global-hidden-columns-changed';
|
||||
|
||||
export const normalizeGlobalHiddenColumnName = (value: unknown): string => (
|
||||
String(value ?? '').trim().toLowerCase()
|
||||
);
|
||||
|
||||
export const parseGlobalHiddenColumnsText = (value: unknown): string[] => {
|
||||
const seen = new Set<string>();
|
||||
const result: string[] = [];
|
||||
String(value ?? '')
|
||||
.split(/[\n\r,,;;\t]+/)
|
||||
.map((item) => String(item || '').trim())
|
||||
.filter(Boolean)
|
||||
.forEach((item) => {
|
||||
const normalized = normalizeGlobalHiddenColumnName(item);
|
||||
if (!normalized || seen.has(normalized)) return;
|
||||
seen.add(normalized);
|
||||
result.push(item);
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
export const normalizeGlobalHiddenColumns = (value: unknown): string[] => {
|
||||
if (Array.isArray(value)) {
|
||||
return parseGlobalHiddenColumnsText(value.join('\n'));
|
||||
}
|
||||
return parseGlobalHiddenColumnsText(value);
|
||||
};
|
||||
|
||||
export const serializeGlobalHiddenColumns = (columns: unknown): string => (
|
||||
normalizeGlobalHiddenColumns(columns).join('\n')
|
||||
);
|
||||
|
||||
export const loadGlobalHiddenColumns = (): string[] => {
|
||||
if (typeof localStorage === 'undefined') return [];
|
||||
try {
|
||||
const raw = localStorage.getItem(GLOBAL_HIDDEN_COLUMNS_STORAGE_KEY);
|
||||
if (!raw) return [];
|
||||
const parsed = JSON.parse(raw);
|
||||
return normalizeGlobalHiddenColumns(parsed);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
export const saveGlobalHiddenColumns = (columns: unknown): string[] => {
|
||||
const normalized = normalizeGlobalHiddenColumns(columns);
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
try {
|
||||
localStorage.setItem(GLOBAL_HIDDEN_COLUMNS_STORAGE_KEY, JSON.stringify(normalized));
|
||||
} catch {
|
||||
// Ignore storage failures; still notify in-memory listeners.
|
||||
}
|
||||
}
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new CustomEvent(GLOBAL_HIDDEN_COLUMNS_CHANGED_EVENT, {
|
||||
detail: { columns: normalized },
|
||||
}));
|
||||
}
|
||||
return normalized;
|
||||
};
|
||||
|
||||
export const subscribeGlobalHiddenColumns = (callback: (columns: string[]) => void): (() => void) => {
|
||||
if (typeof window === 'undefined') {
|
||||
return () => {};
|
||||
}
|
||||
const handleCustomEvent = (event: Event) => {
|
||||
const detail = (event as CustomEvent<{ columns?: unknown }>).detail;
|
||||
callback(normalizeGlobalHiddenColumns(detail?.columns));
|
||||
};
|
||||
const handleStorageEvent = (event: StorageEvent) => {
|
||||
if (event.key !== GLOBAL_HIDDEN_COLUMNS_STORAGE_KEY) return;
|
||||
callback(loadGlobalHiddenColumns());
|
||||
};
|
||||
window.addEventListener(GLOBAL_HIDDEN_COLUMNS_CHANGED_EVENT, handleCustomEvent as EventListener);
|
||||
window.addEventListener('storage', handleStorageEvent);
|
||||
return () => {
|
||||
window.removeEventListener(GLOBAL_HIDDEN_COLUMNS_CHANGED_EVENT, handleCustomEvent as EventListener);
|
||||
window.removeEventListener('storage', handleStorageEvent);
|
||||
};
|
||||
};
|
||||
|
||||
export const useGlobalHiddenColumns = (): string[] => {
|
||||
const [columns, setColumns] = useState<string[]>(() => loadGlobalHiddenColumns());
|
||||
|
||||
useEffect(() => subscribeGlobalHiddenColumns(setColumns), []);
|
||||
|
||||
return columns;
|
||||
};
|
||||
|
||||
export const filterColumnNamesByGlobalHiddenColumns = (
|
||||
columnNames: string[],
|
||||
hiddenColumns: unknown,
|
||||
): string[] => {
|
||||
const hiddenSet = new Set(
|
||||
normalizeGlobalHiddenColumns(hiddenColumns).map(normalizeGlobalHiddenColumnName),
|
||||
);
|
||||
if (hiddenSet.size === 0) {
|
||||
return columnNames;
|
||||
}
|
||||
return (columnNames || []).filter((columnName) => !hiddenSet.has(normalizeGlobalHiddenColumnName(columnName)));
|
||||
};
|
||||
@@ -40,9 +40,9 @@ describe('applyQueryAutoLimit', () => {
|
||||
['dameng'],
|
||||
['dm'],
|
||||
['dm8'],
|
||||
])('adds ROWNUM limit for %s connections', (dbType) => {
|
||||
])('adds FETCH FIRST limit for %s connections', (dbType) => {
|
||||
expect(applyQueryAutoLimit('SELECT * FROM MYCIMLED.EDC_LOG', dbType, 500).sql)
|
||||
.toBe('SELECT * FROM (SELECT * FROM MYCIMLED.EDC_LOG) WHERE ROWNUM <= 500');
|
||||
.toBe('SELECT * FROM MYCIMLED.EDC_LOG FETCH FIRST 500 ROWS ONLY');
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -61,8 +61,8 @@ describe('applyQueryAutoLimit', () => {
|
||||
});
|
||||
|
||||
it.each([
|
||||
['oracle', 'SELECT * FROM (SELECT * FROM users) WHERE ROWNUM <= 500'],
|
||||
['dm8', 'SELECT * FROM (SELECT * FROM users) WHERE ROWNUM <= 500'],
|
||||
['oracle', 'SELECT * FROM users FETCH FIRST 500 ROWS ONLY'],
|
||||
['dm8', 'SELECT * FROM users FETCH FIRST 500 ROWS ONLY'],
|
||||
['mssql', 'SELECT TOP 500 * FROM users'],
|
||||
['postgresql', 'SELECT * FROM users LIMIT 500'],
|
||||
['gauss-db', 'SELECT * FROM users LIMIT 500'],
|
||||
@@ -76,12 +76,17 @@ describe('applyQueryAutoLimit', () => {
|
||||
|
||||
it('keeps trailing semicolon and comments after injected Oracle limit', () => {
|
||||
expect(applyQueryAutoLimit('SELECT * FROM MYCIMLED.EDC_LOG; -- preview', 'oracle', 500).sql)
|
||||
.toBe('SELECT * FROM (SELECT * FROM MYCIMLED.EDC_LOG) WHERE ROWNUM <= 500; -- preview');
|
||||
.toBe('SELECT * FROM MYCIMLED.EDC_LOG FETCH FIRST 500 ROWS ONLY; -- preview');
|
||||
});
|
||||
|
||||
it('uses Oracle 11g compatible ROWNUM limit for simple table queries', () => {
|
||||
it('uses Oracle FETCH FIRST limit for simple table queries', () => {
|
||||
expect(applyQueryAutoLimit('select 1 from xxx', 'oracle', 500).sql)
|
||||
.toBe('SELECT * FROM (select 1 from xxx) WHERE ROWNUM <= 500');
|
||||
.toBe('select 1 from xxx FETCH FIRST 500 ROWS ONLY');
|
||||
});
|
||||
|
||||
it('keeps ORDER BY semantics with Oracle FETCH FIRST', () => {
|
||||
expect(applyQueryAutoLimit('SELECT * FROM users ORDER BY created_at DESC', 'oracle', 100).sql)
|
||||
.toBe('SELECT * FROM users ORDER BY created_at DESC FETCH FIRST 100 ROWS ONLY');
|
||||
});
|
||||
|
||||
it('does not add another generic limit when SQL already limits rows', () => {
|
||||
|
||||
@@ -322,7 +322,7 @@ export const applyQueryAutoLimit = (
|
||||
if (offsetPos >= 0 && (fromPos < 0 || offsetPos > fromPos)) return { sql, applied: false, maxRows };
|
||||
const forPos = findTopLevelKeyword(main, 'for');
|
||||
if (forPos >= 0 && (fromPos < 0 || forPos > fromPos)) return { sql, applied: false, maxRows };
|
||||
return { sql: `SELECT * FROM (${main.trimEnd()}) WHERE ROWNUM <= ${maxRows}${tail}`, applied: true, maxRows };
|
||||
return { sql: `${main.trimEnd()} FETCH FIRST ${maxRows} ROWS ONLY${tail}`, applied: true, maxRows };
|
||||
}
|
||||
|
||||
const offsetPos = findTopLevelKeyword(main, 'offset');
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -135,6 +135,7 @@ const SQL_ERROR_RULES: SqlErrorSemanticRule[] = [
|
||||
/context cancelled/i,
|
||||
/timeout/i,
|
||||
/timed out/i,
|
||||
/driver:\s*bad connection/i,
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
|
||||
92
internal/app/db_context_oceanbase_oracle_test.go
Normal file
92
internal/app/db_context_oceanbase_oracle_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -417,9 +417,16 @@ func buildObjectSequenceMetadataQueries(dbType string, dbName string) []objectMe
|
||||
switch dbType {
|
||||
case "oracle", "dameng":
|
||||
if strings.TrimSpace(dbName) == "" {
|
||||
return []objectMetadataQuerySpec{{sql: "SELECT SEQUENCE_NAME AS sequence_name FROM USER_SEQUENCES ORDER BY SEQUENCE_NAME"}}
|
||||
return []objectMetadataQuerySpec{
|
||||
{sql: "SELECT * FROM USER_SEQUENCES ORDER BY SEQUENCE_NAME"},
|
||||
{sql: "SELECT OBJECT_NAME AS sequence_name FROM USER_OBJECTS WHERE OBJECT_TYPE = 'SEQUENCE' ORDER BY OBJECT_NAME"},
|
||||
}
|
||||
}
|
||||
owner := strings.ToUpper(safeDbName)
|
||||
return []objectMetadataQuerySpec{
|
||||
{sql: fmt.Sprintf("SELECT * FROM ALL_SEQUENCES WHERE SEQUENCE_OWNER = '%s' ORDER BY SEQUENCE_NAME", owner)},
|
||||
{sql: fmt.Sprintf("SELECT OWNER AS schema_name, OBJECT_NAME AS sequence_name FROM ALL_OBJECTS WHERE OWNER = '%s' AND OBJECT_TYPE = 'SEQUENCE' ORDER BY OBJECT_NAME", owner)},
|
||||
}
|
||||
return []objectMetadataQuerySpec{{sql: fmt.Sprintf("SELECT SEQUENCE_OWNER AS schema_name, SEQUENCE_NAME AS sequence_name FROM ALL_SEQUENCES WHERE SEQUENCE_OWNER = '%s' ORDER BY SEQUENCE_NAME", strings.ToUpper(safeDbName))}}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
39
internal/app/methods_db_objects_sequence_test.go
Normal file
39
internal/app/methods_db_objects_sequence_test.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBuildObjectSequenceMetadataQueriesOracleUsesWildcardAndObjectFallback(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
specs := buildObjectSequenceMetadataQueries("oracle", "sbdev")
|
||||
if len(specs) != 2 {
|
||||
t.Fatalf("expected 2 Oracle sequence metadata queries, got %d", len(specs))
|
||||
}
|
||||
if !strings.Contains(specs[0].sql, "SELECT * FROM ALL_SEQUENCES") {
|
||||
t.Fatalf("expected ALL_SEQUENCES wildcard query, got %q", specs[0].sql)
|
||||
}
|
||||
if strings.Contains(specs[0].sql, "LAST_NUMBER") || strings.Contains(specs[0].sql, "ORDER_FLAG") {
|
||||
t.Fatalf("query should not enumerate optional sequence columns: %q", specs[0].sql)
|
||||
}
|
||||
if !strings.Contains(specs[1].sql, "ALL_OBJECTS") || !strings.Contains(specs[1].sql, "OBJECT_TYPE = 'SEQUENCE'") {
|
||||
t.Fatalf("expected ALL_OBJECTS fallback query, got %q", specs[1].sql)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildObjectSequenceMetadataQueriesOracleUserFallbacks(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
specs := buildObjectSequenceMetadataQueries("oracle", "")
|
||||
if len(specs) != 2 {
|
||||
t.Fatalf("expected 2 Oracle user sequence metadata queries, got %d", len(specs))
|
||||
}
|
||||
if specs[0].sql != "SELECT * FROM USER_SEQUENCES ORDER BY SEQUENCE_NAME" {
|
||||
t.Fatalf("unexpected USER_SEQUENCES query: %q", specs[0].sql)
|
||||
}
|
||||
if !strings.Contains(specs[1].sql, "USER_OBJECTS") || !strings.Contains(specs[1].sql, "OBJECT_TYPE = 'SEQUENCE'") {
|
||||
t.Fatalf("expected USER_OBJECTS fallback query, got %q", specs[1].sql)
|
||||
}
|
||||
}
|
||||
363
internal/app/update_cleanup.go
Normal file
363
internal/app/update_cleanup.go
Normal 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"))
|
||||
}
|
||||
113
internal/app/update_cleanup_test.go
Normal file
113
internal/app/update_cleanup_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user