feat(desktop): add database info and SQL statement markers

This commit is contained in:
shen authored and GitHub committed 2026-07-15 18:35:34 +08:00
1 parent 83f7604a11
commit 3748d83cde
60 files changed
+2403 -134

No files matched your search

@@ -13,7 +13,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Switch } from "@/components/ui/switch";
import type { ConnectionConfig, DatabaseType, HttpTunnelConfig, JdbcDriverInfo, JdbcLocalBundleInfo, JdbcMavenBundleInfo, ProxyTunnelConfig, SshConfigHostEntry, SshTunnelConfig, TransportLayerConfig } from "@/types/database";
import type { ConnectionConfig, ConnectionTestResult, DatabaseConnectionInfo, DatabaseType, HttpTunnelConfig, IdentifierCase, JdbcDriverInfo, JdbcLocalBundleInfo, JdbcMavenBundleInfo, ProxyTunnelConfig, SshConfigHostEntry, SshTunnelConfig, TransportLayerConfig } from "@/types/database";
import type { InfluxDbExternalConfig, InfluxDbVersion } from "@/types/influxdb";
import type { MqAdminConfig, MqAuth, MqSystemKind } from "@/types/mq";
import type { NacosAdminConfig, NacosAuthConfig } from "@/types/nacos";
@@ -37,6 +37,7 @@ import { MQ_PINNED_VERSION_OPTIONS, pinnedVersionToSelection, selectionToPinnedV
import { mongodbAuthFailureHint, mongoUrlParam, mongoUrlParamIsTrue, normalizeMongoTlsFormState, setMongoUrlParam, setMongoUrlParamBoolean } from "@/lib/mongo/mongoConnectionOptions";
import { mysqlCleartextPasswordAuthEnabled, setMysqlCleartextPasswordAuthEnabled } from "@/lib/database/mysqlConnectionOptions";
import { copyToClipboard } from "@/lib/common/clipboard";
import { configuredDatabaseProductName, connectionConfigFingerprint, databaseInfoCopyText, databaseInfoRows, normalizeDatabaseConnectionInfo, type DatabaseInfoField } from "@/lib/connection/connectionDatabaseInfo";
import { agentDriverInstallKey, appendAgentDriverUpdateHint, hasAgentDriverUpdate, showAgentDriverInstallHint, type AgentDriverInstallState, type DriverStoreFocus } from "@/lib/connection/agentDriverInstallHint";
import { prestoSqlBuiltinDriverPaths } from "@/lib/database/prestoSqlBuiltinDriver";
import { SQLITE_DATABASE_FILE_EXTENSIONS } from "@/lib/database/databaseFileDetection";
@@ -46,7 +47,35 @@ import { normalizeKafkaBootstrapServers } from "@/lib/connection/kafkaBootstrapS
import { detectMqUiAuthKind, isMqAuthKindAllowedForSystem, type MqUiAuthKind } from "@/lib/connection/mqAuth";
import { driverInstallProgressPercent, type DriverInstallProgress } from "@/lib/connection/driverInstallProgressUi";
import { isSqlServerLegacyCompatibilityMode, requiresSqlServerLegacyCompatibilityComponent, setSqlServerLegacyCompatibilityMode, SQLSERVER_LEGACY_COMPATIBILITY_DRIVER_KEY } from "@/lib/connection/sqlServerLegacyCompatibility";
import { ArrowLeft, ArrowDown, ArrowUp, CheckSquare, ChevronRight, CircleHelp, Copy, ExternalLink, FilePlus2, FolderOpen, GripVertical, Grid3X3, KeyRound, Link2, List, ListFilter, Loader2, Pencil, Pipette, Plus, RefreshCw, Search, ShieldAlert, ShieldCheck, Square, Trash2 } from "@lucide/vue";
import {
ArrowLeft,
ArrowDown,
ArrowUp,
CheckSquare,
ChevronRight,
CircleHelp,
Copy,
Database as DatabaseLucide,
ExternalLink,
FilePlus2,
FolderOpen,
GripVertical,
Grid3X3,
KeyRound,
Link2,
List,
ListFilter,
Loader2,
Pencil,
Pipette,
Plus,
RefreshCw,
Search,
ShieldAlert,
ShieldCheck,
Square,
Trash2,
} from "@lucide/vue";
import { buildDraftVisibleDatabasesConnectionId, connectionCanChooseVisibleDatabases, initialVisibleDatabaseSelection, visibleDatabaseSelectionIsStale } from "@/lib/connection/connectionVisibleDatabases";
import { canSaveVisibleDatabaseSelection, connectionUsesVisibleSchemaFilter, filterDatabaseNamesForVisiblePicker, isSystemDatabaseName, normalizeVisibleDatabaseSelection, buildDraftVisibleSchemasConnectionId, normalizeVisibleSchemaSelection } from "@/lib/database/visibleDatabases";
import { isSchemaAware, isSingleDatabase } from "@/lib/database/databaseFeatureSupport";
@@ -101,6 +130,8 @@ type LegacyTransportFields = {
};
type LegacyConnectionConfig = ConnectionConfig & LegacyTransportFields;
type ConnectionForm = Omit<ConnectionConfig, "id">;
type ConnectionTestState = ConnectionTestResult & { ok: boolean };
type SuccessfulConnectionTest = { result: ConnectionTestResult; config: ConnectionConfig };
const { t } = useI18n();
const { toast } = useToast();
@@ -126,7 +157,13 @@ const store = useConnectionStore();
const tunnelProfileStore = useTunnelProfileStore();
const isTesting = ref(false);
const isSaving = ref(false);
const testResult = ref<{ ok: boolean; message: string } | null>(null);
const testResult = ref<ConnectionTestState | null>(null);
const testedConfigFingerprint = ref("");
const testedConfigId = ref("");
const testedGeneratedName = ref("");
const savedDatabaseInfo = ref<DatabaseConnectionInfo | null>(null);
const savedDatabaseInfoFingerprint = ref("");
const savedConnectionConfigFingerprint = ref("");
const showAgentInstallDialog = ref(false);
const agentInstallRunning = ref(false);
const agentInstallDriverKey = ref("");
@@ -1038,7 +1075,7 @@ function isNacosAdminEndpointNotFound(message: string): boolean {
return /Nacos admin endpoint was not found/i.test(message);
}
async function tryNacosDockerConsoleFallback(config: ConnectionConfig, originalError: string, runId: number): Promise<string | null> {
async function tryNacosDockerConsoleFallback(config: ConnectionConfig, originalError: string, runId: number): Promise<SuccessfulConnectionTest | null> {
if (config.db_type !== "nacos" || !isNacosAdminEndpointNotFound(originalError)) return null;
const fallbackUrl = dockerNacosConsoleFallbackUrl(nacosServerAddr.value);
if (!fallbackUrl || fallbackUrl === nacosServerAddr.value.trim()) return null;
@@ -1046,9 +1083,15 @@ async function tryNacosDockerConsoleFallback(config: ConnectionConfig, originalE
const previousUrl = nacosServerAddr.value;
nacosServerAddr.value = fallbackUrl;
try {
const fallbackConfig = connectionConfigForSubmit(config.id);
const message = await testConnectionWithTimeout(fallbackConfig, runId);
return `${message} ${t("connection.nacosConsoleUrlAutoAdjusted", { from: previousUrl.trim(), to: fallbackUrl })}`;
const fallbackConfig = connectionConfigForSubmit(config.id, config.name);
const result = await testConnectionWithTimeout(fallbackConfig, runId);
return {
config: fallbackConfig,
result: {
...result,
message: `${result.message} ${t("connection.nacosConsoleUrlAutoAdjusted", { from: previousUrl.trim(), to: fallbackUrl })}`,
},
};
} catch {
nacosServerAddr.value = previousUrl;
return null;
@@ -1199,15 +1242,63 @@ function isSqlServerTlsHandshakeFailure(message: string): boolean {
return text.includes("sql server") && text.includes("tls") && (text.includes("handshake") || text.includes("eof") || text.includes("performing i/o"));
}
async function testConnectionWithTimeout(config: ConnectionConfig, runId: number): Promise<string> {
function clearTestedConnectionInfo() {
testedConfigFingerprint.value = "";
testedConfigId.value = "";
testedGeneratedName.value = "";
}
function clearSavedDatabaseInfo() {
savedDatabaseInfo.value = null;
savedDatabaseInfoFingerprint.value = "";
savedConnectionConfigFingerprint.value = "";
}
function applySavedDatabaseInfo(config: ConnectionConfig) {
clearSavedDatabaseInfo();
try {
const current = connectionConfigForSubmit(config.id, config.name);
savedConnectionConfigFingerprint.value = connectionConfigFingerprint(current, form.value.name);
const info = normalizeDatabaseConnectionInfo(config.database_info);
if (info) {
savedDatabaseInfo.value = info;
savedDatabaseInfoFingerprint.value = savedConnectionConfigFingerprint.value;
}
} catch {
clearSavedDatabaseInfo();
}
}
function applySuccessfulConnectionTest(result: ConnectionTestResult, config: ConnectionConfig, sourceName: string) {
testResult.value = { ok: true, ...result };
testedConfigFingerprint.value = connectionConfigFingerprint(config, sourceName);
testedConfigId.value = config.id;
testedGeneratedName.value = config.name;
}
async function persistSuccessfulConnectionTest(result: ConnectionTestResult, config: ConnectionConfig, sourceName: string) {
if (!editingId.value || !result.databaseInfo || !savedConnectionConfigFingerprint.value) return;
const fingerprint = connectionConfigFingerprint(config, sourceName);
if (fingerprint !== savedConnectionConfigFingerprint.value) return;
try {
await store.updateConnectionDatabaseInfo(editingId.value, result.databaseInfo);
savedDatabaseInfo.value = { ...result.databaseInfo };
savedDatabaseInfoFingerprint.value = fingerprint;
} catch {
// The successful test remains valid even when optional metadata persistence fails.
}
}
async function testConnectionWithTimeout(config: ConnectionConfig, runId: number): Promise<ConnectionTestResult> {
const timeoutMs = connectionAttemptTimeoutMs(config);
const timeoutMessage = connectionAttemptTimeoutMessage(timeoutMs);
const promise = api.testConnection(config);
const promise = api.testConnectionWithInfo(config);
let timedOut = false;
let timer: ReturnType<typeof setTimeout> | undefined;
void promise.catch((error) => {
if (!timedOut) return;
if (runId !== testRunId) return;
clearTestedConnectionInfo();
testResult.value = {
ok: false,
message: connectionErrorWithDriverUpdateHint(config, connectionAttemptOriginalErrorMessage(timeoutMessage, errorMessage(error))),
@@ -1216,7 +1307,7 @@ async function testConnectionWithTimeout(config: ConnectionConfig, runId: number
try {
return await Promise.race([
promise,
new Promise<string>((_, reject) => {
new Promise<ConnectionTestResult>((_, reject) => {
timer = setTimeout(() => {
timedOut = true;
reject(new Error(timeoutMessage));
@@ -1436,9 +1527,11 @@ function switchGbaseProfile(profile: "gbase8a" | "gbase8s") {
}
watch(
() => props.editConfig,
(config) => {
[() => props.editConfig, open],
([config, isOpen]) => {
if (!isOpen) return;
if (config) {
clearSavedDatabaseInfo();
const legacyConfig = config as LegacyConnectionConfig;
const profile = profileForConfig(config);
const oceanbaseMode = profile === "oceanbase" ? oceanbaseSubModeFromConfig(config) : "mysql";
@@ -1528,7 +1621,14 @@ watch(
customDriverName.value = isCustomCompatibleProfile() ? config.driver_label || "" : "";
dialogStep.value = "config";
configTab.value = initialConfigTab();
// Form/profile watchers normalize derived fields in this flush. Capture
// the saved baseline afterwards so those initial changes are not treated
// as user edits that invalidate persisted database metadata.
void nextTick(() => {
if (open.value && props.editConfig?.id === config.id) applySavedDatabaseInfo(config);
});
} else {
clearSavedDatabaseInfo();
editingId.value = null;
form.value = defaultForm();
productionProtectionEnabled.value = false;
@@ -2050,6 +2150,71 @@ const visibleObjectSelectedCountKey = computed(() => (visibleFilterUsesSchemas.v
const visibleObjectEmptySelectionKey = computed(() => (visibleFilterUsesSchemas.value ? "visibleSchemas.emptySelection" : "visibleDatabases.emptySelection"));
const visibleObjectLoadFailedKey = computed(() => (visibleFilterUsesSchemas.value ? "visibleSchemas.loadFailed" : "visibleDatabases.loadFailed"));
const visibleObjectSaveKey = computed(() => (visibleFilterUsesSchemas.value ? "visibleSchemas.save" : "visibleDatabases.save"));
const databaseInfoLabelKeys: Record<DatabaseInfoField, string> = {
productName: "connection.databaseInfo.productName",
productVersion: "connection.databaseInfo.productVersion",
currentDatabase: "connection.databaseInfo.currentDatabase",
serverComment: "connection.databaseInfo.serverComment",
serverCharset: "connection.databaseInfo.serverCharset",
serverCollation: "connection.databaseInfo.serverCollation",
unquotedIdentifierCase: "connection.databaseInfo.unquotedIdentifierCase",
quotedIdentifierCase: "connection.databaseInfo.quotedIdentifierCase",
driverName: "connection.databaseInfo.driverName",
driverVersion: "connection.databaseInfo.driverVersion",
jdbcVersion: "connection.databaseInfo.jdbcVersion",
};
function databaseInfoFieldLabel(field: DatabaseInfoField): string {
return t(databaseInfoLabelKeys[field]);
}
function databaseIdentifierCaseLabel(value: IdentifierCase): string {
return t(`connection.databaseInfo.identifierCase.${value}`);
}
const visibleTestDatabaseInfo = computed<DatabaseConnectionInfo | null>(() => {
const result = testResult.value;
if (!result?.ok || !result.databaseInfo || !testedConfigFingerprint.value || !testedConfigId.value) return null;
try {
const current = connectionConfigForSubmit(testedConfigId.value, testedGeneratedName.value);
return connectionConfigFingerprint(current, form.value.name) === testedConfigFingerprint.value ? result.databaseInfo : null;
} catch {
return null;
}
});
const visibleSavedDatabaseInfo = computed<DatabaseConnectionInfo | null>(() => {
if (!savedDatabaseInfo.value || !savedDatabaseInfoFingerprint.value || !editingId.value) return null;
try {
const current = connectionConfigForSubmit(editingId.value, form.value.name);
return connectionConfigFingerprint(current, form.value.name) === savedDatabaseInfoFingerprint.value ? savedDatabaseInfo.value : null;
} catch {
return null;
}
});
const configuredDatabaseInfo = computed<DatabaseConnectionInfo | null>(() => {
const productName = configuredDatabaseProductName({
db_type: form.value.db_type,
driver_label: form.value.driver_label,
});
return normalizeDatabaseConnectionInfo(undefined, productName, form.value.database) ?? null;
});
const visibleDatabaseInfo = computed<DatabaseConnectionInfo | null>(() => visibleTestDatabaseInfo.value ?? visibleSavedDatabaseInfo.value ?? configuredDatabaseInfo.value);
const databaseInfoVerified = computed(() => !!visibleTestDatabaseInfo.value || !!visibleSavedDatabaseInfo.value);
const databaseInfoStatusLabel = computed(() => (databaseInfoVerified.value ? t("connection.databaseInfo.sourceTested") : t("connection.databaseInfo.sourceConfigured")));
const databaseInfoDescription = computed(() => (databaseInfoVerified.value ? t("connection.databaseInfo.testedDescription") : t("connection.databaseInfo.configuredDescription")));
const databaseInfoDisplayRows = computed(() =>
visibleDatabaseInfo.value
? databaseInfoRows(visibleDatabaseInfo.value).map((row) => ({
...row,
label: databaseInfoFieldLabel(row.key),
displayValue: row.key === "unquotedIdentifierCase" || row.key === "quotedIdentifierCase" ? databaseIdentifierCaseLabel(row.value as IdentifierCase) : row.value,
}))
: [],
);
const databaseInfoCompactLabel = computed(() =>
databaseInfoDisplayRows.value
.filter((row) => row.key === "productName" || row.key === "productVersion" || row.key === "currentDatabase")
.slice(0, 3)
.map((row) => row.displayValue)
.join(" · "),
);
const testResultMessage = computed(() => {
if (!testResult.value) return "";
return testResult.value.ok ? t("connection.testSuccess") : translateBackendError(t, testResult.value.message);
@@ -2147,30 +2312,37 @@ async function testConnection() {
isTesting.value = true;
testResult.value = null;
let config: ConnectionConfig | null = null;
const submittedSourceName = form.value.name;
try {
config = connectionConfigForSubmit(editingId.value || draftTestConnectionId.value);
await ensureRequiredAgentDriverInstalled(config);
const msg = await testConnectionWithTimeout(config, runId);
const result = await testConnectionWithTimeout(config, runId);
if (runId !== testRunId) return;
if (config.db_type === "mongodb" && /legacy driver/i.test(msg)) {
let successfulConfig = config;
if (config.db_type === "mongodb" && /legacy driver/i.test(result.message)) {
mongoDriverMode.value = "legacy";
successfulConfig = connectionConfigForSubmit(config.id, config.name);
}
testResult.value = { ok: true, message: msg };
applySuccessfulConnectionTest(result, successfulConfig, submittedSourceName);
void persistSuccessfulConnectionTest(result, successfulConfig, submittedSourceName);
clearEditedConnectionErrorAfterSuccessfulTest();
} catch (e: any) {
if (runId !== testRunId) return;
const rawMessage = mongodbAuthFailureHint(errorMessage(e));
const message = config ? connectionErrorWithDriverUpdateHint(config, rawMessage) : rawMessage;
const fallbackMessage = config ? await tryNacosDockerConsoleFallback(config, message, runId) : null;
const fallback = config ? await tryNacosDockerConsoleFallback(config, message, runId) : null;
if (runId !== testRunId) return;
const shouldShowSqlServerLegacyMode = !fallbackMessage && config?.db_type === "sqlserver" && !isSqlServerLegacyCompatibilityMode(config.url_params) && isSqlServerTlsHandshakeFailure(message);
const shouldShowSqlServerLegacyMode = !fallback && config?.db_type === "sqlserver" && !isSqlServerLegacyCompatibilityMode(config.url_params) && isSqlServerTlsHandshakeFailure(message);
if (shouldShowSqlServerLegacyMode) {
configTab.value = "advanced";
}
testResult.value = fallbackMessage ? { ok: true, message: fallbackMessage } : { ok: false, message };
if (fallbackMessage) {
if (fallback) {
applySuccessfulConnectionTest(fallback.result, fallback.config, submittedSourceName);
void persistSuccessfulConnectionTest(fallback.result, fallback.config, submittedSourceName);
clearEditedConnectionErrorAfterSuccessfulTest();
} else {
clearTestedConnectionInfo();
testResult.value = { ok: false, message };
showConnectionError(message);
}
} finally {
@@ -2319,13 +2491,14 @@ function generateConnectionName(): string {
return `${label}_${rand}`;
}
function connectionConfigForSubmit(id: string): ConnectionConfig {
function connectionConfigForSubmit(id: string, generatedName = ""): ConnectionConfig {
const config = { ...formValueForSubmit(), id } as LegacyConnectionConfig;
config.database_info = undefined;
if (selectedType.value === "oceanbase" && (config.driver_profile === "oceanbase" || config.driver_profile === "oceanbase-oracle")) {
Object.assign(config, oceanbaseModeConnectionPatch(oceanbaseSubMode.value));
}
if (!config.name?.trim()) {
config.name = generateConnectionName();
config.name = generatedName.trim() || generateConnectionName();
}
if (config.db_type === "kingbase") {
config.database = config.database?.trim() || undefined;
@@ -2602,6 +2775,13 @@ function connectionConfigForSubmit(id: string): ConnectionConfig {
return config as ConnectionConfig;
}
function withSavedDatabaseInfo(config: ConnectionConfig, databaseInfo: DatabaseConnectionInfo | null): ConnectionConfig {
return {
...config,
database_info: databaseInfo ? { ...databaseInfo } : undefined,
};
}
function connectionConfigSnapshotForVisibleDatabases(): ConnectionConfig {
return {
...(form.value as ConnectionConfig),
@@ -2813,6 +2993,7 @@ function resetTestState() {
testRunId += 1;
isTesting.value = false;
testResult.value = null;
clearTestedConnectionInfo();
showConnectionErrorDialog.value = false;
connectionErrorDetail.value = "";
}
@@ -3098,6 +3279,17 @@ async function copyTestResult() {
}
}
async function copyDatabaseInfo() {
const info = visibleDatabaseInfo.value;
if (!info) return;
try {
await copyToClipboard(databaseInfoCopyText(info, databaseInfoFieldLabel, databaseIdentifierCaseLabel));
toast(t("grid.copied"));
} catch (e: any) {
toast(t("grid.copyFailed", { message: e?.message || String(e) }), 5000);
}
}
async function copyAgentInstallError() {
if (!agentInstallError.value) return;
try {
@@ -3431,16 +3623,16 @@ function validateTransportLayers(config: LegacyConnectionConfig) {
async function save() {
if (!ensureConnectionHostResolvedFromUrl()) return;
if (isSaving.value) return;
const databaseInfoForSave = visibleTestDatabaseInfo.value ?? visibleSavedDatabaseInfo.value;
isSaving.value = true;
resetTestState();
try {
if (editingId.value) {
const updated = connectionConfigForSubmit(editingId.value);
const updated = withSavedDatabaseInfo(connectionConfigForSubmit(editingId.value), databaseInfoForSave);
await ensureRequiredAgentDriverInstalled(updated);
await store.updateConnection(updated);
store.stopEditing();
} else {
const config = connectionConfigForSubmit(draftTestConnectionId.value);
const config = withSavedDatabaseInfo(connectionConfigForSubmit(draftTestConnectionId.value), databaseInfoForSave);
await ensureRequiredAgentDriverInstalled(config);
await store.addConnection(config);
draftTestConnectionId.value = uuid();
@@ -3817,7 +4009,7 @@ function openExternalUrl(url: string) {
</DialogHeader>
<template v-if="dialogStep === 'select'">
<div class="space-y-4">
<div class="flex min-h-0 flex-1 flex-col gap-4 overflow-hidden">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div class="flex items-center gap-2">
<div class="flex shrink-0 rounded-lg border bg-muted/40 p-0.5">
@@ -3839,7 +4031,7 @@ function openExternalUrl(url: string) {
</Button>
</div>
<div class="max-h-[58vh] space-y-5 overflow-y-auto pr-2">
<div class="min-h-0 flex-1 space-y-5 overflow-y-auto pr-2">
<section v-for="category in filteredDbCategories" :key="category.key" class="space-y-2">
<div class="flex items-center">
<h3 v-if="category.title" class="text-sm font-medium">{{ category.title }}</h3>
@@ -3887,7 +4079,7 @@ function openExternalUrl(url: string) {
</div>
</div>
<DialogFooter class="flex items-center gap-2">
<DialogFooter class="flex shrink-0 items-center gap-2">
<div class="mr-auto flex min-w-0 items-center gap-2 text-sm text-muted-foreground">
<DatabaseIcon :db-type="selectedDbIcon" class="h-4 w-4 shrink-0" />
<span class="truncate">{{ t("connection.selectedDatabase") }}: {{ selectedProfile().label }}</span>
@@ -3900,8 +4092,8 @@ function openExternalUrl(url: string) {
</template>
<template v-else>
<div class="space-y-3">
<Tabs v-model="configTab" class="min-h-0">
<div class="flex min-h-0 flex-1 flex-col gap-4 overflow-hidden">
<Tabs v-model="configTab" class="flex min-h-0 flex-1 flex-col">
<div class="flex items-center justify-between border-b pb-2">
<TabsList>
<TabsTrigger value="connection">{{ t("connection.basicTab") }}</TabsTrigger>
@@ -3911,8 +4103,8 @@ function openExternalUrl(url: string) {
</TabsList>
</div>
<TabsContent value="connection" class="m-0">
<div class="connection-form-body grid gap-4 py-4 pr-2 max-h-[65vh] overflow-y-auto">
<TabsContent value="connection" class="m-0 min-h-0 flex-1 overflow-hidden">
<div class="connection-form-body grid h-full min-h-0 gap-4 overflow-y-auto pt-4 pr-2">
<div v-if="!isJdbcConnection" class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.connectionUrlOptional") }}</Label>
<div class="col-span-3 flex items-center gap-1">
@@ -5059,11 +5251,49 @@ function openExternalUrl(url: string) {
</div>
</template>
</template>
<div v-if="visibleDatabaseInfo" class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.databaseInfo.title") }}</Label>
<Popover>
<PopoverTrigger as-child>
<button
type="button"
class="col-span-3 flex h-9 min-w-0 items-center gap-2 rounded-md border bg-muted/20 px-2.5 text-left text-xs transition-colors hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
:title="databaseInfoCompactLabel"
:aria-label="t('connection.databaseInfo.open', { database: databaseInfoCompactLabel })"
>
<DatabaseLucide class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span class="rounded-full bg-background px-1.5 py-0.5 text-[11px] text-muted-foreground">{{ databaseInfoStatusLabel }}</span>
<span class="min-w-0 flex-1 truncate text-muted-foreground">{{ databaseInfoCompactLabel }}</span>
</button>
</PopoverTrigger>
<PopoverContent side="top" align="start" class="w-[360px] max-w-[calc(100vw-24px)] gap-3 p-3" @click.stop @keydown.stop>
<div class="flex min-w-0 items-start justify-between gap-3">
<div class="min-w-0">
<div class="flex min-w-0 items-center gap-2">
<DatabaseLucide class="h-4 w-4 shrink-0 text-muted-foreground" />
<div class="min-w-0 text-sm font-medium">{{ t("connection.databaseInfo.title") }}</div>
</div>
<p class="mt-1 text-xs text-muted-foreground">{{ databaseInfoDescription }}</p>
</div>
<Button variant="ghost" size="icon-xs" class="h-7 w-7 shrink-0" :title="t('connection.databaseInfo.copy')" :aria-label="t('connection.databaseInfo.copy')" @click="copyDatabaseInfo">
<Copy class="h-3.5 w-3.5" />
</Button>
</div>
<dl class="mt-3 grid min-w-0 grid-cols-[minmax(7.5rem,auto)_minmax(0,1fr)] gap-x-4 gap-y-2 text-xs">
<template v-for="row in databaseInfoDisplayRows" :key="row.key">
<dt class="text-muted-foreground">{{ row.label }}</dt>
<dd class="min-w-0 break-words text-right font-medium">{{ row.displayValue }}</dd>
</template>
</dl>
</PopoverContent>
</Popover>
</div>
</div>
</TabsContent>
<TabsContent v-if="supportsTlsToggle" value="tls" class="m-0">
<div class="connection-form-body grid gap-4 py-4 pr-2 max-h-[65vh] overflow-y-auto overflow-x-hidden">
<TabsContent v-if="supportsTlsToggle" value="tls" class="m-0 min-h-0 flex-1 overflow-hidden">
<div class="connection-form-body grid h-full min-h-0 gap-4 overflow-y-auto overflow-x-hidden pt-4 pr-2">
<div v-if="!supportsPostgresTlsOptions && !supportsMysqlTlsOptions" class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelSmallClass">SSL/TLS</Label>
<label class="col-span-3 flex items-center gap-2 cursor-pointer">
@@ -5327,8 +5557,8 @@ function openExternalUrl(url: string) {
</div>
</TabsContent>
<TabsContent value="advanced" class="m-0">
<div class="connection-form-body grid gap-4 py-4 pr-2 max-h-[65vh] overflow-y-auto">
<TabsContent value="advanced" class="m-0 min-h-0 flex-1 overflow-hidden">
<div class="connection-form-body grid h-full min-h-0 gap-4 overflow-y-auto pt-4 pr-2">
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelSmallClass">{{ t("connection.connectTimeout") }}</Label>
<Input v-model.number="form.connect_timeout_secs" type="number" min="1" max="300" step="1" class="col-span-3" />
@@ -5419,8 +5649,8 @@ function openExternalUrl(url: string) {
</div>
</TabsContent>
<TabsContent v-if="canUseTransportLayers" value="transport" class="m-0">
<div class="connection-form-body grid gap-4 py-4 pr-2 max-h-[65vh] overflow-y-auto overflow-x-hidden">
<TabsContent v-if="canUseTransportLayers" value="transport" class="m-0 min-h-0 flex-1 overflow-hidden">
<div class="connection-form-body grid h-full min-h-0 gap-4 overflow-y-auto overflow-x-hidden pt-4 pr-2">
<div class="connection-label-wide-grid grid min-w-0 grid-cols-4 items-start gap-4">
<Label :class="connectionLabelSmallPaddedClass">{{ t("connection.sshHops") }}</Label>
<div class="col-span-3 grid min-w-0 gap-3">
@@ -5663,7 +5893,7 @@ function openExternalUrl(url: string) {
</Tabs>
</div>
<DialogFooter class="flex min-w-0 items-center gap-2 sm:flex-nowrap">
<DialogFooter class="flex min-w-0 shrink-0 items-center gap-2 sm:flex-nowrap">
<div class="mr-auto flex min-w-0 flex-1 basis-0 items-center gap-2 overflow-hidden">
<Button v-if="!editingId" variant="outline" class="shrink-0" :disabled="isSaving" @click="backToDatabasePicker">
<ArrowLeft class="h-4 w-4" />
@@ -5673,7 +5903,7 @@ function openExternalUrl(url: string) {
<span class="block min-w-0 flex-1 basis-0 truncate text-xs" :class="testResult.ok ? 'text-green-600' : 'text-red-600'" :title="testResultMessage" role="status" aria-live="polite">
{{ testResultMessage }}
</span>
<Button variant="ghost" size="icon-xs" class="h-5 w-5 shrink-0" :title="t('connection.copyTestResult')" :aria-label="t('connection.copyTestResult')" @click="copyTestResult">
<Button v-if="!testResult.ok" variant="ghost" size="icon-xs" class="h-5 w-5 shrink-0" :title="t('connection.copyTestResult')" :aria-label="t('connection.copyTestResult')" @click="copyTestResult">
<Copy class="h-3 w-3" />
</Button>
</template>
@@ -5921,6 +6151,12 @@ function openExternalUrl(url: string) {
</template>
<style>
.connection-dialog-content {
display: flex;
flex-direction: column;
max-height: calc(100dvh - 2rem);
}
@media (min-width: 640px) {
.connection-db-picker-grid {
grid-template-columns: repeat(4, minmax(0, 1fr)) !important;
@@ -62,6 +62,7 @@ import { createInsertValueHintsExtension, requestInsertValueHintsRefresh } from
import { focusEditorView } from "@/lib/editor/queryEditorFocus";
import { createDbxCodeMirrorSqlDialect } from "@/lib/editor/codemirrorSqlDialect";
import { startsQueryEditorRectangularSelection } from "@/lib/editor/queryEditorPointerSelection";
import type { StatementExecutionMarker } from "@/lib/tabs/tabPresentation";
import { isSchemaAware, isSingleDatabase, supportsSqlInListPaste } from "@/lib/database/databaseFeatureSupport";
import { usesLocalOnlyEditorCompletionMetadata, usesOnDemandOnlyEditorColumnMetadata } from "@/lib/metadata/completionMetadataPolicy";
import { qualifiedTableNameAtSqlPosition } from "@/lib/sql/queryCursorTableTarget";
@@ -89,6 +90,7 @@ const props = defineProps<{
hideExecutionControls?: boolean;
initialViewport?: { scrollTop: number; scrollLeft: number };
initialSelection?: { anchor: number; head: number };
statementExecutionMarkers?: StatementExecutionMarker[];
}>();
const COMPLETION_REMOTE_LATENCY_BUDGET_MS = 120;
@@ -217,6 +219,7 @@ let codeMirrorCloseBrackets: typeof import("@codemirror/autocomplete").closeBrac
let codeMirrorCloseBracketsKeymap: readonly import("@codemirror/view").KeyBinding[] | null = null;
let readOnlyComp: import("@codemirror/state").Compartment | null = null;
let runGutterComp: import("@codemirror/state").Compartment | null = null;
let statementExecutionGutterComp: import("@codemirror/state").Compartment | null = null;
let runKeymapComp: import("@codemirror/state").Compartment | null = null;
let completionComp: import("@codemirror/state").Compartment | null = null;
let diagnosticComp: import("@codemirror/state").Compartment | null = null;
@@ -250,9 +253,11 @@ let codeMirrorToggleLineComment: typeof import("@codemirror/commands").toggleLin
let setSqlDiagnosticsEffect: import("@codemirror/state").StateEffectType<SqlSemanticDiagnostic[]> | null = null;
let setPreviewRangeEffect: import("@codemirror/state").StateEffectType<{ from: number; to: number } | null> | null = null;
let setResultSourceRangeEffect: import("@codemirror/state").StateEffectType<{ from: number; to: number } | null> | null = null;
let setStatementExecutionMarkersEffect: import("@codemirror/state").StateEffectType<StatementExecutionMarker[]> | null = null;
let previewRangeComp: import("@codemirror/state").Compartment | null = null;
let buildPreviewRangeExtension: (() => import("@codemirror/state").Extension) | null = null;
let buildResultSourceRangeExtension: (() => import("@codemirror/state").Extension) | null = null;
let buildStatementExecutionMarkersExtension: (() => import("@codemirror/state").Extension) | null = null;
let buildRunStatementGutterExtension: (() => import("@codemirror/state").Extension) | null = null;
let indentComp: import("@codemirror/state").Compartment | null = null;
let codeMirrorIndentUnit: typeof import("@codemirror/language").indentUnit | null = null;
@@ -2673,6 +2678,7 @@ onMounted(async () => {
codeMirrorCloseBracketsKeymap = closeBracketsKeymap;
readOnlyComp = new Compartment();
runGutterComp = new Compartment();
statementExecutionGutterComp = new Compartment();
runKeymapComp = new Compartment();
completionComp = new Compartment();
diagnosticComp = new Compartment();
@@ -2801,6 +2807,85 @@ onMounted(async () => {
return field;
};
class StatementExecutionStatusMarker extends GutterMarker {
constructor(readonly marker: StatementExecutionMarker) {
super();
}
eq(other: import("@codemirror/view").GutterMarker): boolean {
return other instanceof StatementExecutionStatusMarker && other.marker.status === this.marker.status && other.marker.successCount === this.marker.successCount && other.marker.errorCount === this.marker.errorCount;
}
toDOM() {
const element = document.createElement("span");
const title = statementExecutionMarkerTitle(this.marker);
element.className = `cm-statement-execution-marker cm-statement-execution-marker--${this.marker.status}`;
element.title = title;
element.setAttribute("aria-label", title);
element.appendChild(createStatementExecutionStatusIconDom(this.marker.status));
return element;
}
}
function statementExecutionMarkerTitle(marker: StatementExecutionMarker) {
const parts = [];
if (marker.successCount > 0) parts.push(t("editor.statementExecutionSucceeded", { count: marker.successCount }));
if (marker.errorCount > 0) parts.push(t("editor.statementExecutionFailed", { count: marker.errorCount }));
return parts.join(", ");
}
function createStatementExecutionStatusIconDom(status: StatementExecutionMarker["status"]) {
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.setAttribute("viewBox", "0 0 24 24");
svg.setAttribute("fill", "none");
svg.setAttribute("stroke", "currentColor");
svg.setAttribute("stroke-width", "2");
svg.setAttribute("stroke-linecap", "round");
svg.setAttribute("stroke-linejoin", "round");
svg.setAttribute("aria-hidden", "true");
const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle");
circle.setAttribute("cx", "12");
circle.setAttribute("cy", "12");
circle.setAttribute("r", "10");
const mark = document.createElementNS("http://www.w3.org/2000/svg", "path");
mark.setAttribute("d", status === "error" ? "m15 9-6 6m0-6 6 6" : "m9 12 2 2 4-4");
svg.append(circle, mark);
return svg;
}
setStatementExecutionMarkersEffect = StateEffect.define<StatementExecutionMarker[]>();
buildStatementExecutionMarkersExtension = () => {
const effectType = setStatementExecutionMarkersEffect!;
const markersForState = (state: import("@codemirror/state").EditorState, markers: readonly StatementExecutionMarker[]) => {
const ranges = markers.map((marker) => {
const from = Math.max(0, Math.min(marker.from, state.doc.length));
return new StatementExecutionStatusMarker(marker).range(state.doc.lineAt(from).from);
});
return RangeSet.of(ranges, true);
};
const field = StateField.define({
create(state) {
return markersForState(state, props.statementExecutionMarkers ?? []);
},
update(markers, transaction) {
for (const effect of transaction.effects) {
if (effect.is(effectType)) return markersForState(transaction.state, effect.value);
}
if (transaction.docChanged) return RangeSet.empty;
return markers;
},
provide: (field) =>
gutter({
class: "cm-statement-execution-gutter",
markers: (currentView) => currentView.state.field(field),
}),
});
return field;
};
buildSqlSignatureExtension = () =>
showTooltip.compute(["doc", "selection"], (currentState) => {
const signature = getSqlFunctionSignatureHelp(currentState.doc.toString(), currentState.selection.main.head);
@@ -2992,6 +3077,7 @@ onMounted(async () => {
}),
previewRangeComp.of(buildPreviewRangeExtension()),
buildResultSourceRangeExtension(),
statementExecutionGutterComp.of(buildStatementExecutionMarkersExtension()),
Prec.highest(
keymap.of([
{ key: "'", run: handleSqlSingleQuote },
@@ -3287,6 +3373,15 @@ watch(
},
);
watch(
() => props.statementExecutionMarkers ?? [],
(markers) => {
if (!view.value || !setStatementExecutionMarkersEffect) return;
view.value.dispatch({ effects: setStatementExecutionMarkersEffect.of(markers) });
},
{ deep: true },
);
watch(
() => props.connectionId,
() => {
@@ -3658,6 +3753,65 @@ defineExpose({ openSearch, openReplace, scrollCursorIntoView, requestExecute, pa
padding: 0 5px;
}
:deep(.cm-statement-execution-gutter) {
min-width: 34px;
}
:deep(.cm-statement-execution-gutter .cm-gutterElement) {
align-items: center;
box-sizing: border-box;
display: flex;
justify-content: center;
min-width: 34px;
padding: 0 5px;
}
:deep(.cm-statement-execution-marker) {
display: inline-flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
width: min(24px, calc(var(--dbx-editor-font-size, 13px) * 1.6));
height: min(24px, calc(var(--dbx-editor-font-size, 13px) * 1.6));
margin: 0;
padding: 0;
border: 1px solid transparent;
border-radius: 6px;
vertical-align: middle;
white-space: nowrap;
transition:
color 0.15s,
background-color 0.15s;
user-select: none;
flex-shrink: 0;
}
:deep(.cm-statement-execution-marker--success) {
background: rgb(16 185 129 / 0.1);
color: rgb(4 120 87);
}
:deep(.cm-statement-execution-marker--error) {
background: rgb(239 68 68 / 0.1);
color: rgb(185 28 28);
}
:deep(.dark .cm-statement-execution-marker--success) {
color: rgb(110 231 183);
}
:deep(.dark .cm-statement-execution-marker--error) {
color: rgb(252 165 165);
}
:deep(.cm-statement-execution-marker svg) {
display: block;
width: min(14px, 70%);
height: min(14px, 70%);
pointer-events: none;
flex-shrink: 0;
}
:deep(.cm-run-statement-marker) {
display: inline-flex;
align-items: center;
@@ -64,7 +64,7 @@ import { useConnectionStore } from "@/stores/connectionStore";
import { TABLE_FONT_SIZE_MAX, TABLE_FONT_SIZE_MIN, useSettingsStore, type DataGridSearchMode } from "@/stores/settingsStore";
import { useToast } from "@/composables/useToast";
import { canCancelQueryExecution, queryExecutionLabelKey } from "@/lib/sql/queryExecutionState";
import { databaseDisplayNameForTab, executionSummaryItems, queryResultExecutionSql, resultGridCacheKey, resultRunItems, resultSourceRange, resultSqlForGrid, tabularResultItems } from "@/lib/tabs/tabPresentation";
import { databaseDisplayNameForTab, executionSummaryItems, queryResultExecutionSql, resultGridCacheKey, resultRunItems, resultSourceRange, resultSqlForGrid, statementExecutionMarkers, tabularResultItems } from "@/lib/tabs/tabPresentation";
import { defaultQueryResultArchiveFileName } from "@/lib/query/queryResultArchive";
import { saveQueryResultArchiveFile } from "@/lib/query/queryResultArchiveFile";
import { isTableDataEditable } from "@/lib/table/tableEditing";
@@ -329,6 +329,15 @@ const activeResultRunItem = computed(() => resultRuns.value.find((run) => run.ac
const activeResultGridCacheKey = computed(() => resultGridCacheKey(props.activeTab));
const activeResultSql = computed(() => resultSqlForGrid(props.activeTab));
const activeResultExportSql = computed(() => queryResultExecutionSql(props.activeTab));
const activeStatementExecutionMarkers = computed(() =>
statementExecutionMarkers(
props.activeTab.sql,
props.activeTab.results ?? (props.activeTab.result ? [props.activeTab.result] : undefined),
activeEffectiveDatabaseType.value,
props.activeTab.resultBaseSql || props.activeTab.lastExecutedSql || props.activeTab.sql,
props.activeTab.resultEditorFingerprint ?? "",
),
);
const activeElasticsearchJsonResponse = computed(() => elasticsearchJsonResponseForResult(activeEffectiveDatabaseType.value, activeResultSql.value, props.activeTab.result));
const resultArchiveExporting = ref(false);
const canExportResultArchive = computed(() => props.activeTab.mode === "query" && (!!props.activeTab.result || !!props.activeTab.results?.length || !!props.activeTab.resultRuns?.length));
@@ -800,6 +809,7 @@ defineExpose({ focusSearch, refreshData, handleModRTarget, requestQueryEditorExe
:format-request-id="formatSqlRequest?.tabId === activeTab.id ? formatSqlRequest.id : undefined"
:execution-error="activeQueryError"
:execution-error-sql="activeTab.lastExecutedSql"
:statement-execution-markers="activeStatementExecutionMarkers"
:initial-viewport="activeTab.editorViewport"
:initial-selection="activeTab.editorSelection"
@update:model-value="emit('editorUpdate', activeTab.id, $event)"
@@ -633,6 +633,7 @@ const connectionInfoTooltip = computed(() => {
{ label: t("connection.database"), value: cleanTooltipValue(config.database) },
{ label: t("connection.user"), value: cleanTooltipValue(config.username) },
{ label: t("connection.type"), value: config.driver_label || config.driver_profile || config.db_type },
{ label: t("connection.databaseInfo.productVersion"), value: cleanTooltipValue(config.database_info?.productVersion) },
].filter((row) => row.value);
return { rows };
+27
View File
@@ -342,6 +342,31 @@ export default {
save: "Save",
editTitle: "Edit Connection",
testSuccess: "Connection successful",
databaseInfo: {
title: "Database information",
open: "Open database information for {database}",
copy: "Copy database information",
sourceConfigured: "Current configuration",
sourceTested: "Tested",
configuredDescription: "Shows information known from the form first. A successful test adds server version and driver metadata.",
testedDescription: "This information comes from the connection used by the latest successful test.",
productName: "DBMS",
productVersion: "DBMS version",
currentDatabase: "Current database",
serverComment: "Server comment",
serverCharset: "Server charset",
serverCollation: "Server collation",
unquotedIdentifierCase: "Unquoted identifiers",
quotedIdentifierCase: "Quoted identifiers",
driverName: "Driver",
driverVersion: "Driver version",
jdbcVersion: "JDBC version",
identifierCase: {
lower: "Lowercase",
upper: "Uppercase",
mixed: "Mixed case",
},
},
connecting: "Connecting to {name}...",
connectSuccess: "Connected to {name}",
connectFailed: "Connection failed: {message}",
@@ -468,6 +493,8 @@ export default {
connectCancelled: "Connection cancelled",
},
editor: {
statementExecutionSucceeded: "{count} statement succeeded | {count} statements succeeded",
statementExecutionFailed: "{count} statement failed | {count} statements failed",
pressToExecute: "Press {mod}+Enter to execute",
pressToSaveSql: "Press {mod}+S to save SQL",
queryTimeoutError: "Query timed out ({seconds}s). Check whether the database connection is healthy.",
+27
View File
@@ -449,6 +449,31 @@ export default withEnglishFallback({
kafkaKerberosKrb5ConfPlaceholder: "Opcional, ruta en la máquina donde se ejecuta DBX Agent, por ejemplo /etc/krb5.conf",
kafkaKerberosPathHint: "Las rutas de keytab y krb5.conf son leídas por DBX Agent y deben existir en la máquina donde se ejecuta DBX Agent; no se subirán archivos desde el navegador actual.",
kafkaKerberosAuthHint: "Iniciar sesión con GSSAPI + keytab. Si el servidor requiere transmisión cifrada, configure Security como SASL_SSL; de lo contrario, puede usar Auto o SASL_PLAINTEXT.",
databaseInfo: {
title: "Información de la base de datos",
open: "Abrir información de la base de datos {database}",
copy: "Copiar información de la base de datos",
sourceConfigured: "Configuración actual",
sourceTested: "Probada",
configuredDescription: "Muestra primero la información que se puede determinar desde el formulario; después de una prueba exitosa, se complementa con la versión devuelta por el servidor y los metadatos del controlador.",
testedDescription: "La siguiente información proviene de la conexión utilizada para esta prueba exitosa.",
productName: "Sistema de gestión de bases de datos",
productVersion: "Versión de la base de datos",
currentDatabase: "Base de datos actual",
serverComment: "Comentario del servidor",
serverCharset: "Conjunto de caracteres del servidor",
serverCollation: "Collation del servidor",
unquotedIdentifierCase: "Identificador sin comillas",
quotedIdentifierCase: "Identificador entrecomillado",
driverName: "Controlador",
driverVersion: "Versión del controlador",
jdbcVersion: "Versión de JDBC",
identifierCase: {
lower: "Minúsculas",
upper: "Mayúsculas",
mixed: "Mixto",
},
},
},
editor: {
pressToExecute: "Presiona {mod}+Enter para ejecutar",
@@ -561,6 +586,8 @@ export default withEnglishFallback({
currentCommand: "Current command",
allCommands: "All commands",
},
statementExecutionSucceeded: "{count} sentencias exitosas",
statementExecutionFailed: "{count} sentencias fallidas",
},
tabs: {
sql: "SQL",
+27
View File
@@ -447,6 +447,31 @@ export default withEnglishFallback({
kafkaKerberosKrb5ConfPlaceholder: "Opzionale, percorso sulla macchina DBX Agent, ad esempio /etc/krb5.conf",
kafkaKerberosPathHint: "I percorsi di keytab e krb5.conf vengono letti da DBX Agent e devono esistere sulla macchina in cui è in esecuzione DBX Agent; non vengono caricati file dal browser corrente.",
kafkaKerberosAuthHint: "Accedi con GSSAPI + keytab. Se il server richiede trasmissione crittografata, imposta Security su SASL_SSL; altrimenti puoi utilizzare Auto o SASL_PLAINTEXT.",
databaseInfo: {
title: "Informazioni sul database",
open: "Apri informazioni database {database}",
copy: "Copia informazioni database",
sourceConfigured: "Configurazione corrente",
sourceTested: "Testato",
configuredDescription: "Mostra prima le informazioni determinabili dal modulo; dopo un test riuscito, vengono aggiunte la versione restituita dal server e i metadati del driver.",
testedDescription: "Le seguenti informazioni provengono dalla connessione utilizzata per questo test riuscito.",
productName: "Sistema di gestione database",
productVersion: "Versione database",
currentDatabase: "Database corrente",
serverComment: "Commento server",
serverCharset: "Set di caratteri del server",
serverCollation: "Regole di ordinamento del server",
unquotedIdentifierCase: "Identificatore senza virgolette",
quotedIdentifierCase: "Identificatore con virgolette",
driverName: "Driver",
driverVersion: "Versione driver",
jdbcVersion: "Versione JDBC",
identifierCase: {
lower: "Minuscolo",
upper: "Maiuscolo",
mixed: "Misto",
},
},
},
editor: {
pressToExecute: "Premi {mod}+Enter per eseguire",
@@ -559,6 +584,8 @@ export default withEnglishFallback({
currentCommand: "Current command",
allCommands: "All commands",
},
statementExecutionSucceeded: "{count} istruzioni eseguite con successo",
statementExecutionFailed: "{count} istruzioni fallite",
},
tabs: {
sql: "SQL",
+27
View File
@@ -447,6 +447,31 @@ export default withEnglishFallback({
kafkaKerberosKrb5ConfPlaceholder: "オプション。DBX Agent が動作するマシンのパス。例: /etc/krb5.conf",
kafkaKerberosPathHint: "keytab と krb5.conf のパスは DBX Agent が読み取るため、DBX Agent が動作するマシン上に存在する必要があります。ブラウザからファイルをアップロードすることはありません。",
kafkaKerberosAuthHint: "GSSAPI + keytab を使用してログインします。サーバーが暗号化転送を要求する場合は、Security を SASL_SSL に設定してください。それ以外の場合は Auto または SASL_PLAINTEXT を使用できます。",
databaseInfo: {
title: "データベース情報",
open: "{database} データベース情報を開く",
copy: "データベース情報をコピー",
sourceConfigured: "現在の設定",
sourceTested: "テスト済み",
configuredDescription: "まずフォームで確定可能な情報を表示します。テスト成功後、サーバーから返されたバージョンとドライバーのメタデータが補足されます。",
testedDescription: "以下の情報は、今回の成功したテストで使用された接続からのものです。",
productName: "データベース管理システム",
productVersion: "データベースバージョン",
currentDatabase: "現在のデータベース",
serverComment: "サーバーコメント",
serverCharset: "サーバー文字セット",
serverCollation: "サーバー照合順序",
unquotedIdentifierCase: "引用符なし識別子",
quotedIdentifierCase: "引用符付き識別子",
driverName: "ドライバー",
driverVersion: "ドライバーバージョン",
jdbcVersion: "JDBC バージョン",
identifierCase: {
lower: "小文字",
upper: "大文字",
mixed: "大文字小文字混在",
},
},
},
editor: {
pressToExecute: "{mod}+Enter で実行",
@@ -559,6 +584,8 @@ export default withEnglishFallback({
allCommands: "All commands",
},
selectDatabaseRequired: "先にデータベースを選択してください",
statementExecutionSucceeded: "{count} 件のステートメントが成功しました",
statementExecutionFailed: "{count} 件のステートメントが失敗しました",
},
tabs: {
sql: "SQL",
+27
View File
@@ -448,6 +448,31 @@ export default withEnglishFallback({
kafkaKerberosKrb5ConfPlaceholder: "Opcional, caminho na máquina do DBX Agent, por exemplo /etc/krb5.conf",
kafkaKerberosPathHint: "Os caminhos do keytab e do krb5.conf são lidos pelo DBX Agent e devem existir na máquina onde o DBX Agent é executado; os arquivos não são enviados do navegador atual.",
kafkaKerberosAuthHint: "Faça login usando GSSAPI + keytab. Se o servidor exigir transmissão criptografada, defina Security como SASL_SSL; caso contrário, você pode usar Auto ou SASL_PLAINTEXT.",
databaseInfo: {
title: "Informações do Banco de Dados",
open: "Abrir informações do banco de dados {database}",
copy: "Copiar informações do banco de dados",
sourceConfigured: "Configuração atual",
sourceTested: "Testado",
configuredDescription: "Exibe as informações que podem ser determinadas no formulário; após um teste bem-sucedido, serão complementadas com a versão e os metadados do driver retornados pelo servidor.",
testedDescription: "As informações abaixo são da conexão usada neste teste bem-sucedido.",
productName: "Sistema de Gerenciamento de Banco de Dados",
productVersion: "Versão do banco de dados",
currentDatabase: "Banco de dados atual",
serverComment: "Descrição do servidor",
serverCharset: "Charset do servidor",
serverCollation: "Collation do servidor",
unquotedIdentifierCase: "Identificador sem aspas",
quotedIdentifierCase: "Identificador com aspas",
driverName: "Driver",
driverVersion: "Versão do driver",
jdbcVersion: "Versão do JDBC",
identifierCase: {
lower: "Minúsculas",
upper: "Maiúsculas",
mixed: "Maiúsculas e minúsculas",
},
},
},
editor: {
pressToExecute: "Pressione {mod}+Enter para executar",
@@ -560,6 +585,8 @@ export default withEnglishFallback({
currentCommand: "Current command",
allCommands: "All commands",
},
statementExecutionSucceeded: "{count} instrução(ões) executada(s) com sucesso",
statementExecutionFailed: "{count} instrução(ões) com falha",
},
tabs: {
sql: "SQL",
+27
View File
@@ -344,6 +344,31 @@ export default withEnglishFallback({
save: "保存",
editTitle: "编辑连接",
testSuccess: "连接成功",
databaseInfo: {
title: "数据库信息",
open: "打开 {database} 数据库信息",
copy: "复制数据库信息",
sourceConfigured: "当前配置",
sourceTested: "已测试",
configuredDescription: "先显示表单中可确定的信息;测试成功后会补充服务器返回的版本和驱动元数据。",
testedDescription: "以下信息来自本次成功测试使用的连接。",
productName: "数据库管理系统",
productVersion: "数据库版本",
currentDatabase: "当前数据库",
serverComment: "服务器说明",
serverCharset: "服务器字符集",
serverCollation: "服务器排序规则",
unquotedIdentifierCase: "普通标识符",
quotedIdentifierCase: "引用标识符",
driverName: "驱动",
driverVersion: "驱动版本",
jdbcVersion: "JDBC 版本",
identifierCase: {
lower: "小写",
upper: "大写",
mixed: "混合大小写",
},
},
connecting: "正在连接 {name}...",
cancelConnecting: "取消连接",
connectCancelled: "连接已取消",
@@ -470,6 +495,8 @@ export default withEnglishFallback({
colorCustom: "自定义颜色",
},
editor: {
statementExecutionSucceeded: "{count} 条语句成功",
statementExecutionFailed: "{count} 条语句失败",
pressToExecute: "按 {mod}+Enter 执行查询",
pressToSaveSql: "按 {mod}+S 保存 SQL",
queryTimeoutError: "查询超时 ({seconds}s),请检查数据库连接是否正常",
+27
View File
@@ -448,6 +448,31 @@ export default withEnglishFallback({
kafkaKerberosKrb5ConfPlaceholder: "可選,DBX Agent 所在機器路徑,例如 /etc/krb5.conf",
kafkaKerberosPathHint: "keytab 和 krb5.conf 路徑由 DBX Agent 讀取,必須存在於執行 DBX Agent 的機器上;不會從當前瀏覽器上傳檔案。",
kafkaKerberosAuthHint: "使用 GSSAPI + keytab 登入。若伺服器端要求加密傳輸,請將 Security 設為 SASL_SSL;否則可使用 Auto 或 SASL_PLAINTEXT。",
databaseInfo: {
title: "資料庫資訊",
open: "開啟 {database} 資料庫資訊",
copy: "複製資料庫資訊",
sourceConfigured: "目前設定",
sourceTested: "已測試",
configuredDescription: "先顯示表單中可確定的資訊;測試成功後會補充伺服器回傳的版本和驅動元數據。",
testedDescription: "以下資訊來自本次成功測試使用的連線。",
productName: "資料庫管理系統",
productVersion: "資料庫版本",
currentDatabase: "目前資料庫",
serverComment: "伺服器說明",
serverCharset: "伺服器字元集",
serverCollation: "伺服器排序規則",
unquotedIdentifierCase: "一般識別碼",
quotedIdentifierCase: "引號識別碼",
driverName: "驅動",
driverVersion: "驅動版本",
jdbcVersion: "JDBC 版本",
identifierCase: {
lower: "小寫",
upper: "大寫",
mixed: "混合大小寫",
},
},
},
editor: {
pressToExecute: "按 {mod}+Enter 執行查詢",
@@ -560,6 +585,8 @@ export default withEnglishFallback({
currentCommand: "目前命令",
allCommands: "全部命令",
},
statementExecutionSucceeded: "{count} 條陳述式成功",
statementExecutionFailed: "{count} 條陳述式失敗",
},
tabs: {
sql: "SQL",
+56 -1
View File
@@ -1,7 +1,7 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createPinia, setActivePinia } from "pinia";
import { useConnectionStore } from "@/stores/connectionStore";
import { connectionGroupDisplayName, middleEllipsis, queryResultBaseSql, queryResultExecutionSql, resultSourceRange, tabTooltipLines, tabularResultItems } from "@/lib/tabs/tabPresentation";
import { connectionGroupDisplayName, middleEllipsis, queryResultBaseSql, queryResultExecutionSql, resultSourceRange, statementExecutionMarkers, tabTooltipLines, tabularResultItems } from "@/lib/tabs/tabPresentation";
import type { ConnectionConfig, QueryTab } from "@/types/database";
const translations: Record<string, string> = {
@@ -222,3 +222,58 @@ describe("query result source ranges", () => {
expect(resultSourceRange("SELECT * FROM users; SELECT * FROM users;", { sourceStatement: "SELECT * FROM users" }, undefined, "mysql")).toBeUndefined();
});
});
describe("statement execution markers", () => {
it("projects explicit statement indexes to current editor lines", () => {
const sql = "SELECT 1;\nSELECT * FROM missing;\nSELECT 3;";
const secondFrom = sql.indexOf("SELECT *");
const markers = statementExecutionMarkers(
sql,
[
{ columns: ["value"], rows: [[1]], affected_rows: 0, execution_time_ms: 1, statement_index: 0, sourceStatement: "SELECT 1", sourceFrom: 0, sourceTo: 8 },
{ columns: ["Error"], rows: [["no such table"]], affected_rows: 0, execution_time_ms: 1, execution_error: true, statement_index: 1, sourceStatement: "SELECT * FROM missing", sourceFrom: secondFrom, sourceTo: secondFrom + "SELECT * FROM missing".length },
],
"sqlite",
sql,
);
expect(markers).toEqual([
{ from: 0, status: "success", successCount: 1, errorCount: 0 },
{ from: secondFrom, status: "error", successCount: 0, errorCount: 1 },
]);
});
it("omits unindexed query-level errors and single-statement executions", () => {
expect(statementExecutionMarkers("SELECT 1; SELECT 2;", [{ columns: ["Error"], rows: [["pool failed"]], affected_rows: 0, execution_time_ms: 1, execution_error: true }], "mysql", "SELECT 1; SELECT 2;")).toEqual([]);
expect(statementExecutionMarkers("SELECT 1", [{ columns: ["value"], rows: [[1]], affected_rows: 0, execution_time_ms: 1, statement_index: 0, sourceStatement: "SELECT 1", sourceFrom: 0, sourceTo: 8 }], "mysql", "SELECT 1")).toEqual([]);
});
it("keeps duplicate statements scoped by preserved absolute ranges", () => {
const sql = "SELECT * FROM users;\nSELECT * FROM users;";
const from = sql.lastIndexOf("SELECT");
expect(statementExecutionMarkers(sql, [{ columns: ["id"], rows: [[1]], affected_rows: 0, execution_time_ms: 1, statement_index: 1, sourceStatement: "SELECT * FROM users", sourceFrom: from, sourceTo: sql.length - 1 }], "mysql", sql)).toEqual([
{ from, status: "success", successCount: 1, errorCount: 0 },
]);
});
it("aggregates same-line statements with error precedence", () => {
const sql = "SELECT 1; SELECT bad;";
expect(
statementExecutionMarkers(
sql,
[
{ columns: ["value"], rows: [[1]], affected_rows: 0, execution_time_ms: 1, statement_index: 0, sourceStatement: "SELECT 1", sourceFrom: 0, sourceTo: 8 },
{ columns: ["Error"], rows: [["bad"]], affected_rows: 0, execution_time_ms: 1, execution_error: true, statement_index: 1, sourceStatement: "SELECT bad", sourceFrom: 10, sourceTo: 20 },
],
"mysql",
sql,
),
).toEqual([{ from: 0, status: "error", successCount: 1, errorCount: 1 }]);
});
it("invalidates every marker after the editor document changes", () => {
const executedSql = "SELECT 1;\nSELECT 2;";
expect(statementExecutionMarkers(`-- edited\n${executedSql}`, [{ columns: ["value"], rows: [[1]], affected_rows: 0, execution_time_ms: 1, statement_index: 0, sourceStatement: "SELECT 1" }], "mysql", "stale-editor-fingerprint", executedSql)).toEqual([]);
});
});
@@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import { decodeTabResultSnapshot, encodeTabResultSnapshot } from "@/lib/tabs/tabResultCache";
describe("tab result cache statement execution metadata", () => {
it("preserves statement identity and the editor fingerprint", () => {
const encoded = encodeTabResultSnapshot({
results: [
{
columns: ["value"],
rows: [[1]],
affected_rows: 0,
execution_time_ms: 1,
statement_index: 0,
sourceStatement: "SELECT 1",
},
{
columns: ["Error"],
rows: [["failed"]],
affected_rows: 0,
execution_time_ms: 1,
execution_error: true,
statement_index: 1,
sourceStatement: "SELECT bad",
},
],
resultEditorFingerprint: "15:0123456789abcdef",
cachedAt: 1,
});
const restored = decodeTabResultSnapshot(encoded);
expect(restored?.resultEditorFingerprint).toBe("15:0123456789abcdef");
expect(restored?.results?.map((result) => ({ statementIndex: result.statement_index, error: result.execution_error }))).toEqual([
{ statementIndex: 0, error: undefined },
{ statementIndex: 1, error: true },
]);
});
});
+3
View File
@@ -51,7 +51,10 @@ function forward<K extends keyof Backend>(name: K): Backend[K] {
// Connection
export const testConnection = forward("testConnection");
export const testConnectionWithInfo = forward("testConnectionWithInfo");
export const connectDb = forward("connectDb");
export const connectionDatabaseInfo = forward("connectionDatabaseInfo");
export const saveConnectionDatabaseInfo = forward("saveConnectionDatabaseInfo");
export const connectionFinalProxyPort = forward("connectionFinalProxyPort");
export const disconnectDb = forward("disconnectDb");
export const checkConnectionHealth = forward("checkConnectionHealth");
+25
View File
@@ -1,5 +1,7 @@
import type {
ConnectionConfig,
ConnectionTestResult,
DatabaseConnectionInfo,
DatabaseInfo,
SchemaInfo,
LinkedServerInfo,
@@ -150,6 +152,7 @@ import type {
NacosServiceQuery,
} from "@/types/nacos";
import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/backend/safeStorage";
import { normalizeConnectionTestResult } from "@/lib/connection/connectionDatabaseInfo";
// ---------------------------------------------------------------------------
// Helpers
@@ -209,10 +212,32 @@ export async function testConnection(config: ConnectionConfig): Promise<string>
return post("/api/connection/test", { config });
}
export async function testConnectionWithInfo(config: ConnectionConfig): Promise<ConnectionTestResult> {
const response = await fetch(apiUrl("/api/connection/test-info"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ config }),
});
if (response.status === 404) {
return normalizeConnectionTestResult(await testConnection(config), config);
}
if (!response.ok) throw new Error(await response.text());
return normalizeConnectionTestResult(await response.json(), config);
}
export async function connectDb(config: ConnectionConfig, clientAttempt?: number): Promise<string> {
return post("/api/connection/connect", { config, clientAttempt });
}
export async function connectionDatabaseInfo(connectionId: string, database?: string): Promise<DatabaseConnectionInfo | undefined> {
const info = await post<DatabaseConnectionInfo | null>("/api/connection/database-info", { connectionId, database });
return info ?? undefined;
}
export async function saveConnectionDatabaseInfo(connectionId: string, databaseInfo: DatabaseConnectionInfo): Promise<void> {
return post("/api/connection/database-info/save", { connectionId, databaseInfo });
}
export async function connectionFinalProxyPort(config: ConnectionConfig): Promise<number> {
return post("/api/connection/final-proxy-port", { config });
}
+22
View File
@@ -2,6 +2,8 @@ import { invoke } from "@tauri-apps/api/core";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import type {
ConnectionConfig,
ConnectionTestResult,
DatabaseConnectionInfo,
DatabaseInfo,
SchemaInfo,
LinkedServerInfo,
@@ -36,6 +38,7 @@ import type {
SshConfigHostEntry,
TunnelProfile,
} from "@/types/database";
import { isTauriCommandUnavailable, normalizeConnectionTestResult } from "@/lib/connection/connectionDatabaseInfo";
import type { CollectionInfo } from "@/types/database";
import type { SidebarObjectKind } from "@/lib/database/databaseObjectCapabilities";
import type { AiConfig, AiTestConnectionResult } from "@/stores/settingsStore";
@@ -636,10 +639,29 @@ export async function testConnection(config: ConnectionConfig): Promise<string>
return invoke("test_connection", { config });
}
export async function testConnectionWithInfo(config: ConnectionConfig): Promise<ConnectionTestResult> {
try {
const result = await invoke<unknown>("test_connection_with_info", { config });
return normalizeConnectionTestResult(result, config);
} catch (error) {
if (!isTauriCommandUnavailable(error, "test_connection_with_info")) throw error;
return normalizeConnectionTestResult(await testConnection(config), config);
}
}
export async function connectDb(config: ConnectionConfig, clientAttempt?: number): Promise<string> {
return invoke("connect_db", { config, clientAttempt });
}
export async function connectionDatabaseInfo(connectionId: string, database?: string): Promise<DatabaseConnectionInfo | undefined> {
const info = await invoke<DatabaseConnectionInfo | null>("connection_database_info", { connectionId, database });
return info ?? undefined;
}
export async function saveConnectionDatabaseInfo(connectionId: string, databaseInfo: DatabaseConnectionInfo): Promise<void> {
return invoke("save_connection_database_info", { connectionId, databaseInfo });
}
export async function connectionFinalProxyPort(config: ConnectionConfig): Promise<number> {
return invoke("connection_final_proxy_port", { config });
}
@@ -0,0 +1,99 @@
import { describe, expect, it } from "vitest";
import type { ConnectionConfig } from "@/types/database";
import { connectionConfigFingerprint, databaseInfoCopyText, databaseInfoRows, isTauriCommandUnavailable, normalizeConnectionTestResult } from "@/lib/connection/connectionDatabaseInfo";
function config(overrides: Partial<ConnectionConfig> = {}): ConnectionConfig {
return {
id: "connection-1",
name: "Local H2",
db_type: "h2",
driver_label: "H2",
host: "127.0.0.1",
port: 9092,
username: "sa",
password: "secret",
...overrides,
};
}
describe("connectionDatabaseInfo", () => {
it("normalizes structured and legacy responses with a configured product fallback", () => {
expect(normalizeConnectionTestResult("Connection successful", config())).toEqual({
message: "Connection successful",
databaseInfo: { productName: "H2" },
});
expect(normalizeConnectionTestResult("Connection successful", config({ database: "app" })).databaseInfo).toMatchObject({
productName: "H2",
currentDatabase: "app",
});
expect(
normalizeConnectionTestResult(
{
message: "Connection successful",
databaseInfo: {
productName: " H2 ",
productVersion: " 2.2.224 ",
currentDatabase: " testdb ",
serverCharset: " utf8mb4 ",
driverName: " ",
unquotedIdentifierCase: "UPPER",
},
},
config(),
).databaseInfo,
).toEqual({
productName: "H2",
productVersion: "2.2.224",
currentDatabase: "testdb",
serverComment: undefined,
serverCharset: "utf8mb4",
serverCollation: undefined,
unquotedIdentifierCase: "upper",
quotedIdentifierCase: undefined,
driverName: undefined,
driverVersion: undefined,
jdbcVersion: undefined,
});
});
it("fingerprints the complete submitted config without depending on object key order", () => {
const original = config({ transport_layers: [{ id: "ssh", type: "ssh", host: "jump", port: 22, user: "root", password: "hop-secret" }] });
const reordered = Object.fromEntries(Object.entries(original).reverse()) as unknown as ConnectionConfig;
expect(connectionConfigFingerprint(reordered)).toBe(connectionConfigFingerprint(original));
expect(connectionConfigFingerprint({ ...original, password: "changed" })).not.toBe(connectionConfigFingerprint(original));
expect(connectionConfigFingerprint({ ...original, name: "Renamed" })).not.toBe(connectionConfigFingerprint(original));
expect(connectionConfigFingerprint({ ...original, transport_layers: [{ ...original.transport_layers![0], host: "other-jump" }] })).not.toBe(connectionConfigFingerprint(original));
expect(connectionConfigFingerprint(original, "")).not.toBe(connectionConfigFingerprint(original, original.name));
expect(connectionConfigFingerprint({ ...original, database_info: { productName: "MySQL", productVersion: "8.4.0" } })).toBe(connectionConfigFingerprint(original));
});
it("formats only database metadata for rows and copied text", () => {
const info = normalizeConnectionTestResult(
{
message: "ok",
databaseInfo: { productName: "H2", currentDatabase: "app", driverName: "H2 JDBC Driver", jdbcVersion: "4.2" },
},
config(),
).databaseInfo!;
const copy = databaseInfoCopyText(
info,
(field) => field,
(value) => value,
);
expect(databaseInfoRows(info).map((row) => row.key)).toEqual(["productName", "currentDatabase", "driverName", "jdbcVersion"]);
expect(copy).toContain("productName: H2");
expect(copy).toContain("currentDatabase: app");
expect(copy).not.toContain("127.0.0.1");
expect(copy).not.toContain("secret");
expect(copy).not.toContain("connection_string");
});
it("recognizes only explicit missing-command errors for Tauri fallback", () => {
expect(isTauriCommandUnavailable("Command test_connection_with_info not found", "test_connection_with_info")).toBe(true);
expect(isTauriCommandUnavailable("unknown command 'test_connection_with_info'", "test_connection_with_info")).toBe(true);
expect(isTauriCommandUnavailable("Authentication failed", "test_connection_with_info")).toBe(false);
expect(isTauriCommandUnavailable("Command test_connection not found", "test_connection_with_info")).toBe(false);
expect(isTauriCommandUnavailable("Database not found while invoking command test_connection_with_info", "test_connection_with_info")).toBe(false);
});
});
@@ -0,0 +1,120 @@
import type { ConnectionConfig, ConnectionTestResult, DatabaseConnectionInfo, IdentifierCase } from "@/types/database";
export type DatabaseInfoField = keyof DatabaseConnectionInfo;
export interface DatabaseInfoRow {
key: DatabaseInfoField;
value: string;
}
const DATABASE_INFO_FIELDS: readonly DatabaseInfoField[] = ["productName", "productVersion", "currentDatabase", "serverComment", "serverCharset", "serverCollation", "unquotedIdentifierCase", "quotedIdentifierCase", "driverName", "driverVersion", "jdbcVersion"];
const IDENTIFIER_CASES = new Set<IdentifierCase>(["lower", "upper", "mixed"]);
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value);
}
function nonBlankString(value: unknown): string | undefined {
if (typeof value !== "string") return undefined;
const trimmed = value.trim();
return trimmed || undefined;
}
function identifierCase(value: unknown): IdentifierCase | undefined {
const normalized = nonBlankString(value)?.toLowerCase() as IdentifierCase | undefined;
return normalized && IDENTIFIER_CASES.has(normalized) ? normalized : undefined;
}
export function configuredDatabaseProductName(config: Pick<ConnectionConfig, "db_type" | "driver_label">): string {
return config.driver_label?.trim() || config.db_type;
}
export function normalizeDatabaseConnectionInfo(value: unknown, fallbackProductName?: string, fallbackCurrentDatabase?: string): DatabaseConnectionInfo | undefined {
const source = isRecord(value) ? value : {};
const result: DatabaseConnectionInfo = {
productName: nonBlankString(source.productName) ?? nonBlankString(fallbackProductName),
productVersion: nonBlankString(source.productVersion),
currentDatabase: nonBlankString(source.currentDatabase) ?? nonBlankString(fallbackCurrentDatabase),
serverComment: nonBlankString(source.serverComment),
serverCharset: nonBlankString(source.serverCharset),
serverCollation: nonBlankString(source.serverCollation),
unquotedIdentifierCase: identifierCase(source.unquotedIdentifierCase),
quotedIdentifierCase: identifierCase(source.quotedIdentifierCase),
driverName: nonBlankString(source.driverName),
driverVersion: nonBlankString(source.driverVersion),
jdbcVersion: nonBlankString(source.jdbcVersion),
};
return DATABASE_INFO_FIELDS.some((key) => result[key] !== undefined) ? result : undefined;
}
export function normalizeConnectionTestResult(value: unknown, config: ConnectionConfig): ConnectionTestResult {
const fallbackProductName = configuredDatabaseProductName(config);
const fallbackCurrentDatabase = config.database;
if (typeof value === "string") {
return {
message: value,
databaseInfo: normalizeDatabaseConnectionInfo(undefined, fallbackProductName, fallbackCurrentDatabase),
};
}
if (!isRecord(value) || typeof value.message !== "string") {
throw new Error("Invalid connection test response");
}
return {
message: value.message,
databaseInfo: normalizeDatabaseConnectionInfo(value.databaseInfo, fallbackProductName, fallbackCurrentDatabase),
};
}
function stableValue(value: unknown): unknown {
if (Array.isArray(value)) return value.map(stableValue);
if (!isRecord(value)) return value;
const result: Record<string, unknown> = {};
for (const key of Object.keys(value).sort()) {
const child = value[key];
if (child !== undefined) result[key] = stableValue(child);
}
return result;
}
function fnv1a(value: string, seed: number): number {
let hash = seed >>> 0;
for (let index = 0; index < value.length; index += 1) {
hash ^= value.charCodeAt(index);
hash = Math.imul(hash, 0x01000193);
}
return hash >>> 0;
}
export function connectionConfigFingerprint(config: ConnectionConfig, sourceName = config.name): string {
const { database_info: _databaseInfo, ...submittedConfig } = config;
const serialized = JSON.stringify(stableValue({ config: submittedConfig, sourceName }));
const first = fnv1a(serialized, 0x811c9dc5).toString(16).padStart(8, "0");
const second = fnv1a(serialized, 0x9e3779b9).toString(16).padStart(8, "0");
return `${first}${second}`;
}
export function databaseInfoRows(info: DatabaseConnectionInfo): DatabaseInfoRow[] {
return DATABASE_INFO_FIELDS.flatMap((key) => {
const value = info[key];
return value ? [{ key, value }] : [];
});
}
export function databaseInfoSummary(info: DatabaseConnectionInfo): string {
return [info.productName, info.productVersion].filter(Boolean).join(" ");
}
export function databaseInfoCopyText(info: DatabaseConnectionInfo, fieldLabel: (field: DatabaseInfoField) => string, caseLabel: (value: IdentifierCase) => string): string {
return databaseInfoRows(info)
.map((row) => {
const value = row.key === "unquotedIdentifierCase" || row.key === "quotedIdentifierCase" ? caseLabel(row.value as IdentifierCase) : row.value;
return `${fieldLabel(row.key)}: ${value}`;
})
.join("\n");
}
export function isTauriCommandUnavailable(error: unknown, command: string): boolean {
const message = error instanceof Error ? error.message : String(error);
const escapedCommand = command.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return new RegExp(`unknown command\\s*[:=]?\\s*['"]?${escapedCommand}['"]?`, "i").test(message) || new RegExp(`command\\s+['"]?${escapedCommand}['"]?\\s+(?:was\\s+)?not found`, "i").test(message);
}
@@ -0,0 +1,14 @@
function fnv1a(value: string, seed: number): number {
let hash = seed >>> 0;
for (let index = 0; index < value.length; index += 1) {
hash ^= value.charCodeAt(index);
hash = Math.imul(hash, 0x01000193);
}
return hash >>> 0;
}
export function sqlTextFingerprint(sql: string): string {
const first = fnv1a(sql, 0x811c9dc5).toString(16).padStart(8, "0");
const second = fnv1a(sql, 0x9e3779b9).toString(16).padStart(8, "0");
return `${sql.length.toString(16)}:${first}${second}`;
}
+59 -1
View File
@@ -3,6 +3,7 @@ import { useSettingsStore } from "@/stores/settingsStore";
import { findConnectionGroupPath } from "@/lib/sidebar/sidebarLayout";
import { splitMongoCommandRanges } from "@/lib/mongo/mongoShellCommand";
import { executableStatementRanges, splitSqlStatementRanges, type SqlTextRange } from "@/lib/sql/sqlStatementRanges";
import { sqlTextFingerprint } from "@/lib/sql/sqlTextFingerprint";
import type { ConnectionConfig, DatabaseType, QueryResult, QueryTab } from "@/types/database";
type Translate = (key: string, params?: Record<string, unknown>) => string;
@@ -179,7 +180,7 @@ export function resultSourceRange(editorSql: string, result: Pick<QueryResult, "
return { from: result.sourceFrom, to: result.sourceTo, sql: sourceStatement };
}
const statements = databaseType === "redis" ? executableStatementRanges(editorSql, databaseType) : databaseType === "mongodb" ? splitMongoCommandRanges(editorSql).map(({ from, to, text }) => ({ from, to, sql: text })) : splitSqlStatementRanges(editorSql, databaseType);
const statements = statementRanges(editorSql, databaseType);
const indexed = typeof resultIndex === "number" ? statements[resultIndex] : undefined;
if (indexed?.sql === sourceStatement) {
return { from: indexed.from, to: indexed.to, sql: indexed.sql };
@@ -191,6 +192,63 @@ export function resultSourceRange(editorSql: string, result: Pick<QueryResult, "
return { from: match.from, to: match.to, sql: match.sql };
}
export type StatementExecutionMarkerStatus = "success" | "error";
export interface StatementExecutionMarker {
from: number;
status: StatementExecutionMarkerStatus;
successCount: number;
errorCount: number;
}
function lineStartOffset(sql: string, from: number): number {
return sql.lastIndexOf("\n", Math.max(0, from - 1)) + 1;
}
function statementRanges(sql: string, databaseType?: DatabaseType): SqlTextRange[] {
if (databaseType === "redis") return executableStatementRanges(sql, databaseType);
if (databaseType === "mongodb") return splitMongoCommandRanges(sql).map(({ from, to, text }) => ({ from, to, sql: text }));
return splitSqlStatementRanges(sql, databaseType);
}
export function statementExecutionMarkers(editorSql: string, results: QueryResult[] | undefined, databaseType?: DatabaseType, submittedSql = editorSql, executionEditorFingerprint = sqlTextFingerprint(editorSql)): StatementExecutionMarker[] {
if (!results?.length || sqlTextFingerprint(editorSql) !== executionEditorFingerprint) return [];
const submittedStatements = statementRanges(submittedSql, databaseType);
if (submittedStatements.length <= 1) return [];
const editorStatements = submittedSql === editorSql ? submittedStatements : statementRanges(editorSql, databaseType);
const byLine = new Map<number, { success: number; error: number }>();
for (const result of results) {
if (!Number.isInteger(result.statement_index) || result.statement_index! < 0) continue;
const statementIndex = result.statement_index!;
const submittedStatement = submittedStatements[statementIndex];
if (!submittedStatement || submittedStatement.sql !== result.sourceStatement) continue;
const range =
typeof result.sourceFrom === "number" && typeof result.sourceTo === "number" && editorSql.slice(result.sourceFrom, result.sourceTo) === result.sourceStatement
? { from: result.sourceFrom, to: result.sourceTo, sql: result.sourceStatement }
: editorStatements[statementIndex]?.sql === result.sourceStatement
? editorStatements[statementIndex]
: undefined;
if (!range) continue;
const from = lineStartOffset(editorSql, range.from);
const current = byLine.get(from) ?? { success: 0, error: 0 };
if (result.execution_error === true) current.error += 1;
else current.success += 1;
byLine.set(from, current);
}
return [...byLine.entries()]
.sort(([a], [b]) => a - b)
.map(([from, counts]) => {
return {
from,
status: counts.error > 0 ? "error" : "success",
successCount: counts.success,
errorCount: counts.error,
};
});
}
export function queryResultBaseSql(tab: Pick<QueryTab, "result" | "resultBaseSql" | "lastExecutedSql" | "sql">): string {
return resultSqlForGrid(tab);
}
@@ -16,6 +16,7 @@ export interface TabResultSnapshot {
result?: QueryResult;
results?: QueryResult[];
activeResultIndex?: number;
resultEditorFingerprint?: string;
/**
* Source ordering retained while a local grid sort is active. It must travel
* with the snapshot so clearing the sort after a cache/archive restore can
@@ -41,6 +42,7 @@ export interface TabResultSnapshot {
interface ColumnarQueryResult {
columns: string[];
execution_error?: true;
statement_index?: number;
column_types?: string[];
columnValues: CellValue[][];
rowCount: number;
@@ -138,6 +140,7 @@ function stripSessionIds(result: QueryResult | undefined): QueryResult | undefin
return {
columns: [...result.columns],
execution_error: result.execution_error,
statement_index: result.statement_index,
column_types: result.column_types ? [...result.column_types] : undefined,
rows: result.rows.map((row) => [...row]),
mongo_documents: result.mongo_documents ? clonePlain(result.mongo_documents) : undefined,
@@ -174,6 +177,7 @@ function toColumnarResult(result: QueryResult | undefined): ColumnarQueryResult
return removeUndefinedFields({
columns: [...result.columns],
execution_error: result.execution_error,
statement_index: result.statement_index,
column_types: result.column_types ? [...result.column_types] : undefined,
columnValues,
rowCount: result.rows.length,
@@ -195,6 +199,7 @@ function fromColumnarResult(result: ColumnarQueryResult | undefined): QueryResul
return {
columns: [...result.columns],
execution_error: result.execution_error,
statement_index: result.statement_index,
column_types: result.column_types ? [...result.column_types] : undefined,
rows,
mongo_documents: result.mongo_documents ? clonePlain(result.mongo_documents) : undefined,
@@ -389,6 +394,7 @@ export function buildTabResultSnapshot(tab: QueryTab): TabResultSnapshot | undef
result: stripSessionIds(tab.result),
results: stripResultSessionIds(tab.results),
activeResultIndex: tab.activeResultIndex,
resultEditorFingerprint: tab.resultEditorFingerprint,
resultLocalSortOriginalRows: tab.resultLocalSortOriginalRows?.map((row) => [...row]),
resultLocalSortOriginalMongoDocuments: tab.resultLocalSortOriginalMongoDocuments ? clonePlain(tab.resultLocalSortOriginalMongoDocuments) : undefined,
resultRuns: stripResultRunSessionIds(tab.resultRuns),
@@ -0,0 +1,122 @@
import { createPinia, setActivePinia } from "pinia";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ConnectionConfig } from "@/types/database";
function installLocalStorage() {
const data = new Map<string, string>();
vi.stubGlobal("localStorage", {
getItem: vi.fn((key: string) => data.get(key) ?? null),
setItem: vi.fn((key: string, value: string) => data.set(key, value)),
removeItem: vi.fn((key: string) => data.delete(key)),
});
}
function mysqlConnection(): ConnectionConfig {
return {
id: "mysql-info",
name: "MySQL",
db_type: "mysql",
driver_profile: "mysql",
driver_label: "MySQL",
host: "127.0.0.1",
port: 3306,
username: "root",
password: "secret",
database: "app",
};
}
describe("connectionStore database info", () => {
beforeEach(() => {
vi.resetModules();
vi.unstubAllGlobals();
installLocalStorage();
setActivePinia(createPinia());
});
it("persists metadata from a successful live connection without marking it disconnected", async () => {
const config = mysqlConnection();
const saveConnections = vi.fn().mockResolvedValue(undefined);
const saveConnectionDatabaseInfo = vi.fn().mockResolvedValue(undefined);
const connectionDatabaseInfo = vi.fn().mockResolvedValue({ productName: "MySQL", productVersion: "8.0.34" });
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
vi.doMock("@/lib/backend/api", () => ({
connectDb: vi.fn().mockResolvedValue(config.id),
connectionDatabaseInfo,
saveConnectionDatabaseInfo,
saveConnections,
saveSidebarLayout: vi.fn().mockResolvedValue(undefined),
connectionIdentifierQuote: vi.fn().mockResolvedValue(undefined),
}));
const { useConnectionStore } = await import("@/stores/connectionStore");
const store = useConnectionStore();
await store.addConnection(config);
await store.connect(config);
await vi.waitFor(() => expect(saveConnectionDatabaseInfo).toHaveBeenCalled());
expect(connectionDatabaseInfo).toHaveBeenCalledWith(config.id);
expect(saveConnectionDatabaseInfo).toHaveBeenCalledWith(config.id, {
productName: "MySQL",
productVersion: "8.0.34",
currentDatabase: "app",
serverComment: undefined,
serverCharset: undefined,
serverCollation: undefined,
unquotedIdentifierCase: undefined,
quotedIdentifierCase: undefined,
driverName: undefined,
driverVersion: undefined,
jdbcVersion: undefined,
});
expect(store.getConfig(config.id)?.database_info?.productVersion).toBe("8.0.34");
expect(store.connectedIds.has(config.id)).toBe(true);
});
it("does not delay connection success while optional metadata is loading", async () => {
const config = mysqlConnection();
let resolveDatabaseInfo!: (value: { productName: string; productVersion: string }) => void;
const databaseInfo = new Promise<{ productName: string; productVersion: string }>((resolve) => {
resolveDatabaseInfo = resolve;
});
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
vi.doMock("@/lib/backend/api", () => ({
connectDb: vi.fn().mockResolvedValue(config.id),
connectionDatabaseInfo: vi.fn(() => databaseInfo),
saveConnectionDatabaseInfo: vi.fn().mockResolvedValue(undefined),
saveConnections: vi.fn().mockResolvedValue(undefined),
saveSidebarLayout: vi.fn().mockResolvedValue(undefined),
connectionIdentifierQuote: vi.fn().mockResolvedValue(undefined),
}));
const { useConnectionStore } = await import("@/stores/connectionStore");
const store = useConnectionStore();
await store.addConnection(config);
await expect(store.connect(config)).resolves.toBe(config.id);
expect(store.connectedIds.has(config.id)).toBe(true);
resolveDatabaseInfo({ productName: "MySQL", productVersion: "8.0.34" });
await vi.waitFor(() => expect(store.getConfig(config.id)?.database_info?.productVersion).toBe("8.0.34"));
});
it("keeps a successful connection when optional metadata refresh fails", async () => {
const config = mysqlConnection();
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
vi.doMock("@/lib/backend/api", () => ({
connectDb: vi.fn().mockResolvedValue(config.id),
connectionDatabaseInfo: vi.fn().mockRejectedValue(new Error("metadata unavailable")),
saveConnections: vi.fn().mockResolvedValue(undefined),
saveSidebarLayout: vi.fn().mockResolvedValue(undefined),
connectionIdentifierQuote: vi.fn().mockResolvedValue(undefined),
}));
const { useConnectionStore } = await import("@/stores/connectionStore");
const store = useConnectionStore();
await store.addConnection(config);
await expect(store.connect(config)).resolves.toBe(config.id);
expect(store.connectedIds.has(config.id)).toBe(true);
});
});
@@ -99,6 +99,34 @@ describe("queryStore multi-statement errors", () => {
});
});
it("uses explicit statement indexes for selected multi-statement ranges", async () => {
mocks.executeMulti.mockResolvedValue([
{ columns: ["value"], rows: [[2]], affected_rows: 0, execution_time_ms: 1, statement_index: 1 },
{ columns: ["Error"], rows: [["failed"]], affected_rows: 0, execution_time_ms: 1, execution_error: true, statement_index: 2 },
]);
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
const tabId = store.createTab("mysql-1", "app", "Query");
const selectedSql = "SELECT 1; SELECT 2; SELECT bad";
await store.executeTabSql(tabId, selectedSql, { sourceOffset: 10 });
const tab = store.tabs.find((item) => item.id === tabId)!;
expect(tab.results?.[0]).toMatchObject({
sourceStatement: "SELECT 2",
sourceFrom: 20,
sourceTo: 28,
statement_index: 1,
});
expect(tab.results?.[1]).toMatchObject({
sourceStatement: "SELECT bad",
sourceFrom: 30,
sourceTo: 40,
statement_index: 2,
execution_error: true,
});
});
it("does not promote an unmarked Error alias without type metadata as a batch failure", async () => {
mocks.executeMulti.mockResolvedValue([
{ columns: ["value"], rows: [[1]], affected_rows: 0, execution_time_ms: 1 },
+51 -1
View File
@@ -1,7 +1,23 @@
import { defineStore } from "pinia";
import { uuid } from "@/lib/common/utils";
import { ref, computed, watch, markRaw } from "vue";
import type { ColumnInfo, CompletionAssistantCandidate, CompletionAssistantObjectKind, CompletionAssistantRequest, ConnectionConfig, CatalogInfo, ForeignKeyInfo, ObjectInfo, SchemaInfo, SidebarLayout, TableInfo, TreeNode, TunnelProfile, VectorCollectionMeta } from "@/types/database";
import type {
ColumnInfo,
CompletionAssistantCandidate,
CompletionAssistantObjectKind,
CompletionAssistantRequest,
ConnectionConfig,
DatabaseConnectionInfo,
CatalogInfo,
ForeignKeyInfo,
ObjectInfo,
SchemaInfo,
SidebarLayout,
TableInfo,
TreeNode,
TunnelProfile,
VectorCollectionMeta,
} from "@/types/database";
import { applyPinnedTreeNodeState, inheritNaturalTreeNodeOrder, migrateLegacyPinnedTreeNodeIds, syncPinnedTreeNodeStateInPlace, treeNodePinKey } from "@/lib/app/pinnedItems";
import {
reconcileLayout,
@@ -67,6 +83,7 @@ import { REDIS_SCAN_PAGE_SIZE_DEFAULT } from "@/lib/redis/redisKeyPattern";
import { appendAgentDriverUpdateHint, hasAgentDriverUpdate, type AgentDriverInstallState } from "@/lib/connection/agentDriverInstallHint";
import { appendConnectionErrorHints } from "@/lib/connection/connectionErrorHints";
import { appendVisibleDatabaseSelection } from "@/lib/connection/connectionVisibleDatabases";
import { configuredDatabaseProductName, connectionConfigFingerprint, normalizeDatabaseConnectionInfo } from "@/lib/connection/connectionDatabaseInfo";
import { createMetadataLoadTrace, logMetadataLoadTrace, MetadataLoadCoordinator, type MetadataLoadTraceLogger } from "@/lib/metadata/metadataLoadCoordinator";
import type { MetadataScopeInput } from "@/lib/metadata/metadataLoadScope";
import { MetadataResultCache, type MetadataCacheInvalidation } from "@/lib/metadata/metadataResultCache";
@@ -872,6 +889,7 @@ export const useConnectionStore = defineStore("connection", () => {
query_timeout_secs: config.query_timeout_secs ?? 30,
idle_timeout_secs: config.idle_timeout_secs ?? 60,
keepalive_interval_secs: config.keepalive_interval_secs ?? DEFAULT_KEEPALIVE_INTERVAL_SECS,
database_info: normalizeDatabaseConnectionInfo(config.database_info),
};
}
@@ -1767,6 +1785,35 @@ export const useConnectionStore = defineStore("connection", () => {
}
}
async function updateConnectionDatabaseInfo(connectionId: string, databaseInfo: DatabaseConnectionInfo, expectedConfigFingerprint?: string): Promise<void> {
const normalized = normalizeDatabaseConnectionInfo(databaseInfo);
if (!normalized) return;
const current = connections.value.find((connection) => connection.id === connectionId);
if (!current) return;
if (expectedConfigFingerprint && connectionConfigFingerprint(current) !== expectedConfigFingerprint) return;
if (JSON.stringify(current.database_info) === JSON.stringify(normalized)) return;
await api.saveConnectionDatabaseInfo(connectionId, normalized);
const index = connections.value.findIndex((connection) => connection.id === connectionId);
if (index < 0) return;
if (expectedConfigFingerprint && connectionConfigFingerprint(connections.value[index]) !== expectedConfigFingerprint) return;
const nextConnections = [...connections.value];
nextConnections[index] = { ...nextConnections[index], database_info: normalized };
connections.value = nextConnections;
rebuildTreeNodes();
}
async function refreshConnectedDatabaseInfo(connectionId: string, config: ConnectionConfig): Promise<void> {
const expectedConfigFingerprint = connectionConfigFingerprint(config);
try {
const detected = await api.connectionDatabaseInfo(connectionId);
const normalized = normalizeDatabaseConnectionInfo(detected, configuredDatabaseProductName(config), config.database);
if (normalized) await updateConnectionDatabaseInfo(connectionId, normalized, expectedConfigFingerprint);
} catch {
// Database metadata is optional and must not turn a successful connection into a failure.
}
}
async function syncMongoLegacyDriverFallback(connectionId: string, previousConfig: ConnectionConfig) {
if (!isDesktop || previousConfig.db_type !== "mongodb" || previousConfig.driver_profile === MONGO_LEGACY_DRIVER_PROFILE) {
return;
@@ -1951,6 +1998,7 @@ export const useConnectionStore = defineStore("connection", () => {
await ensureLocalConnectionAttemptActiveAfterConnectResult(config.id, localAttempt, id);
activeConnectionId.value = id;
connectedIds.value.add(id);
void refreshConnectedDatabaseInfo(id, { ...config, id });
await refreshConnectionIdentifierQuote(id, { ...config, id });
if (id !== config.id) markSuccessfulLocalConnectionAttempt(config.id, localAttempt);
markSuccessfulLocalConnectionAttempt(id, localAttempt);
@@ -2115,6 +2163,7 @@ export const useConnectionStore = defineStore("connection", () => {
await syncMongoLegacyDriverFallback(connectionId, config);
await ensureLocalConnectionAttemptActiveAfterConnectResult(connectionId, localAttempt, id);
connectedIds.value.add(connectionId);
void refreshConnectedDatabaseInfo(connectionId, config);
await refreshConnectionIdentifierQuote(connectionId, config);
markSuccessfulLocalConnectionAttempt(connectionId, localAttempt);
markConnectionHealthChecked(connectionId);
@@ -5278,6 +5327,7 @@ export const useConnectionStore = defineStore("connection", () => {
pasteConnectionClipboard,
addEphemeralConnection,
updateConnection,
updateConnectionDatabaseInfo,
setDefaultDatabase,
clearDefaultDatabase,
isDefaultDatabase,
+13 -1
View File
@@ -55,6 +55,7 @@ import { useSavedSqlStore } from "@/stores/savedSqlStore";
import { createSavedSqlEditorPosition, initSavedSqlEditorPositions, restoreSavedSqlEditorPosition, saveSavedSqlEditorPosition } from "@/lib/app/savedSqlEditorPosition";
import { ensureSqlExtension } from "@/lib/savedSql/savedSqlFileName";
import { safeLocalStorageGet, safeLocalStorageRemove } from "@/lib/backend/safeStorage";
import { sqlTextFingerprint } from "@/lib/sql/sqlTextFingerprint";
import type { SavedSqlFile } from "@/types/database";
const ORACLE_LIKE_METADATA_TYPES = new Set<string>(["oracle", "dameng", "oceanbase-oracle"]);
@@ -143,7 +144,10 @@ function annotateQueryResultSources(results: QueryResult[], sql: string, databas
const statements = splitSqlStatementRanges(sql, databaseType);
let statementIndex = 0;
for (const result of results) {
const statement = statements[statementIndex++];
const explicitIndex = Number.isInteger(result.statement_index) && result.statement_index! >= 0 ? result.statement_index : undefined;
const sourceIndex = explicitIndex ?? statementIndex;
statementIndex = Math.max(statementIndex, sourceIndex + 1);
const statement = statements[sourceIndex];
if (!statement) continue;
annotateQueryResultSource(result, statement.sql, database, databaseType, sourceOffset === undefined ? undefined : { from: sourceOffset + statement.from, to: sourceOffset + statement.to });
}
@@ -454,6 +458,7 @@ export const useQueryStore = defineStore("query", () => {
tab.result = undefined;
tab.results = undefined;
tab.activeResultIndex = undefined;
tab.resultEditorFingerprint = undefined;
tab.resultLocalSortOriginalRows = undefined;
tab.resultLocalSortOriginalMongoDocuments = undefined;
tab.resultSortMode = undefined;
@@ -500,6 +505,7 @@ export const useQueryStore = defineStore("query", () => {
tab.results = run.results;
tab.activeResultIndex = run.activeResultIndex;
tab.resultBaseSql = run.resultBaseSql;
tab.resultEditorFingerprint = run.resultEditorFingerprint;
tab.resultSortedSql = run.resultSortedSql;
tab.resultSortColumn = run.resultSortColumn;
tab.resultSortColumnIndex = run.resultSortColumnIndex;
@@ -598,6 +604,7 @@ export const useQueryStore = defineStore("query", () => {
result: run.result,
results: run.results,
activeResultIndex: run.activeResultIndex,
resultEditorFingerprint: run.resultEditorFingerprint,
resultRuns: [run],
activeResultRunId: run.id,
queryAnalysis: run.queryAnalysis,
@@ -642,6 +649,7 @@ export const useQueryStore = defineStore("query", () => {
results: tab.results,
activeResultIndex: tab.activeResultIndex,
resultBaseSql: tab.resultBaseSql,
resultEditorFingerprint: tab.resultEditorFingerprint,
resultSortedSql: tab.resultSortedSql,
resultSortColumn: tab.resultSortColumn,
resultSortColumnIndex: tab.resultSortColumnIndex,
@@ -693,6 +701,7 @@ export const useQueryStore = defineStore("query", () => {
results: tab.results,
activeResultIndex: tab.activeResultIndex,
resultBaseSql: tab.resultBaseSql,
resultEditorFingerprint: tab.resultEditorFingerprint,
resultSortedSql: tab.resultSortedSql,
resultSortColumn: tab.resultSortColumn,
resultSortColumnIndex: tab.resultSortColumnIndex,
@@ -2597,6 +2606,7 @@ export const useQueryStore = defineStore("query", () => {
if (!tab || !sql.trim()) return;
const executionId = uuid();
const executionEditorFingerprint = tab.mode === "query" ? sqlTextFingerprint(tab.sql) : undefined;
const traceId = executionId.slice(0, 8);
const startedAt = performance.now();
const elapsed = () => `${Math.round(performance.now() - startedAt)}ms`;
@@ -3133,6 +3143,7 @@ export const useQueryStore = defineStore("query", () => {
current.result = results[0];
}
current.resultBaseSql = shouldReplaceActiveResultInGroup ? (current.resultBaseSql ?? queryBaseSql) : queryBaseSql;
current.resultEditorFingerprint = shouldReplaceActiveResultInGroup ? (current.resultEditorFingerprint ?? executionEditorFingerprint) : executionEditorFingerprint;
current.resultSortedSql = resultSortedSql;
current.resultPageSql = pageSql;
current.resultPageLimit = pageLimit;
@@ -3644,6 +3655,7 @@ export const useQueryStore = defineStore("query", () => {
const activeIndex = snapshot.activeResultIndex ?? 0;
tab.results = results;
tab.activeResultIndex = snapshot.activeResultIndex;
tab.resultEditorFingerprint = snapshot.resultEditorFingerprint;
tab.result = snapshot.result ? markQueryResultRowsRaw(snapshot.result) : results?.[activeIndex] ? markQueryResultRowsRaw(results[activeIndex]) : undefined;
tab.resultLocalSortOriginalRows = snapshot.resultLocalSortOriginalRows ? markRaw(snapshot.resultLocalSortOriginalRows) : undefined;
tab.resultLocalSortOriginalMongoDocuments = snapshot.resultLocalSortOriginalMongoDocuments ? markRaw(snapshot.resultLocalSortOriginalMongoDocuments) : undefined;
+29
View File
@@ -163,6 +163,29 @@ export interface ConnectionConfig {
is_production?: boolean;
/** Database-level production markers for multi-database connections. */
production_databases?: string[];
/** Metadata captured from the latest successful connection test for the saved config. */
database_info?: DatabaseConnectionInfo;
}
export type IdentifierCase = "lower" | "upper" | "mixed";
export interface DatabaseConnectionInfo {
productName?: string;
productVersion?: string;
currentDatabase?: string;
serverComment?: string;
serverCharset?: string;
serverCollation?: string;
unquotedIdentifierCase?: IdentifierCase;
quotedIdentifierCase?: IdentifierCase;
driverName?: string;
driverVersion?: string;
jdbcVersion?: string;
}
export interface ConnectionTestResult {
message: string;
databaseInfo?: DatabaseConnectionInfo;
}
export type TransportLayerConfig = ({ type: "ssh" } & SshTunnelConfig) | ({ type: "proxy" } & ProxyTunnelConfig) | ({ type: "http_tunnel" } & HttpTunnelConfig);
@@ -471,6 +494,8 @@ export interface QueryResult {
columns: string[];
/** Set for synthesized query execution failures. */
execution_error?: true;
/** Zero-based index of the submitted statement that produced this result. */
statement_index?: number;
/** Internal row identifiers appended to editable query results. */
hidden_column_indexes?: number[];
/**
@@ -512,6 +537,8 @@ export interface QueryResultRun {
results?: QueryResult[];
activeResultIndex?: number;
resultBaseSql?: string;
/** Fingerprint of the complete editor document when this result run started. */
resultEditorFingerprint?: string;
resultSortedSql?: string;
resultSortColumn?: string;
resultSortColumnIndex?: number;
@@ -733,6 +760,8 @@ export interface QueryTab {
originalSql?: string;
lastExecutedSql?: string;
resultBaseSql?: string;
/** Fingerprint of the complete editor document when the displayed result started. */
resultEditorFingerprint?: string;
resultSortedSql?: string;
resultSortColumn?: string;
resultSortColumnIndex?: number;