🐛 fix(explorer): 补齐 Redis 部分导入与库节点加载态

- 新增 Redis 导入预览与按 key 勾选导入能力

- 后端支持导入文件预读、选中 key 过滤与本地化错误提示

- 打开数据库节点时同步展示加载态并补齐前后端回归测试

Fixes #607

Fixes #609
This commit is contained in:
Syngnat
2026-06-30 21:17:00 +08:00
parent b0d8ccbf23
commit a9bae6bb3f
14 changed files with 1926 additions and 2 deletions

View File

@@ -17,6 +17,9 @@ describe('RedisViewer i18n', () => {
'模糊',
'精确',
'新建 Key',
'导出全部',
'导出已选',
'导入',
'重命名 Key',
].forEach((snippet) => {
expect(source).not.toContain(snippet);
@@ -26,8 +29,19 @@ describe('RedisViewer i18n', () => {
expect(source).toContain("tr('redis_viewer.message.load_keys_failed'");
expect(source).toContain("tr('redis_viewer.message.value_load_failed'");
expect(source).toContain("tr('redis_viewer.message.rename_success'");
expect(source).toContain("tr('redis_viewer.message.export_success'");
expect(source).toContain("tr('redis_viewer.message.import_summary'");
expect(source).toContain("tr('redis_viewer.message.import_file_required'");
expect(source).toContain("tr('redis_viewer.message.import_selection_required'");
expect(source).toContain("tr('redis_viewer.state.empty_selection'");
expect(source).toContain("tr('redis_viewer.state.import_preview_empty'");
expect(source).toContain("tr('redis_viewer.action.copy_key_name'");
expect(source).toContain("tr('redis_viewer.action.export_all'");
expect(source).toContain("tr('redis_viewer.action.export_selected'");
expect(source).toContain("tr('redis_viewer.action.import'");
expect(source).toContain("tr('redis_viewer.action.select_import_file'");
expect(source).toContain("tr('redis_viewer.action.change_import_file'");
expect(source).toContain("tr('redis_viewer.action.select_all_import_keys'");
});
it('localizes TTL and table labels with catalog keys', () => {

View File

@@ -30,6 +30,9 @@ const storeState = vi.hoisted(() => ({
const redisBackend = vi.hoisted(() => ({
RedisScanKeys: vi.fn(),
RedisGetValue: vi.fn(),
RedisExportKeys: vi.fn(),
RedisPreviewImportKeys: vi.fn(),
RedisImportKeys: vi.fn(),
}));
const antdState = vi.hoisted(() => ({
@@ -109,7 +112,17 @@ vi.mock('antd', async () => {
},
Spin: ({ children }: any) => React.createElement(React.Fragment, null, children),
message: antdState.message,
Modal: Object.assign(passthrough('div'), { confirm: vi.fn() }),
Modal: Object.assign(({ children, open, onOk, onCancel, okButtonProps, title, ...props }: any) => {
if (!open) {
return null;
}
return React.createElement('div', props, [
React.createElement('div', { key: 'title', 'data-modal-title': true }, title),
children,
onOk ? React.createElement('button', { key: 'ok', onClick: onOk, disabled: okButtonProps?.disabled }, 'modal-ok') : null,
onCancel ? React.createElement('button', { key: 'cancel', onClick: onCancel }, 'modal-cancel') : null,
]);
}, { confirm: vi.fn() }),
Form: FormComponent,
InputNumber: ({ ...props }: any) => React.createElement('input', props),
Popconfirm: passthrough('span'),
@@ -153,6 +166,24 @@ const countLeafNodes = (nodes: any[]): number => {
}, 0);
};
const findFirstLeafNode = (nodes: any[]): any | null => {
for (const node of nodes) {
if (!node || typeof node !== 'object') {
continue;
}
if (node.nodeType === 'leaf') {
return node;
}
if (Array.isArray(node.children)) {
const nested = findFirstLeafNode(node.children);
if (nested) {
return nested;
}
}
}
return null;
};
describe('RedisViewer tree interactions', () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -184,6 +215,26 @@ describe('RedisViewer tree interactions', () => {
success: true,
data: { key: 'app:user:1', type: 'string', ttl: -1, value: 'demo' },
});
redisBackend.RedisExportKeys.mockResolvedValue({
success: true,
data: { exported: 2 },
});
redisBackend.RedisPreviewImportKeys.mockResolvedValue({
success: true,
data: {
file: 'C:\\tmp\\redis-keys.json',
database: 0,
total: 2,
keys: [
{ key: 'app:user:1', type: 'string', ttl: -1 },
{ key: 'app:user:2', type: 'string', ttl: 120 },
],
},
});
redisBackend.RedisImportKeys.mockResolvedValue({
success: true,
data: { imported: 1, skipped: 0, total: 1 },
});
vi.stubGlobal('window', {
innerWidth: 1280,
innerHeight: 800,
@@ -317,4 +368,117 @@ describe('RedisViewer tree interactions', () => {
renderer!.unmount();
});
it('exports the current filtered key set when the export-all action is clicked', async () => {
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<RedisViewer connectionId="redis-1" redisDB={0} />);
});
await flushEffects();
const exportAllButton = findButtonByText(renderer!, 'Export all');
expect(exportAllButton).toBeTruthy();
await act(async () => {
exportAllButton!.props.onClick?.();
});
await flushEffects();
expect(redisBackend.RedisExportKeys).toHaveBeenCalledWith(
expect.objectContaining({ type: 'redis', host: '127.0.0.1', port: 6379, redisDB: 0 }),
{ scope: 'all', keys: [], pattern: '*' },
);
renderer!.unmount();
});
it('exports checked leaf keys when the export-selected action is clicked', async () => {
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<RedisViewer connectionId="redis-1" redisDB={0} />);
});
await flushEffects();
const leafNode = findFirstLeafNode(antdState.treeProps.treeData);
expect(leafNode?.rawKey).toBe('app:user:1');
await act(async () => {
antdState.treeProps.onCheck?.(
{ checked: [leafNode.key], halfChecked: [] },
{ checked: true, node: leafNode },
);
});
await flushEffects();
const exportSelectedButton = findButtonByText(renderer!, 'Export selected');
expect(exportSelectedButton).toBeTruthy();
expect(exportSelectedButton?.props.disabled).toBe(false);
await act(async () => {
exportSelectedButton!.props.onClick?.();
});
await flushEffects();
expect(redisBackend.RedisExportKeys).toHaveBeenCalledWith(
expect.objectContaining({ type: 'redis', host: '127.0.0.1', port: 6379, redisDB: 0 }),
{ scope: 'selected', keys: ['app:user:1'], pattern: '*' },
);
renderer!.unmount();
});
it('imports only the checked preview keys from the selected file', async () => {
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<RedisViewer connectionId="redis-1" redisDB={0} />);
});
await flushEffects();
const importButton = findButtonByText(renderer!, 'Import');
expect(importButton).toBeTruthy();
await act(async () => {
importButton!.props.onClick?.();
});
await flushEffects();
const chooseFileButton = findButtonByText(renderer!, 'Select import file');
expect(chooseFileButton).toBeTruthy();
await act(async () => {
chooseFileButton!.props.onClick?.();
});
await flushEffects();
expect(redisBackend.RedisPreviewImportKeys).toHaveBeenCalledWith(
expect.objectContaining({ type: 'redis', host: '127.0.0.1', port: 6379, redisDB: 0 }),
);
const secondCheckbox = renderer!.root.findByProps({ 'data-import-key': 'app:user:2' });
await act(async () => {
secondCheckbox.props.onChange?.({ target: { checked: false } });
});
await flushEffects();
const modalOkButton = findButtonByText(renderer!, 'modal-ok');
expect(modalOkButton).toBeTruthy();
expect(modalOkButton?.props.disabled).toBe(false);
await act(async () => {
modalOkButton!.props.onClick?.();
});
await flushEffects();
expect(redisBackend.RedisImportKeys).toHaveBeenCalledWith(
expect.objectContaining({ type: 'redis', host: '127.0.0.1', port: 6379, redisDB: 0 }),
{
conflictMode: 'overwrite',
file: 'C:\\tmp\\redis-keys.json',
scope: 'selected',
keys: ['app:user:1'],
},
);
renderer!.unmount();
});
});

View File

@@ -31,6 +31,7 @@ import { buildRedisWorkbenchTheme } from './redisViewerWorkbenchTheme';
import { noAutoCapInputProps } from '../utils/inputAutoCap';
import { normalizeRedisSearchDraftChange, normalizeRedisSearchInput, type RedisSearchMode } from '../utils/redisSearchPattern';
import { decodeRedisUtf8Value, formatRedisStringValue, toHexDisplay } from '../utils/redisValueDisplay';
import { isConnectionDataImportRestricted } from '../utils/connectionReadOnly';
import { t, type I18nParams } from '../i18n';
import { useOptionalI18n } from '../i18n/provider';
@@ -53,6 +54,19 @@ interface RedisViewerProps {
redisDB: number;
}
type RedisExportScope = 'all' | 'selected';
type RedisImportConflictMode = 'overwrite' | 'skip';
type RedisImportPreview = {
file: string;
exportedAt?: string;
database: number;
scope?: string;
pattern?: string;
sourceAppName?: string;
total: number;
keys: RedisKeyInfo[];
};
// Draggable divider uses direct DOM updates to avoid resize lag.
const ResizableDivider: React.FC<{
onResizeEnd: (newWidth: number) => void;
@@ -149,6 +163,14 @@ const isRedisKeyGoneErrorMessage = (messageText: string): boolean => {
};
const normalizeToolbarText = (value: unknown): string => String(value || '').trim();
const extractFilenameFromPath = (value: unknown): string => {
const normalized = String(value || '').trim().replace(/\\/g, '/');
if (!normalized) {
return '';
}
const segments = normalized.split('/');
return segments[segments.length - 1] || normalized;
};
const mergeRedisKeyInfoLists = (existing: RedisKeyInfo[], incoming: RedisKeyInfo[]): RedisKeyInfo[] => {
const keyMap = new Map<string, RedisKeyInfo>();
@@ -214,6 +236,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
const redisTopology = useMemo(() => resolveRedisTopology(connection), [connection]);
const redisSeedAddresses = useMemo(() => buildRedisSeedAddresses(connection), [connection]);
const redisSentinelMaster = normalizeToolbarText(connection?.config?.redisSentinelMaster);
const importRestricted = isConnectionDataImportRestricted(connection?.config);
const [keys, setKeys] = useState<RedisKeyInfo[]>([]);
const [loading, setLoading] = useState(false);
@@ -223,6 +246,8 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
const [cursor, setCursor] = useState<string>('0');
const [hasMore, setHasMore] = useState(false);
const [loadingAllKeys, setLoadingAllKeys] = useState(false);
const [exportingScope, setExportingScope] = useState<RedisExportScope | null>(null);
const [importingKeys, setImportingKeys] = useState(false);
const [selectedKey, setSelectedKey] = useState<string | null>(null);
const [keyValue, setKeyValue] = useState<RedisValue | null>(null);
const [valueLoading, setValueLoading] = useState(false);
@@ -234,6 +259,11 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
const [renameTargetKey, setRenameTargetKey] = useState<string | null>(null);
const [ttlModalOpen, setTtlModalOpen] = useState(false);
const [ttlForm] = Form.useForm();
const [importModalOpen, setImportModalOpen] = useState(false);
const [importPreviewLoading, setImportPreviewLoading] = useState(false);
const [importConflictMode, setImportConflictMode] = useState<RedisImportConflictMode>('overwrite');
const [importPreview, setImportPreview] = useState<RedisImportPreview | null>(null);
const [importSelectedKeys, setImportSelectedKeys] = useState<string[]>([]);
const [selectedKeys, setSelectedKeys] = useState<string[]>([]);
const [editValue, setEditValue] = useState('');
const [treeContextMenu, setTreeContextMenu] = useState<{ x: number; y: number; rawKey: string } | null>(null);
@@ -502,6 +532,161 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
setSelectedKeys([]);
}, []);
const handleExportKeys = useCallback(async (scope: RedisExportScope) => {
const config = getConfig();
if (!config) return;
if (scope === 'selected' && selectedKeys.length === 0) {
message.warning(tr('redis_viewer.message.export_selection_required'));
return;
}
setExportingScope(scope);
try {
const res = await (window as any).go.app.App.RedisExportKeys(
buildRpcConnectionConfig(config),
{
scope,
keys: scope === 'selected' ? selectedKeys : [],
pattern: searchPattern,
},
);
if (res?.success) {
const exportedCount = Number(res?.data?.exported ?? (scope === 'selected' ? selectedKeys.length : 0));
message.success(tr('redis_viewer.message.export_success', { count: exportedCount }));
return;
}
if (String(res?.message || '').trim() === '已取消') {
return;
}
message.error(tr('redis_viewer.message.export_failed', { detail: res?.message || 'Unknown error' }));
} catch (e: any) {
message.error(tr('redis_viewer.message.export_failed', { detail: e?.message || String(e) }));
} finally {
setExportingScope(null);
}
}, [getConfig, searchPattern, selectedKeys, tr]);
const resetImportModalState = useCallback(() => {
setImportModalOpen(false);
setImportPreview(null);
setImportSelectedKeys([]);
setImportPreviewLoading(false);
setImportConflictMode('overwrite');
}, []);
const handleChooseImportFile = useCallback(async () => {
const config = getConfig();
if (!config) return;
setImportPreviewLoading(true);
try {
const res = await (window as any).go.app.App.RedisPreviewImportKeys(
buildRpcConnectionConfig(config),
);
if (res?.success) {
const previewData = (res.data || {}) as RedisImportPreview;
const previewKeys = Array.isArray(previewData.keys) ? previewData.keys : [];
const nextPreview: RedisImportPreview = {
file: String(previewData.file || '').trim(),
exportedAt: previewData.exportedAt,
database: Number(previewData.database ?? redisDB),
scope: String(previewData.scope || '').trim(),
pattern: String(previewData.pattern || '').trim(),
sourceAppName: String(previewData.sourceAppName || '').trim(),
total: Number(previewData.total ?? previewKeys.length),
keys: previewKeys,
};
setImportPreview(nextPreview);
setImportSelectedKeys(previewKeys.map((item) => item.key));
return;
}
if (String(res?.message || '').trim() === '已取消') {
return;
}
message.error(tr('redis_viewer.message.import_failed', { detail: res?.message || 'Unknown error' }));
} catch (e: any) {
message.error(tr('redis_viewer.message.import_failed', { detail: e?.message || String(e) }));
} finally {
setImportPreviewLoading(false);
}
}, [getConfig, redisDB, tr]);
const handleOpenImportModal = useCallback(() => {
setImportModalOpen(true);
setImportPreview(null);
setImportSelectedKeys([]);
setImportPreviewLoading(false);
setImportConflictMode('overwrite');
}, []);
const handleConfirmImportKeys = useCallback(async () => {
const config = getConfig();
if (!config) return;
if (!importPreview) {
message.warning(tr('redis_viewer.message.import_file_required'));
return;
}
if (importSelectedKeys.length === 0) {
message.warning(tr('redis_viewer.message.import_selection_required'));
return;
}
setImportingKeys(true);
try {
const scope = importSelectedKeys.length === importPreview.keys.length ? 'all' : 'selected';
const res = await (window as any).go.app.App.RedisImportKeys(
buildRpcConnectionConfig(config),
{
conflictMode: importConflictMode,
file: importPreview.file,
scope,
keys: scope === 'selected' ? importSelectedKeys : [],
},
);
if (res?.success) {
const imported = Number(res?.data?.imported ?? 0);
const skipped = Number(res?.data?.skipped ?? 0);
message.success(tr('redis_viewer.message.import_summary', {
imported,
skipped,
}));
resetImportModalState();
setSelectedKeys([]);
setSelectedKey(null);
setKeyValue(null);
setCursor('0');
loadKeys(searchPattern, '0', false, getRedisScanLoadCount(searchPattern, false));
return;
}
if (String(res?.message || '').trim() === '已取消') {
return;
}
message.error(tr('redis_viewer.message.import_failed', { detail: res?.message || 'Unknown error' }));
} catch (e: any) {
message.error(tr('redis_viewer.message.import_failed', { detail: e?.message || String(e) }));
} finally {
setImportingKeys(false);
}
}, [getConfig, importConflictMode, importPreview, importSelectedKeys, loadKeys, resetImportModalState, searchPattern, tr]);
const importSelectedKeySet = useMemo(() => new Set(importSelectedKeys), [importSelectedKeys]);
const handleToggleImportPreviewKey = useCallback((key: string, checked: boolean) => {
setImportSelectedKeys((prev) => {
if (checked) {
return prev.includes(key) ? prev : [...prev, key];
}
return prev.filter((item) => item !== key);
});
}, []);
const handleSelectAllImportPreviewKeys = useCallback(() => {
if (!importPreview) return;
setImportSelectedKeys(importPreview.keys.map((item) => item.key));
}, [importPreview]);
const handleClearImportPreviewSelection = useCallback(() => {
setImportSelectedKeys([]);
}, []);
const removeMissingKeyFromView = useCallback((missingKey: string) => {
setKeys(prev => prev.filter(item => item.key !== missingKey));
setSelectedKeys(prev => prev.filter(item => item !== missingKey));
@@ -1995,6 +2180,32 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
<Button size="small" style={primaryActionButtonStyle} onClick={handleSelectAllLoadedKeys} disabled={keys.length === 0}>{tr('redis_viewer.action.select_all_loaded')}</Button>
<Button size="small" style={actionButtonStyle} onClick={handleLoadAllKeys} disabled={!hasMore || loading} loading={loadingAllKeys}>{tr('redis_viewer.action.load_all')}</Button>
<Button size="small" style={actionButtonStyle} onClick={handleClearAllSelectedKeys} disabled={selectedKeys.length === 0}>{tr('redis_viewer.action.clear_selection')}</Button>
<Button
size="small"
style={actionButtonStyle}
onClick={() => void handleExportKeys('all')}
loading={exportingScope === 'all'}
>
{tr('redis_viewer.action.export_all')}
</Button>
<Button
size="small"
style={actionButtonStyle}
onClick={() => void handleExportKeys('selected')}
disabled={selectedKeys.length === 0}
loading={exportingScope === 'selected'}
>
{tr('redis_viewer.action.export_selected')}
</Button>
<Button
size="small"
style={primaryActionButtonStyle}
onClick={handleOpenImportModal}
disabled={importRestricted}
loading={importingKeys}
>
{tr('redis_viewer.action.import')}
</Button>
</Space>
<Popconfirm
title={tr('redis_viewer.confirm.delete_selected', { count: selectedKeys.length })}
@@ -2109,6 +2320,114 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
</Form>
</Modal>
<Modal
title={tr('redis_viewer.modal.import_keys')}
open={importModalOpen}
okButtonProps={{ disabled: !importPreview || importPreview.keys.length === 0 || importSelectedKeys.length === 0 || importPreviewLoading }}
confirmLoading={importingKeys}
onOk={() => void handleConfirmImportKeys()}
onCancel={() => {
if (importingKeys || importPreviewLoading) return;
resetImportModalState();
}}
width={760}
styles={{ content: redisModalContentStyle, header: { background: 'transparent', borderBottom: 'none', color: workbenchTheme.textPrimary }, body: { paddingTop: 8 }, footer: { background: 'transparent', borderTop: 'none' } }}
>
<Form layout="vertical">
<Form.Item label={tr('redis_viewer.field.import_file')}>
<Space wrap size={8}>
<Button
style={actionButtonStyle}
onClick={() => void handleChooseImportFile()}
loading={importPreviewLoading}
>
{importPreview ? tr('redis_viewer.action.change_import_file') : tr('redis_viewer.action.select_import_file')}
</Button>
{importPreview && (
<>
<Tag style={mutedPillTagStyle}>{extractFilenameFromPath(importPreview.file)}</Tag>
<Tag style={mutedPillTagStyle}>{tr('redis_viewer.label.import_selection', { selected: importSelectedKeys.length, total: importPreview.total })}</Tag>
</>
)}
</Space>
</Form.Item>
{importPreview ? (
<Form.Item label={tr('redis_viewer.field.import_keys')}>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 8, marginBottom: 10, flexWrap: 'wrap' }}>
<Space wrap size={8}>
<Button
size="small"
style={primaryActionButtonStyle}
onClick={handleSelectAllImportPreviewKeys}
disabled={importPreview.keys.length === 0}
>
{tr('redis_viewer.action.select_all_import_keys')}
</Button>
<Button
size="small"
style={actionButtonStyle}
onClick={handleClearImportPreviewSelection}
disabled={importSelectedKeys.length === 0}
>
{tr('redis_viewer.action.clear_selection')}
</Button>
</Space>
<div style={{ color: workbenchTheme.textMuted, fontSize: 12 }}>
{tr('redis_viewer.label.import_database', { database: importPreview.database })}
</div>
</div>
<div style={{ maxHeight: 320, overflowY: 'auto', border: workbenchTheme.panelBorder, borderRadius: 12, padding: 8, background: workbenchTheme.panelBg }}>
{importPreview.keys.map((item) => {
const checked = importSelectedKeySet.has(item.key);
return (
<label
key={item.key}
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
padding: '8px 6px',
borderRadius: 10,
cursor: 'pointer',
}}
>
<input
data-import-key={item.key}
type="checkbox"
checked={checked}
onChange={(event) => handleToggleImportPreviewKey(item.key, event.target.checked)}
/>
<span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', color: workbenchTheme.textPrimary }}>
{item.key}
</span>
<Tag color={getTypeColor(item.type)} style={{ margin: 0 }}>
{item.type}
</Tag>
<span style={{ color: workbenchTheme.textMuted, fontSize: 12, whiteSpace: 'nowrap' }}>
{formatTTL(item.ttl)}
</span>
</label>
);
})}
</div>
</Form.Item>
) : (
<div style={{ marginBottom: 16, color: workbenchTheme.textMuted }}>
{tr('redis_viewer.state.import_preview_empty')}
</div>
)}
<Form.Item label={tr('redis_viewer.field.import_conflict_mode')}>
<Radio.Group
value={importConflictMode}
onChange={(event) => setImportConflictMode(event.target.value as RedisImportConflictMode)}
>
<Radio.Button value="overwrite">{tr('redis_viewer.option.import_overwrite')}</Radio.Button>
<Radio.Button value="skip">{tr('redis_viewer.option.import_skip_existing')}</Radio.Button>
</Radio.Group>
</Form.Item>
</Form>
</Modal>
{/* TTL Modal */}
<Modal
title={tr('redis_viewer.modal.rename_key')}

