dbx/apps/desktop/src/App.vue

1748 lines
67 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 DdlViewDialog from "@/components/objects/DdlViewDialog.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 { useCloseActionPrompt } from "@/composables/useCloseActionPrompt";
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, type SqlExecutionSnapshot } 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, ObjectSourceKind, QueryTab } from "@/types/database";
import { parseConnectionDeepLink, type ConnectionDeepLinkDraft } from "@/lib/connectionDeepLink";
import {
isBrowserReloadShortcut,
isCloseTabShortcut,
isExecuteSqlShortcut,
isFocusSearchShortcut,
isModRShortcut,
isNewQueryShortcut,
isObjectSourceSaveShortcutTarget,
isOpenSettingsShortcut,
isQuickOpenShortcut,
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, usesTreeSchemaMode } from "@/lib/databaseFeatureSupport";
import { codeMirrorSqlDialect, connectionUsesDatabaseObjectTreeMode, effectiveDatabaseTypeForConnection } from "@/lib/jdbcDialect";
import { detectDatabaseFileType } from "@/lib/databaseFileDetection";
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 CloseActionPromptDialog = defineAsyncComponent(() => import("@/components/layout/CloseActionPromptDialog.vue"));
const LoginPage = defineAsyncComponent(() => import("@/components/auth/LoginPage.vue"));
const QuickOpenDialog = defineAsyncComponent(() => import("@/components/quick-open/QuickOpenDialog.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 settingsInitialTab = ref("editor");
const settingsInitialSection = ref<string | undefined>(undefined);
const showQueryEditorDdlDialog = ref(false);
const driverStoreTabOpen = ref(false);
const driverStoreActive = ref(false);
const showDriverStore = computed(() => driverStoreTabOpen.value && driverStoreActive.value);
const showQuickOpen = 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 queryEditorDdlTarget = ref<{ connectionId: string; database: string; schema?: string; tableName: string } | null>(null);
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(snapshot?: SqlExecutionSnapshot) {
const tab = activeTab.value;
return tab
? await resolveExecutableSqlWithBackend(snapshot?.fullSql ?? tab.sql, snapshot?.selectedSql ?? selectedSql.value, {
mode: settingsStore.editorSettings.executeMode,
cursorPos: snapshot?.cursorPos ?? cursorPos.value,
databaseType: activeConnection.value?.db_type,
})
: "";
}
const blockDangerousRedisCommands = ref(true);
const databaseRequiredSignal = ref(0);
const databaseRequiredTabId = ref<string | null>(null);
function promptActiveDatabaseSelection() {
const tab = activeTab.value;
if (!tab) return;
databaseRequiredTabId.value = tab.id;
databaseRequiredSignal.value += 1;
toast(t("editor.selectDatabaseRequired"), 2500);
}
const { dangerSql, pendingDangerSql, showDangerDialog, suppressDangerConfirm, tryExecute, doExecute, cancelActiveExecution, tryExplain, onDangerConfirm, showSqlParameterDialog, sqlParameterSourceSql, sqlParameterNames, onSqlParametersConfirm, explainMode } = useSqlExecution({
activeTab,
activeConnection,
executableSql,
resolveExecutableSql: resolveActiveExecutableSql,
activeOutputView,
blockDangerousRedisCommands,
onMissingDatabase: promptActiveDatabaseSelection,
});
function requestActiveEditorExecute() {
if (contentAreaRef.value?.requestQueryEditorExecute?.()) return;
void tryExecute();
}
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,
});
const { showCloseActionPrompt, chooseQuit, chooseMinimize, setupCloseActionPromptListener, cleanupCloseActionPromptListener } = useCloseActionPrompt();
useVisibilityChange();
const appVersion = ref("");
const isClassicLayout = computed(() => settingsStore.editorSettings.appLayout === "classic");
const updateNotificationsEnabled = computed(() => settingsStore.editorSettings.updateNotificationsEnabled);
function openSettings(initialTab = "editor", initialSection?: string) {
settingsInitialTab.value = initialTab;
settingsInitialSection.value = initialSection;
showSettingsDialog.value = true;
}
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 queryEditorDdlDialect = computed(() => {
if (!queryEditorDdlTarget.value?.connectionId) return "mysql";
return codeMirrorSqlDialect(effectiveDatabaseTypeForConnection(connectionStore.getConfig(queryEditorDdlTarget.value.connectionId)));
});
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";
if (id && driverStoreActive.value) driverStoreActive.value = false;
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 toggleSqlKeywordCase() {
const sqlFormatter = settingsStore.editorSettings.sqlFormatter;
settingsStore.updateEditorSettings({
sqlFormatter: {
...sqlFormatter,
keywordCase: sqlFormatter.keywordCase === "lower" ? "upper" : "lower",
},
});
}
function defaultSavedSqlName(title: string) {
const trimmed = title.trim() || "query";
const normalized = trimmed.replace(/\s+/g, "_");
return normalized.endsWith(".sql") ? normalized : `${normalized}.sql`;
}
function canSaveSqlTab(tab: QueryTab): boolean {
return !!tab.externalSqlPath || !!tab.sql.trim();
}
function closePendingSavedTab() {
if (!pendingSaveAndCloseTabId.value) return;
const closeId = pendingSaveAndCloseTabId.value;
pendingSaveAndCloseTabId.value = null;
if (pendingPrevActiveTabId.value) queryStore.activeTabId = pendingPrevActiveTabId.value;
pendingPrevActiveTabId.value = null;
queryStore.closeTab(closeId, { force: true });
}
async function saveExternalSqlPath(tab: QueryTab, options: { closeAfterSave?: boolean } = {}): Promise<boolean> {
if (!tab.externalSqlPath || !isTauriRuntime()) return false;
try {
await api.writeExternalSqlFile(tab.externalSqlPath, tab.sql);
queryStore.markTabClean(tab);
toast(t("savedSql.saved"), 2000);
if (options.closeAfterSave) queryStore.closeTab(tab.id, { force: true });
return true;
} catch (e: any) {
toast(t("toolbar.sqlSaveFailed", { message: e?.message || String(e) }), 5000);
return true;
}
}
async function handleSaveTab(tabId: string) {
const tab = queryStore.tabs.find((t) => t.id === tabId);
if (!tab || !canSaveSqlTab(tab)) return;
if (tab.objectSource) {
const saved = await saveActiveObjectSource(tab);
if (saved) queryStore.closeTab(tabId, { force: true });
return;
}
if (await saveExternalSqlPath(tab, { closeAfterSave: true })) 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 || !canSaveSqlTab(tab)) return;
if (tab.objectSource) {
await saveActiveObjectSource(tab);
return;
}
if (await saveExternalSqlPath(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);
queryStore.markTabClean(tab);
toast(t("savedSql.saved"), 2000);
return;
}
saveSqlName.value = defaultSavedSqlName(tab.title);
saveSqlFolderId.value = ROOT_SAVED_SQL_FOLDER;
showSaveSqlDialog.value = true;
}
async function saveActiveObjectSource(tab: QueryTab): Promise<boolean> {
const connection = connectionStore.getConfig(tab.connectionId);
const source = tab.objectSource;
if (!connection || !source) return false;
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);
}
}
queryStore.markTabClean(tab);
toast(t("objects.sourceSaved"), 2000);
return true;
} catch (e: any) {
toast(t("objects.sourceSaveFailed", { message: e?.message || String(e) }), 5000);
return false;
}
}
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;
closePendingSavedTab();
toast(t("savedSql.saved"), 2000);
} catch (e: any) {
toast(t("savedSql.saveFailed", { message: e?.message || String(e) }), 5000);
}
}
async function saveActiveSqlAsLocalFile() {
const tab = activeTab.value;
if (!tab || !canSaveSqlTab(tab) || !isTauriRuntime()) return;
try {
const { save } = await import("@tauri-apps/plugin-dialog");
const path = await save({
defaultPath: defaultSavedSqlName(tab.title),
filters: [{ name: "SQL", extensions: ["sql"] }],
});
if (!path) return;
await api.writeExternalSqlFile(path, tab.sql);
queryStore.linkExternalSqlPath(tab.id, path, sqlFileTitleFromPath(path));
showSaveSqlDialog.value = false;
closePendingSavedTab();
toast(t("savedSql.saved"), 2000);
} catch (e: any) {
toast(t("toolbar.sqlSaveFailed", { 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 sqlPath = path as string;
const content = await api.readExternalSqlFile(sqlPath);
queryStore.updateSql(tab.id, content);
queryStore.linkExternalSqlPath(tab.id, sqlPath, sqlFileTitleFromPath(sqlPath));
}
} 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);
queryStore.linkExternalSqlPath(tabId, path, sqlFileTitleFromPath(path));
} 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 */
}
}
async function openDbFilePath(path: string) {
if (!isTauriRuntime()) return;
await connectionStore.initFromDisk();
try {
const name = path.split("/").pop()?.split("\\").pop() || path;
const dbType = await detectDatabaseFileType(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;
}
if (initialTarget.kind === "nacos-admin") {
try {
await connectionStore.ensureConnected(connectionId);
await connectionStore.loadNacosNamespaces(connectionId);
} catch (e: any) {
toast(
t("connection.connectFailed", {
message: translateBackendError(t, e?.message || String(e)),
}),
5000,
);
}
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);
}
function tableTargetFromActiveTab(tableName: string) {
const tab = activeTab.value;
if (!tab) return null;
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];
}
}
return { connectionId, database, schema, tableName: rawTableName };
}
async function onClickTable(tableName: string) {
const target = tableTargetFromActiveTab(tableName);
if (!target) return;
try {
await openTableTarget(target, { tableInfoTab: "ddl" });
} catch (e: any) {
toast(t("connection.connectFailed", { message: translateBackendError(t, e?.message || String(e)) }), 5000);
}
}
async function onViewTableData(tableName: string) {
const target = tableTargetFromActiveTab(tableName);
if (!target) return;
try {
await openTableTarget(target);
} catch (e: any) {
toast(t("connection.connectFailed", { message: translateBackendError(t, e?.message || String(e)) }), 5000);
}
}
function onViewTableDdl(tableName: string) {
const target = tableTargetFromActiveTab(tableName);
if (!target) return;
queryEditorDdlTarget.value = target;
showQueryEditorDdlDialog.value = true;
}
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);
if (databaseRequiredTabId.value === tab.id && database) {
databaseRequiredTabId.value = null;
}
}
}
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);
});
}
async function handleQuickOpenSelect(item: any) {
const connectionStore = useConnectionStore();
const queryStore = useQueryStore();
// For all types, set the active connection
connectionStore.activeConnectionId = item.connectionId;
// Ensure connection is connected
try {
await connectionStore.ensureConnected(item.connectionId);
} catch (error) {
console.error("Failed to connect:", error);
return;
}
// Navigate based on type
if (item.type === "connection") {
// Expand connection node in sidebar
// Tree node ID for connection is just the connectionId
const connNode = findTreeNodeById(connectionStore.treeNodes, item.connectionId);
if (connNode && !connNode.isExpanded) {
const config = connectionStore.getConfig(item.connectionId);
if (config?.db_type === "redis") {
await connectionStore.loadRedisDatabases(item.connectionId);
} else if (config?.db_type === "etcd") {
await connectionStore.loadEtcdRoot(item.connectionId);
} else if (config?.db_type === "zookeeper") {
await connectionStore.loadZooKeeperRoot(item.connectionId);
} else if (config?.db_type === "mongodb") {
await connectionStore.loadMongoDatabases(item.connectionId);
} else if (config?.db_type === "elasticsearch") {
await connectionStore.loadElasticsearchIndices(item.connectionId);
} else if (config?.db_type === "qdrant" || config?.db_type === "milvus" || config?.db_type === "weaviate" || config?.db_type === "chromadb") {
await connectionStore.loadVectorCollections(item.connectionId);
} else if (config?.db_type === "mq") {
await connectionStore.loadMqTenants(item.connectionId);
} else {
await connectionStore.loadDatabases(item.connectionId);
}
}
return;
} else if (item.type === "database") {
// Expand connection node first
// Tree node ID for connection is just the connectionId
const connNode = findTreeNodeById(connectionStore.treeNodes, item.connectionId);
if (connNode && !connNode.isExpanded) {
const config = connectionStore.getConfig(item.connectionId);
if (config?.db_type === "redis") {
await connectionStore.loadRedisDatabases(item.connectionId);
} else if (config?.db_type === "etcd") {
await connectionStore.loadEtcdRoot(item.connectionId);
} else if (config?.db_type === "zookeeper") {
await connectionStore.loadZooKeeperRoot(item.connectionId);
} else if (config?.db_type === "mongodb") {
await connectionStore.loadMongoDatabases(item.connectionId);
} else if (config?.db_type === "elasticsearch") {
await connectionStore.loadElasticsearchIndices(item.connectionId);
} else if (config?.db_type === "qdrant" || config?.db_type === "milvus" || config?.db_type === "weaviate" || config?.db_type === "chromadb") {
await connectionStore.loadVectorCollections(item.connectionId);
} else if (config?.db_type === "mq") {
await connectionStore.loadMqTenants(item.connectionId);
} else {
await connectionStore.loadDatabases(item.connectionId);
}
}
// Expand database node
// Tree node ID for database is `${connectionId}:${database_name}`
const dbNodeId = `${item.connectionId}:${item.database}`;
const dbNode = findTreeNodeById(connectionStore.treeNodes, dbNodeId);
if (dbNode && !dbNode.isExpanded) {
const config = connectionStore.getConfig(item.connectionId);
const effectiveDbType = effectiveDatabaseTypeForConnection(config);
if (config?.db_type === "sqlserver") {
await connectionStore.loadSqlServerDatabaseObjects(item.connectionId, item.database);
} else if (usesTreeSchemaMode(effectiveDbType) && !connectionUsesDatabaseObjectTreeMode(config)) {
await connectionStore.loadSchemas(item.connectionId, item.database);
} else {
await connectionStore.loadTables(item.connectionId, item.database);
}
}
return;
} else if (item.type === "table" || item.type === "view" || item.type === "materialized_view") {
// Open the table/view in a data tab
await openTableTarget({
connectionId: item.connectionId,
database: item.database,
schema: item.schema,
tableName: item.objectName || item.tableName,
});
} else if (item.type === "procedure" || item.type === "function" || item.type === "sequence" || item.type === "package" || item.type === "package-body") {
// Open the object source in a source tab
const objectTypeMap: Record<string, ObjectSourceKind> = {
procedure: "PROCEDURE",
function: "FUNCTION",
sequence: "SEQUENCE",
package: "PACKAGE",
"package-body": "PACKAGE_BODY",
};
const objectType = objectTypeMap[item.type];
if (!objectType) return;
const schema = item.schema || item.database;
try {
const result = await api.getObjectSource(item.connectionId, item.database, schema, item.objectName || item.tableName, objectType);
const tabId = queryStore.createTab(item.connectionId, item.database, `Source - ${item.objectName || item.tableName}`);
queryStore.updateSql(tabId, result.source);
if (item.type !== "sequence") {
queryStore.setObjectSource(tabId, {
schema,
name: item.objectName || item.tableName,
objectType,
});
}
queryStore.markTabClean(queryStore.tabs.find((tab) => tab.id === tabId));
} catch (error) {
toast((error as any)?.message || String(error), 5000);
}
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.defaultPrevented) return;
const shortcuts = settingsStore.editorSettings.shortcuts;
if (isOpenSettingsShortcut(e, shortcuts)) {
e.preventDefault();
e.stopPropagation();
openSettings();
return;
}
if (isQuickOpenShortcut(e, shortcuts)) {
e.preventDefault();
e.stopPropagation();
showQuickOpen.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) {
driverStoreTabOpen.value = false;
driverStoreActive.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();
requestActiveEditorExecute();
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() {
driverStoreTabOpen.value = true;
driverStoreActive.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();
setupCloseActionPromptListener();
void openPendingSqlFiles();
void openPendingDbFiles();
void openPendingConnectionLinks();
console.log(`[STARTUP] onMounted sync done: ${(performance.now() - mountStart).toFixed(0)}ms`);
});
onUnmounted(() => {
cleanupTauriListeners();
cleanupCloseActionPromptListener();
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="openSettings()"
@open-driver-store="
driverStoreTabOpen = true;
driverStoreActive = true;
"
@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
:driver-store-open="driverStoreTabOpen"
:driver-store-active="driverStoreActive"
:agent-driver-update-count="toolbarAgentDriverUpdateCount"
@activate-driver-store="
driverStoreTabOpen = true;
driverStoreActive = true;
"
@activate-tab="driverStoreActive = false"
@close-driver-store="
driverStoreTabOpen = false;
driverStoreActive = false;
"
@save-tab="handleSaveTab"
/>
<DriverStorePage v-if="driverStoreTabOpen" v-show="driverStoreActive" class="flex-1 min-h-0" :update-notifications-enabled="updateNotificationsEnabled" @update-count-change="updateAgentDriverUpdateCount" />
<div v-if="activeTab" v-show="!driverStoreActive" 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"
:sql-keyword-case="settingsStore.editorSettings.sqlFormatter.keywordCase"
:database-required-signal="databaseRequiredTabId === activeTab.id ? databaseRequiredSignal : 0"
@update:explain-mode="(m: 'explain' | 'autotrace') => (explainMode = m)"
@update:block-dangerous-redis-commands="(v: boolean) => (blockDangerousRedisCommands = v)"
@execute="requestActiveEditorExecute()"
@cancel="cancelActiveExecution()"
@explain="tryExplain()"
@format-sql="formatActiveSql"
@toggle-sql-keyword-case="toggleSqlKeywordCase"
@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($event)"
@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"
@view-table-data="onViewTableData"
@view-table-ddl="onViewTableDdl"
@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)"
@open-settings="openSettings"
/>
</KeepAlive>
</div>
<WelcomeScreen
v-else-if="!driverStoreActive"
: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="toggleSqlLibrary" />
</div>
</div>
</div>
<AppDialogs
:show-connection-dialog="showConnectionDialog"
:connection-prefill="connectionDialogPrefill"
:show-settings-dialog="showSettingsDialog"
:settings-initial-tab="settingsInitialTab"
:settings-initial-section="settingsInitialSection"
:app-version="appVersion"
:show-danger-dialog="showDangerDialog"
:danger-sql="dangerSql"
:suppress-danger-confirm="suppressDangerConfirm"
:show-sql-parameter-dialog="showSqlParameterDialog"
:sql-parameter-source-sql="sqlParameterSourceSql"
:sql-parameter-names="sqlParameterNames"
@update:show-connection-dialog="setConnectionDialogOpen"
@update:show-settings-dialog="showSettingsDialog = $event"
@update:show-danger-dialog="showDangerDialog = $event"
@update:suppress-danger-confirm="suppressDangerConfirm = $event"
@update:show-sql-parameter-dialog="showSqlParameterDialog = $event"
@danger-confirm="onDangerConfirm"
@sql-parameters-confirm="onSqlParametersConfirm"
@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);
driverStoreTabOpen = true;
driverStoreActive = 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"
/>
<CloseActionPromptDialog v-if="isDesktop && showCloseActionPrompt" v-model:open="showCloseActionPrompt" @quit="chooseQuit" @minimize="chooseMinimize" />
<QuickOpenDialog :open="showQuickOpen" @update:open="showQuickOpen = $event" @select="handleQuickOpenSelect" />
</div>
<Teleport to="body">
<Transition name="toast">
<div v-if="toastVisible" class="fixed bottom-6 inset-x-0 w-max max-w-[90vw] sm:max-w-3xl mx-auto z-99999 px-4 py-2 rounded-lg bg-foreground text-background text-sm shadow-lg select-text whitespace-pre-wrap break-words">
{{ 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 v-if="isDesktop" variant="secondary" @click="saveActiveSqlAsLocalFile">{{ t("savedSql.saveToFile") }}</Button>
<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>
<DdlViewDialog v-if="queryEditorDdlTarget" v-model:open="showQueryEditorDdlDialog" :connection-id="queryEditorDdlTarget.connectionId" :database="queryEditorDdlTarget.database" :schema="queryEditorDdlTarget.schema" :table-name="queryEditorDdlTarget.tableName" :dialect="queryEditorDdlDialect" />
</TooltipProvider>
</div>
</template>
<style scoped>
.toast-enter-active,
.toast-leave-active {
transition: 0.25s ease;
transition-property: transform, opacity;
}
.toast-enter-from,
.toast-leave-to {
opacity: 0;
transform: translateY(100%) scale(0.95);
}
</style>