Merge remote-tracking branch 'origin/dev' into feature/20260602_connection_driver_i18n

# Conflicts:
#	frontend/src/App.tsx
#	frontend/src/components/AISettingsModal.tsx
#	frontend/src/components/ConnectionModal.edit-password.test.tsx
#	frontend/src/components/ConnectionModal.tsx
#	frontend/src/components/DataSyncModal.i18n.test.ts
#	frontend/src/components/DataSyncModal.tsx
#	frontend/src/components/QueryEditor.external-sql-save.test.tsx
#	frontend/src/components/QueryEditor.tsx
#	frontend/src/components/Sidebar.locate-toolbar.test.tsx
#	frontend/src/components/Sidebar.tsx
#	frontend/src/components/SnippetSettingsModal.tsx
#	frontend/src/components/TableOverview.tsx
#	frontend/src/components/ai/AIChatHeader.test.tsx
#	frontend/src/components/ai/AISettingsProvidersSection.tsx
#	frontend/src/components/ai/aiChatPayloadDispatch.ts
#	frontend/src/components/ai/aiChatReadiness.ts
#	frontend/src/components/ai/aiSettingsModalConfig.tsx
#	frontend/src/components/ai/messageBubble/AIMessageCodeBlock.tsx
#	frontend/src/components/sidebarV2Utils.ts
#	frontend/src/i18n/catalog.test.ts
#	frontend/src/utils/connectionTypeCatalog.test.ts
#	frontend/src/utils/connectionTypeCatalog.ts
#	frontend/src/utils/tabDisplay.ts
#	internal/ai/provider/custom.go
#	internal/ai/service/service.go
#	internal/app/methods_driver.go
#	internal/app/methods_file.go
#	internal/db/custom_impl.go
#	internal/db/iris_impl.go
#	internal/db/mariadb_impl.go
#	internal/db/sqlserver_impl.go
#	shared/i18n/de-DE.json
#	shared/i18n/en-US.json
#	shared/i18n/ja-JP.json
#	shared/i18n/ru-RU.json
#	shared/i18n/zh-CN.json
#	shared/i18n/zh-TW.json
This commit is contained in:
tianqijiuyun-latiao
2026-06-23 12:41:27 +08:00
295 changed files with 107350 additions and 69164 deletions

View File

