mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-07-20 20:35:14 +08:00
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:
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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" ||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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']);
|
||||
|
||||
172
frontend/src/utils/explainTypes.ts
Normal file
172
frontend/src/utils/explainTypes.ts
Normal 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`
|
||||
}
|
||||
41
frontend/src/utils/exportProgress.test.ts
Normal file
41
frontend/src/utils/exportProgress.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
96
frontend/src/utils/exportProgress.ts
Normal file
96
frontend/src/utils/exportProgress.ts
Normal 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)}`;
|
||||
};
|
||||
80
frontend/src/utils/redisDbAlias.test.ts
Normal file
80
frontend/src/utils/redisDbAlias.test.ts
Normal 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)');
|
||||
});
|
||||
});
|
||||
144
frontend/src/utils/redisDbAlias.ts
Normal file
144
frontend/src/utils/redisDbAlias.ts
Normal 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}`;
|
||||
};
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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+P(P = Plan)
|
||||
diagnoseQuery: {
|
||||
mac: { combo: 'Meta+Shift+P', enabled: true },
|
||||
windows: { combo: 'Ctrl+Shift+P', enabled: true },
|
||||
},
|
||||
// 慢查询历史:避开 toggleLogPanel 的 Ctrl+H / Meta+Shift+H,用 Ctrl+Shift+L(L = 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 '';
|
||||
|
||||
42
frontend/src/utils/sqlAnalysisTab.test.ts
Normal file
42
frontend/src/utils/sqlAnalysisTab.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
42
frontend/src/utils/sqlAnalysisTab.ts
Normal file
42
frontend/src/utils/sqlAnalysisTab.ts
Normal 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()}`,
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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) {
|
||||
|
||||
105
frontend/src/utils/tableExportTab.test.ts
Normal file
105
frontend/src/utils/tableExportTab.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
181
frontend/src/utils/tableExportTab.ts
Normal file
181
frontend/src/utils/tableExportTab.ts
Normal 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',
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user