Files
dbx/apps/desktop/src/App.vue
T
miracleandt8y2 2a59d3143e feat(mq): implement Apache Pulsar message queue admin
* feat(mq): implement Apache Pulsar message queue admin

- Add complete MQ admin adapter layer with version detection
  * Pulsar adapter with multi-version API support
  * OAuth2 token caching and authentication
  * JWT token signing for Pulsar clients
  * Transport layer (SSH/Proxy) integration

- Implement comprehensive admin operations
  * Tenant, namespace, topic management
  * Subscription and consumer/producer monitoring
  * Rate limits, backlog quotas, retention policies
  * Permissions and role-based access control
  * Raw admin API for advanced operations

- Add desktop UI components
  * MQ admin console with tabbed interface
  * Tenant, namespace, topic panels
  * Subscription management and cursor operations
  * Monitoring panel with stats visualization
  * Policy configuration UI

- Add web routes for MQ admin operations
- Add secret store support for MQ auth credentials
- Add comprehensive test coverage

* fix: del local test files

* fix(mq): mark message queue as unsupported mcpMode in driver manifest

MQ admin connections do not use the MCP query bridge - they use a
separate REST admin API. Mark mcpMode as 'unsupported' to align with
the test expectations and reflect the actual architecture.

Fixes CI test: driver-manifest.test.ts

* fix(mq): fix TypeScript errors in MQ components

- Fix typo in SubscriptionsPanel.vue: messagformatError(e)Id -> message.messageId
- Use formatError() helper in RawApiPanel.vue instead of accessing e?.message
- Fix undefined 'tab' reference in ConnectionTree.vue: use config.db_type instead

Fixes frontend CI TypeScript check errors.

* fix(mcp): add 'mq' to MCP server connection type description

The MCP server's connection type description must include all database
types from the driver manifest. Add 'mq' to the list to fix the test:
'add connection database type description includes every manifest database type'

Fixes: packages/mcp-server/tests/driver-manifest.test.ts

* fix(mq): add missing ConnectionConfig fields for Rust compilation

- Add gbase_server and keepalive_interval_secs fields to all ConnectionConfig
  test constructors in mq/config.rs, mq/service.rs, and storage.rs
- Import uses_bare_mysql_pool in connection.rs tests module

Fixes compilation errors from upstream ConnectionConfig changes.

* chore(ui): simplify Pulsar icon to logo-only version

Remove text from Pulsar icon (127x30 -> 35x30), keeping only
the wave logo symbol for a cleaner, more consistent appearance
with other database icons.

* fix(mq): return removed pools from drain_connection_pools

---------

Co-authored-by: t8y2 <1156263951@qq.com>
2026-06-16 21:57:17 +08:00

1440 lines
54 KiB
Vue