View File

@@ -1122,7 +1122,7 @@ describe('Sidebar locate toolbar', () => {
expect(css).toMatch(/\.gn-v2-explorer-filter-tabs button \{[^}]*flex: 0 0 auto;[^}]*white-space: nowrap;/s);
});
it('shows a pending state while a connection root is loading', () => {
it('shows a pending state while a database node is loading', () => {
const css = readV2ThemeCss();
const source = readSidebarSource();
const treeLoaderSource = readSourceFile('./sidebar/useSidebarTreeLoaders.tsx');
@@ -1131,6 +1131,7 @@ describe('Sidebar locate toolbar', () => {
expect(source).toContain("export type SidebarConnectionState = 'loading' | 'success' | 'error';");
expect(treeLoaderSource).toContain("setConnectionStates(prev => ({ ...prev, [conn.id]: 'loading' }));");
expect(treeLoaderSource).toContain("setConnectionStates(prev => ({ ...prev, [key as string]: 'loading' }));");
expect(titleRenderSource).toContain("let status: 'loading' | 'success' | 'error' | 'default' = 'default';");
expect(titleRenderSource).toContain("if (connectionStates[node.key] === 'loading') status = 'loading';");
expect(v2ContextMenuSource).toContain("const statusMap = new Map<string, 'loading' | 'live' | 'error' | 'idle'>();");

View File

@@ -449,6 +449,7 @@ export const useSidebarTreeLoaders = ({
const loadKey = `tables-${conn.id}-${dbName}`;
if (loadingNodesRef.current.has(loadKey)) return;
loadingNodesRef.current.add(loadKey);
setConnectionStates(prev => ({ ...prev, [key as string]: 'loading' }));
const dbQueries = savedQueries.filter(q => q.connectionId === conn.id && q.dbName === dbName);
const queriesNode: TreeNode = {