Merge remote-tracking branch 'origin/dev' into dev

This commit is contained in:
Syngnat
2026-08-11 11:39:27 +08:00
40 changed files with 1462 additions and 405 deletions

View File

@@ -504,6 +504,52 @@ describe('DataGrid layout', () => {
expect(css).not.toContain('modified-hover');
});
it('keeps fixed row controls opaque when their row is hovered or selected', () => {
const css = buildDataGridCssText({
darkMode: false,
densityParams: { dataFontSize: 12 },
gridId: 'row-number-state-grid',
bgContent: '#ffffff',
});
const transparentHover = css.indexOf(
'.row-number-state-grid.data-grid-root .ant-table-tbody .ant-table-row:hover > .ant-table-cell',
);
expect(transparentHover).toBeGreaterThanOrEqual(0);
const fixedRowControl = ':is(.data-grid-row-number-cell, .ant-table-selection-column)';
const fixedRowControlHoverSelectors = [
`.row-number-state-grid.data-grid-root .ant-table-tbody-virtual-holder .ant-table-row:hover > .ant-table-cell${fixedRowControl}`,
`.row-number-state-grid.data-grid-root .ant-table-tbody-virtual .ant-table-row:hover > .ant-table-cell${fixedRowControl}`,
`.row-number-state-grid.data-grid-root .ant-table-tbody-virtual-holder-inner .ant-table-row:hover > .ant-table-cell${fixedRowControl}`,
`.row-number-state-grid.data-grid-root .ant-table-tbody > tr:hover > td${fixedRowControl}`,
];
fixedRowControlHoverSelectors.forEach((selector) => {
const ruleStart = css.indexOf(selector);
expect(ruleStart).toBeGreaterThan(transparentHover);
const ruleEnd = css.indexOf('}', ruleStart);
const rule = css.slice(ruleStart, ruleEnd + 1);
expect(rule).toContain('background: var(--gn-bg-panel, #ffffff) !important;');
expect(rule).toContain('background-image: none !important;');
});
const fixedRowControlSelectedSelectors = [
`.row-number-state-grid.data-grid-root .ant-table-tbody-virtual-holder .ant-table-row:is(.ant-table-row-selected, .ant-table-row-selected:hover) > .ant-table-cell${fixedRowControl}`,
`.row-number-state-grid.data-grid-root .ant-table-tbody-virtual .ant-table-row:is(.ant-table-row-selected, .ant-table-row-selected:hover) > .ant-table-cell${fixedRowControl}`,
`.row-number-state-grid.data-grid-root .ant-table-tbody-virtual-holder-inner .ant-table-row:is(.ant-table-row-selected, .ant-table-row-selected:hover) > .ant-table-cell${fixedRowControl}`,
`.row-number-state-grid.data-grid-root .ant-table-tbody > tr:is(.ant-table-row-selected, .ant-table-row-selected:hover) > td${fixedRowControl}`,
];
fixedRowControlSelectedSelectors.forEach((selector) => {
const ruleStart = css.indexOf(selector);
expect(ruleStart).toBeGreaterThan(transparentHover);
const ruleEnd = css.indexOf('}', ruleStart);
const rule = css.slice(ruleStart, ruleEnd + 1);
expect(rule).toContain('background-color: var(--gn-bg-panel, #ffffff) !important;');
expect(rule).toContain('background-image: linear-gradient(');
expect(rule).toContain('var(--gn-bg-selected, rgba(34, 197, 94, 0.14))');
});
});
it('uses the table cell as the only V2 inline edit frame', () => {
const css = readV2ThemeCss();
const inlineEditorCss = css.slice(

View File

@@ -1186,6 +1186,8 @@ describe('Sidebar locate toolbar', () => {
expect(source).toContain('onWheelCapture={handleTreeWheel}');
expect(source).toContain('onTouchMoveCapture={markTreeScrollActivity}');
expect(source).toContain('setIsTreeScrolling(false)');
expect(source).toContain('SIDEBAR_TREE_SCROLL_IDLE_DELAY_MS = 2000');
expect(source).toContain('}, SIDEBAR_TREE_SCROLL_IDLE_DELAY_MS);');
const idleScrollbarCss = readCssRuleBlock(
css,

View File

@@ -335,6 +335,7 @@ const SIDEBAR_LOCATE_LOAD_WAIT_ATTEMPTS = 160;
const SIDEBAR_CACHED_DATABASE_TREE_LIMIT = 12;
const NACOS_SERVICES_CHANGED_EVENT = 'gonavi:nacos-services-changed';
const SIDEBAR_GROUP_HOVER_EXPAND_DELAY_MS = 500;
const SIDEBAR_TREE_SCROLL_IDLE_DELAY_MS = 2000;
type SidebarTreeDragEventLike = {
dataTransfer?: DataTransfer | null;
@@ -1048,7 +1049,7 @@ const Sidebar: React.FC<{
treeScrollIdleTimerRef.current = window.setTimeout(() => {
treeScrollIdleTimerRef.current = null;
setIsTreeScrolling(false);
}, 500);
}, SIDEBAR_TREE_SCROLL_IDLE_DELAY_MS);
}, [isV2Ui]);
const handleTreeWheel = useCallback((event: React.WheelEvent<HTMLDivElement>) => {
@@ -1469,7 +1470,7 @@ const Sidebar: React.FC<{
case 'external-sql-root':
return <FolderOpenOutlined />;
case 'external-sql-directory':
return <HddOutlined />;
return node.dataRef.directoryStatus === 'missing' ? <WarningOutlined /> : <HddOutlined />;
case 'external-sql-folder':
return <FolderOutlined />;
default:
@@ -1487,9 +1488,14 @@ const Sidebar: React.FC<{
const buildExternalSQLRootTreeNode = useCallback((
directories: ExternalSQLDirectory[] = externalSQLDirectories,
directoryTrees: Record<string, ExternalSQLTreeEntry[]> = externalSQLDirectoryTreesRef.current,
directoryStatuses: Record<string, 'missing'> = {},
): TreeNode => decorateExternalSQLTreeNode(buildExternalSQLRootNode({
directories,
directoryTrees,
directoryStatuses,
labels: {
missingDirectory: t('sidebar.message.external_sql_directory_not_found'),
},
})), [externalSQLDirectories]);
const refreshGlobalExternalSQLRootNode = useCallback(async (
@@ -1498,16 +1504,22 @@ const Sidebar: React.FC<{
) => {
const targetDirectories = directoriesOverride || externalSQLDirectories;
const directoryTrees: Record<string, ExternalSQLTreeEntry[]> = {};
const directoryStatuses: Record<string, 'missing'> = {};
await Promise.all(targetDirectories.map(async (directory) => {
const directoryRes = await ListSQLDirectory(directory.path);
if (!directoryRes.success) {
message.warning({
key: `external-sql-${directory.id}`,
content: t('sidebar.message.external_sql_directory_read_failed', {
name: directory.name,
error: directoryRes.message,
}),
});
const errorCode = String((directoryRes.data as Record<string, unknown> | undefined)?.errorCode || '').trim();
if (errorCode === 'directory_not_found') {
directoryStatuses[directory.id] = 'missing';
} else {
message.warning({
key: `external-sql-${directory.id}`,
content: t('sidebar.message.external_sql_directory_read_failed', {
name: directory.name,
error: directoryRes.message,
}),
});
}
directoryTrees[directory.id] = [];
return;
}
@@ -1516,7 +1528,7 @@ const Sidebar: React.FC<{
: [];
}));
externalSQLDirectoryTreesRef.current = directoryTrees;
const rootNode = buildExternalSQLRootTreeNode(targetDirectories, directoryTrees);
const rootNode = buildExternalSQLRootTreeNode(targetDirectories, directoryTrees, directoryStatuses);
setTreeData((prev) => {
const withoutExternalRoot = prev.filter((node) => node.type !== 'external-sql-root');
const nextTreeData = [...withoutExternalRoot, rootNode];

View File

@@ -435,6 +435,79 @@ describe('TableOverview metadata compatibility', () => {
expect(renderedText).toContain('embeddings');
});
it.each(['postgres', 'kingbase'])('shows bare table names for %s while preserving qualified operation targets', async (type) => {
storeState.appearance = { uiVersion: 'v2', tableDoubleClickAction: 'open-data' };
storeState.connections = [
{
id: 'conn-1',
config: {
type,
host: '127.0.0.1',
port: 20035,
user: 'postgres',
password: 'secret',
database: 'dbx_test',
useSSH: false,
ssh: { host: '', port: 22, user: '', password: '', keyPath: '' },
},
},
];
backendApp.DBQuery.mockResolvedValue({
success: true,
data: [
{
table_name: 'reporting.orders',
table_comment: 'Orders',
table_rows: 12,
data_length: 4096,
index_length: 1024,
},
],
});
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<TableOverview tab={{
id: 'tab-1',
title: '表概览 - dbx_test',
type: 'table-overview',
connectionId: 'conn-1',
dbName: 'dbx_test',
schemaName: 'reporting',
} as any} />);
});
await flushPromises();
expect(backendApp.DBQuery).toHaveBeenCalledOnce();
expect(String(backendApp.DBQuery.mock.calls[0]?.[2] || '')).toContain("n.nspname = 'reporting'");
const assertBareTableName = () => {
const renderedText = collectText(renderer!.toJSON());
expect(renderedText).toContain('dbx_test · reporting');
expect(renderedText).toContain('orders');
expect(renderedText).not.toContain('reporting.orders');
};
assertBareTableName();
await act(async () => {
renderer!.root.findByProps({ 'data-table-overview-view-mode': 'list' }).props.onClick();
});
assertBareTableName();
await act(async () => {
renderer!.root.findByProps({ 'data-table-overview-view-mode': 'table' }).props.onClick();
});
assertBareTableName();
storeState.addTab.mockClear();
await act(async () => {
renderer!.root.findByProps({ 'data-table-overview-row': 'reporting.orders' }).props.onDoubleClick();
});
expect(storeState.addTab).toHaveBeenCalledWith(expect.objectContaining({
type: 'table',
tableName: 'reporting.orders',
}));
});
it.each([
{ type: 'oracle', dbName: 'APP' },
{ type: 'trino', dbName: 'catalog' },

View File

@@ -34,6 +34,7 @@ import { confirmCopyTable } from './tableCopyAction';
import { APP_POPUP_Z_INDEX } from '../utils/overlayZIndex';
import { formatSidebarTableTimestamp } from './sidebar/sidebarHelpers';
import { confirmProductionMutation } from '../utils/productionRiskConfirm';
import { stripSchemaFromTabObjectLabel } from '../utils/tabDisplay';
interface TableOverviewProps {
tab: TabData;
@@ -142,6 +143,21 @@ const getMetadataDialect = (connType: string, driver?: string, oceanBaseProtocol
return type;
};
const isSchemaScopedTableOverviewDialect = (dialect: string): boolean => [
'postgres',
'kingbase',
'vastbase',
'highgo',
'opengauss',
'gaussdb',
].includes(dialect);
const getTableOverviewDisplayName = (dialect: string, tableName: string): string => {
const rawName = String(tableName || '').trim();
if (!isSchemaScopedTableOverviewDialect(dialect)) return rawName;
return stripSchemaFromTabObjectLabel(rawName) || rawName;
};
const buildTableStatusSQL = (dialect: string, dbName: string, schemaName?: string): string => {
const escapeLiteral = (s: string) => s.replace(/'/g, "''");
const iotdbDevicePattern = (name: string) => {
@@ -291,6 +307,9 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
[connection?.config?.driver, connection?.config?.oceanBaseProtocol, connection?.config?.type]
);
const schemaName = String((tab as any).schemaName || '').trim();
const overviewSchemaName = isSchemaScopedTableOverviewDialect(metadataDialect)
? (schemaName || 'public')
: '';
const supportsDesignWrite = !getDataSourceCapabilities(connection?.config).forceReadOnlyStructureDesigner;
const supportsCopyTable = getDataSourceCapabilities(connection?.config).supportsCopyTable;
const autoFetchVisible = useAutoFetchVisibility();
@@ -1186,9 +1205,9 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
>
<div className={isV2Ui ? 'gn-v2-table-card-name' : undefined} style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<TableOutlined style={{ fontSize: 14, color: accentColor }} />
<Tooltip title={table.name} mouseEnterDelay={0.4}>
<Tooltip title={getTableOverviewDisplayName(metadataDialect, table.name)} mouseEnterDelay={0.4}>
<span style={{ fontSize: 13, fontWeight: 600, color: textPrimary, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1, display: 'block' }}>
{table.name}
{getTableOverviewDisplayName(metadataDialect, table.name)}
</span>
</Tooltip>
</div>
@@ -1230,6 +1249,7 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
const renderListTable = (table: TableStatRow) => {
const combinedSize = getCombinedTableSize(table);
const displayName = getTableOverviewDisplayName(metadataDialect, table.name);
const sizeRatio = maxCombinedSize > 0 && hasKnownTableSize(table) ? combinedSize / maxCombinedSize : 0;
const fillWidth = maxCombinedSize > 0 && hasKnownTableSize(table) ? `${Math.max(10, Math.round(sizeRatio * 100))}%` : '0%';
const fillColor = isV2Ui
@@ -1285,9 +1305,9 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
<div style={{ minWidth: 0, flex: '1 1 320px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
<TableOutlined style={{ fontSize: 13, color: accentColor, flexShrink: 0 }} />
<Tooltip title={table.name} mouseEnterDelay={0.4}>
<Tooltip title={displayName} mouseEnterDelay={0.4}>
<span style={{ color: textPrimary, fontWeight: 600, fontSize: 13, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{table.name}
{displayName}
</span>
</Tooltip>
{table.engine && (
@@ -1423,6 +1443,7 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
);
const renderCompactTableRow = (table: TableStatRow) => {
const displayName = getTableOverviewDisplayName(metadataDialect, table.name);
const content = (
<div
className="gn-table-overview-compact-row"
@@ -1447,9 +1468,9 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
}),
}}
>
<div className="gn-table-overview-compact-name" role="cell" title={table.name}>
<div className="gn-table-overview-compact-name" role="cell" title={displayName}>
<TableOutlined aria-hidden="true" />
<span>{table.name}</span>
<span>{displayName}</span>
</div>
<div className="gn-table-overview-compact-cell" role="cell" title={table.comment || undefined}>{table.comment || '—'}</div>
<div className="gn-table-overview-compact-cell gn-table-overview-compact-number" role="cell" title={table.rows >= 0 ? String(table.rows) : undefined}>{formatRows(table.rows)}</div>
@@ -1508,7 +1529,9 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
<span className={isV2Ui ? 'gn-v2-table-overview-icon' : undefined}>
<DatabaseOutlined style={{ fontSize: 16, color: isV2Ui ? undefined : accentColor }} />
</span>
<span className={isV2Ui ? 'gn-v2-table-overview-title' : undefined} style={{ fontSize: 14, fontWeight: 600, color: textPrimary }}>{tab.dbName}</span>
<span className={isV2Ui ? 'gn-v2-table-overview-title' : undefined} style={{ fontSize: 14, fontWeight: 600, color: textPrimary }}>
{[tab.dbName, overviewSchemaName].filter(Boolean).join(' · ')}
</span>
<span className={isV2Ui ? 'gn-table-overview-summary gn-v2-table-overview-summary' : 'gn-table-overview-summary'} style={{ fontSize: 12, color: textMuted }}>
{renderToolbarSummary()}
</span>

View File

@@ -1,6 +1,8 @@
import { describe, expect, it } from 'vitest';
import {
ATLAS_CLOUD_BASE_URL,
ATLAS_CLOUD_DEFAULT_MODEL,
QWEN_CODING_PLAN_ANTHROPIC_BASE_URL,
resolvePresetBaseURL,
resolvePresetTransport,
@@ -53,6 +55,21 @@ describe('aiSettingsModalConfig', () => {
expect(preset.key).toBe('cursor');
});
it('exposes and recognizes the Atlas Cloud preset', () => {
const preset = findPreset('atlascloud');
expect(preset).toMatchObject({
label: 'Atlas Cloud',
backendType: 'openai',
defaultBaseUrl: ATLAS_CLOUD_BASE_URL,
defaultModel: ATLAS_CLOUD_DEFAULT_MODEL,
});
expect(matchProviderPreset({
type: 'openai',
baseUrl: ATLAS_CLOUD_BASE_URL,
}).key).toBe('atlascloud');
});
it('supports every configured MiniMax region and protocol endpoint', () => {
const preset = findPreset('minimax');
@@ -125,6 +142,7 @@ describe('aiSettingsModalConfig', () => {
});
it('keeps the provider preset list available for the settings modal', () => {
expect(PROVIDER_PRESETS.some((item) => item.key === 'atlascloud')).toBe(true);
expect(PROVIDER_PRESETS.some((item) => item.key === 'codex')).toBe(true);
expect(PROVIDER_PRESETS.some((item) => item.key === 'claude-subscription')).toBe(true);
expect(PROVIDER_PRESETS.some((item) => item.key === 'codebuddy')).toBe(true);

View File

@@ -16,6 +16,8 @@ import type {
AIUserPromptSettings,
} from '../../types';
import {
ATLAS_CLOUD_BASE_URL,
ATLAS_CLOUD_DEFAULT_MODEL,
QWEN_BAILIAN_ANTHROPIC_BASE_URL,
QWEN_CODING_PLAN_ANTHROPIC_BASE_URL,
QWEN_CODING_PLAN_MODELS,
@@ -50,6 +52,7 @@ export const MINIMAX_ENDPOINTS: ProviderPresetEndpoint[] = [
export const PROVIDER_PRESETS: ProviderPreset[] = [
{ key: 'openai', label: 'OpenAI', labelKey: 'ai_settings.provider_preset.openai.label', icon: <ApiOutlined />, desc: 'GPT-5.4 / 5.3 series', descKey: 'ai_settings.provider_preset.openai.desc', color: '#10b981', backendType: 'openai', defaultBaseUrl: 'https://api.openai.com/v1', defaultModel: 'gpt-4o', models: [] },
{ key: 'atlascloud', label: 'Atlas Cloud', labelKey: 'ai_settings.provider_preset.atlascloud.label', icon: <CloudOutlined />, desc: 'Qwen3.8 Max / OpenAI-compatible', descKey: 'ai_settings.provider_preset.atlascloud.desc', color: '#0891b2', backendType: 'openai', defaultBaseUrl: ATLAS_CLOUD_BASE_URL, defaultModel: ATLAS_CLOUD_DEFAULT_MODEL, models: [] },
{ key: 'codex', label: 'Codex Subscription', labelKey: 'ai_settings.provider_preset.codex.label', icon: <ApiOutlined />, desc: 'Local Codex CLI / ChatGPT subscription login', descKey: 'ai_settings.provider_preset.codex.desc', color: '#111827', backendType: 'custom', fixedApiFormat: 'codex-cli', authMode: 'local-cli', defaultBaseUrl: '', defaultModel: '', models: [] },
{ key: 'deepseek', label: 'DeepSeek', labelKey: 'ai_settings.provider_preset.deepseek.label', icon: <ThunderboltOutlined />, desc: 'DeepSeek-V4 / R1', descKey: 'ai_settings.provider_preset.deepseek.desc', color: '#3b82f6', backendType: 'openai', defaultBaseUrl: 'https://api.deepseek.com/v1', defaultModel: 'deepseek-chat', models: [] },
{ key: 'qwen-bailian', label: 'Qwen (Bailian General)', labelKey: 'ai_settings.provider_preset.qwen_bailian.label', icon: <CloudOutlined />, desc: 'Bailian Anthropic-compatible endpoint / remote model list', descKey: 'ai_settings.provider_preset.qwen_bailian.desc', color: '#6366f1', backendType: 'anthropic', defaultBaseUrl: QWEN_BAILIAN_ANTHROPIC_BASE_URL, defaultModel: '', models: [] },

View File

@@ -426,6 +426,15 @@ export const buildDataGridCssText = ({
.${gridId}.data-grid-root .ant-table-tbody .ant-table-row:hover > .ant-table-cell { background-color: transparent !important; }
/* 固定行控制列(序号/单选/多选)在整行 hover 透明时仍保持实心底。 */
.${gridId}.data-grid-root .ant-table-tbody-virtual-holder .ant-table-row:hover > .ant-table-cell:is(.data-grid-row-number-cell, .ant-table-selection-column),
.${gridId}.data-grid-root .ant-table-tbody-virtual .ant-table-row:hover > .ant-table-cell:is(.data-grid-row-number-cell, .ant-table-selection-column),
.${gridId}.data-grid-root .ant-table-tbody-virtual-holder-inner .ant-table-row:hover > .ant-table-cell:is(.data-grid-row-number-cell, .ant-table-selection-column),
.${gridId}.data-grid-root .ant-table-tbody > tr:hover > td:is(.data-grid-row-number-cell, .ant-table-selection-column) {
background: var(--gn-bg-panel, ${bgContent}) !important;
background-image: none !important;
}
/*
* 行选中:整行统一绿色。
* 关键virtual-holder 下有
@@ -457,6 +466,18 @@ export const buildDataGridCssText = ({
background-image: none !important;
}
/* 选中行同样覆盖固定行控制列,保持与整行一致的选中底色。 */
.${gridId}.data-grid-root .ant-table-tbody-virtual-holder .ant-table-row:is(.ant-table-row-selected, .ant-table-row-selected:hover) > .ant-table-cell:is(.data-grid-row-number-cell, .ant-table-selection-column),
.${gridId}.data-grid-root .ant-table-tbody-virtual .ant-table-row:is(.ant-table-row-selected, .ant-table-row-selected:hover) > .ant-table-cell:is(.data-grid-row-number-cell, .ant-table-selection-column),
.${gridId}.data-grid-root .ant-table-tbody-virtual-holder-inner .ant-table-row:is(.ant-table-row-selected, .ant-table-row-selected:hover) > .ant-table-cell:is(.data-grid-row-number-cell, .ant-table-selection-column),
.${gridId}.data-grid-root .ant-table-tbody > tr:is(.ant-table-row-selected, .ant-table-row-selected:hover) > td:is(.data-grid-row-number-cell, .ant-table-selection-column) {
background-color: var(--gn-bg-panel, ${bgContent}) !important;
background-image: linear-gradient(
var(--gn-bg-selected, rgba(34, 197, 94, 0.14)),
var(--gn-bg-selected, rgba(34, 197, 94, 0.14))
) !important;
}
.${gridId} .row-added td,
.${gridId} .row-added > .ant-table-cell { background-color: ${rowAddedBg} !important; color: ${darkMode ? '#e6fffb' : 'inherit'}; }

View File

@@ -7,6 +7,7 @@ import { noAutoCapInputProps } from '../../utils/inputAutoCap';
import {
buildExternalSQLDirectoryId,
buildExternalSQLTabId,
findExternalSQLDirectoriesByPath,
moveExternalSQLFileBindings,
normalizeExternalSQLPath,
removeExternalSQLFileBindings,
@@ -913,9 +914,9 @@ export const useSidebarExternalSqlWorkflow = ({
}
if (externalSQLFileTarget?.type === 'external-sql-directory') {
const nextName = String(payload.name || name).trim();
const previousDirectoryPath = normalizeExternalSQLPath(directoryPath);
const matchingDirectories = externalSQLDirectories.filter(
(directory) => normalizeExternalSQLPath(directory.path) === previousDirectoryPath,
const matchingDirectories = findExternalSQLDirectoriesByPath(
externalSQLDirectories,
directoryPath,
);
if (!nextPath || matchingDirectories.length === 0) {
message.error(t('sidebar.message.external_sql_directory_rename_sync_failed'));
@@ -1020,9 +1021,9 @@ export const useSidebarExternalSqlWorkflow = ({
removeRecentSQLFilesByDirectory(directoryPath);
if (node?.type === 'external-sql-directory') {
const normalizedDirectoryPath = normalizeExternalSQLPath(directoryPath);
const matchingDirectories = externalSQLDirectories.filter(
(directory) => normalizeExternalSQLPath(directory.path) === normalizedDirectoryPath,
const matchingDirectories = findExternalSQLDirectoriesByPath(
externalSQLDirectories,
directoryPath,
);
if (matchingDirectories.length > 0) {
const matchingDirectoryIds = new Set(matchingDirectories.map((directory) => directory.id));
@@ -1087,13 +1088,18 @@ export const useSidebarExternalSqlWorkflow = ({
};
const handleRemoveExternalSQLDirectory = async (node: any) => {
const directoryId = String(node?.dataRef?.id || '').trim();
if (!directoryId) {
const directoryPath = String(node?.dataRef?.path || '').trim();
if (!directoryPath) {
message.error(t('sidebar.message.external_sql_directory_not_found'));
return;
}
deleteExternalSQLDirectory(directoryId);
const nextDirectories = externalSQLDirectories.filter((item) => item.id !== directoryId);
const matchingDirectories = findExternalSQLDirectoriesByPath(
externalSQLDirectories,
directoryPath,
);
matchingDirectories.forEach((directory) => deleteExternalSQLDirectory(directory.id));
const matchingDirectoryIds = new Set(matchingDirectories.map((directory) => directory.id));
const nextDirectories = externalSQLDirectories.filter((item) => !matchingDirectoryIds.has(item.id));
await refreshGlobalExternalSQLRootNode(false, nextDirectories);
message.success(t('sidebar.message.external_sql_directory_removed'));
};

View File

@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest';
import type { AIProviderAuthMode, AIProviderType } from '../types';
import {
ATLAS_CLOUD_BASE_URL,
LEGACY_QWEN_CODING_PLAN_OPENAI_BASE_URL,
QWEN_BAILIAN_ANTHROPIC_BASE_URL,
QWEN_BAILIAN_MODELS_BASE_URL,
@@ -24,6 +25,7 @@ type PresetMatcher = {
const PRESETS: PresetMatcher[] = [
{ key: 'openai', backendType: 'openai', defaultBaseUrl: 'https://api.openai.com/v1' },
{ key: 'atlascloud', backendType: 'openai', defaultBaseUrl: ATLAS_CLOUD_BASE_URL },
{ key: 'qwen-bailian', backendType: 'anthropic', defaultBaseUrl: QWEN_BAILIAN_ANTHROPIC_BASE_URL },
{
key: 'qwen-coding-plan',
@@ -212,6 +214,13 @@ describe('ai provider preset helpers', () => {
});
describe('resolveProviderPresetKey', () => {
it('recognizes Atlas Cloud by its OpenAI-compatible endpoint', () => {
expect(resolveProviderPresetKey({
type: 'openai',
baseUrl: `${ATLAS_CLOUD_BASE_URL}/`,
}, PRESETS, 'custom')).toBe('atlascloud');
});
it('不会把自定义 OpenAI 端点误识别成千问 Coding Plan', () => {
const key = resolveProviderPresetKey(
{

View File

@@ -5,6 +5,8 @@ export const LEGACY_QWEN_CODING_PLAN_OPENAI_BASE_URL = 'https://coding.dashscope
export const QWEN_BAILIAN_ANTHROPIC_BASE_URL = 'https://dashscope.aliyuncs.com/apps/anthropic';
export const QWEN_CODING_PLAN_ANTHROPIC_BASE_URL = 'https://coding.dashscope.aliyuncs.com/apps/anthropic';
export const QWEN_BAILIAN_MODELS_BASE_URL = LEGACY_QWEN_BAILIAN_OPENAI_BASE_URL;
export const ATLAS_CLOUD_BASE_URL = 'https://api.atlascloud.ai/v1';
export const ATLAS_CLOUD_DEFAULT_MODEL = 'qwen/qwen3.8-max';
export const QWEN_CODING_PLAN_MODELS = [
'qwen3.5-plus',

View File

@@ -4,6 +4,7 @@ import type { ExternalSQLDirectory, ExternalSQLTreeEntry } from '../types';
import {
buildExternalSQLRootNode,
buildExternalSQLTabId,
findExternalSQLDirectoriesByPath,
moveExternalSQLFileBindings,
removeExternalSQLFileBindings,
resolveExternalSQLFileBinding,
@@ -60,6 +61,32 @@ describe('externalSqlTree helpers', () => {
});
});
it('marks a missing directory instead of presenting it as empty', () => {
const node = buildExternalSQLRootNode({
directories: [{
id: 'dir-missing',
name: 'archived scripts',
path: 'D:/sql/missing',
createdAt: 1,
}],
directoryTrees: {},
directoryStatuses: {
'dir-missing': 'missing',
},
labels: {
missingDirectory: 'Missing',
},
});
expect(node.children?.[0]).toMatchObject({
title: 'archived scripts (Missing)',
isLeaf: true,
dataRef: {
directoryStatus: 'missing',
},
});
});
it('uses localized root and directory fallback labels while preserving explicit and path segment names', () => {
const directories: ExternalSQLDirectory[] = [
{
@@ -237,6 +264,38 @@ describe('externalSqlTree helpers', () => {
});
});
it('finds every binding for the same directory path', () => {
const matching = findExternalSQLDirectoriesByPath([
{
id: 'dir-orders',
name: 'scripts',
path: 'D:/sql/shared',
connectionId: 'connection-1',
dbName: 'orders',
createdAt: 1,
},
{
id: 'dir-reporting',
name: 'scripts',
path: 'D:\\sql\\shared',
connectionId: 'connection-2',
dbName: 'reporting',
createdAt: 2,
},
{
id: 'dir-other',
name: 'other',
path: 'D:/sql/other',
createdAt: 3,
},
], 'D:/sql/shared');
expect(matching.map((directory) => directory.id)).toEqual([
'dir-orders',
'dir-reporting',
]);
});
it('updates file bindings when a file or containing folder moves and removes deleted subtrees', () => {
const directory: ExternalSQLDirectory = {
id: 'dir-1',

View File

@@ -25,12 +25,14 @@ type BuildExternalSQLRootNodeParams = {
dbName?: string;
directories: ExternalSQLDirectory[];
directoryTrees: Record<string, ExternalSQLTreeEntry[]>;
directoryStatuses?: Record<string, 'missing'>;
labels?: Partial<ExternalSQLTreeLabels>;
};
export type ExternalSQLTreeLabels = {
root: string;
directoryFallback: string;
missingDirectory: string;
};
export const normalizeExternalSQLPath = (value: string): string =>
@@ -98,6 +100,17 @@ export const resolveExternalSQLFileBinding = (
return undefined;
};
export const findExternalSQLDirectoriesByPath = (
directories: ExternalSQLDirectory[],
directoryPath: string,
): ExternalSQLDirectory[] => {
const normalizedDirectoryPath = normalizeExternalSQLPath(directoryPath);
if (!normalizedDirectoryPath) return [];
return directories.filter(
(directory) => normalizeExternalSQLPath(directory.path) === normalizedDirectoryPath,
);
};
export const setExternalSQLFileBinding = (
directory: ExternalSQLDirectory,
filePath: string,
@@ -174,12 +187,15 @@ export const removeExternalSQLFileBindings = (
const DEFAULT_EXTERNAL_SQL_TREE_LABELS: ExternalSQLTreeLabels = {
root: 'External SQL files',
directoryFallback: 'SQL directory',
missingDirectory: 'Missing',
};
const resolveExternalSQLTreeLabels = (labels?: Partial<ExternalSQLTreeLabels>): ExternalSQLTreeLabels => ({
root: String(labels?.root || '').trim() || DEFAULT_EXTERNAL_SQL_TREE_LABELS.root,
directoryFallback:
String(labels?.directoryFallback || '').trim() || DEFAULT_EXTERNAL_SQL_TREE_LABELS.directoryFallback,
missingDirectory:
String(labels?.missingDirectory || '').trim() || DEFAULT_EXTERNAL_SQL_TREE_LABELS.missingDirectory,
});
const resolveDirectoryDisplayName = (
@@ -289,6 +305,7 @@ export const buildExternalSQLRootNode = ({
dbName = '',
directories,
directoryTrees,
directoryStatuses = {},
labels,
}: BuildExternalSQLRootNodeParams): ExternalSQLTreeNode => {
const resolvedLabels = resolveExternalSQLTreeLabels(labels);
@@ -303,6 +320,7 @@ export const buildExternalSQLRootNode = ({
// must retain its own target so every nested SQL file opens against that DB.
const directoryConnectionId = String(directory.connectionId || '').trim() || connectionId;
const directoryDbName = String(directory.dbName || '').trim() || dbName;
const directoryStatus = directoryStatuses[directory.id];
const directoryChildren = mapExternalSQLTreeEntries(directoryTrees[directory.id] || [], {
connectionId: directoryConnectionId,
dbName: directoryDbName,
@@ -310,14 +328,18 @@ export const buildExternalSQLRootNode = ({
directoryId: directory.id,
fileBindings: directory.fileBindings,
});
const directoryTitle = resolveDirectoryDisplayName(directory, resolvedLabels);
return {
title: resolveDirectoryDisplayName(directory, resolvedLabels),
title: directoryStatus === 'missing'
? `${directoryTitle} (${resolvedLabels.missingDirectory})`
: directoryTitle,
key: buildExternalSQLNodeKey('external-sql-directory', directory.id),
type: 'external-sql-directory' as const,
isLeaf: directoryChildren.length === 0,
children: directoryChildren.length > 0 ? directoryChildren : undefined,
dataRef: {
...directory,
...(directoryStatus ? { directoryStatus } : {}),
connectionId: directoryConnectionId,
dbName: directoryDbName,
dbNodeKey,

View File

@@ -248,7 +248,7 @@ describe('tabDisplay', () => {
layout: 'single',
primaryElements: ['object', 'schema', 'host'],
secondaryElements: [],
})).toBe('andon_events SCHEMA:ldf_server 192.168.10.8');
})).toBe('andon_events ldf_server 192.168.10.8');
});
it('builds the default configurable model with the object on the primary line', () => {
@@ -404,8 +404,8 @@ describe('tabDisplay', () => {
expect(model.layout).toBe('double');
expect(model.primaryText).toBe('TABLE events');
expect(model.secondaryText).toBe('[PROD]·analytics·SCHEMA:reporting·10.0.0.9');
expect(model.fullTitle).toBe('TABLE events · [PROD]·analytics·SCHEMA:reporting·10.0.0.9');
expect(model.secondaryText).toBe('[PROD]·analytics·reporting·10.0.0.9');
expect(model.fullTitle).toBe('TABLE events · [PROD]·analytics·reporting·10.0.0.9');
});
it('uses explicit schema metadata for unqualified table names', () => {
@@ -426,7 +426,7 @@ describe('tabDisplay', () => {
});
expect(model.primaryText).toBe('events');
expect(model.secondaryText).toBe('SCHEMA:reporting');
expect(model.secondaryText).toBe('reporting');
});
it('sanitizes tab display settings with fallback defaults', () => {

View File

@@ -600,7 +600,6 @@ const getTabDisplayElementValue = (
const formatTabDisplayPartValue = (key: TabDisplayElementKey, value: string): string => {
if (!value) return '';
if (key === 'connection') return `[${value}]`;
if (key === 'schema') return `SCHEMA:${value}`;
return value;
};