<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted, nextTick, defineAsyncComponent } from "vue";
import { useI18n } from "vue-i18n";
import { invoke } from "@tauri-apps/api/core";
import { ChevronsRight } from "@lucide/vue";
import { TooltipProvider } from "@/components/ui/tooltip";
import AppToolbar from "@/components/layout/AppToolbar.vue";
import AppTabBar from "@/components/layout/AppTabBar.vue";
import AppSidebar from "@/components/layout/AppSidebar.vue";
import EditorToolbar from "@/components/layout/EditorToolbar.vue";
import ContentArea from "@/components/layout/ContentArea.vue";
import AppDialogs from "@/components/layout/AppDialogs.vue";
import WelcomeScreen from "@/components/layout/WelcomeScreen.vue";
import { useConnectionStore } from "@/stores/connectionStore";
import { useQueryStore } from "@/stores/queryStore";
import { useSettingsStore } from "@/stores/settingsStore";
import { useSavedSqlStore } from "@/stores/savedSqlStore";
import { useToast } from "@/composables/useToast";
import { useTheme } from "@/composables/useTheme";
import { useAppUpdater } from "@/composables/useAppUpdater";
import { useFileDrop } from "@/composables/useFileDrop";
import { usePanelResize } from "@/composables/usePanelResize";
import { useDatabaseOptions } from "@/composables/useDatabaseOptions";
import { useSqlExecution } from "@/composables/useSqlExecution";
import { useDialogSources } from "@/composables/useDialogSources";
import { useNavigationTargets } from "@/composables/useNavigationTargets";
import { useDataGridActions } from "@/composables/useDataGridActions";
import { useTauriEvents } from "@/composables/useTauriEvents";
import { useVisibilityChange } from "@/composables/useVisibilityChange";
import "@/i18n";
import { translateBackendError } from "@/i18n/backend-errors";
import * as api from "@/lib/api";
import { connectionRedactedNameLabel } from "@/lib/connectionPresentation";
import { quickConnectionOpenTarget } from "@/lib/connectionOpenTarget";
import { resolveDefaultDatabase } from "@/lib/defaultDatabase";
import { findTreeNodeById, resolveNewQueryTarget } from "@/lib/newQueryContext";
import { buildExecutableObjectSourceStatements, objectSourceSaveExecutionMode } from "@/lib/objectSourceEditor";
import { resolveExecutableSql, resolveExecutableSqlWithBackend } from "@/lib/sqlExecutionTarget";
import { uuid } from "@/lib/utils";
import { isTauriRuntime } from "@/lib/tauriRuntime";
import { openQueryResultArchiveFile } from "@/lib/queryResultArchiveFile";
import { sqlFileTitleFromPath } from "@/lib/sqlFileOpen";
import type { ConnectionConfig } from "@/types/database";
import { parseConnectionDeepLink, type ConnectionDeepLinkDraft } from "@/lib/connectionDeepLink";
import {
isBrowserReloadShortcut,
isCloseTabShortcut,
isExecuteSqlShortcut,
isFocusSearchShortcut,
isModRShortcut,
isNewQueryShortcut,
isObjectSourceSaveShortcutTarget,
isOpenSettingsShortcut,
isResetZoomShortcut,
isRefreshDataShortcut,
isSaveShortcut,
isToggleSidebarShortcut,
isZoomInShortcut,
isZoomOutShortcut,
} from "@/lib/keyboardShortcuts";
import { isPreviewTab } from "@/lib/tabPresentation";
import { supportsSqlFileExecution } from "@/lib/databaseCapabilities";
import { classifyAiSqlExecution } from "@/lib/aiSqlExecutionPolicy";
import { buildHistoryAiAnalysisPrompt } from "@/lib/historyAiAnalysis";
import { countAvailableAgentDriverUpdates, type AgentDriverUpdateBadgeState } from "@/lib/agentDriverUpdateBadge";
import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/safeStorage";
import { rankSavedSqlHistory } from "@/lib/savedSqlHistory";
import { isSchemaAware, isSingleDatabase } from "@/lib/databaseFeatureSupport";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import type { HistoryEntry } from "@/lib/tauri";
import type { AiAction } from "@/lib/ai";
const AiAssistant = defineAsyncComponent(() => import("@/components/editor/AiAssistant.vue"));
const QueryHistory = defineAsyncComponent(() => import("@/components/editor/QueryHistory.vue"));
const SqlLibraryPanel = defineAsyncComponent(() => import("@/components/layout/SqlLibraryPanel.vue"));
const DriverStorePage = defineAsyncComponent(() => import("@/components/config/DriverStoreDialog.vue"));
const UpdateDialog = defineAsyncComponent(() => import("@/components/layout/UpdateDialog.vue"));
const LoginPage = defineAsyncComponent(() => import("@/components/auth/LoginPage.vue"));
type AiAssistantHandle = {
triggerAction: (action: AiAction, instruction?: string) => void;
};
const { t } = useI18n();
const connectionStore = useConnectionStore();
const queryStore = useQueryStore();
const settingsStore = useSettingsStore();
const savedSqlStore = useSavedSqlStore();
const { message: toastMessage, visible: toastVisible, toast } = useToast();
const { isDark, themeMode, applyTheme, setThemeMode } = useTheme();
const { checkingUpdates, updateInfo, updateCheckMessage, showUpdateDialog, isDownloadingUpdate, downloadProgress, updateReady, hasUpdateAvailable, openUrl, checkUpdates, openLatestRelease, downloadAndInstallUpdate, restartApp } = useAppUpdater();
const { setupFileDrop } = useFileDrop();
const isDesktop = isTauriRuntime();
const UPDATE_CHECK_INTERVAL_MS = 60 * 60 * 1000;
let updateCheckTimer: ReturnType<typeof setInterval> | undefined;
const needsAuth = ref(!isDesktop);
const authenticated = ref(isDesktop);
const setupRequired = ref(false);
const showConnectionDialog = ref(false);
const connectionDialogPrefill = ref<ConnectionDeepLinkDraft | null>(null);
const showSettingsDialog = ref(false);
const showDriverStore = ref(false);
const agentDriverUpdateCount = ref(0);
const showHistory = ref(false);
const showAiPanel = ref(safeLocalStorageGet("dbx-ai-panel-open") === "true");
const showSqlLibraryPanel = ref(safeLocalStorageGet("dbx-sql-library-open") === "true");
const sidebarOpen = ref(safeLocalStorageGet("dbx-sidebar-open") !== "false");
const aiPanelReady = ref(false);
const { sidebarWidth, aiPanelWidth, historyWidth, sqlLibraryWidth, startSidebarResize, startAiPanelResize, startHistoryResize, startSqlLibraryResize } = usePanelResize();
const aiAssistantRef = ref<AiAssistantHandle | null>(null);
const appSidebarRef = ref<InstanceType<typeof AppSidebar> | null>(null);
const contentAreaRef = ref<InstanceType<typeof ContentArea> | null>(null);
const selectedSql = ref("");
const cursorPos = ref(0);
const formatSqlRequest = ref<{ id: number; tabId: string } | null>(null);
const activeOutputView = ref<"result" | "summary" | "explain" | "chart">("result");
const newQueryContextSource = ref<"tab" | "sidebar">("tab");
const showSaveSqlDialog = ref(false);
const saveSqlName = ref("");
const saveSqlFolderId = ref("");
const pendingSaveAndCloseTabId = ref<string | null>(null);
const pendingPrevActiveTabId = ref<string | null>(null);
const ROOT_SAVED_SQL_FOLDER = "__root__";
const activeTab = computed(() => queryStore.tabs.find((t) => t.id === queryStore.activeTabId));
const activeConnection = computed(() => {
const tab = activeTab.value;
return tab ? connectionStore.getConfig(tab.connectionId) : undefined;
});
function updateAgentDriverUpdateCount(count: number) {
if (!settingsStore.editorSettings.updateNotificationsEnabled) {
agentDriverUpdateCount.value = 0;
return;
}
agentDriverUpdateCount.value = count;
}
async function refreshAgentDriverUpdateCount() {
if (!isDesktop || !settingsStore.editorSettings.updateNotificationsEnabled) return;
try {
const drivers = await invoke<AgentDriverUpdateBadgeState[]>("list_installed_agents");
if (!settingsStore.editorSettings.updateNotificationsEnabled) return;
updateAgentDriverUpdateCount(countAvailableAgentDriverUpdates(drivers));
} catch {
// Driver update availability is only a badge hint; keep the existing count if the registry cannot be reached.
}
}
function restoreHistorySql(sql: string, entry: HistoryEntry) {
const tab = activeTab.value;
if (tab?.mode === "query") {
queryStore.updateSql(tab.id, sql);
return;
}
const connectionId = entry.connection_id || tab?.connectionId || connectionStore.connections[0]?.id;
if (!connectionId) return;
const config = connectionStore.getConfig(connectionId);
const database = entry.database || tab?.database || (config ? resolveDefaultDatabase(config, []) : "");
const tabId = queryStore.createTab(connectionId, database || "", t("tabs.sql"));
queryStore.updateSql(tabId, sql);
}
const executableSql = computed(() => {
const tab = activeTab.value;
return tab
? resolveExecutableSql(tab.sql, selectedSql.value, {
mode: settingsStore.editorSettings.executeMode,
cursorPos: cursorPos.value,
})
: "";
});
async function resolveActiveExecutableSql() {
const tab = activeTab.value;
return tab
? await resolveExecutableSqlWithBackend(tab.sql, selectedSql.value, {
mode: settingsStore.editorSettings.executeMode,
cursorPos: cursorPos.value,
databaseType: activeConnection.value?.db_type,
})
: "";
}
const blockDangerousRedisCommands = ref(true);
const { dangerSql, pendingDangerSql, showDangerDialog, suppressDangerConfirm, tryExecute, doExecute, cancelActiveExecution, tryExplain, onDangerConfirm, explainMode } = useSqlExecution({
activeTab,
activeConnection,
executableSql,
resolveExecutableSql: resolveActiveExecutableSql,
activeOutputView,
blockDangerousRedisCommands,
});
const dialogs = useDialogSources();
const { getDatabaseOptions } = useDatabaseOptions();
const { openLineageTarget, openDatabaseSearchTarget, onStructureEditorSaved, openTableTarget } = useNavigationTargets(dialogs);
const { onExecuteSql, onReloadData, onPaginate, onSort } = useDataGridActions(activeTab);
const { setupTauriListeners, cleanupTauriListeners } = useTauriEvents({
openTableTarget,
openSqlFilePath,
openDbFilePath,
openConnectionDeepLink,
});
useVisibilityChange();
const appVersion = ref("");
const isClassicLayout = computed(() => settingsStore.editorSettings.appLayout === "classic");
const updateNotificationsEnabled = computed(() => settingsStore.editorSettings.updateNotificationsEnabled);
const toolbarAgentDriverUpdateCount = computed(() => (updateNotificationsEnabled.value ? agentDriverUpdateCount.value : 0));
const toolbarHasUpdateAvailable = computed(() => updateNotificationsEnabled.value && hasUpdateAvailable.value);
const hasSqlFileConnections = computed(() => connectionStore.connections.some((c) => supportsSqlFileExecution(c.db_type)));
const connectionStats = computed(() => ({
total: connectionStore.connections.length,
connected: connectionStore.connectedIds.size,
types: new Set(connectionStore.connections.map((c) => c.driver_profile || c.db_type)).size,
}));
const recentConnections = computed(() => connectionStore.connections.slice(0, 5));
const savedSqlHistoryItems = computed(() => {
const folderById = new Map(savedSqlStore.allFolders.map((folder) => [folder.id, folder]));
const folderPath = (folderId?: string): string | undefined => {
if (!folderId) return undefined;
const parts: string[] = [];
const seen = new Set<string>();
let folder = folderById.get(folderId);
while (folder && !seen.has(folder.id)) {
seen.add(folder.id);
parts.unshift(folder.name);
folder = folder.parentFolderId ? folderById.get(folder.parentFolderId) : undefined;
}
return parts.join("/");
};
return rankSavedSqlHistory(savedSqlStore.allFiles, { limit: 6 }).map((file) => {
const connection = connectionStore.getConfig(file.connectionId);
return {
id: file.id,
name: file.name,
connectionName: connection ? connectionRedactedNameLabel(connection) : t("welcome.unknownConnection"),
database: file.database,
folderName: folderPath(file.folderId),
openCount: file.openCount ?? 0,
};
});
});
const saveSqlFolders = computed(() => {
const folderById = new Map(savedSqlStore.allFolders.map((folder) => [folder.id, folder]));
const pathForFolder = (folderId: string) => {
const parts: string[] = [];
const seen = new Set<string>();
let folder = folderById.get(folderId);
while (folder && !seen.has(folder.id)) {
seen.add(folder.id);
parts.unshift(folder.name);
folder = folder.parentFolderId ? folderById.get(folder.parentFolderId) : undefined;
}
return parts.join(" / ");
};
return savedSqlStore.allFoldersTreeOrder.map((folder) => ({
...folder,
displayName: pathForFolder(folder.id) || folder.name,
}));
});
async function applyUiScale(scale: number) {
if (!isDesktop) return;
try {
const { getCurrentWebview } = await import("@tauri-apps/api/webview");
await getCurrentWebview().setZoom(scale);
window.dispatchEvent(new CustomEvent("dbx:ui-scale-applied", { detail: { scale } }));
} catch (error) {
console.warn("[DBX] Failed to apply UI scale", { scale, error });
}
}
function setGlobalUiScale(scale: number) {
settingsStore.updateEditorSettings({ uiScale: scale });
}
function zoomInUi() {
setGlobalUiScale(settingsStore.editorSettings.uiScale + 0.1);
}
function zoomOutUi() {
setGlobalUiScale(settingsStore.editorSettings.uiScale - 0.1);
}
function resetUiZoom() {
setGlobalUiScale(1);
}
function isGlobalUiZoomTarget(target: EventTarget | null): target is Element {
if (!(target instanceof Element)) return false;
if (target.closest("[data-query-editor-root], [data-cell-detail-editor-root], [data-object-source-editor]")) {
return true;
}
if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || (target instanceof HTMLElement && target.isContentEditable)) {
return false;
}
return !target.closest("[contenteditable='true']");
}
watch(
() => queryStore.activeTabId,
(id, previousId) => {
if (previousId && previousId !== id && typeof window !== "undefined") {
window.dispatchEvent(
new CustomEvent("dbx:before-tab-switch", {
detail: { tabId: id, fromTabId: previousId },
}),
);
}
if (id) newQueryContextSource.value = "tab";
selectedSql.value = "";
activeOutputView.value = "result";
if (id) queryStore.reloadEvictedTab(id);
},
);
watch(
() => connectionStore.selectedTreeNodeId,
(id) => {
if (id) newQueryContextSource.value = "sidebar";
},
);
watch(
() => settingsStore.editorSettings.uiScale,
(scale) => {
void applyUiScale(scale);
},
{ immediate: true },
);
function toggleAiPanel() {
showAiPanel.value = !showAiPanel.value;
safeLocalStorageSet("dbx-ai-panel-open", String(showAiPanel.value));
}
function toggleSqlLibrary() {
showSqlLibraryPanel.value = !showSqlLibraryPanel.value;
safeLocalStorageSet("dbx-sql-library-open", String(showSqlLibraryPanel.value));
}
function fixWithAi(errorMessage: string) {
if (!showAiPanel.value) {
showAiPanel.value = true;
safeLocalStorageSet("dbx-ai-panel-open", "true");
}
nextTick(() => aiAssistantRef.value?.triggerAction("fix", errorMessage));
}
function openAiPanel() {
if (!showAiPanel.value) {
showAiPanel.value = true;
safeLocalStorageSet("dbx-ai-panel-open", "true");
}
}
function analyzeHistoryWithAi(entry: HistoryEntry) {
const connectionId = entry.connection_id || activeTab.value?.connectionId;
if (!connectionId) {
toast(t("history.aiAnalyzeNoConnection"), 5000);
return;
}
const config = connectionStore.getConfig(connectionId);
if (!config) {
toast(t("history.aiAnalyzeNoConnection"), 5000);
return;
}
openAiPanel();
const database = entry.database || activeTab.value?.database || resolveDefaultDatabase(config, []);
const title = t("history.aiAnalysisTab");
const tabId = queryStore.createTab(connectionId, database || "", title, "query");
queryStore.updateSql(tabId, entry.sql);
nextTick(() => aiAssistantRef.value?.triggerAction("explain", buildHistoryAiAnalysisPrompt(entry)));
}
function formatActiveSql() {
const tab = activeTab.value;
if (!tab || tab.mode !== "query" || !tab.sql.trim()) return;
formatSqlRequest.value = {
id: (formatSqlRequest.value?.id ?? 0) + 1,
tabId: tab.id,
};
}
function defaultSavedSqlName(title: string) {
const trimmed = title.trim() || "query";
const normalized = trimmed.replace(/\s+/g, "_");
return normalized.endsWith(".sql") ? normalized : `${normalized}.sql`;
}
async function handleSaveTab(tabId: string) {
const tab = queryStore.tabs.find((t) => t.id === tabId);
if (!tab || !tab.sql.trim()) return;
const existing = tab.savedSqlId ? savedSqlStore.getFile(tab.savedSqlId) : undefined;
if (existing) {
const updated = await savedSqlStore.saveFile({
id: existing.id,
connectionId: tab.connectionId,
folderId: existing.folderId,
name: existing.name,
database: tab.database,
schema: tab.schema,
sql: tab.sql,
});
queryStore.linkSavedSql(tab.id, updated.id, updated.name);
queryStore.markTabClean(tab);
toast(t("savedSql.saved"), 2000);
queryStore.closeTab(tabId, { force: true });
return;
}
// No existing saved SQL — open save dialog, then close after save
const prevActive = queryStore.activeTabId;
queryStore.activeTabId = tabId;
saveSqlName.value = defaultSavedSqlName(tab.title);
saveSqlFolderId.value = ROOT_SAVED_SQL_FOLDER;
pendingSaveAndCloseTabId.value = tabId;
pendingPrevActiveTabId.value = prevActive;
showSaveSqlDialog.value = true;
}
async function openSaveSqlDialog() {
const tab = activeTab.value;
if (!tab || !tab.sql.trim()) return;
if (tab.objectSource) {
await saveActiveObjectSource(tab);
return;
}
const existing = tab.savedSqlId ? savedSqlStore.getFile(tab.savedSqlId) : undefined;
if (existing) {
const updated = await savedSqlStore.saveFile({
id: existing.id,
connectionId: tab.connectionId,
folderId: existing.folderId,
name: existing.name,
database: tab.database,
schema: tab.schema,
sql: tab.sql,
});
queryStore.linkSavedSql(tab.id, updated.id, updated.name);
toast(t("savedSql.saved"), 2000);
return;
}
saveSqlName.value = defaultSavedSqlName(tab.title);
saveSqlFolderId.value = ROOT_SAVED_SQL_FOLDER;
showSaveSqlDialog.value = true;
}
async function saveActiveObjectSource(tab: NonNullable<typeof activeTab.value>) {
const connection = connectionStore.getConfig(tab.connectionId);
const source = tab.objectSource;
if (!connection || !source) return;
try {
const statements = await buildExecutableObjectSourceStatements({
databaseType: connection.db_type,
objectType: source.objectType,
schema: source.schema || tab.schema || tab.database,
name: source.name,
source: tab.sql,
});
for (const sql of statements) {
if (objectSourceSaveExecutionMode(connection.db_type) === "single") {
await api.executeQuery(tab.connectionId, tab.database, sql, source.schema || tab.schema);
} else {
await api.executeScript(tab.connectionId, tab.database, sql, source.schema || tab.schema);
}
}
toast(t("objects.sourceSaved"), 2000);
} catch (e: any) {
toast(t("objects.sourceSaveFailed", { message: e?.message || String(e) }), 5000);
}
}
async function confirmSaveSqlToLibrary() {
const tab = activeTab.value;
const name = saveSqlName.value.trim();
if (!tab || !tab.sql.trim() || !name) return;
try {
const saved = await savedSqlStore.saveFile({
id: tab.savedSqlId,
connectionId: tab.connectionId,
folderId: saveSqlFolderId.value === ROOT_SAVED_SQL_FOLDER ? undefined : saveSqlFolderId.value,
name: defaultSavedSqlName(name),
database: tab.database,
schema: tab.schema,
sql: tab.sql,
});
queryStore.linkSavedSql(tab.id, saved.id, saved.name);
queryStore.markTabClean(tab);
showSaveSqlDialog.value = false;
if (pendingSaveAndCloseTabId.value) {
const closeId = pendingSaveAndCloseTabId.value;
pendingSaveAndCloseTabId.value = null;
if (pendingPrevActiveTabId.value) queryStore.activeTabId = pendingPrevActiveTabId.value;
pendingPrevActiveTabId.value = null;
queryStore.closeTab(closeId, { force: true });
}
toast(t("savedSql.saved"), 2000);
} catch (e: any) {
toast(t("savedSql.saveFailed", { message: e?.message || String(e) }), 5000);
}
}
async function openSqlFile() {
const tab = activeTab.value;
if (!tab) return;
try {
if (isTauriRuntime()) {
const { open } = await import("@tauri-apps/plugin-dialog");
const path = await open({
filters: [{ name: "SQL", extensions: ["sql"] }],
multiple: false,
});
if (path) {
const content = await api.readExternalSqlFile(path as string);
queryStore.updateSql(tab.id, content);
}
} else {
const input = document.createElement("input");
input.type = "file";
input.accept = ".sql";
input.onchange = () => {
const file = input.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
if (typeof reader.result === "string") {
queryStore.updateSql(tab.id, reader.result);
}
};
reader.readAsText(file);
};
input.click();
}
} catch (e: any) {
toast(t("toolbar.sqlOpenFailed", { message: e?.message || String(e) }), 5000);
}
}
async function importResultArchive() {
try {
const bytes = await openQueryResultArchiveFile();
if (!bytes) return;
const tabId = await queryStore.importResultArchive(bytes);
if (!tabId) {
toast(t("tabs.resultArchiveImportInvalid"), 5000);
return;
}
activeOutputView.value = "result";
toast(t("tabs.resultArchiveImported"), 2500);
} catch (e: any) {
toast(t("tabs.resultArchiveImportFailed", { message: e?.message || String(e) }), 5000);
}
}
async function openSqlFilePath(path: string) {
if (!isTauriRuntime()) return;
try {
const content = await api.readExternalSqlFile(path);
const connectionId = connectionStore.activeConnectionId || activeTab.value?.connectionId || connectionStore.connections[0]?.id || "";
const connection = connectionId ? connectionStore.getConfig(connectionId) : undefined;
const database = activeTab.value?.database || (connection ? resolveDefaultDatabase(connection, []) : "");
const tabId = queryStore.createTab(connectionId, database, sqlFileTitleFromPath(path), "query");
queryStore.updateSql(tabId, content);
} catch (e: any) {
toast(t("toolbar.sqlOpenFailed", { message: e?.message || String(e) }), 5000);
}
}
async function openPendingSqlFiles() {
if (!isTauriRuntime()) return;
try {
const paths = await api.pendingOpenSqlFiles();
for (const path of paths) {
await openSqlFilePath(path);
}
} catch {
/* ignore startup file-open probing errors */
}
}
const DB_EXTENSIONS = [".db", ".db3", ".sqlite", ".sqlite3", ".duckdb"];
function getDbTypeFromPath(path: string): "sqlite" | "duckdb" | null {
const lower = path.toLowerCase();
if (lower.endsWith(".duckdb")) return "duckdb";
if (DB_EXTENSIONS.some((ext) => lower.endsWith(ext))) return "sqlite";
return null;
}
async function openDbFilePath(path: string) {
if (!isTauriRuntime()) return;
await connectionStore.initFromDisk();
try {
const name = path.split("/").pop()?.split("\\").pop() || path;
const dbType = getDbTypeFromPath(path);
if (!dbType) return;
// Check for existing connection with the same file path
const existing = connectionStore.connections.find((c) => c.host === path);
if (existing) {
const { ask } = await import("@tauri-apps/plugin-dialog");
const switchTo = await ask(`A connection to "${path}" already exists. Switch to it?`, {
title: "Database Already Open",
kind: "info",
});
if (switchTo) {
connectionStore.activeConnectionId = existing.id;
connectionStore.ensureConnected(existing.id).catch(() => {});
const node = connectionStore.treeNodes.find((n) => n.id === existing.id);
if (node && !node.isExpanded) {
connectionStore.loadDatabases(existing.id);
}
}
return;
}
const config: ConnectionConfig = {
id: uuid(),
name,
db_type: dbType,
driver_profile: dbType,
driver_label: dbType === "duckdb" ? "DuckDB" : "SQLite",
url_params: "",
host: path,
port: 0,
username: "",
password: "",
};
await connectionStore.addConnection(config);
void connectionStore.connect(config);
toast(t("welcome.fileOpened", { name }));
} catch (e: any) {
toast(t("toolbar.sqlOpenFailed", { message: e?.message || String(e) }), 5000);
}
}
async function openPendingDbFiles() {
if (!isTauriRuntime()) return;
try {
const paths = await api.pendingOpenDbFiles();
for (const path of paths) {
await openDbFilePath(path);
}
} catch {
/* ignore startup file-open probing errors */
}
}
async function openConnectionDeepLink(url: string) {
await connectionStore.initFromDisk();
try {
const draft = parseConnectionDeepLink(url);
if (!draft) return;
connectionStore.stopEditing();
connectionStore.stopCreatingConnectionInGroup();
connectionDialogPrefill.value = draft;
showConnectionDialog.value = true;
} catch (e: any) {
toast(
t("connection.parseConnectionUrlFailed", {
message: e?.message || String(e),
}),
5000,
);
}
}
async function openPendingConnectionLinks() {
if (!isTauriRuntime()) return;
try {
const links = await api.pendingOpenConnectionLinks();
for (const link of links) {
await openConnectionDeepLink(link);
}
} catch {
/* ignore startup deep-link probing errors */
}
}
function setConnectionDialogOpen(value: boolean) {
showConnectionDialog.value = value;
if (!value) connectionDialogPrefill.value = null;
}
async function newQuery() {
const target = resolveNewQueryTarget({
activeTab: activeTab.value,
selectedTreeNode: findTreeNodeById(connectionStore.treeNodes, connectionStore.selectedTreeNodeId),
activeConnectionId: connectionStore.activeConnectionId,
connections: connectionStore.connections,
preferredSource: newQueryContextSource.value,
});
if (!target) return;
const conn = connectionStore.getConfig(target.connectionId);
if (!conn) return;
connectionStore.activeConnectionId = target.connectionId;
const tabId = queryStore.createTab(conn.id, target.database, undefined, "query", target.schema);
try {
await connectionStore.ensureConnected(target.connectionId);
if (target.shouldRefreshDefaultDatabase) {
const options = await getDatabaseOptions(target.connectionId);
queryStore.updateDatabase(tabId, resolveDefaultDatabase(conn, options));
}
} catch (e: any) {
toast(
t("connection.connectFailed", {
message: translateBackendError(t, e?.message || String(e)),
}),
5000,
);
}
}
async function openConnectionQuery(connectionId: string) {
const connection = connectionStore.getConfig(connectionId);
if (!connection) return;
connectionStore.activeConnectionId = connectionId;
const initialTarget = quickConnectionOpenTarget(connection);
if (initialTarget.kind === "mq-admin") {
queryStore.openMqAdmin(connectionId);
return;
}
const tabId = queryStore.createTab(connectionId, initialTarget.database);
try {
await connectionStore.ensureConnected(connectionId);
const options = await getDatabaseOptions(connectionId);
const target = quickConnectionOpenTarget(connection, options);
if (target.kind === "query") {
queryStore.updateDatabase(tabId, target.database);
}
} catch (e: any) {
toast(
t("connection.connectFailed", {
message: translateBackendError(t, e?.message || String(e)),
}),
5000,
);
}
}
function openSavedSqlFromWelcome(fileId: string) {
const file = savedSqlStore.getFile(fileId);
if (!file) return;
queryStore.openSavedSql(file);
connectionStore.activeConnectionId = file.connectionId;
void savedSqlStore.recordFileUsage(file.id);
toast(t("welcome.fileOpened", { name: file.name }), 2000);
}
async function onClickTable(tableName: string) {
const tab = activeTab.value;
if (!tab) return;
const connectionId = tab.connectionId;
let database = tab.database;
let schema = tab.schema;
const parts = tableName.split(".").filter(Boolean);
const rawTableName = parts[parts.length - 1] || tableName;
if (parts.length >= 3) {
database = parts[parts.length - 3] || database;
schema = parts[parts.length - 2];
} else if (parts.length === 2) {
const dbType = connectionStore.getConfig(connectionId)?.db_type;
if (dbType && !isSchemaAware(dbType) && !isSingleDatabase(dbType)) {
database = parts[0] || database;
schema = undefined;
} else {
schema = parts[0];
}
}
try {
await openTableTarget({ connectionId, database, schema, tableName: rawTableName }, { tableInfoTab: "ddl" });
} catch (e: any) {
toast(t("connection.connectFailed", { message: translateBackendError(t, e?.message || String(e)) }), 5000);
}
}
async function changeActiveConnection(connectionId: string) {
const tab = activeTab.value;
if (!tab) return;
const connection = connectionStore.getConfig(connectionId);
if (!connection) return;
queryStore.updateConnection(tab.id, connectionId, resolveDefaultDatabase(connection, []));
connectionStore.activeConnectionId = connectionId;
try {
await connectionStore.ensureConnected(connectionId);
const options = await getDatabaseOptions(connectionId);
queryStore.updateDatabase(tab.id, resolveDefaultDatabase(connection, options));
} catch (e: any) {
toast(
t("connection.connectFailed", {
message: translateBackendError(t, e?.message || String(e)),
}),
5000,
);
}
}
function changeActiveDatabase(database: string) {
const tab = activeTab.value;
if (tab) queryStore.updateDatabase(tab.id, database);
}
async function setActiveDatabaseAsDefault() {
const tab = activeTab.value;
if (!tab || !tab.connectionId || !tab.database) return;
await connectionStore.setDefaultDatabase(tab.connectionId, tab.database);
}
async function clearActiveDefaultDatabase() {
const tab = activeTab.value;
if (!tab || !tab.connectionId) return;
await connectionStore.clearDefaultDatabase(tab.connectionId);
}
function changeActiveSchema(schema: string | undefined) {
const tab = activeTab.value;
if (tab) queryStore.updateSchema(tab.id, schema);
}
function openGitHub() {
openUrl("https://github.com/t8y2/dbx");
}
function openMcpGuide() {
openUrl("https://dbxio.com/cn/docs/mcp");
}
function setSidebarOpen(open: boolean) {
sidebarOpen.value = open;
safeLocalStorageSet("dbx-sidebar-open", open ? "true" : "false");
}
function ensureQueryTab(): string {
const tab = activeTab.value;
if (tab && tab.mode === "query") return tab.id;
const connId = connectionStore.activeConnectionId || connectionStore.connections[0]?.id || "";
const db = tab?.connectionId === connId ? tab.database : connectionStore.getConfig(connId)?.database || "";
return queryStore.createTab(connId, db, undefined, "query");
}
function onAiReplaceSql(sql: string) {
const tabId = ensureQueryTab();
queryStore.updateSql(tabId, sql);
}
function onAiExecuteSql(sql: string) {
const tabId = ensureQueryTab();
queryStore.updateSql(tabId, sql);
selectedSql.value = "";
nextTick(() => tryExecute(sql));
}
function onAiRequestAutoExecuteSql(sql: string) {
const tabId = ensureQueryTab();
queryStore.updateSql(tabId, sql);
selectedSql.value = "";
const decision = classifyAiSqlExecution(sql, activeConnection.value);
if (decision.action === "block") {
toast(t("ai.autoSqlBlocked"), 5000);
return;
}
nextTick(() => {
if (decision.action === "auto_execute") {
void doExecute(sql);
return;
}
dangerSql.value = sql;
pendingDangerSql.value = sql;
showDangerDialog.value = true;
});
}
function onAiOpenExplainPlan(sql: string) {
const tabId = ensureQueryTab();
queryStore.updateSql(tabId, sql);
selectedSql.value = "";
nextTick(() => {
void tryExplain(sql);
});
}
function handleKeydown(e: KeyboardEvent) {
if (e.defaultPrevented) return;
const shortcuts = settingsStore.editorSettings.shortcuts;
if (isOpenSettingsShortcut(e, shortcuts)) {
e.preventDefault();
e.stopPropagation();
showSettingsDialog.value = true;
return;
}
if (isFocusSearchShortcut(e, shortcuts)) {
const focused = contentAreaRef.value?.focusSearch() || appSidebarRef.value?.focusSearch();
if (focused) {
e.preventDefault();
e.stopPropagation();
}
return;
}
if (isRefreshDataShortcut(e, shortcuts)) {
e.preventDefault();
e.stopPropagation();
contentAreaRef.value?.refreshData();
return;
}
if (isNewQueryShortcut(e, shortcuts)) {
e.preventDefault();
e.stopPropagation();
void newQuery();
return;
}
if (isToggleSidebarShortcut(e, shortcuts)) {
e.preventDefault();
e.stopPropagation();
setSidebarOpen(!sidebarOpen.value);
return;
}
if (isCloseTabShortcut(e, shortcuts)) {
e.preventDefault();
if (showDriverStore.value) {
showDriverStore.value = false;
} else if (queryStore.activeTabId) {
queryStore.closeTab(queryStore.activeTabId);
}
return;
}
if (isSaveShortcut(e, shortcuts) && e.target instanceof Element && isObjectSourceSaveShortcutTarget(e.target)) {
return;
}
if (activeTab.value?.mode === "query" && !showSaveSqlDialog.value && isSaveShortcut(e, shortcuts)) {
e.preventDefault();
e.stopPropagation();
void openSaveSqlDialog();
return;
}
if (activeTab.value?.mode === "query" && isExecuteSqlShortcut(e, shortcuts) && e.target instanceof Element && e.target.closest("[data-query-editor-root]")) {
e.preventDefault();
e.stopPropagation();
tryExecute();
return;
}
if (isModRShortcut(e) && e.target instanceof Element && contentAreaRef.value?.handleModRTarget(e.target)) {
e.preventDefault();
e.stopPropagation();
return;
}
if (isDesktop && isGlobalUiZoomTarget(e.target)) {
if (isZoomInShortcut(e, shortcuts)) {
e.preventDefault();
e.stopPropagation();
zoomInUi();
return;
}
if (isZoomOutShortcut(e, shortcuts)) {
e.preventDefault();
e.stopPropagation();
zoomOutUi();
return;
}
if (isResetZoomShortcut(e, shortcuts)) {
e.preventDefault();
e.stopPropagation();
resetUiZoom();
return;
}
}
if (isDesktop && isBrowserReloadShortcut(e)) {
e.preventDefault();
e.stopPropagation();
}
}
function onLoginSuccess() {
authenticated.value = true;
setupRequired.value = false;
needsAuth.value = true;
window.history.replaceState(null, "", "/");
initApp();
}
function initApp() {
const t0 = performance.now();
console.log("[STARTUP] initApp begin");
settingsStore
.initDesktopSettings()
.catch(() => {})
.then(() => savedSqlStore.initFromStorage())
.then(() => {
console.log(`[STARTUP] savedSqlStore.initFromStorage: ${(performance.now() - t0).toFixed(0)}ms`);
return connectionStore.initFromDisk();
})
.then(() => {
console.log(`[STARTUP] connectionStore.initFromDisk: ${(performance.now() - t0).toFixed(0)}ms`);
restoreActiveConnectionContext();
})
.catch((e: any) => {
toast(t("connection.loadFailed", { message: e?.message || String(e) }), 5000);
});
settingsStore.initAiConfig();
}
function restoreActiveConnectionContext() {
const activeConnectionId = activeTab.value?.connectionId || connectionStore.activeConnectionId;
if (activeConnectionId && connectionStore.getConfig(activeConnectionId)) {
connectionStore.activeConnectionId = activeConnectionId;
}
}
function handleContextMenu(e: MouseEvent) {
const target = e.target as HTMLElement;
// Check if target is a standard editable input element
if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) {
if (import.meta.env.DEV) {
console.debug("[contextmenu] Allowing for input/textarea:", target);
}
return;
}
// Check if target or any parent has contenteditable attribute
if (target.isContentEditable || target.closest("[contenteditable]")) {
if (import.meta.env.DEV) {
console.debug("[contextmenu] Allowing for contenteditable:", target);
}
return;
}
// Check if target is within a custom context menu container or collection item
if (target.closest("[data-reka-collection-item], [data-radix-vue-collection-item], [data-context-menu]")) {
if (import.meta.env.DEV) {
console.debug("[contextmenu] Allowing for custom context menu container:", target);
}
return;
}
// Prevent default context menu for all other elements
if (import.meta.env.DEV) {
console.debug("[contextmenu] Preventing default for:", target);
}
e.preventDefault();
}
function openDriverStoreFromEvent() {
showDriverStore.value = true;
}
function runUpdateNotificationChecks() {
if (!updateNotificationsEnabled.value) return;
checkUpdates({ silent: true });
void refreshAgentDriverUpdateCount();
}
watch(updateNotificationsEnabled, (enabled) => {
if (!enabled) {
agentDriverUpdateCount.value = 0;
if (updateCheckTimer) {
clearInterval(updateCheckTimer);
updateCheckTimer = undefined;
}
return;
}
runUpdateNotificationChecks();
if (!updateCheckTimer) {
updateCheckTimer = setInterval(runUpdateNotificationChecks, UPDATE_CHECK_INTERVAL_MS);
}
});
onMounted(async () => {
console.log("[STARTUP] onMounted begin");
const mountStart = performance.now();
requestAnimationFrame(() => {
aiPanelReady.value = true;
});
applyTheme();
void applyUiScale(settingsStore.editorSettings.uiScale);
window.addEventListener("keydown", handleKeydown);
window.addEventListener("dbx-open-driver-store", openDriverStoreFromEvent);
if (isDesktop) {
document.addEventListener("contextmenu", handleContextMenu);
}
// macOS: Ctrl+click fires both click and contextmenu.
// Intercept click in capture phase to prevent unwanted navigation.
document.addEventListener(
"click",
(e) => {
if (e.ctrlKey) e.stopPropagation();
},
true,
);
if (!isDesktop) {
try {
const res = await fetch("/api/auth/check");
const data = await res.json();
needsAuth.value = data.required;
authenticated.value = data.authenticated;
setupRequired.value = data.setup_required;
} catch {
/* server unreachable */
}
if (needsAuth.value && !authenticated.value) {
history.replaceState(null, "", "/login");
}
if (!setupRequired.value && (!needsAuth.value || authenticated.value)) initApp();
api
.getAppVersion()
.then((v) => {
appVersion.value = v;
})
.catch(() => {});
return;
}
initApp();
setupFileDrop().catch(() => {});
setTimeout(() => {
runUpdateNotificationChecks();
if (updateNotificationsEnabled.value && !updateCheckTimer) {
updateCheckTimer = setInterval(runUpdateNotificationChecks, UPDATE_CHECK_INTERVAL_MS);
}
}, 10_000);
api
.getAppVersion()
.then((v) => {
appVersion.value = v;
})
.catch(() => {});
setupTauriListeners();
void openPendingSqlFiles();
void openPendingDbFiles();
void openPendingConnectionLinks();
console.log(`[STARTUP] onMounted sync done: ${(performance.now() - mountStart).toFixed(0)}ms`);
});
onUnmounted(() => {
cleanupTauriListeners();
if (updateCheckTimer) {
clearInterval(updateCheckTimer);
}
window.removeEventListener("keydown", handleKeydown);
window.removeEventListener("dbx-open-driver-store", openDriverStoreFromEvent);
document.removeEventListener("contextmenu", handleContextMenu);
});
</script>
<template>
<LoginPage v-if="setupRequired || (needsAuth && !authenticated)" :setup-mode="setupRequired" @authenticated="onLoginSuccess" />
<div v-show="!setupRequired && (!needsAuth || authenticated)" class="fixed inset-0 h-screen w-screen overflow-hidden">
<TooltipProvider :delay-duration="300">
<div class="h-screen w-screen max-w-full min-w-[760px] min-h-[600px] flex flex-col bg-background text-foreground overflow-hidden">
<AppToolbar
:is-dark="isDark"
:theme-mode="themeMode"
:show-ai-panel="showAiPanel"
:show-history="showHistory"
:show-sql-library="showSqlLibraryPanel"
:show-driver-store="showDriverStore"
:checking-updates="checkingUpdates"
:has-update-available="toolbarHasUpdateAvailable"
:agent-driver-update-count="toolbarAgentDriverUpdateCount"
:has-connections="connectionStore.connections.length > 0"
:has-sql-file-connections="hasSqlFileConnections"
@new-connection="showConnectionDialog = true"
@new-query="newQuery"
@set-theme-mode="setThemeMode"
@toggle-ai="toggleAiPanel"
@toggle-history="showHistory = !showHistory"
@toggle-sql-library="toggleSqlLibrary"
@open-github="openGitHub"
@open-settings="showSettingsDialog = true"
@open-driver-store="showDriverStore = !showDriverStore"
@check-updates="checkUpdates()"
@open-transfer="dialogs.showTransferDialog.value = true"
@open-sql-file="dialogs.showSqlFileDialog.value = true"
@open-schema-diff="dialogs.showSchemaDiffDialog.value = true"
@open-data-compare="dialogs.showDataCompareDialog.value = true"
/>
<div :class="isClassicLayout ? 'app-layout-classic flex-1 flex min-h-0' : 'app-panel-gutter flex-1 flex min-h-0 gap-1 p-1'">
<AppSidebar v-show="sidebarOpen" ref="appSidebarRef" :sidebar-width="sidebarWidth" :classic-layout="isClassicLayout" @import="dialogs.onImportClick" @export="dialogs.onExportClick" @start-resize="startSidebarResize" @collapse="setSidebarOpen(false)" />
<div v-show="!sidebarOpen" class="flex h-full w-8 shrink-0 items-start justify-center border-r bg-background/80 pt-2" :class="isClassicLayout ? '' : 'rounded-md border border-border/80'">
<Button variant="ghost" size="icon" class="h-7 w-7" :title="t('sidebar.expand')" :aria-label="t('sidebar.expand')" @click="setSidebarOpen(true)">
<ChevronsRight class="h-4 w-4" />
</Button>
</div>
<div :class="isClassicLayout ? 'flex-1 min-w-0 overflow-hidden' : 'flex-1 min-w-0 overflow-hidden rounded-md border border-border/80 bg-background'">
<div class="h-full flex flex-col min-w-0">
<AppTabBar :show-driver-store="showDriverStore" :agent-driver-update-count="toolbarAgentDriverUpdateCount" @toggle-driver-store="showDriverStore = true" @close-driver-store="showDriverStore = false" @save-tab="handleSaveTab" />
<DriverStorePage v-if="showDriverStore" class="flex-1 min-h-0" :update-notifications-enabled="updateNotificationsEnabled" @update-count-change="updateAgentDriverUpdateCount" />
<div v-else-if="activeTab" class="flex flex-col flex-1 min-h-0">
<EditorToolbar
v-if="activeTab.mode === 'query' && !isPreviewTab(activeTab)"
:active-tab="activeTab"
:active-connection="activeConnection"
:executable-sql="executableSql"
:explain-mode="explainMode"
:block-dangerous-redis-commands="blockDangerousRedisCommands"
@update:explain-mode="(m: 'explain' | 'autotrace') => (explainMode = m)"
@update:block-dangerous-redis-commands="(v: boolean) => (blockDangerousRedisCommands = v)"
@execute="tryExecute()"
@cancel="cancelActiveExecution()"
@explain="tryExplain()"
@format-sql="formatActiveSql"
@save-sql="void openSaveSqlDialog()"
@open-sql="openSqlFile"
@import-result-archive="importResultArchive"
@change-connection="changeActiveConnection"
@change-database="changeActiveDatabase"
@change-schema="changeActiveSchema"
@set-default-database="setActiveDatabaseAsDefault"
@clear-default-database="clearActiveDefaultDatabase"
/>
<KeepAlive :max="4">
<ContentArea
ref="contentAreaRef"
:key="activeTab.id"
:active-tab="activeTab"
:active-connection="activeConnection"
:executable-sql="executableSql"
:active-output-view="activeOutputView"
:format-sql-request="formatSqlRequest"
:selected-sql="selectedSql"
:cursor-pos="cursorPos"
@update:active-output-view="activeOutputView = $event"
@fix-with-ai="fixWithAi"
@execute="tryExecute()"
@cancel="cancelActiveExecution()"
@explain="tryExplain()"
@editor-update="(tabId: string, v: string) => queryStore.updateSql(tabId, v)"
@editor-selection-change="(v: string) => (selectedSql = v)"
@editor-cursor-change="(p: number) => (cursorPos = p)"
@editor-viewport-change="(tabId: string, viewport: { scrollTop: number; scrollLeft: number }) => queryStore.updateEditorViewport(tabId, viewport)"
@editor-selection-state-change="(tabId: string, selection: { anchor: number; head: number }) => queryStore.updateEditorSelection(tabId, selection)"
@format-error="toast(t('toolbar.formatSqlFailed'))"
@save-sql="void openSaveSqlDialog()"
@reload="(sql?: string, searchText?: string, whereInput?: string, orderBy?: string, limit?: number, offset?: number) => onReloadData(sql, searchText, whereInput, orderBy, limit, offset)"
@paginate="onPaginate"
@sort="onSort"
@execute-sql="onExecuteSql"
@click-table="onClickTable"
@open-object-table="
(target) =>
activeTab &&
openTableTarget({
connectionId: activeTab.connectionId,
database: activeTab.database,
schema: target.schema,
tableName: target.tableName,
})
"
@object-schema-change="(schema) => activeTab && queryStore.updateSchema(activeTab.id, schema)"
@structure-editor-saved="
(commentChanged) =>
activeTab &&
onStructureEditorSaved(
onReloadData,
toast,
{
connectionId: activeTab.connectionId,
database: activeTab.database,
schema: activeTab.schema,
tableName: activeTab.structureTableName || '',
},
commentChanged,
)
"
@structure-editor-close="activeTab && queryStore.closeTab(activeTab.id)"
/>
</KeepAlive>
</div>
<WelcomeScreen
v-else
:connection-stats="connectionStats"
:recent-connections="recentConnections"
:saved-sql-history-items="savedSqlHistoryItems"
:app-version="appVersion"
:has-connections="connectionStore.connections.length > 0"
@open-connection-query="openConnectionQuery"
@open-saved-sql="openSavedSqlFromWelcome"
@new-connection="showConnectionDialog = true"
@new-query="newQuery"
@show-history="showHistory = true"
@import-config="dialogs.onImportClick"
@open-github="openGitHub"
@open-mcp-guide="openMcpGuide"
/>
</div>
</div>
<div v-if="showAiPanel" :class="isClassicLayout ? 'h-full shrink-0 relative z-30 isolate bg-background' : 'h-full shrink-0 relative z-30 isolate rounded-md border border-border/80 bg-background'" :style="{ width: aiPanelWidth + 'px' }">
<div class="panel-resize-handle panel-resize-handle--left" @mousedown="startAiPanelResize" />
<div class="h-full min-h-0 overflow-hidden">
<AiAssistant v-if="aiPanelReady" ref="aiAssistantRef" :tab="activeTab" :connection="activeConnection" @replace-sql="onAiReplaceSql" @execute-sql="onAiExecuteSql" @request-auto-execute-sql="onAiRequestAutoExecuteSql" @open-explain-plan="onAiOpenExplainPlan" @close="toggleAiPanel" />
</div>
</div>
<div v-if="showHistory" :class="isClassicLayout ? 'h-full shrink-0 relative z-30 isolate bg-background' : 'h-full shrink-0 relative z-30 isolate rounded-md border border-border/80 bg-background'" :style="{ width: historyWidth + 'px' }">
<div class="panel-resize-handle panel-resize-handle--left" @mousedown="startHistoryResize" />
<QueryHistory @restore="restoreHistorySql" @analyze-ai="analyzeHistoryWithAi" @close="showHistory = false" />
</div>
<div v-if="showSqlLibraryPanel" :class="isClassicLayout ? 'h-full shrink-0 relative z-30 isolate bg-background' : 'h-full shrink-0 relative z-30 isolate rounded-md border border-border/80 bg-background'" :style="{ width: sqlLibraryWidth + 'px' }">
<div class="panel-resize-handle panel-resize-handle--left" @mousedown="startSqlLibraryResize" />
<div class="h-full min-h-0 overflow-hidden">
<SqlLibraryPanel @close="showSqlLibraryPanel = false" />
</div>
</div>
</div>
<AppDialogs
:show-connection-dialog="showConnectionDialog"
:connection-prefill="connectionDialogPrefill"
:show-settings-dialog="showSettingsDialog"
:app-version="appVersion"
:show-danger-dialog="showDangerDialog"
:danger-sql="dangerSql"
:suppress-danger-confirm="suppressDangerConfirm"
@update:show-connection-dialog="setConnectionDialogOpen"
@update:show-settings-dialog="showSettingsDialog = $event"
@update:show-danger-dialog="showDangerDialog = $event"
@update:suppress-danger-confirm="suppressDangerConfirm = $event"
@danger-confirm="onDangerConfirm"
@connect-started="(name: string) => toast(t('connection.connecting', { name }), 30000)"
@connect-succeeded="(name: string) => toast(t('connection.connectSuccess', { name }), 2000)"
@connect-failed="
(msg: string) =>
toast(
t('connection.connectFailed', {
message: translateBackendError(t, msg),
}),
5000,
)
"
@open-driver-store="
setConnectionDialogOpen(false);
showDriverStore = true;
"
@open-lineage-target="openLineageTarget"
@open-database-search-target="openDatabaseSearchTarget"
/>
<UpdateDialog
v-if="showUpdateDialog"
v-model:open="showUpdateDialog"
:update-info="updateInfo"
:update-check-message="updateCheckMessage"
:is-downloading-update="isDownloadingUpdate"
:download-progress="downloadProgress"
:update-ready="updateReady"
@open-latest-release="openLatestRelease"
@download-and-install="downloadAndInstallUpdate"
@restart="restartApp"
/>
</div>
<Teleport to="body">
<Transition name="toast">
<div v-if="toastVisible" class="fixed bottom-6 left-1/2 -translate-x-1/2 z-99999 px-4 py-2 rounded-lg bg-foreground text-background text-sm shadow-lg select-text">
{{ toastMessage }}
</div>
</Transition>
</Teleport>
<Dialog
:open="showSaveSqlDialog"
@update:open="
(open: boolean) => {
showSaveSqlDialog = open;
if (!open) pendingSaveAndCloseTabId = null;
}
"
>
<DialogContent class="sm:max-w-[420px]">
<DialogHeader>
<DialogTitle>{{ t("savedSql.saveToLibrary") }}</DialogTitle>
</DialogHeader>
<div class="space-y-3">
<div class="space-y-1.5">
<label class="text-xs font-medium text-muted-foreground">{{ t("savedSql.fileName") }}</label>
<Input v-model="saveSqlName" @keydown.enter.prevent="confirmSaveSqlToLibrary" />
</div>
<div class="space-y-1.5">
<label class="text-xs font-medium text-muted-foreground">{{ t("savedSql.folder") }}</label>
<Select v-model="saveSqlFolderId">
<SelectTrigger class="h-8 w-full">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
<SelectItem :value="ROOT_SAVED_SQL_FOLDER">{{ t("savedSql.rootFolder") }}</SelectItem>
<SelectItem v-for="folder in saveSqlFolders" :key="folder.id" :value="folder.id">
{{ folder.displayName }}
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
@click="
showSaveSqlDialog = false;
pendingSaveAndCloseTabId = null;
"
>{{ t("dangerDialog.cancel") }}</Button
>
<Button :disabled="!saveSqlName.trim()" @click="confirmSaveSqlToLibrary">{{ t("savedSql.save") }}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</TooltipProvider>
</div>
</template>
<style scoped>
.toast-enter-active,
.toast-leave-active {
transition: all 0.25s ease;
}
.toast-enter-from,
.toast-leave-to {
opacity: 0;
transform: translate(-50%, 8px);
}
</style>