@@ -1,4 +1,4 @@
import { DEFAULT_SHORTCUT_OPTIONS, getShortcutDisplayLabel, isShortcutMatch, type ShortcutPlatform, type ShortcutPlatformBinding } from './shortcuts';
import { DEFAULT_SHORTCUT_OPTIONS, getShortcutDisplayLabel, isImeComposingKeyEvent, isShortcutMatch, type ShortcutPlatform, type ShortcutPlatformBinding } from './shortcuts';
export interface AIChatSendShortcutKeyEventLike {
key?: string;
@@ -43,12 +43,7 @@ export const shouldSendAIChatOnKeyDown = (
if (!binding?.enabled) {
return false;
}
// Some IMEs report Enter during an active candidate/composition as keyCode 229.
const isImeCandidateEvent = event.keyCode === 229
|| event.which === 229
|| event.nativeEvent?.keyCode === 229
|| event.nativeEvent?.which === 229;
if (event.shiftKey || event.isComposing || event.nativeEvent?.isComposing || isImeCandidateEvent) {
if (event.shiftKey || isImeComposingKeyEvent(event as KeyboardEvent)) {
return false;
}
return isShortcutMatch(event as KeyboardEvent, binding.combo);

View File

@@ -29,6 +29,8 @@ const PRESETS: PresetMatcher[] = [
defaultBaseUrl: QWEN_CODING_PLAN_ANTHROPIC_BASE_URL,
fixedApiFormat: 'claude-cli',
},
{ key: 'codebuddy', backendType: 'custom', defaultBaseUrl: '', fixedApiFormat: 'codebuddy-cli' },
{ key: 'cursor', backendType: 'custom', defaultBaseUrl: 'https://api.cursor.com/v1', fixedApiFormat: 'cursor-agent' },
{ key: 'custom', backendType: 'custom', defaultBaseUrl: '' },
];
@@ -102,6 +104,19 @@ describe('ai provider preset helpers', () => {
});
});
it('keeps Cursor model empty when only a suggested model list is configured', () => {
expect(resolvePresetModelSelection({
presetKey: 'cursor',
presetDefaultModel: '',
presetModels: [],
valuesModel: '',
customModels: ['composer-2', 'composer-latest'],
})).toEqual({
model: '',
models: ['composer-2', 'composer-latest'],
});
});
it('forces built-in presets back to their standard base URL when saving or testing', () => {
expect(resolvePresetBaseURL({
presetKey: 'qwen-bailian',
@@ -112,12 +127,20 @@ describe('ai provider preset helpers', () => {
it('keeps the user-entered base URL for custom-like presets', () => {
expect(resolvePresetBaseURL({
presetKey: 'custom',
presetKey: 'codebuddy',
presetDefaultBaseUrl: '',
valuesBaseUrl: 'https://example-proxy.internal/v1',
})).toBe('https://example-proxy.internal/v1');
});
it('keeps the user-entered base URL for the Cursor preset', () => {
expect(resolvePresetBaseURL({
presetKey: 'cursor',
presetDefaultBaseUrl: 'https://api.cursor.com/v1',
valuesBaseUrl: 'https://cursor-proxy.internal/v1',
})).toBe('https://cursor-proxy.internal/v1');
});
it('forces qwen coding plan to save as custom plus claude-cli', () => {
expect(resolvePresetTransport({
presetBackendType: 'custom',
@@ -182,4 +205,32 @@ describe('resolveProviderPresetKey', () => {
expect(key).toBe('qwen-bailian');
});
it('能识别没有 Base URL 的 CodeBuddy CLI 预设', () => {
const key = resolveProviderPresetKey(
{
type: 'custom',
apiFormat: 'codebuddy-cli',
baseUrl: '',
},
PRESETS,
'custom',
);
expect(key).toBe('codebuddy');
});
it('能识别 Cursor Agent 预设', () => {
const key = resolveProviderPresetKey(
{
type: 'custom',
apiFormat: 'cursor-agent',
baseUrl: 'https://api.cursor.com/v1',
},
PRESETS,
'custom',
);
expect(key).toBe('cursor');
});
});

View File

@@ -17,7 +17,7 @@ export const QWEN_CODING_PLAN_MODELS = [
'glm-4.7',
];
const CUSTOM_LIKE_PRESET_KEYS = new Set(['custom', 'ollama']);
const CUSTOM_LIKE_PRESET_KEYS = new Set(['custom', 'ollama', 'codebuddy', 'cursor']);
export interface ResolvePresetModelSelectionInput {
presetKey: string;
@@ -126,6 +126,17 @@ export const resolveProviderPresetKey = (
}
const fingerprint = getProviderFingerprint(provider.baseUrl);
const formatOnlyPreset = presets.find((preset) =>
preset.backendType === provider.type
&& Boolean(preset.fixedApiFormat)
&& preset.fixedApiFormat === provider.apiFormat
&& getProviderFingerprint(preset.defaultBaseUrl) === ''
&& fingerprint === '',
);
if (formatOnlyPreset) {
return formatOnlyPreset.key;
}
const exactPreset = presets.find((preset) =>
preset.backendType === provider.type
&& fingerprint !== ''
@@ -172,6 +183,12 @@ export const resolvePresetModelSelection = ({
}: ResolvePresetModelSelectionInput): ResolvePresetModelSelectionResult => {
const isCustomLike = CUSTOM_LIKE_PRESET_KEYS.has(presetKey);
const resolvedModels = isCustomLike ? (customModels || []) : presetModels;
if (presetKey === 'cursor') {
return {
models: resolvedModels,
model: valuesModel || '',
};
}
const fallbackModel = resolvedModels.length > 0 ? resolvedModels[0] : '';
return {
models: resolvedModels,

View File

@@ -1,57 +1,71 @@
import { describe, expect, it } from 'vitest';
import { describe, expect, it } from "vitest";
import {
isPostgresSchemaDialect,
normalizeDriverType,
resolveConnectionDriverType,
resolveSavedConnectionDriverType,
} from './connectionDriverType';
supportsIndependentSchemaSelection,
} from "./connectionDriverType";
describe('connectionDriverType', () => {
it('normalizes built-in driver aliases shared by connection modal and sidebar', () => {
expect(normalizeDriverType('postgresql')).toBe('postgres');
expect(normalizeDriverType('pgx')).toBe('postgres');
expect(normalizeDriverType('elastic')).toBe('elasticsearch');
expect(normalizeDriverType('chromadb')).toBe('chroma');
expect(normalizeDriverType('chroma-db')).toBe('chroma');
expect(normalizeDriverType('qdrantdb')).toBe('qdrant');
expect(normalizeDriverType('qdrant-db')).toBe('qdrant');
expect(normalizeDriverType('apache-iotdb')).toBe('iotdb');
expect(normalizeDriverType('apache_iotdb')).toBe('iotdb');
expect(normalizeDriverType('apache-kafka')).toBe('kafka');
expect(normalizeDriverType('apache_kafka')).toBe('kafka');
expect(normalizeDriverType('doris')).toBe('diros');
expect(normalizeDriverType('open-gauss')).toBe('opengauss');
expect(normalizeDriverType('gauss-db')).toBe('gaussdb');
expect(normalizeDriverType('greatdb')).toBe('goldendb');
expect(normalizeDriverType('gdb')).toBe('goldendb');
expect(normalizeDriverType('InterSystemsIRIS')).toBe('iris');
describe("connectionDriverType", () => {
it("normalizes built-in driver aliases shared by connection modal and sidebar", () => {
expect(normalizeDriverType("postgresql")).toBe("postgres");
expect(normalizeDriverType("pgx")).toBe("postgres");
expect(normalizeDriverType("elastic")).toBe("elasticsearch");
expect(normalizeDriverType("chromadb")).toBe("chroma");
expect(normalizeDriverType("chroma-db")).toBe("chroma");
expect(normalizeDriverType("qdrantdb")).toBe("qdrant");
expect(normalizeDriverType("qdrant-db")).toBe("qdrant");
expect(normalizeDriverType("apache-iotdb")).toBe("iotdb");
expect(normalizeDriverType("apache_iotdb")).toBe("iotdb");
expect(normalizeDriverType("apache-kafka")).toBe("kafka");
expect(normalizeDriverType("apache_kafka")).toBe("kafka");
expect(normalizeDriverType("doris")).toBe("diros");
expect(normalizeDriverType("open-gauss")).toBe("opengauss");
expect(normalizeDriverType("gauss-db")).toBe("gaussdb");
expect(normalizeDriverType("greatdb")).toBe("goldendb");
expect(normalizeDriverType("gdb")).toBe("goldendb");
expect(normalizeDriverType("InterSystemsIRIS")).toBe("iris");
});
it('resolves custom connection driver types from the selected driver field', () => {
expect(resolveConnectionDriverType('mysql', 'postgresql')).toBe('mysql');
expect(resolveConnectionDriverType('custom', 'postgresql')).toBe('postgres');
expect(resolveConnectionDriverType('custom', 'open_gauss')).toBe('opengauss');
expect(resolveConnectionDriverType('custom', 'gauss_db')).toBe('gaussdb');
expect(resolveConnectionDriverType('custom', 'goldendb')).toBe('goldendb');
expect(resolveConnectionDriverType('custom', '')).toBe('');
it("resolves custom connection driver types from the selected driver field", () => {
expect(resolveConnectionDriverType("mysql", "postgresql")).toBe("mysql");
expect(resolveConnectionDriverType("custom", "postgresql")).toBe(
"postgres",
);
expect(resolveConnectionDriverType("custom", "open_gauss")).toBe(
"opengauss",
);
expect(resolveConnectionDriverType("custom", "gauss_db")).toBe("gaussdb");
expect(resolveConnectionDriverType("custom", "goldendb")).toBe("goldendb");
expect(resolveConnectionDriverType("custom", "")).toBe("");
});
it('resolves saved custom connections using the same driver aliases', () => {
it("resolves saved custom connections using the same driver aliases", () => {
const conn = {
config: {
type: 'custom',
driver: 'pg',
type: "custom",
driver: "pg",
},
} as any;
expect(resolveSavedConnectionDriverType(conn)).toBe('postgres');
expect(resolveSavedConnectionDriverType(conn)).toBe("postgres");
});
it('detects postgres-compatible schema dialects', () => {
expect(isPostgresSchemaDialect('postgres')).toBe(true);
expect(isPostgresSchemaDialect('kingbase')).toBe(true);
expect(isPostgresSchemaDialect('open-gauss')).toBe(true);
expect(isPostgresSchemaDialect('gauss-db')).toBe(true);
expect(isPostgresSchemaDialect('mysql')).toBe(false);
it("detects postgres-compatible schema dialects", () => {
expect(isPostgresSchemaDialect("postgres")).toBe(true);
expect(isPostgresSchemaDialect("kingbase")).toBe(true);
expect(isPostgresSchemaDialect("open-gauss")).toBe(true);
expect(isPostgresSchemaDialect("gauss-db")).toBe(true);
expect(isPostgresSchemaDialect("mysql")).toBe(false);
});
it("detects dialects that need independent schema selection", () => {
expect(supportsIndependentSchemaSelection("postgres")).toBe(true);
expect(supportsIndependentSchemaSelection("sqlserver")).toBe(true);
expect(supportsIndependentSchemaSelection("iris")).toBe(true);
expect(supportsIndependentSchemaSelection("duckdb")).toBe(true);
expect(supportsIndependentSchemaSelection("oracle")).toBe(false);
expect(supportsIndependentSchemaSelection("mysql")).toBe(false);
});
});

View File

@@ -1,4 +1,4 @@
import type { SavedConnection } from '../types';
import type { SavedConnection } from "../types";
export type DriverStatusSnapshot = {
type: string;
@@ -12,53 +12,102 @@ export type DriverStatusSnapshot = {
};
export const normalizeDriverType = (value: string): string => {
const normalized = String(value || '').trim().toLowerCase();
if (normalized === 'postgresql' || normalized === 'pg' || normalized === 'pq' || normalized === 'pgx') return 'postgres';
if (normalized === 'elastic') return 'elasticsearch';
if (normalized === 'chromadb' || normalized === 'chroma-db') return 'chroma';
if (normalized === 'qdrantdb' || normalized === 'qdrant-db') return 'qdrant';
if (normalized === 'rocket-mq' || normalized === 'rocket_mq' || normalized === 'apache-rocketmq' || normalized === 'apache_rocketmq' || normalized === 'rmq') return 'rocketmq';
if (normalized === 'apache-iotdb' || normalized === 'apache_iotdb') return 'iotdb';
if (normalized === 'mqtts') return 'mqtt';
if (normalized === 'apache-kafka' || normalized === 'apache_kafka') return 'kafka';
if (normalized === 'rabbit-mq' || normalized === 'rabbit_mq') return 'rabbitmq';
if (normalized === 'doris') return 'diros';
const normalized = String(value || "")
.trim()
.toLowerCase();
if (
normalized === 'open_gauss' ||
normalized === 'open-gauss' ||
normalized === 'opengauss'
) return 'opengauss';
normalized === "postgresql" ||
normalized === "pg" ||
normalized === "pq" ||
normalized === "pgx"
)
return "postgres";
if (normalized === "elastic") return "elasticsearch";
if (normalized === "chromadb" || normalized === "chroma-db") return "chroma";
if (normalized === "qdrantdb" || normalized === "qdrant-db") return "qdrant";
if (
normalized === 'gaussdb' ||
normalized === 'gauss_db' ||
normalized === 'gauss-db'
) return 'gaussdb';
normalized === "rocket-mq" ||
normalized === "rocket_mq" ||
normalized === "apache-rocketmq" ||
normalized === "apache_rocketmq" ||
normalized === "rmq"
)
return "rocketmq";
if (normalized === "apache-iotdb" || normalized === "apache_iotdb")
return "iotdb";
if (normalized === "mqtts") return "mqtt";
if (normalized === "apache-kafka" || normalized === "apache_kafka")
return "kafka";
if (normalized === "rabbit-mq" || normalized === "rabbit_mq")
return "rabbitmq";
if (normalized === "doris") return "diros";
if (
normalized === 'goldendb' ||
normalized === 'greatdb' ||
normalized === 'gdb'
) return 'goldendb';
normalized === "open_gauss" ||
normalized === "open-gauss" ||
normalized === "opengauss"
)
return "opengauss";
if (
normalized === 'intersystems' ||
normalized === 'intersystemsiris' ||
normalized === 'inter-systems' ||
normalized === 'inter-systems-iris'
) return 'iris';
normalized === "gaussdb" ||
normalized === "gauss_db" ||
normalized === "gauss-db"
)
return "gaussdb";
if (
normalized === "goldendb" ||
normalized === "greatdb" ||
normalized === "gdb"
)
return "goldendb";
if (
normalized === "intersystems" ||
normalized === "intersystemsiris" ||
normalized === "inter-systems" ||
normalized === "inter-systems-iris"
)
return "iris";
return normalized;
};
export const resolveConnectionDriverType = (type: string, driver?: string): string => {
export const resolveConnectionDriverType = (
type: string,
driver?: string,
): string => {
const normalizedType = normalizeDriverType(type);
if (normalizedType !== 'custom') {
if (normalizedType !== "custom") {
return normalizedType;
}
return normalizeDriverType(driver || '');
return normalizeDriverType(driver || "");
};
export const resolveSavedConnectionDriverType = (conn: SavedConnection | undefined): string => {
return resolveConnectionDriverType(conn?.config?.type || '', conn?.config?.driver || '');
export const resolveSavedConnectionDriverType = (
conn: SavedConnection | undefined,
): string => {
return resolveConnectionDriverType(
conn?.config?.type || "",
conn?.config?.driver || "",
);
};
export const isPostgresSchemaDialect = (dialect: string): boolean => (
['postgres', 'kingbase', 'highgo', 'vastbase', 'opengauss', 'gaussdb'].includes(normalizeDriverType(dialect))
);
export const isPostgresSchemaDialect = (dialect: string): boolean =>
[
"postgres",
"kingbase",
"highgo",
"vastbase",
"opengauss",
"gaussdb",
].includes(normalizeDriverType(dialect));
export const supportsIndependentSchemaSelection = (dialect: string): boolean =>
[
"postgres",
"kingbase",
"highgo",
"vastbase",
"opengauss",
"gaussdb",
"sqlserver",
"iris",
"duckdb",
].includes(normalizeDriverType(dialect));

View File

@@ -117,6 +117,7 @@ describe('connectionModalPresentation', () => {
'starrocks',
'sphinx',
'clickhouse',
'trino',
'postgres',
'sqlserver',
'sqlite',
@@ -237,6 +238,14 @@ describe('connectionModalPresentation', () => {
'credentials',
'databaseScope',
]);
expect(resolveConnectionConfigLayout('trino').sections).toEqual([
'identity',
'uri',
'target',
'service',
'credentials',
'databaseScope',
]);
expect(resolveConnectionConfigLayout('gaussdb').sections).toEqual([
'identity',
'uri',

View File

@@ -300,6 +300,19 @@ export const resolveConnectionConfigLayout = (
],
};
}
if (type === 'trino') {
return {
kind: 'generic-sql',
sections: [
'identity',
'uri',
'target',
'service',
'credentials',
'databaseScope',
],
};
}
if (postgresCompatibleTypes.has(type)) {
return {
kind: 'postgres-compatible',

View File

@@ -16,6 +16,7 @@ describe('connectionTypeCapabilities', () => {
expect(singleHostUriSchemesByType.postgres).toEqual(['postgresql', 'postgres']);
expect(singleHostUriSchemesByType.opengauss).toContain('jdbc:opengauss');
expect(singleHostUriSchemesByType.gaussdb).toEqual(['gaussdb', 'postgresql', 'postgres']);
expect(singleHostUriSchemesByType.trino).toEqual(['trino', 'http', 'https']);
expect(singleHostUriSchemesByType.dameng).toEqual(['dameng', 'dm']);
expect(singleHostUriSchemesByType.elasticsearch).toEqual(['http', 'https']);
expect(singleHostUriSchemesByType.chroma).toEqual(['http', 'https', 'chroma']);
@@ -28,6 +29,7 @@ describe('connectionTypeCapabilities', () => {
expect(supportsSSLForType('redis')).toBe(true);
expect(supportsSSLForType('MongoDB')).toBe(true);
expect(supportsSSLForType('elasticsearch')).toBe(true);
expect(supportsSSLForType('trino')).toBe(true);
expect(supportsSSLForType('gaussdb')).toBe(true);
expect(supportsSSLForType('greatdb')).toBe(true);
expect(supportsSSLForType('chroma')).toBe(true);
@@ -45,7 +47,9 @@ describe('connectionTypeCapabilities', () => {
expect(supportsSSLCAPathForType('gaussdb')).toBe(true);
expect(supportsSSLClientCertificateForType('gaussdb')).toBe(true);
expect(supportsSSLCAPathForType('sqlserver')).toBe(true);
expect(supportsSSLCAPathForType('trino')).toBe(true);
expect(supportsSSLClientCertificateForType('sqlserver')).toBe(false);
expect(supportsSSLClientCertificateForType('trino')).toBe(true);
expect(supportsSSLCAPathForType('redis')).toBe(true);
expect(supportsSSLClientCertificateForType('redis')).toBe(true);
expect(supportsSSLCAPathForType('chroma')).toBe(true);
@@ -80,6 +84,7 @@ describe('connectionTypeCapabilities', () => {
expect(supportsConnectionParamsForType('gdb')).toBe(true);
expect(supportsConnectionParamsForType('postgres')).toBe(true);
expect(supportsConnectionParamsForType('gaussdb')).toBe(true);
expect(supportsConnectionParamsForType('trino')).toBe(true);
expect(supportsConnectionParamsForType('oracle')).toBe(true);
expect(supportsConnectionParamsForType('mongodb')).toBe(true);
expect(supportsConnectionParamsForType('dameng')).toBe(true);

View File

@@ -3,6 +3,7 @@ export const singleHostUriSchemesByType: Record<string, string[]> = {
opengauss: ["opengauss", "jdbc:opengauss", "postgresql", "postgres"],
gaussdb: ["gaussdb", "postgresql", "postgres"],
clickhouse: ["clickhouse"],
trino: ["trino", "http", "https"],
oracle: ["oracle"],
sqlserver: ["sqlserver"],
iris: ["iris", "intersystems"],
@@ -55,6 +56,7 @@ const sslSupportedTypes = new Set([
"sphinx",
"dameng",
"clickhouse",
"trino",
"postgres",
"sqlserver",
"oracle",
@@ -86,6 +88,7 @@ const sslCAPathSupportedTypes = new Set([
"starrocks",
"sphinx",
"clickhouse",
"trino",
"postgres",
"sqlserver",
"kingbase",
@@ -113,6 +116,7 @@ const sslClientCertificateSupportedTypes = new Set([
"sphinx",
"dameng",
"clickhouse",
"trino",
"postgres",
"kingbase",
"highgo",
@@ -167,6 +171,7 @@ export const supportsConnectionParamsForType = (type: string) =>
type === "sqlserver" ||
type === "iris" ||
type === "clickhouse" ||
type === "trino" ||
type === "mongodb" ||
type === "dameng" ||
type === "tdengine" ||

View File

@@ -65,6 +65,7 @@ describe('connectionTypeCatalog', () => {
expect(keys).toContain('oceanbase');
expect(keys).toContain('gaussdb');
expect(keys).toContain('goldendb');
expect(keys).toContain('trino');
expect(keys).toContain('mongodb');
expect(keys).toContain('redis');
expect(keys).toContain('elasticsearch');
@@ -87,6 +88,7 @@ describe('connectionTypeCatalog', () => {
expect(getConnectionTypeDefaultPort('redis')).toBe(6379);
expect(getConnectionTypeDefaultPort('oracle')).toBe(1521);
expect(getConnectionTypeDefaultPort('mongodb')).toBe(27017);
expect(getConnectionTypeDefaultPort('trino')).toBe(8080);
expect(getConnectionTypeDefaultPort('elasticsearch')).toBe(9200);
expect(getConnectionTypeDefaultPort('chroma')).toBe(8000);
expect(getConnectionTypeDefaultPort('qdrant')).toBe(6333);
@@ -107,6 +109,7 @@ describe('connectionTypeCatalog', () => {
expect(getConnectionTypeHint('kafka')).toContain('Consumer Group');
expect(getConnectionTypeHint('oceanbase', translate)).toBe('T:oceanbase');
expect(getConnectionTypeHint('goldendb', translate)).toBe('T:goldendb');
expect(getConnectionTypeHint('trino')).toBe('HTTP / HTTPS / catalog.schema');
expect(getConnectionTypeHint('duckdb', translate)).toBe('T:file');
expect(getConnectionTypeHint('mysql', translate)).toBe('T:standard');
});

View File

@@ -36,6 +36,7 @@ export const CONNECTION_TYPE_GROUPS: ConnectionTypeCatalogGroup[] = [
{ key: 'starrocks', name: 'StarRocks' },
{ key: 'sphinx', name: 'Sphinx' },
{ key: 'clickhouse', name: 'ClickHouse' },
{ key: 'trino', name: 'Trino' },
{ key: 'postgres', name: 'PostgreSQL' },
{ key: 'sqlserver', name: 'SQL Server' },
{ key: 'iris', name: 'InterSystems IRIS' },
@@ -135,6 +136,8 @@ export const getConnectionTypeDefaultPort = (type: string): number => {
return 9306;
case 'clickhouse':
return 9000;
case 'trino':
return 8080;
case 'postgres':
case 'opengauss':
case 'gaussdb':
@@ -237,6 +240,10 @@ export const getConnectionTypeHint = (
case 'sqlite':
case 'duckdb':
return translateCatalogCopy(translate, 'connection_modal.step1.hint.file', 'Local file connection');
case 'trino':
return 'HTTP / HTTPS / catalog.schema';
case 'trino':
return 'HTTP / HTTPS / catalog.schema';
default:
return translateCatalogCopy(
translate,

View File

@@ -58,6 +58,19 @@ describe('dataSourceCapabilities', () => {
});
});
it('treats Trino as an editable SQL datasource without database-level DDL shortcuts', () => {
expect(getDataSourceCapabilities({ type: 'trino' })).toMatchObject({
type: 'trino',
supportsQueryEditor: true,
supportsSqlQueryExport: true,
supportsCopyInsert: true,
supportsCreateDatabase: false,
supportsRenameDatabase: false,
supportsDropDatabase: false,
forceReadOnlyQueryResult: false,
});
});
it('keeps InterSystems IRIS as an editable SQL datasource capability', () => {
expect(getDataSourceCapabilities({ type: 'iris' })).toMatchObject({
type: 'iris',

View File

@@ -111,6 +111,7 @@ const SQL_QUERY_EXPORT_TYPES = new Set([
'dameng',
'tdengine',
'clickhouse',
'trino',
]);
const COPY_INSERT_TYPES = new Set([
@@ -135,6 +136,7 @@ const COPY_INSERT_TYPES = new Set([
'dameng',
'tdengine',
'clickhouse',
'trino',
]);
const QUERY_EDITOR_DISABLED_TYPES = new Set(['redis']);

View File

@@ -0,0 +1,172 @@
// SQL 诊断工作台前端类型定义。
//
// 本文件镜像后端 internal/connection/explain.go 的数据结构。
// 当 Wails 重新生成 models.ts 后,可逐步迁移到 import { connection } from '../wailsjs/go/models'
// 但在过渡期保持独立类型便于前端独立开发。
// 节点操作类型(与后端 ExplainOp* 常量对齐)。
export type ExplainOpType =
| 'SCAN' // 全表扫描
| 'INDEX_SCAN' // 索引扫描
| 'INDEX_ONLY' // 覆盖索引
| 'JOIN'
| 'AGGREGATE'
| 'SORT'
| 'LIMIT'
| 'FILTER'
| 'SUBQUERY'
| 'UNION'
| 'WINDOW'
| 'MATERIALIZE'
| 'INSERT'
| 'UPDATE'
| 'DELETE'
| 'OTHER'
// 节点警告标志(用于 UI 高亮 + 规则匹配)。
export type ExplainNodeFlag =
| 'FULL_SCAN'
| 'FILESORT'
| 'TEMP_TABLE'
| 'NO_INDEX'
| 'HIGH_COST'
| 'LOW_BUFFER_HIT'
| 'UNCERTAIN_ROWS'
// EXPLAIN 原文格式。
export type ExplainFormat = 'json' | 'table' | 'xml' | 'text'
// 建议严重度。
export type IndexSuggestionSeverity = 'critical' | 'warning' | 'info'
export interface ExplainNode {
id: string
parentId?: string
opType: ExplainOpType | string
opDetail?: string
table?: string
index?: string
estRows?: number
actualRows?: number
loops?: number
cost?: number
durationMs?: number
bufferHit?: number
flags?: ExplainNodeFlag[] | string[]
extra?: Record<string, unknown>
}
export interface ExplainEdge {
from: string
to: string
label?: string
}
export interface ExplainStats {
totalCost?: number
totalDurationMs?: number
rowsRead?: number
bufferHitRate?: number
hasFullScan: boolean
hasFilesort: boolean
hasTempTable: boolean
maxEstRows?: number
}
export interface ExplainResult {
dbType: string
sourceSql: string
nodes: ExplainNode[]
edges?: ExplainEdge[]
stats: ExplainStats
warnings?: string[]
rawFormat: ExplainFormat | string
rawPayload?: string
}
export interface IndexSuggestion {
severity: IndexSuggestionSeverity | string
rule: string
reason: string
suggestedIndex?: string
affectedNodeId?: string
affectedTable?: string
estRows?: number
}
export interface DiagnoseReport {
plan: ExplainResult
suggestions: IndexSuggestion[]
}
// severityRank 用于 UI 排序critical 最前。
export const severityRank: Record<string, number> = {
critical: 0,
warning: 1,
info: 2,
}
// opTypeTheme 按 OpType 返回主题色 token对应 v2-theme.css 的 CSS 变量)。
// 颜色规则SCAN 红橙警告、JOIN 蓝、AGGREGATE 紫、SORT 黄、其他灰。
export function opTypeColor(opType: string): string {
switch (opType) {
case 'SCAN':
return 'var(--gn-explain-scan, #e8590c)'
case 'INDEX_SCAN':
return 'var(--gn-explain-index-scan, #1971c2)'
case 'INDEX_ONLY':
return 'var(--gn-explain-index-only, #2f9e44)'
case 'JOIN':
return 'var(--gn-explain-join, #1971c2)'
case 'AGGREGATE':
return 'var(--gn-explain-aggregate, #6741d9)'
case 'SORT':
return 'var(--gn-explain-sort, #f08c00)'
case 'LIMIT':
return 'var(--gn-explain-limit, #495057)'
case 'FILTER':
return 'var(--gn-explain-filter, #495057)'
case 'SUBQUERY':
return 'var(--gn-explain-subquery, #7048e8)'
case 'MATERIALIZE':
return 'var(--gn-explain-materialize, #e8590c)'
default:
return 'var(--gn-explain-other, #868e96)'
}
}
// severityColor 用于建议列表的左侧色条。
export function severityColor(severity: string): string {
switch (severity) {
case 'critical':
return 'var(--gn-explain-critical, #fa5252)'
case 'warning':
return 'var(--gn-explain-warning, #f08c00)'
case 'info':
return 'var(--gn-explain-info, #1c7ed6)'
default:
return 'var(--gn-explain-other, #868e96)'
}
}
// formatNumber 容错格式化大数字(千分位)。
export function formatNumber(n?: number): string {
if (n === undefined || n === null || isNaN(n)) return '-'
if (Math.abs(n) >= 10000) {
return new Intl.NumberFormat('en-US').format(n)
}
return String(n)
}
// formatPercent 把 0-1 的小数格式化为百分比字符串。
export function formatPercent(ratio?: number): string {
if (ratio === undefined || ratio === null || isNaN(ratio)) return '-'
return `${(ratio * 100).toFixed(1)}%`
}
// formatMs 把毫秒格式化为人类可读(>1s 显示秒)。
export function formatMs(ms?: number): string {
if (ms === undefined || ms === null || isNaN(ms)) return '-'
if (ms >= 1000) return `${(ms / 1000).toFixed(2)}s`
return `${ms.toFixed(1)}ms`
}

View File

@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest';
import {
formatExportElapsed,
formatExportProgressRows,
resolveExportElapsedMs,
resolveExportProgressPercent,
shouldUseExactExportProgress,
shouldUseIndeterminateExportProgress,
} from './exportProgress';
describe('exportProgress', () => {
it('uses actual percent when total row count is known', () => {
expect(resolveExportProgressPercent('running', 25, 100, true)).toBe(25);
});
it('does not fabricate percentages when total row count is unknown', () => {
expect(resolveExportProgressPercent('running', 5000, 0, false)).toBe(0);
expect(resolveExportProgressPercent('finalizing', 5000, 0, false)).toBe(0);
expect(shouldUseExactExportProgress('running', 0, false)).toBe(false);
expect(shouldUseIndeterminateExportProgress('running', 0, false)).toBe(true);
});
it('falls back to indeterminate progress when total row hint is zero', () => {
expect(resolveExportProgressPercent('running', 754000, 0, true)).toBe(0);
expect(shouldUseExactExportProgress('running', 0, true)).toBe(false);
expect(shouldUseIndeterminateExportProgress('running', 0, true)).toBe(true);
expect(formatExportProgressRows(754000, 0, true)).toBe('已写入 754,000 行');
});
it('formats row summary for known and unknown totals', () => {
expect(formatExportProgressRows(12345, 0, false)).toBe('已写入 12,345 行');
expect(formatExportProgressRows(12345, 880000, true)).toBe('已写入 12,345 / 880,000 行');
});
it('resolves and formats elapsed export duration', () => {
expect(resolveExportElapsedMs(1000, 91_000)).toBe(90_000);
expect(resolveExportElapsedMs(1000, 0, 31_500)).toBe(30_500);
expect(formatExportElapsed(30_500)).toBe('00:30');
expect(formatExportElapsed(3_723_000)).toBe('01:02:03');
});
});

View File

@@ -0,0 +1,96 @@
export type ExportProgressStatus = 'idle' | 'start' | 'running' | 'finalizing' | 'done' | 'error';
const hasUsableExportTotal = (total: number, totalRowsKnown: boolean): boolean => {
const normalizedTotal = Number.isFinite(total) ? Math.max(0, Math.trunc(total)) : 0;
return totalRowsKnown && normalizedTotal > 0;
};
const clampPercent = (value: number): number => {
if (!Number.isFinite(value)) return 0;
if (value <= 0) return 0;
if (value >= 100) return 100;
return value;
};
export const shouldUseExactExportProgress = (
status: ExportProgressStatus,
total: number,
totalRowsKnown: boolean,
): boolean => {
if (hasUsableExportTotal(total, totalRowsKnown)) {
return true;
}
if ((status === 'done' || status === 'error') && hasUsableExportTotal(total, totalRowsKnown)) {
return true;
}
return false;
};
export const shouldUseIndeterminateExportProgress = (
status: ExportProgressStatus,
total: number,
totalRowsKnown: boolean,
): boolean => !hasUsableExportTotal(total, totalRowsKnown) && status !== 'idle' && status !== 'done' && status !== 'error';
export const resolveExportProgressPercent = (
status: ExportProgressStatus,
current: number,
total: number,
totalRowsKnown: boolean,
): number => {
const normalizedCurrent = Number.isFinite(current) ? Math.max(0, current) : 0;
const normalizedTotal = Number.isFinite(total) ? Math.max(0, total) : 0;
if (hasUsableExportTotal(total, totalRowsKnown)) {
return clampPercent((normalizedCurrent / normalizedTotal) * 100);
}
if ((status === 'done' || status === 'error') && (totalRowsKnown || normalizedCurrent >= 0)) {
return 100;
}
return 0;
};
export const formatExportProgressRows = (
current: number,
total: number,
totalRowsKnown: boolean,
): string => {
const formatter = new Intl.NumberFormat('zh-CN');
const safeCurrent = formatter.format(Math.max(0, Math.trunc(Number(current) || 0)));
if (!hasUsableExportTotal(total, totalRowsKnown)) {
return `已写入 ${safeCurrent}`;
}
const safeTotal = formatter.format(Math.max(0, Math.trunc(Number(total) || 0)));
return `已写入 ${safeCurrent} / ${safeTotal}`;
};
export const resolveExportElapsedMs = (
startedAt: number,
finishedAt = 0,
now = Date.now(),
): number => {
const safeStartedAt = Number(startedAt);
if (!Number.isFinite(safeStartedAt) || safeStartedAt <= 0) {
return 0;
}
const safeFinishedAt = Number(finishedAt);
const endAt = Number.isFinite(safeFinishedAt) && safeFinishedAt > 0
? safeFinishedAt
: Number(now);
if (!Number.isFinite(endAt) || endAt <= safeStartedAt) {
return 0;
}
return Math.max(0, Math.trunc(endAt - safeStartedAt));
};
const padTimePart = (value: number): string => String(Math.max(0, Math.trunc(value))).padStart(2, '0');
export const formatExportElapsed = (elapsedMs: number): string => {
const totalSeconds = Math.max(0, Math.trunc(Number(elapsedMs) / 1000));
const hours = Math.trunc(totalSeconds / 3600);
const minutes = Math.trunc((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
if (hours > 0) {
return `${padTimePart(hours)}:${padTimePart(minutes)}:${padTimePart(seconds)}`;
}
return `${padTimePart(minutes)}:${padTimePart(seconds)}`;
};

View File

@@ -0,0 +1,80 @@
import { describe, expect, it } from 'vitest';
import {
MAX_REDIS_DB_ALIAS_LENGTH,
buildRedisDbNodeLabel,
getRedisDbAlias,
sanitizeRedisDbAlias,
sanitizeRedisDbAliases,
setRedisDbAlias,
} from './redisDbAlias';
describe('redisDbAlias helpers', () => {
it('sanitizes a single alias by trimming, collapsing whitespace, and capping length', () => {
expect(sanitizeRedisDbAlias(' cache ')).toBe('cache');
expect(sanitizeRedisDbAlias('user\n sessions')).toBe('user sessions');
expect(sanitizeRedisDbAlias(' ')).toBe('');
expect(sanitizeRedisDbAlias(42)).toBe('');
expect(sanitizeRedisDbAlias('x'.repeat(200))).toHaveLength(MAX_REDIS_DB_ALIAS_LENGTH);
});
it('sanitizes the full alias map, dropping malformed and empty entries', () => {
const sanitized = sanitizeRedisDbAliases({
'conn-a': { '0': 'cache', '1': ' ', notANumber: 'x' },
'conn-b': { '0': 'sessions' },
'conn-empty': { '0': '' },
'': { '0': 'orphan' },
bogus: 'not-an-object',
});
expect(sanitized).toEqual({
'conn-a': { '0': 'cache' },
'conn-b': { '0': 'sessions' },
});
});
it('returns an empty map for non-object input', () => {
expect(sanitizeRedisDbAliases(undefined)).toEqual({});
expect(sanitizeRedisDbAliases(null)).toEqual({});
expect(sanitizeRedisDbAliases(['cache'])).toEqual({});
});
it('looks up an alias by connection id and db index', () => {
const aliases = { 'conn-a': { '0': 'cache' } };
expect(getRedisDbAlias(aliases, 'conn-a', 0)).toBe('cache');
expect(getRedisDbAlias(aliases, 'conn-a', 1)).toBe('');
expect(getRedisDbAlias(aliases, 'conn-b', 0)).toBe('');
expect(getRedisDbAlias(undefined, 'conn-a', 0)).toBe('');
});
it('keeps aliases independent across connections that share a db index', () => {
let aliases = setRedisDbAlias({}, 'conn-a', 0, 'cache');
aliases = setRedisDbAlias(aliases, 'conn-b', 0, 'sessions');
expect(getRedisDbAlias(aliases, 'conn-a', 0)).toBe('cache');
expect(getRedisDbAlias(aliases, 'conn-b', 0)).toBe('sessions');
});
it('clears an alias when set to an empty/whitespace value and prunes the connection', () => {
let aliases = setRedisDbAlias({}, 'conn-a', 0, 'cache');
aliases = setRedisDbAlias(aliases, 'conn-a', 0, ' ');
expect(getRedisDbAlias(aliases, 'conn-a', 0)).toBe('');
expect(aliases).toEqual({});
});
it('does not mutate the input map when setting an alias', () => {
const original = { 'conn-a': { '0': 'cache' } };
const next = setRedisDbAlias(original, 'conn-a', 1, 'queue');
expect(original).toEqual({ 'conn-a': { '0': 'cache' } });
expect(next).toEqual({ 'conn-a': { '0': 'cache', '1': 'queue' } });
});
it('builds the sidebar label with and without an alias', () => {
expect(buildRedisDbNodeLabel(0, 'cache')).toBe('db0 (cache)');
expect(buildRedisDbNodeLabel(3, '')).toBe('db3');
expect(buildRedisDbNodeLabel(0, ' ')).toBe('db0');
});
it('appends the key-count suffix after the alias', () => {
expect(buildRedisDbNodeLabel(0, 'cache', ' (12)')).toBe('db0 (cache) (12)');
expect(buildRedisDbNodeLabel(0, '', ' (12)')).toBe('db0 (12)');
});
});

View File

@@ -0,0 +1,144 @@
/**
* Per-database aliases for Redis connections.
*
* Redis exposes logical databases as bare numeric indices (db0..db15). When a
* user works with several connections that each use those indices for a
* different purpose, the numbers are indistinguishable in the sidebar. An alias
* map lets the user label, for example, `db0` as `cache` and have the sidebar
* render `db0 (cache)`.
*
* The map is purely a client-side display preference and is keyed by
* connection id so aliases stay independent across connections. The underlying
* Redis SELECT index is never affected.
*/
/** Aliases for a single connection, keyed by the numeric DB index. */
export type RedisConnectionDbAliasMap = Record<string, string>;
/** Alias map for every connection, keyed by connection id. */
export type RedisDbAliasMap = Record<string, RedisConnectionDbAliasMap>;
export const DEFAULT_REDIS_DB_ALIASES: RedisDbAliasMap = {};
/**
* Mirrors `MAX_SIDEBAR_PERSISTED_FILTER_LENGTH` in store.ts: a single alias is
* a short human label, so cap it to keep persisted state bounded.
*/
export const MAX_REDIS_DB_ALIAS_LENGTH = 64;
const isValidDbIndexKey = (value: string): boolean => /^\d+$/.test(value);
/** Trim, collapse newlines, and length-cap an alias. Empty -> empty string. */
export const sanitizeRedisDbAlias = (value: unknown): string => {
if (typeof value !== 'string') {
return '';
}
return value.replace(/\s+/g, ' ').trim().slice(0, MAX_REDIS_DB_ALIAS_LENGTH);
};
const sanitizeConnectionAliasMap = (value: unknown): RedisConnectionDbAliasMap => {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return {};
}
const result: RedisConnectionDbAliasMap = {};
Object.entries(value as Record<string, unknown>).forEach(([dbIndex, alias]) => {
if (!isValidDbIndexKey(dbIndex)) {
return;
}
const sanitized = sanitizeRedisDbAlias(alias);
if (sanitized) {
result[dbIndex] = sanitized;
}
});
return result;
};
/**
* Normalize an arbitrary persisted/runtime value into a well-formed alias map,
* dropping malformed entries and empty aliases so the persisted state never
* grows unbounded or carries blank labels.
*/
export const sanitizeRedisDbAliases = (value: unknown): RedisDbAliasMap => {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return { ...DEFAULT_REDIS_DB_ALIASES };
}
const result: RedisDbAliasMap = {};
Object.entries(value as Record<string, unknown>).forEach(([connectionId, aliases]) => {
const trimmedId = String(connectionId).trim();
if (!trimmedId) {
return;
}
const connectionAliases = sanitizeConnectionAliasMap(aliases);
if (Object.keys(connectionAliases).length > 0) {
result[trimmedId] = connectionAliases;
}
});
return result;
};
/** Look up the alias for a given connection + DB index, or '' if none. */
export const getRedisDbAlias = (
aliases: RedisDbAliasMap | undefined,
connectionId: string,
dbIndex: number,
): string => {
if (!aliases) {
return '';
}
const connectionAliases = aliases[connectionId];
if (!connectionAliases) {
return '';
}
return sanitizeRedisDbAlias(connectionAliases[String(dbIndex)]);
};
/**
* Return a new alias map with the alias for a connection + DB index set, or
* cleared when the sanitized alias is empty. Pure: never mutates the input.
*/
export const setRedisDbAlias = (
aliases: RedisDbAliasMap | undefined,
connectionId: string,
dbIndex: number,
alias: string,
): RedisDbAliasMap => {
const base = sanitizeRedisDbAliases(aliases);
const trimmedId = String(connectionId).trim();
if (!trimmedId) {
return base;
}
const sanitized = sanitizeRedisDbAlias(alias);
const dbKey = String(dbIndex);
const nextConnectionAliases: RedisConnectionDbAliasMap = { ...(base[trimmedId] || {}) };
if (sanitized) {
nextConnectionAliases[dbKey] = sanitized;
} else {
delete nextConnectionAliases[dbKey];
}
const next: RedisDbAliasMap = { ...base };
if (Object.keys(nextConnectionAliases).length > 0) {
next[trimmedId] = nextConnectionAliases;
} else {
delete next[trimmedId];
}
return next;
};
/**
* Build the sidebar label for a Redis DB node. Returns `dbN` when there is no
* alias, and `dbN (alias)` when one is set. `suffix` carries the existing
* key-count fragment (e.g. ` (12)`) and is always appended last so the alias
* stays adjacent to the index.
*/
export const buildRedisDbNodeLabel = (
dbIndex: number,
alias: string,
suffix = '',
): string => {
const base = `db${dbIndex}`;
const sanitizedAlias = sanitizeRedisDbAlias(alias);
const labelled = sanitizedAlias ? `${base} (${sanitizedAlias})` : base;
return `${labelled}${suffix}`;
};

View File

@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
setCurrentLanguage,
@@ -11,11 +11,17 @@ import {
normalizeShortcutCombo,
RESERVED_SHORTCUTS,
comboToMonacoKeyBinding,
eventToShortcut,
getPrimaryShortcutDisplayLabel,
getShortcutDisplayLabel,
getShortcutPrimaryModifierDisplayLabel,
installGlobalImeCompositionTracking,
isGlobalImeCompositionActive,
isImeComposingKeyEvent,
isShortcutMatch,
resolveShortcutBinding,
resolveShortcutDisplay,
setGlobalImeCompositionActive,
sanitizeShortcutOptions,
SHORTCUT_ACTION_META,
} from './shortcuts';
@@ -23,6 +29,7 @@ import type { ConflictInfo } from './shortcuts';
beforeEach(() => {
setCurrentLanguage('zh-CN');
setGlobalImeCompositionActive(false);
});
// ─── findReservedConflict ────────────────────────────────────────────
@@ -166,6 +173,125 @@ describe('RESERVED_SHORTCUTS', () => {
});
});
describe('IME shortcut guards', () => {
it('tracks composition state through global listeners', () => {
const windowListeners = new Map<string, EventListener[]>();
const documentListeners = new Map<string, EventListener[]>();
const target = {
addEventListener: vi.fn((type: string, listener: EventListener) => {
windowListeners.set(type, [...(windowListeners.get(type) || []), listener]);
}),
removeEventListener: vi.fn((type: string, listener: EventListener) => {
windowListeners.set(type, (windowListeners.get(type) || []).filter(item => item !== listener));
}),
};
const documentTarget = {
visibilityState: 'visible' as DocumentVisibilityState,
addEventListener: vi.fn((type: string, listener: EventListener) => {
documentListeners.set(type, [...(documentListeners.get(type) || []), listener]);
}),
removeEventListener: vi.fn((type: string, listener: EventListener) => {
documentListeners.set(type, (documentListeners.get(type) || []).filter(item => item !== listener));
}),
};
const dispose = installGlobalImeCompositionTracking(
target as unknown as Window,
documentTarget as unknown as Document,
);
windowListeners.get('compositionstart')?.forEach(listener => listener(new Event('compositionstart')));
expect(isGlobalImeCompositionActive()).toBe(true);
windowListeners.get('compositionend')?.forEach(listener => listener(new Event('compositionend')));
expect(isGlobalImeCompositionActive()).toBe(false);
windowListeners.get('compositionstart')?.forEach(listener => listener(new Event('compositionstart')));
windowListeners.get('blur')?.forEach(listener => listener(new Event('blur')));
expect(isGlobalImeCompositionActive()).toBe(false);
windowListeners.get('compositionstart')?.forEach(listener => listener(new Event('compositionstart')));
documentTarget.visibilityState = 'hidden';
documentListeners.get('visibilitychange')?.forEach(listener => listener(new Event('visibilitychange')));
expect(isGlobalImeCompositionActive()).toBe(false);
dispose();
expect(target.removeEventListener).toHaveBeenCalledWith('compositionstart', expect.any(Function), true);
expect(documentTarget.removeEventListener).toHaveBeenCalledWith('visibilitychange', expect.any(Function), true);
});
it('treats composing key events as non-shortcuts', () => {
const event = {
key: 'Process',
keyCode: 229,
which: 229,
isComposing: true,
ctrlKey: true,
metaKey: false,
altKey: false,
shiftKey: false,
nativeEvent: {
isComposing: true,
keyCode: 229,
which: 229,
},
} as unknown as KeyboardEvent;
expect(isImeComposingKeyEvent(event)).toBe(true);
expect(eventToShortcut(event)).toBe('');
expect(isShortcutMatch(event, 'Ctrl+Enter')).toBe(false);
});
it('treats number keys as non-shortcuts while a composition session is active', () => {
setGlobalImeCompositionActive(true);
const event = {
key: '1',
keyCode: 49,
which: 49,
ctrlKey: false,
metaKey: false,
altKey: false,
shiftKey: false,
isComposing: false,
nativeEvent: {
isComposing: false,
},
} as unknown as KeyboardEvent;
expect(isImeComposingKeyEvent(event)).toBe(true);
expect(eventToShortcut(event)).toBe('');
expect(isShortcutMatch(event, '1')).toBe(false);
});
it('treats Monaco visible IME textarea events as composing even without native flags', () => {
const target = {
className: 'inputarea monaco-mouse-cursor-text ime-input',
classList: {
contains: (name: string) => name === 'ime-input',
},
closest: vi.fn(),
} as unknown as EventTarget;
const event = {
key: '1',
keyCode: 49,
which: 49,
ctrlKey: false,
metaKey: false,
altKey: false,
shiftKey: false,
isComposing: false,
nativeEvent: {
isComposing: false,
},
target,
} as unknown as KeyboardEvent;
expect(isImeComposingKeyEvent(event)).toBe(true);
expect(eventToShortcut(event)).toBe('');
expect(isShortcutMatch(event, '1')).toBe(false);
});
});
// ─── shortcut defaults ───────────────────────────────────────────────
describe('shortcut defaults', () => {

View File

@@ -18,7 +18,9 @@ export type ShortcutAction =
| 'toggleTheme'
| 'openShortcutManager'
| 'toggleMacFullscreen'
| 'resetWindowZoom';
| 'resetWindowZoom'
| 'diagnoseQuery'
| 'showSlowQueries';
export type ShortcutPlatform = 'mac' | 'windows';
@@ -111,6 +113,8 @@ export const SHORTCUT_ACTION_ORDER: ShortcutAction[] = [
'toggleAIPanel',
'toggleLogPanel',
'toggleTheme',
'diagnoseQuery',
'showSlowQueries',
'openShortcutManager',
'toggleMacFullscreen',
'resetWindowZoom',
@@ -202,6 +206,18 @@ const SHORTCUT_ACTION_META_DEFINITIONS: Record<ShortcutAction, ShortcutActionMet
labelKey: 'app.shortcuts.action.toggleTheme.label',
descriptionKey: 'app.shortcuts.action.toggleTheme.description',
},
diagnoseQuery: {
labelKey: 'app.shortcuts.action.diagnoseQuery.label',
descriptionKey: 'app.shortcuts.action.diagnoseQuery.description',
scope: 'queryEditor',
allowInEditable: true,
},
showSlowQueries: {
labelKey: 'app.shortcuts.action.showSlowQueries.label',
descriptionKey: 'app.shortcuts.action.showSlowQueries.description',
scope: 'queryEditor',
allowInEditable: true,
},
openShortcutManager: {
labelKey: 'app.shortcuts.action.openShortcutManager.label',
descriptionKey: 'app.shortcuts.action.openShortcutManager.description',
@@ -279,6 +295,16 @@ export const DEFAULT_SHORTCUT_OPTIONS: ShortcutOptions = {
mac: { combo: 'Meta+Shift+D', enabled: true },
windows: { combo: 'Ctrl+Shift+D', enabled: true },
},
// SQL 诊断:避开 toggleTheme 的 Ctrl+Shift+D用 Ctrl+Shift+PP = Plan
diagnoseQuery: {
mac: { combo: 'Meta+Shift+P', enabled: true },
windows: { combo: 'Ctrl+Shift+P', enabled: true },
},
// 慢查询历史:避开 toggleLogPanel 的 Ctrl+H / Meta+Shift+H用 Ctrl+Shift+LL = Log
showSlowQueries: {
mac: { combo: 'Meta+Shift+L', enabled: true },
windows: { combo: 'Ctrl+Shift+L', enabled: true },
},
openShortcutManager: {
mac: { combo: 'Meta+,', enabled: true },
windows: { combo: 'Ctrl+,', enabled: true },
@@ -353,7 +379,106 @@ const normalizeKeyboardKey = (key: string): string => {
return token.length > 1 ? token[0].toUpperCase() + token.slice(1) : token;
};
let globalImeCompositionActive = false;
export const setGlobalImeCompositionActive = (active: boolean): void => {
globalImeCompositionActive = active === true;
};
export const isGlobalImeCompositionActive = (): boolean => globalImeCompositionActive;
type ImeCompositionEventTarget = Pick<Window, 'addEventListener' | 'removeEventListener'>;
type ImeCompositionDocumentTarget = Pick<Document, 'addEventListener' | 'removeEventListener'> & {
visibilityState?: DocumentVisibilityState;
};
export const installGlobalImeCompositionTracking = (
eventTarget: ImeCompositionEventTarget = window,
documentTarget: ImeCompositionDocumentTarget | null = document,
): (() => void) => {
const handleCompositionStart = () => setGlobalImeCompositionActive(true);
const handleCompositionEnd = () => setGlobalImeCompositionActive(false);
const handleBlur = () => setGlobalImeCompositionActive(false);
const handleVisibilityChange = () => {
if (!documentTarget || documentTarget.visibilityState === 'hidden') {
setGlobalImeCompositionActive(false);
}
};
eventTarget.addEventListener('compositionstart', handleCompositionStart, true);
eventTarget.addEventListener('compositionend', handleCompositionEnd, true);
eventTarget.addEventListener('blur', handleBlur, true);
documentTarget?.addEventListener('visibilitychange', handleVisibilityChange, true);
return () => {
eventTarget.removeEventListener('compositionstart', handleCompositionStart, true);
eventTarget.removeEventListener('compositionend', handleCompositionEnd, true);
eventTarget.removeEventListener('blur', handleBlur, true);
documentTarget?.removeEventListener('visibilitychange', handleVisibilityChange, true);
setGlobalImeCompositionActive(false);
};
};
const isMonacoImeInputTarget = (target: EventTarget | null | undefined): boolean => {
if (!target || typeof target !== 'object') {
return false;
}
const element = target as Element & {
className?: unknown;
classList?: { contains?: (name: string) => boolean };
closest?: (selector: string) => Element | null;
};
if (typeof element.classList?.contains === 'function' && element.classList.contains('ime-input')) {
return true;
}
if (typeof element.className === 'string' && /\bime-input\b/.test(element.className)) {
return true;
}
if (typeof element.closest === 'function') {
return Boolean(element.closest('.monaco-editor .inputarea.ime-input, .monaco-editor textarea.ime-input, .ime-input'));
}
return false;
};
export const isImeComposingKeyEvent = (
event: (KeyboardEvent | ReactKeyboardEvent | null | undefined) & {
nativeEvent?: {
isComposing?: boolean;
keyCode?: number;
which?: number;
};
keyCode?: number;
which?: number;
isComposing?: boolean;
key?: string;
target?: EventTarget | null;
},
): boolean => {
if (!event) {
return false;
}
const nativeEvent = event.nativeEvent;
const key = String(event.key || '').trim();
const keyCode = Number(event.keyCode ?? nativeEvent?.keyCode ?? 0);
const which = Number(event.which ?? nativeEvent?.which ?? 0);
return Boolean(
globalImeCompositionActive
|| event.isComposing
|| nativeEvent?.isComposing
|| isMonacoImeInputTarget(event.target)
|| key === 'Process'
|| keyCode === 229
|| which === 229,
);
};
export const eventToShortcut = (event: KeyboardEvent | ReactKeyboardEvent): string => {
if (isImeComposingKeyEvent(event)) {
return '';
}
const key = normalizeKeyboardKey(event.key);
if (!key || MODIFIER_SET.has(key as typeof MODIFIER_ORDER[number])) {
return '';

View File

@@ -0,0 +1,42 @@
import { describe, expect, it } from 'vitest'
import { buildSqlAnalysisWorkbenchTab, resolveSqlAnalysisWorkbenchTabId } from './sqlAnalysisTab'
describe('sqlAnalysisTab', () => {
it('builds a stable workbench tab per connection and database', () => {
expect(resolveSqlAnalysisWorkbenchTabId('conn-1', 'analytics')).toBe('sql-analysis-conn-1-analytics')
expect(resolveSqlAnalysisWorkbenchTabId('conn-1')).toBe('sql-analysis-conn-1-default')
})
it('keeps diagnose requests on the sql-analysis tab with optional seeded sql', () => {
const tab = buildSqlAnalysisWorkbenchTab({
connectionId: 'conn-1',
dbName: 'analytics',
query: 'select * from orders',
view: 'diagnose',
requestKey: 'diagnose-1',
})
expect(tab).toMatchObject({
id: 'sql-analysis-conn-1-analytics',
title: 'SQL 分析 · analytics',
type: 'sql-analysis',
connectionId: 'conn-1',
dbName: 'analytics',
query: 'select * from orders',
sqlAnalysisView: 'diagnose',
sqlAnalysisRequestKey: 'diagnose-1',
})
})
it('does not clear existing sql when opening the slow-query view without a seeded query', () => {
const tab = buildSqlAnalysisWorkbenchTab({
connectionId: 'conn-1',
view: 'slow-query',
requestKey: 'slow-1',
})
expect(tab.query).toBeUndefined()
expect(tab.sqlAnalysisView).toBe('slow-query')
expect(tab.sqlAnalysisRequestKey).toBe('slow-1')
})
})

View File

@@ -0,0 +1,42 @@
import type { TabData } from '../types'
export type SqlAnalysisView = 'diagnose' | 'slow-query'
type BuildSqlAnalysisWorkbenchTabInput = {
connectionId: string
dbName?: string
title?: string
query?: string
view?: SqlAnalysisView
requestKey?: string
}
export const resolveSqlAnalysisWorkbenchTabId = (
connectionId: string,
dbName?: string,
): string => {
const normalizedConnectionId = String(connectionId || '').trim() || 'none'
const normalizedDbName = String(dbName || '').trim() || 'default'
return `sql-analysis-${normalizedConnectionId}-${normalizedDbName}`
}
export const buildSqlAnalysisWorkbenchTab = (
input: BuildSqlAnalysisWorkbenchTabInput,
): TabData => {
const connectionId = String(input.connectionId || '').trim()
const dbName = String(input.dbName || '').trim()
const view = input.view === 'slow-query' ? 'slow-query' : 'diagnose'
const title = String(input.title || (dbName ? `SQL 分析 · ${dbName}` : 'SQL 分析')).trim()
const query = typeof input.query === 'string' ? input.query : ''
return {
id: resolveSqlAnalysisWorkbenchTabId(connectionId, dbName || undefined),
title: title || (dbName ? `SQL 分析 · ${dbName}` : 'SQL 分析'),
type: 'sql-analysis',
connectionId,
...(dbName ? { dbName } : {}),
...(query.trim() ? { query } : {}),
sqlAnalysisView: view,
sqlAnalysisRequestKey: input.requestKey || `${view}-${Date.now()}`,
}
}

View File

@@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest';
import {
resolveSqlEditorOperationKeyword,
shouldUseSqlEditorManagedTransaction,
shouldUseSqlEditorManagedTransactionForType,
} from './sqlEditorTransaction';
describe('sqlEditorTransaction', () => {
@@ -44,4 +45,10 @@ describe('sqlEditorTransaction', () => {
'DELETE FROM users WHERE id = 1',
])).toBe(false);
});
it('keeps Trino DML on the plain multi-statement execution path', () => {
expect(shouldUseSqlEditorManagedTransactionForType('trino', [
'UPDATE hive.default.orders SET status = \'done\'',
])).toBe(false);
});
});

View File

@@ -249,7 +249,13 @@ const isSqlEditorTransactionControlStatement = (statement: string): boolean => {
return keyword === 'start' && /\btransaction\b/i.test(statement);
};
export const shouldUseSqlEditorManagedTransaction = (statements: string[]): boolean => {
export const shouldUseSqlEditorManagedTransactionForType = (
type: string,
statements: string[],
): boolean => {
if (String(type || '').trim().toLowerCase() === 'trino') {
return false;
}
let hasManagedWrite = false;
for (const statement of statements) {
const trimmed = String(statement || '').trim();
@@ -265,3 +271,6 @@ export const shouldUseSqlEditorManagedTransaction = (statements: string[]): bool
}
return hasManagedWrite;
};
export const shouldUseSqlEditorManagedTransaction = (statements: string[]): boolean =>
shouldUseSqlEditorManagedTransactionForType('', statements);

View File

@@ -102,6 +102,19 @@ describe('tabDisplay', () => {
expect(buildTabDisplayTitle(tableTab, redisConnection)).toBe('[订单缓存] orders');
});
it('keeps table export tabs on the same connection prefix strategy', () => {
const exportTab: TabData = {
id: 'table-export-1',
title: '导出 public.orders',
type: 'table-export',
connectionId: 'redis-1',
dbName: 'app',
tableName: 'public.orders',
};
expect(buildTabDisplayTitle(exportTab, redisConnection)).toBe('[订单缓存] 导出 orders');
});
it('hides schema prefixes from schema-qualified table tab labels', () => {
const connection: SavedConnection = {
id: 'kingbase-1',

View File

@@ -445,6 +445,9 @@ const buildCompactObjectTabTitle = (tab: TabData, translate: TabDisplayTranslate
if (tab.type === 'table-overview') {
return stripSchemaFromTableOverviewTitle(tab.title);
}
if (tab.type === 'table-export') {
return replaceTitleObjectLabel(tab.title, tab.tableName);
}
if (tab.type === 'view-def') {
return replaceTitleObjectLabel(tab.title, tab.viewName);
}
@@ -465,6 +468,8 @@ export const getTabDisplayKindLabel = (tab: TabData): string => {
if (tab.type === 'table') return 'TABLE';
if (tab.type === 'design') return 'DESIGN';
if (tab.type === 'table-overview') return 'DB';
if (tab.type === 'table-export') return 'EXPORT';
if (tab.type === 'sql-analysis') return 'ANALYZE';
if (tab.type.startsWith('redis')) return 'REDIS';
if (tab.type.startsWith('jvm')) return 'JVM';
if (tab.type === 'trigger') return 'TRG';
@@ -599,7 +604,13 @@ export const buildTabDisplayTitle = (
}
const baseTitle = buildCompactObjectTabTitle(tab, translate);
if (tab.type !== 'table' && tab.type !== 'design' && tab.type !== 'table-overview') {
if (
tab.type !== 'table' &&
tab.type !== 'design' &&
tab.type !== 'table-overview' &&
tab.type !== 'table-export' &&
tab.type !== 'sql-analysis'
) {
return baseTitle;
}
if (!connectionName) {

View File

@@ -0,0 +1,105 @@
import { describe, expect, it } from 'vitest';
import {
buildBatchDatabaseExportWorkbenchTab,
buildBatchTableExportWorkbenchTab,
buildExportWorkbenchHistoryKey,
buildTableExportHistoryKey,
buildTableExportTab,
DEFAULT_TABLE_EXPORT_SCOPE_OPTION,
} from './tableExportTab';
describe('tableExportTab', () => {
it('builds a stable history key for persisted export records', () => {
expect(buildTableExportHistoryKey(' conn-1 ', ' app ', ' public.orders ')).toBe('conn-1::app::public.orders');
});
it('builds batch workbench history keys by mode', () => {
expect(buildExportWorkbenchHistoryKey({
connectionId: ' conn-1 ',
dbName: ' app ',
tableName: 'orders',
exportWorkbenchMode: 'batch-tables',
})).toBe('conn-1::app::__batch_tables__');
expect(buildExportWorkbenchHistoryKey({
connectionId: ' conn-1 ',
dbName: ' ignored ',
exportWorkbenchMode: 'batch-databases',
})).toBe('conn-1::__batch_databases__');
});
it('builds a stable table export tab with normalized defaults', () => {
const tab = buildTableExportTab({
connectionId: 'conn-1',
dbName: 'app',
tableName: 'public.orders',
});
expect(tab.id).toBe('table-export-conn-1-app-public.orders');
expect(tab.type).toBe('table-export');
expect(tab.title).toBe('导出 public.orders');
expect(tab.exportWorkbenchMode).toBe('single');
expect(tab.tableExportScopeOptions).toEqual([DEFAULT_TABLE_EXPORT_SCOPE_OPTION]);
expect(tab.tableExportInitialScope).toBe('all');
expect(tab.tableExportQueryByScope).toBeUndefined();
expect(tab.tableExportRowCountByScope).toBeUndefined();
});
it('deduplicates scope options and sanitizes scope payloads', () => {
const tab = buildTableExportTab({
connectionId: 'conn-1',
dbName: 'app',
tableName: 'orders',
scopeOptions: [
{ value: 'filteredAll', label: '筛选结果', description: 'desc' },
{ value: 'filteredAll', label: '重复项应移除' },
{ value: 'page', label: '' },
],
initialScope: 'filteredAll',
queryByScope: {
filteredAll: ' select * from orders where status = 1 ',
page: ' ',
},
rowCountByScope: {
filteredAll: 42.8,
page: -1,
},
});
expect(tab.tableExportScopeOptions).toEqual([
{ value: 'filteredAll', label: '筛选结果', description: 'desc', disabled: false },
{ value: 'page', label: 'page', description: undefined, disabled: false },
]);
expect(tab.tableExportInitialScope).toBe('filteredAll');
expect(tab.tableExportQueryByScope).toEqual({
filteredAll: 'select * from orders where status = 1',
});
expect(tab.tableExportRowCountByScope).toEqual({
filteredAll: 42,
});
});
it('builds batch table export workbench tabs with stable ids', () => {
const tab = buildBatchTableExportWorkbenchTab({
connectionId: 'conn-1',
dbName: 'SYS',
});
expect(tab.id).toBe('table-export-batch-tables-conn-1-SYS');
expect(tab.type).toBe('table-export');
expect(tab.title).toBe('批量导出对象');
expect(tab.exportWorkbenchMode).toBe('batch-tables');
expect(tab.dbName).toBe('SYS');
});
it('builds batch database export workbench tabs with stable ids', () => {
const tab = buildBatchDatabaseExportWorkbenchTab({
connectionId: 'conn-1',
});
expect(tab.id).toBe('table-export-batch-databases-conn-1');
expect(tab.type).toBe('table-export');
expect(tab.title).toBe('批量导出库');
expect(tab.exportWorkbenchMode).toBe('batch-databases');
});
});

View File

@@ -0,0 +1,181 @@
import type { TabData, TableExportScope, TableExportScopeOption } from '../types';
export const DEFAULT_TABLE_EXPORT_SCOPE_OPTION: TableExportScopeOption = {
value: 'all',
label: '全表数据',
description: '后台重新查询整张表并导出全部数据。',
};
export const buildTableExportHistoryKey = (
connectionId: string,
dbName: string | undefined,
tableName: string | undefined,
): string => {
return [
String(connectionId || '').trim(),
String(dbName || '').trim(),
String(tableName || '').trim(),
].join('::');
};
export const buildExportWorkbenchHistoryKey = (
input: Pick<TabData, 'connectionId' | 'dbName' | 'tableName' | 'exportWorkbenchMode'>,
): string => {
const mode = input.exportWorkbenchMode || 'single';
const connectionId = String(input.connectionId || '').trim();
const dbName = String(input.dbName || '').trim();
if (mode === 'batch-tables') {
return [connectionId, dbName, '__batch_tables__'].join('::');
}
if (mode === 'batch-databases') {
return [connectionId, '__batch_databases__'].join('::');
}
return buildTableExportHistoryKey(connectionId, dbName, input.tableName);
};
type BuildTableExportTabInput = {
connectionId: string;
dbName?: string;
tableName: string;
title?: string;
objectType?: TabData['objectType'];
schemaName?: string;
sidebarLocateKey?: string;
scopeOptions?: TableExportScopeOption[];
initialScope?: TableExportScope;
queryByScope?: Partial<Record<TableExportScope, string>>;
rowCountByScope?: Partial<Record<TableExportScope, number>>;
};
type BuildBatchTableExportWorkbenchTabInput = {
connectionId: string;
dbName?: string;
title?: string;
};
type BuildBatchDatabaseExportWorkbenchTabInput = {
connectionId: string;
title?: string;
};
const normalizeScopeOptions = (
scopeOptions: TableExportScopeOption[] | undefined,
): TableExportScopeOption[] => {
if (!Array.isArray(scopeOptions) || scopeOptions.length === 0) {
return [{ ...DEFAULT_TABLE_EXPORT_SCOPE_OPTION }];
}
const seen = new Set<TableExportScope>();
const normalized = scopeOptions
.filter((item): item is TableExportScopeOption => !!item && typeof item.value === 'string')
.map((item) => ({
value: item.value,
label: String(item.label || '').trim() || item.value,
description: typeof item.description === 'string' ? item.description : undefined,
disabled: item.disabled === true,
}))
.filter((item) => {
if (seen.has(item.value)) return false;
seen.add(item.value);
return true;
});
return normalized.length > 0 ? normalized : [{ ...DEFAULT_TABLE_EXPORT_SCOPE_OPTION }];
};
const resolveInitialScope = (
scopeOptions: TableExportScopeOption[],
initialScope?: TableExportScope,
): TableExportScope => {
if (initialScope && scopeOptions.some((item) => item.value === initialScope && !item.disabled)) {
return initialScope;
}
return scopeOptions.find((item) => !item.disabled)?.value || 'all';
};
const normalizeQueryByScope = (
queryByScope: BuildTableExportTabInput['queryByScope'],
): Partial<Record<TableExportScope, string>> | undefined => {
if (!queryByScope || typeof queryByScope !== 'object') {
return undefined;
}
const next: Partial<Record<TableExportScope, string>> = {};
(['selected', 'page', 'all', 'filteredAll'] as TableExportScope[]).forEach((scope) => {
const value = String(queryByScope[scope] || '').trim();
if (value) {
next[scope] = value;
}
});
return Object.keys(next).length > 0 ? next : undefined;
};
const normalizeRowCountByScope = (
rowCountByScope: BuildTableExportTabInput['rowCountByScope'],
): Partial<Record<TableExportScope, number>> | undefined => {
if (!rowCountByScope || typeof rowCountByScope !== 'object') {
return undefined;
}
const next: Partial<Record<TableExportScope, number>> = {};
(['selected', 'page', 'all', 'filteredAll'] as TableExportScope[]).forEach((scope) => {
const value = Number(rowCountByScope[scope]);
if (Number.isFinite(value) && value >= 0) {
next[scope] = Math.trunc(value);
}
});
return Object.keys(next).length > 0 ? next : undefined;
};
export const buildTableExportTab = (input: BuildTableExportTabInput): TabData => {
const connectionId = String(input.connectionId || '').trim();
const dbName = String(input.dbName || '').trim();
const tableName = String(input.tableName || '').trim();
const scopeOptions = normalizeScopeOptions(input.scopeOptions);
const initialScope = resolveInitialScope(scopeOptions, input.initialScope);
const objectLabel = tableName || '未命名对象';
return {
id: `table-export-${connectionId}-${dbName}-${tableName}`,
title: String(input.title || `导出 ${objectLabel}`).trim() || `导出 ${objectLabel}`,
type: 'table-export',
exportWorkbenchMode: 'single',
connectionId,
dbName,
tableName,
objectType: input.objectType,
schemaName: input.schemaName,
sidebarLocateKey: input.sidebarLocateKey,
initialTab: 'config',
tableExportScopeOptions: scopeOptions,
tableExportInitialScope: initialScope,
tableExportQueryByScope: normalizeQueryByScope(input.queryByScope),
tableExportRowCountByScope: normalizeRowCountByScope(input.rowCountByScope),
};
};
export const buildBatchTableExportWorkbenchTab = (
input: BuildBatchTableExportWorkbenchTabInput,
): TabData => {
const connectionId = String(input.connectionId || '').trim();
const dbName = String(input.dbName || '').trim();
const scopeSuffix = dbName || 'all';
return {
id: `table-export-batch-tables-${connectionId || 'none'}-${scopeSuffix}`,
title: String(input.title || '批量导出对象').trim() || '批量导出对象',
type: 'table-export',
exportWorkbenchMode: 'batch-tables',
connectionId,
dbName: dbName || undefined,
initialTab: 'config',
};
};
export const buildBatchDatabaseExportWorkbenchTab = (
input: BuildBatchDatabaseExportWorkbenchTabInput,
): TabData => {
const connectionId = String(input.connectionId || '').trim();
return {
id: `table-export-batch-databases-${connectionId || 'none'}`,
title: String(input.title || '批量导出库').trim() || '批量导出库',
type: 'table-export',
exportWorkbenchMode: 'batch-databases',
connectionId,
initialTab: 'config',
};
};