diff --git a/src/App.vue b/src/App.vue index 9a669e01d..cd361157b 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,73 +1,52 @@ - + - - - - - {{ t('toolbar.newConnection') }} - + - - - {{ t('toolbar.newQuery') }} - - - - - {{ t('transfer.dataTransfer') }} - - - - - {{ t('sqlFile.title') }} - - - - - - - - - - - - {{ t('updates.check') }} - - - - - - - - - {{ t('history.title') }} - - - - - - - - - AI - - - - - - - - - - {{ isDark ? 'Light' : 'Dark' }} - - - - - - - - - {{ t('common.language') }} - - - - - - - - - GitHub - - - - - - - - - {{ t('settings.title') }} - - - - - - - - - {{ t('sidebar.connections') }} - - - - - - - - {{ t('sidebar.import') }} - - - - - - - - {{ t('sidebar.export') }} - - - - - - - - + - - - - - - - - - - - - - - {{ tabDisplayTitle(tab) }} - - - - - - - {{ tab.pinned ? t('contextMenu.unpin') : t('contextMenu.pin') }} - - - {{ tabModeLabel(tab) }} - - - - - - - - - {{ line.label }} - {{ line.value }} - - - - - - - - - {{ tab.pinned ? t('contextMenu.unpin') : t('contextMenu.pin') }} - - - - - {{ t('contextMenu.closeTab') }} - - - - {{ t('contextMenu.closeOtherTabs') }} - - - - {{ t('contextMenu.closeAllTabs') }} - - - + + + + { if (queryStore.activeTabId) queryStore.updateSql(queryStore.activeTabId, v) }" + @editor-selection-change="(v: string) => selectedSql = v" + @editor-cursor-change="(p: number) => cursorPos = p" + @format-error="toast(t('toolbar.formatSqlFailed'))" + @reload="onReloadData" @paginate="onPaginate" @sort="onSort" @execute-sql="onExecuteSql" + /> - - - - - - - - - - - - - - - - - - {{ activeTab.isExecuting ? t('toolbar.stopQuery') : t('toolbar.executeShortcut') }} - - - - - - - - - {{ activeTab.isExplaining ? t('toolbar.stopExplain') : t('toolbar.explainPlan') }} - - - - - - - - {{ t('toolbar.formatSql') }} - - - - - - - - - - - {{ connectionDisplayName(activeConnectionValue) }} - - - - - - - - {{ connection.name }} - - - - - - - - { if (open && activeConnection) loadDatabaseOptions(activeConnection.id).catch(() => {}) }" - > - - - {{ databaseDisplayName(activeDatabaseValue) }} - - - - - {{ databaseDisplayName(database) }} - - - {{ databaseDisplayName(activeDatabaseValue) }} - - - - - - - - {{ activeTab.tableMeta.columns.length }} {{ t('tree.columns') }} - - - - - - - - - - - - - - - {{ t('tabs.tableData') }} - - - - - {{ t('tabs.resultN', { n: rIdx + 1 }) }} - - - - - {{ t('explain.title') }} - - - - - - - - - - - {{ t('ai.fixWithAi') }} - - - - - - {{ t(queryExecutionLabelKey(activeTab)) }} - - - - {{ t('editor.pressToExecute') }} - - - - - - - - - - - - - - {{ t('tabs.tableData') }} - - {{ activeTab.tableMeta?.tableName || activeTab.title }} - - {{ databaseDisplayNameForTab(activeTab.connectionId, activeTab.database) }} - ยท {{ activeTab.tableMeta.schema }} - - - {{ activeTab.tableMeta.columns.length }} {{ t('tree.columns') }} - - - - - - - {{ t(queryExecutionLabelKey(activeTab)) }} - - - - - {{ t('toolbar.stopQuery') }} - - - - - - - - - - - - - - - - - - - - - - - - - - - {{ t('welcome.connections') }} - - {{ connectionStats.total }} - - - - {{ t('welcome.connected') }} - - {{ connectionStats.connected }} - - - - {{ t('welcome.databaseTypes') }} - - {{ connectionStats.types }} - - - - - - - {{ t('welcome.quickConnections') }} - - - - - - - {{ connection.name }} - - {{ connectionOptionSubtitle(connection) || connectionDriverLabel(connection) }} - - - - - - {{ t('sidebar.noConnections') }} - - - - - - - {{ t('welcome.shortcuts') }} - - - - {{ t('toolbar.newConnection') }} - - - {{ t('toolbar.newQuery') }} - - - {{ t('history.title') }} - - - {{ t('sidebar.import') }} - - - - {{ t('welcome.tip') }} - - - - - - - - - - - {{ t('welcome.mcpTitle') }} - {{ t('welcome.mcpDescription') }} - - npx @dbx-app/mcp-server - {{ t('welcome.mcpLearnMore') }} - - - - - - - - DBX {{ appVersion ? 'v' + appVersion : '' }} - ยท - GitHub - - - + - - - - - + + + + { const tab = activeTab; if (tab) queryStore.updateSql(tab.id, sql) }" + @execute-sql="tryExecute" @close="toggleAiPanel" + /> + + + + + + { if (queryStore.activeTabId) queryStore.updateSql(queryStore.activeTabId, sql) }" @close="showHistory = false" /> - - - - - - + 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: msg }), 5000)" + @structure-editor-saved="onStructureEditorSaved(onReloadData, toast)" + @open-lineage-target="openLineageTarget" + @open-database-search-target="openDatabaseSearchTarget" + /> + - - - - - - - - - - - - - - - - {{ t('configExport.importLayoutTitle') }} - - {{ t('configExport.importLayoutConfirm') }} - - {{ t('dangerDialog.cancel') }} - {{ t('configExport.importLayoutApply') }} - - - - - - - {{ updateInfo?.update_available ? t('updates.availableTitle') : t('updates.title') }} - - - - {{ t('updates.availableMessage', { current: updateInfo.current_version, latest: updateInfo.latest_version }) }} - - - {{ updateCheckMessage || t('updates.upToDate', { version: updateInfo?.current_version || '' }) }} - - - {{ updateInfo.release_notes }} - - - - {{ t('dangerDialog.cancel') }} - - {{ t('updates.openRelease') }} - {{ t('updates.restart') }} - - - {{ t('updates.downloading', { progress: downloadProgress }) }} - - {{ t('updates.downloadAndInstall') }} - - {{ t('updates.openRelease') }} - - - - - {{ toastMessage }} @@ -1910,11 +322,6 @@ async function setupFileDrop() { diff --git a/src/components/layout/AppDialogs.vue b/src/components/layout/AppDialogs.vue new file mode 100644 index 000000000..81843adbf --- /dev/null +++ b/src/components/layout/AppDialogs.vue @@ -0,0 +1,152 @@ + + + + + + + + + + + + + + + + + + + {{ t('configExport.importLayoutTitle') }} + + {{ t('configExport.importLayoutConfirm') }} + + {{ t('dangerDialog.cancel') }} + {{ t('configExport.importLayoutApply') }} + + + + diff --git a/src/components/layout/AppSidebar.vue b/src/components/layout/AppSidebar.vue new file mode 100644 index 000000000..f91c42704 --- /dev/null +++ b/src/components/layout/AppSidebar.vue @@ -0,0 +1,50 @@ + + + + + + + {{ t('sidebar.connections') }} + + + + + + + + {{ t('sidebar.import') }} + + + + + + + + {{ t('sidebar.export') }} + + + + + + + + + diff --git a/src/components/layout/AppTabBar.vue b/src/components/layout/AppTabBar.vue new file mode 100644 index 000000000..6d84a27e1 --- /dev/null +++ b/src/components/layout/AppTabBar.vue @@ -0,0 +1,144 @@ + + + + + + + + + + + + + + + {{ tabDisplayTitle(tab) }} + + + + + + + {{ tab.pinned ? t('contextMenu.unpin') : t('contextMenu.pin') }} + + + {{ tabModeLabel(tab) }} + + + + + + + + + {{ line.label }} + {{ line.value }} + + + + + + + + + {{ tab.pinned ? t('contextMenu.unpin') : t('contextMenu.pin') }} + + + + + {{ t('contextMenu.closeTab') }} + + + + {{ t('contextMenu.closeOtherTabs') }} + + + + {{ t('contextMenu.closeAllTabs') }} + + + + + + + + + diff --git a/src/components/layout/AppToolbar.vue b/src/components/layout/AppToolbar.vue new file mode 100644 index 000000000..f0ff1bb34 --- /dev/null +++ b/src/components/layout/AppToolbar.vue @@ -0,0 +1,122 @@ + + + + + + + {{ t('toolbar.newConnection') }} + + + + + {{ t('toolbar.newQuery') }} + + + + + {{ t('transfer.dataTransfer') }} + + + + + {{ t('sqlFile.title') }} + + + + + + + + + + + + {{ t('updates.check') }} + + + + + + + + + {{ t('history.title') }} + + + + + + + + + AI + + + + + + + + + + {{ isDark ? 'Light' : 'Dark' }} + + + + + + + + + {{ t('common.language') }} + + + + + + + + + GitHub + + + + + + + + + {{ t('settings.title') }} + + + diff --git a/src/components/layout/ContentArea.vue b/src/components/layout/ContentArea.vue new file mode 100644 index 000000000..41904506f --- /dev/null +++ b/src/components/layout/ContentArea.vue @@ -0,0 +1,241 @@ + + + + + + + + + + + + + + + + + {{ t('tabs.tableData') }} + + + + + {{ t('tabs.resultN', { n: rIdx + 1 }) }} + + + + + {{ t('explain.title') }} + + + + + + + + + + + {{ t('ai.fixWithAi') }} + + + + + + {{ t(queryExecutionLabelKey(activeTab)) }} + + + + {{ t('editor.pressToExecute') }} + + + + + + + + + + + + + + {{ t('tabs.tableData') }} + + {{ activeTab.tableMeta?.tableName || activeTab.title }} + + {{ databaseDisplayNameForTab(activeTab.connectionId, activeTab.database) }} + · {{ activeTab.tableMeta.schema }} + + + {{ activeTab.tableMeta.columns.length }} {{ t('tree.columns') }} + + + emit('paginate', offset, limit, whereInput)" + @sort="(column: string, direction: 'asc' | 'desc' | null, whereInput?: string) => emit('sort', column, direction, whereInput)" + /> + + + + {{ t(queryExecutionLabelKey(activeTab)) }} + + + + + {{ t('toolbar.stopQuery') }} + + + + + + + + + + + + + + + + + + + + diff --git a/src/components/layout/EditorToolbar.vue b/src/components/layout/EditorToolbar.vue new file mode 100644 index 000000000..90b83fa94 --- /dev/null +++ b/src/components/layout/EditorToolbar.vue @@ -0,0 +1,159 @@ + + + + + + + + + + + + + + {{ activeTab.isExecuting ? t('toolbar.stopQuery') : t('toolbar.executeShortcut') }} + + + + + + + + + {{ activeTab.isExplaining ? t('toolbar.stopExplain') : t('toolbar.explainPlan') }} + + + + + + + + {{ t('toolbar.formatSql') }} + + + + + + + emit('changeConnection', v)" + > + + + + {{ connectionDisplayName(activeConnectionValue) }} + + + + + + + + {{ connection.name }} + + + + + + + + emit('changeDatabase', v)" + @update:open="(open: boolean) => { if (open && activeConnection) loadDatabaseOptions(activeConnection.id).catch(() => {}) }" + > + + + {{ databaseDisplayName(activeDatabaseValue) }} + + + + + {{ databaseDisplayName(database) }} + + + {{ databaseDisplayName(activeDatabaseValue) }} + + + + + + + + {{ activeTab.tableMeta.columns.length }} {{ t('tree.columns') }} + + + diff --git a/src/components/layout/UpdateDialog.vue b/src/components/layout/UpdateDialog.vue new file mode 100644 index 000000000..e6bcd0b51 --- /dev/null +++ b/src/components/layout/UpdateDialog.vue @@ -0,0 +1,61 @@ + + + + + + + {{ updateInfo?.update_available ? t('updates.availableTitle') : t('updates.title') }} + + + + {{ t('updates.availableMessage', { current: updateInfo.current_version, latest: updateInfo.latest_version }) }} + + + {{ updateCheckMessage || t('updates.upToDate', { version: updateInfo?.current_version || '' }) }} + + + {{ updateInfo.release_notes }} + + + + {{ t('dangerDialog.cancel') }} + + {{ t('updates.openRelease') }} + {{ t('updates.restart') }} + + + {{ t('updates.downloading', { progress: downloadProgress }) }} + + {{ t('updates.downloadAndInstall') }} + + {{ t('updates.openRelease') }} + + + + diff --git a/src/components/layout/WelcomeScreen.vue b/src/components/layout/WelcomeScreen.vue new file mode 100644 index 000000000..3e7be65a8 --- /dev/null +++ b/src/components/layout/WelcomeScreen.vue @@ -0,0 +1,128 @@ + + + + + + + + + {{ t('welcome.connections') }} + + {{ connectionStats.total }} + + + + {{ t('welcome.connected') }} + + {{ connectionStats.connected }} + + + + {{ t('welcome.databaseTypes') }} + + {{ connectionStats.types }} + + + + + + + {{ t('welcome.quickConnections') }} + + + + + + + {{ connection.name }} + + {{ connectionOptionSubtitle(connection) || connectionDriverLabel(connection) }} + + + + + + {{ t('sidebar.noConnections') }} + + + + + + + {{ t('welcome.shortcuts') }} + + + + {{ t('toolbar.newConnection') }} + + + {{ t('toolbar.newQuery') }} + + + {{ t('history.title') }} + + + {{ t('sidebar.import') }} + + + + {{ t('welcome.tip') }} + + + + + + + + + + + {{ t('welcome.mcpTitle') }} + {{ t('welcome.mcpDescription') }} + + npx @dbx-app/mcp-server + {{ t('welcome.mcpLearnMore') }} + + + + + + + + DBX {{ appVersion ? 'v' + appVersion : '' }} + ยท + GitHub + + + + diff --git a/src/components/ui/dialog/DialogScrollContent.vue b/src/components/ui/dialog/DialogScrollContent.vue index f6b6dab6f..3f3d6f293 100644 --- a/src/components/ui/dialog/DialogScrollContent.vue +++ b/src/components/ui/dialog/DialogScrollContent.vue @@ -28,7 +28,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits) (null); + const updateCheckMessage = ref(""); + const showUpdateDialog = ref(false); + const isDownloadingUpdate = ref(false); + const downloadProgress = ref(0); + const updateReady = ref(false); + const latestReleaseUrl = "https://github.com/t8y2/dbx/releases/latest"; + + function openUrl(url: string) { + if (isTauriRuntime()) { + import("@tauri-apps/plugin-shell").then(({ open }) => open(url)); + } else { + window.open(url, "_blank"); + } + } + + async function checkUpdates(options: { silent?: boolean } = {}) { + if (checkingUpdates.value) return; + checkingUpdates.value = true; + updateCheckMessage.value = ""; + try { + const info = await api.checkForUpdates(); + updateInfo.value = info; + if (info.update_available) { + showUpdateDialog.value = true; + } else if (!options.silent) { + updateCheckMessage.value = t("updates.upToDate", { version: info.current_version }); + showUpdateDialog.value = true; + } + } catch (e: any) { + if (!options.silent) { + updateCheckMessage.value = formatUpdateError(String(e)); + showUpdateDialog.value = true; + } + } finally { + checkingUpdates.value = false; + } + } + + function formatUpdateError(message: string): string { + const lower = message.toLowerCase(); + if (lower.includes("403") || lower.includes("rate limit")) { + return t("updates.rateLimited"); + } + return t("updates.failed", { error: message }); + } + + function openLatestRelease() { + const url = updateInfo.value?.release_url || latestReleaseUrl; + openUrl(url); + } + + async function downloadAndInstallUpdate() { + if (!isTauriRuntime() || isDownloadingUpdate.value) return; + isDownloadingUpdate.value = true; + downloadProgress.value = 0; + try { + const { check } = await import("@tauri-apps/plugin-updater"); + const update = await check(); + if (!update) return; + let totalBytes = 0; + let downloadedBytes = 0; + await update.downloadAndInstall((event) => { + if (event.event === "Started" && event.data.contentLength) { + totalBytes = event.data.contentLength; + } else if (event.event === "Progress") { + downloadedBytes += event.data.chunkLength; + downloadProgress.value = totalBytes > 0 ? Math.round((downloadedBytes / totalBytes) * 100) : 0; + } else if (event.event === "Finished") { + downloadProgress.value = 100; + } + }); + updateReady.value = true; + } catch (e: any) { + toast(t("updates.downloadFailed", { error: e?.message || String(e) }), 5000); + } finally { + isDownloadingUpdate.value = false; + } + } + + async function restartApp() { + if (!isTauriRuntime()) return; + const { relaunch } = await import("@tauri-apps/plugin-process"); + await relaunch(); + } + + return { + checkingUpdates, + updateInfo, + updateCheckMessage, + showUpdateDialog, + isDownloadingUpdate, + downloadProgress, + updateReady, + latestReleaseUrl, + openUrl, + checkUpdates, + formatUpdateError, + openLatestRelease, + downloadAndInstallUpdate, + restartApp, + }; +} diff --git a/src/composables/useDataGridActions.ts b/src/composables/useDataGridActions.ts new file mode 100644 index 000000000..c8e094b50 --- /dev/null +++ b/src/composables/useDataGridActions.ts @@ -0,0 +1,68 @@ +import { type ComputedRef } from "vue"; +import { useConnectionStore } from "@/stores/connectionStore"; +import { useQueryStore } from "@/stores/queryStore"; +import { buildTableSelectSql, quoteTableIdentifier } from "@/lib/tableSelectSql"; +import type { QueryTab } from "@/types/database"; + +export function useDataGridActions(activeTab: ComputedRef) { + const connectionStore = useConnectionStore(); + const queryStore = useQueryStore(); + + function quoteIdent(tab: QueryTab, name: string): string { + const config = connectionStore.getConfig(tab.connectionId); + return quoteTableIdentifier(config?.db_type, name); + } + + function buildTableSql( + tab: QueryTab, + options: { orderBy?: string; limit?: number; offset?: number; whereInput?: string } = {}, + ): string { + const config = connectionStore.getConfig(tab.connectionId); + const fallbackOrderColumns = config?.db_type === "sqlserver" && !tab.tableMeta?.primaryKeys?.length + ? tab.tableMeta?.columns.slice(0, 1).map((column) => column.name) + : undefined; + return buildTableSelectSql({ + databaseType: config?.db_type, + schema: tab.tableMeta?.schema, + tableName: tab.tableMeta?.tableName ?? "", + primaryKeys: tab.tableMeta?.primaryKeys, + fallbackOrderColumns, + ...options, + }); + } + + async function onExecuteSql(sql: string) { + const tab = activeTab.value; + if (!tab) return; + queryStore.updateSql(tab.id, sql); + await queryStore.executeTabSql(tab.id, sql); + } + + async function onReloadData() { + const tab = activeTab.value; + if (!tab) return; + if (tab.mode === "data" && tab.tableMeta) { + queryStore.updateSql(tab.id, buildTableSql(tab)); + } + queryStore.executeCurrentTab(); + } + + async function onPaginate(offset: number, limit: number, whereInput?: string) { + const tab = activeTab.value; + if (!tab?.tableMeta) return; + const sql = buildTableSql(tab, { limit, offset, whereInput }); + queryStore.updateSql(tab.id, sql); + await queryStore.executeCurrentTab(); + } + + async function onSort(column: string, direction: "asc" | "desc" | null, whereInput?: string) { + const tab = activeTab.value; + if (!tab?.tableMeta) return; + const orderBy = direction ? `${quoteIdent(tab, column)} ${direction.toUpperCase()}` : undefined; + const sql = buildTableSql(tab, { orderBy, whereInput }); + queryStore.updateSql(tab.id, sql); + await queryStore.executeCurrentTab(); + } + + return { onExecuteSql, onReloadData, onPaginate, onSort }; +} diff --git a/src/composables/useDatabaseOptions.ts b/src/composables/useDatabaseOptions.ts new file mode 100644 index 000000000..3bf4f1b4c --- /dev/null +++ b/src/composables/useDatabaseOptions.ts @@ -0,0 +1,40 @@ +import { ref } from "vue"; +import { useConnectionStore } from "@/stores/connectionStore"; +import * as api from "@/lib/api"; + +export function useDatabaseOptions() { + const connectionStore = useConnectionStore(); + + const databaseOptions = ref>({}); + const loadingDatabaseOptions = ref>({}); + + async function loadDatabaseOptions(connectionId: string) { + const connection = connectionStore.getConfig(connectionId); + if (!connection || loadingDatabaseOptions.value[connectionId]) return; + + loadingDatabaseOptions.value[connectionId] = true; + try { + await connectionStore.ensureConnected(connectionId); + if (connection.db_type === "redis") { + const dbs = await api.redisListDatabases(connectionId); + databaseOptions.value[connectionId] = dbs.map(String); + } else if (connection.db_type === "mongodb") { + databaseOptions.value[connectionId] = await api.mongoListDatabases(connectionId); + } else { + const dbs = await api.listDatabases(connectionId); + databaseOptions.value[connectionId] = dbs.map((db) => db.name); + } + } finally { + loadingDatabaseOptions.value[connectionId] = false; + } + } + + async function getDatabaseOptions(connectionId: string): Promise { + if (!databaseOptions.value[connectionId]) { + await loadDatabaseOptions(connectionId); + } + return databaseOptions.value[connectionId] ?? []; + } + + return { databaseOptions, loadingDatabaseOptions, loadDatabaseOptions, getDatabaseOptions }; +} diff --git a/src/composables/useDialogSources.ts b/src/composables/useDialogSources.ts new file mode 100644 index 000000000..24031487f --- /dev/null +++ b/src/composables/useDialogSources.ts @@ -0,0 +1,242 @@ +import { ref, watch } from "vue"; +import { useI18n } from "vue-i18n"; +import { useConnectionStore } from "@/stores/connectionStore"; +import { useToast } from "@/composables/useToast"; +import type { SidebarLayout } from "@/types/database"; + +const showTransferDialog = ref(false); +const showSchemaDiffDialog = ref(false); +const showSqlFileDialog = ref(false); +const showDiagramDialog = ref(false); +const showTableImportDialog = ref(false); +const showStructureEditorDialog = ref(false); +const showFieldLineageDialog = ref(false); +const showDatabaseSearchDialog = ref(false); +const showImportLayoutConfirm = ref(false); +const pendingImportLayout = ref(null); +const showConfigPassphraseDialog = ref(false); +const configPassphraseMode = ref<"export" | "import">("export"); +const configPassphraseError = ref(""); +const pendingImportContent = ref(""); + +const transferPrefillConnectionId = ref(""); +const transferPrefillDatabase = ref(""); +const schemaDiffPrefillConnectionId = ref(""); +const schemaDiffPrefillDatabase = ref(""); +const sqlFilePrefillConnectionId = ref(""); +const sqlFilePrefillDatabase = ref(""); +const diagramPrefillConnectionId = ref(""); +const diagramPrefillDatabase = ref(""); +const diagramPrefillSchema = ref(""); +const diagramFocusTableName = ref(""); +const tableImportPrefillConnectionId = ref(""); +const tableImportPrefillDatabase = ref(""); +const tableImportPrefillSchema = ref(""); +const tableImportPrefillTable = ref(""); +const structurePrefillConnectionId = ref(""); +const structurePrefillDatabase = ref(""); +const structurePrefillSchema = ref(""); +const structurePrefillTable = ref(""); +const lineagePrefillConnectionId = ref(""); +const lineagePrefillDatabase = ref(""); +const lineagePrefillSchema = ref(""); +const lineagePrefillTable = ref(""); +const lineagePrefillColumn = ref(""); +const databaseSearchPrefillConnectionId = ref(""); +const databaseSearchPrefillDatabase = ref(""); +const databaseSearchPrefillSchema = ref(""); + +let watchersRegistered = false; + +export function useDialogSources() { + const { t } = useI18n(); + const connectionStore = useConnectionStore(); + const { toast } = useToast(); + + // Watchers for store source triggers (register only once) + if (!watchersRegistered) { + watchersRegistered = true; + + watch(() => connectionStore.transferSource, (v) => { + if (v) { + transferPrefillConnectionId.value = v.connectionId; + transferPrefillDatabase.value = v.database; + showTransferDialog.value = true; + connectionStore.transferSource = null; + } + }); + + watch(() => connectionStore.schemaDiffSource, (v) => { + if (v) { + schemaDiffPrefillConnectionId.value = v.connectionId; + schemaDiffPrefillDatabase.value = v.database; + showSchemaDiffDialog.value = true; + connectionStore.schemaDiffSource = null; + } + }); + + watch(() => connectionStore.sqlFileSource, (v) => { + if (v) { + sqlFilePrefillConnectionId.value = v.connectionId; + sqlFilePrefillDatabase.value = v.database; + showSqlFileDialog.value = true; + connectionStore.sqlFileSource = null; + } + }); + + watch(() => connectionStore.diagramSource, (v) => { + if (v) { + diagramPrefillConnectionId.value = v.connectionId; + diagramPrefillDatabase.value = v.database; + diagramPrefillSchema.value = v.schema ?? ""; + diagramFocusTableName.value = v.tableName ?? ""; + showDiagramDialog.value = true; + connectionStore.diagramSource = null; + } + }); + + watch(() => connectionStore.tableImportSource, (v) => { + if (v) { + tableImportPrefillConnectionId.value = v.connectionId; + tableImportPrefillDatabase.value = v.database; + tableImportPrefillSchema.value = v.schema ?? ""; + tableImportPrefillTable.value = v.tableName; + showTableImportDialog.value = true; + connectionStore.tableImportSource = null; + } + }); + + watch(() => connectionStore.structureEditorSource, (v) => { + if (v) { + structurePrefillConnectionId.value = v.connectionId; + structurePrefillDatabase.value = v.database; + structurePrefillSchema.value = v.schema ?? ""; + structurePrefillTable.value = v.tableName; + showStructureEditorDialog.value = true; + connectionStore.structureEditorSource = null; + } + }); + + watch(() => connectionStore.fieldLineageSource, (v) => { + if (v) { + lineagePrefillConnectionId.value = v.connectionId; + lineagePrefillDatabase.value = v.database; + lineagePrefillSchema.value = v.schema ?? ""; + lineagePrefillTable.value = v.tableName; + lineagePrefillColumn.value = v.columnName; + showFieldLineageDialog.value = true; + connectionStore.fieldLineageSource = null; + } + }); + + watch(() => connectionStore.databaseSearchSource, (v) => { + if (v) { + databaseSearchPrefillConnectionId.value = v.connectionId; + databaseSearchPrefillDatabase.value = v.database; + databaseSearchPrefillSchema.value = v.schema ?? ""; + showDatabaseSearchDialog.value = true; + connectionStore.databaseSearchSource = null; + } + }); + } // end watchersRegistered + + // Config export/import helpers + function onExportClick() { + configPassphraseMode.value = "export"; + configPassphraseError.value = ""; + showConfigPassphraseDialog.value = true; + } + + async function onExportConfirm(passphrase: string) { + try { + await connectionStore.exportConnectionsToFile(passphrase); + showConfigPassphraseDialog.value = false; + toast(t("configExport.exportSuccess"), 2000); + } catch (e: any) { + configPassphraseError.value = e?.message || String(e); + } + } + + async function onImportClick() { + try { + const result = await connectionStore.readImportFile(); + if (!result) return; + pendingImportContent.value = result.content; + if (result.encrypted) { + configPassphraseMode.value = "import"; + configPassphraseError.value = ""; + showConfigPassphraseDialog.value = true; + } else { + const { count, layout } = await connectionStore.importConnectionsFromFile(result.content, null); + toast(count > 0 ? t("configExport.importSuccess", { count }) : t("configExport.importNone"), 2000); + if (layout && count > 0) { + pendingImportLayout.value = layout; + showImportLayoutConfirm.value = true; + } + } + } catch (e: any) { + toast(e?.message || String(e), 4000); + } + } + + async function onImportConfirm(passphrase: string) { + try { + const { count, layout } = await connectionStore.importConnectionsFromFile(pendingImportContent.value, passphrase); + showConfigPassphraseDialog.value = false; + toast(count > 0 ? t("configExport.importSuccess", { count }) : t("configExport.importNone"), 2000); + if (layout && count > 0) { + pendingImportLayout.value = layout; + showImportLayoutConfirm.value = true; + } + } catch (e: any) { + configPassphraseError.value = e?.message === "wrong_passphrase" ? t("configExport.wrongPassphrase") : (e?.message || String(e)); + } + } + + return { + showTransferDialog, + showSchemaDiffDialog, + showSqlFileDialog, + showDiagramDialog, + showTableImportDialog, + showStructureEditorDialog, + showFieldLineageDialog, + showDatabaseSearchDialog, + showImportLayoutConfirm, + pendingImportLayout, + showConfigPassphraseDialog, + configPassphraseMode, + configPassphraseError, + pendingImportContent, + transferPrefillConnectionId, + transferPrefillDatabase, + schemaDiffPrefillConnectionId, + schemaDiffPrefillDatabase, + sqlFilePrefillConnectionId, + sqlFilePrefillDatabase, + diagramPrefillConnectionId, + diagramPrefillDatabase, + diagramPrefillSchema, + diagramFocusTableName, + tableImportPrefillConnectionId, + tableImportPrefillDatabase, + tableImportPrefillSchema, + tableImportPrefillTable, + structurePrefillConnectionId, + structurePrefillDatabase, + structurePrefillSchema, + structurePrefillTable, + lineagePrefillConnectionId, + lineagePrefillDatabase, + lineagePrefillSchema, + lineagePrefillTable, + lineagePrefillColumn, + databaseSearchPrefillConnectionId, + databaseSearchPrefillDatabase, + databaseSearchPrefillSchema, + onExportClick, + onExportConfirm, + onImportClick, + onImportConfirm, + }; +} diff --git a/src/composables/useFileDrop.ts b/src/composables/useFileDrop.ts new file mode 100644 index 000000000..f268348b8 --- /dev/null +++ b/src/composables/useFileDrop.ts @@ -0,0 +1,90 @@ +import { useI18n } from "vue-i18n"; +import { useConnectionStore } from "@/stores/connectionStore"; +import { useQueryStore } from "@/stores/queryStore"; +import { useToast } from "@/composables/useToast"; +import * as api from "@/lib/api"; +import type { ConnectionConfig } from "@/types/database"; + +const DB_EXTENSIONS = [".db", ".sqlite", ".sqlite3", ".duckdb"]; + +function getDbType(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; +} + +function getDataFileQuery(path: string): string | null { + const lower = path.toLowerCase(); + const escaped = path.replace(/'/g, "''"); + if (lower.endsWith(".parquet")) return `SELECT * FROM read_parquet('${escaped}') LIMIT 1000`; + if (lower.endsWith(".csv")) return `SELECT * FROM read_csv('${escaped}') LIMIT 1000`; + if (lower.endsWith(".tsv")) return `SELECT * FROM read_csv('${escaped}', delim='\\t') LIMIT 1000`; + if (lower.endsWith(".json")) return `SELECT * FROM read_json('${escaped}') LIMIT 1000`; + return null; +} + +export function useFileDrop() { + const { t } = useI18n(); + const connectionStore = useConnectionStore(); + const queryStore = useQueryStore(); + const { toast } = useToast(); + + async function setupFileDrop() { + const { getCurrentWebview } = await import("@tauri-apps/api/webview"); + const webview = getCurrentWebview(); + await webview.onDragDropEvent(async (event) => { + if (event.payload.type !== "drop") return; + for (const path of event.payload.paths) { + const name = path.split("/").pop()?.split("\\").pop() || path; + + const dataQuery = getDataFileQuery(path); + if (dataQuery) { + const config: ConnectionConfig = { + id: crypto.randomUUID(), + name: `[Preview] ${name}`, + db_type: "duckdb", + driver_profile: "duckdb", + driver_label: "DuckDB", + url_params: "", + host: ":memory:", + port: 0, + username: "", + password: "", + }; + const connectionId = await api.connectDb(config); + connectionStore.addEphemeralConnection({ ...config, id: connectionId }); + const tabId = queryStore.createTab(connectionId, "", name, "query"); + queryStore.updateSql(tabId, dataQuery); + queryStore.executeCurrentTab(); + toast(t("welcome.fileOpened", { name })); + continue; + } + + const dbType = getDbType(path); + if (!dbType) continue; + const config: ConnectionConfig = { + id: crypto.randomUUID(), + name, + db_type: dbType, + driver_profile: dbType, + driver_label: dbType === "duckdb" ? "DuckDB" : "SQLite", + url_params: "", + host: path, + port: 0, + username: "", + password: "", + }; + try { + await connectionStore.addConnection(config); + void connectionStore.connect(config); + toast(t("welcome.fileOpened", { name })); + } catch (e: any) { + toast(t("connection.saveFailed", { message: e?.message || String(e) }), 5000); + } + } + }); + } + + return { setupFileDrop }; +} diff --git a/src/composables/useNavigationTargets.ts b/src/composables/useNavigationTargets.ts new file mode 100644 index 000000000..fbdc482ec --- /dev/null +++ b/src/composables/useNavigationTargets.ts @@ -0,0 +1,84 @@ +import * as api from "@/lib/api"; +import { buildTableSelectSql } from "@/lib/tableSelectSql"; +import { useConnectionStore } from "@/stores/connectionStore"; +import { useQueryStore } from "@/stores/queryStore"; + +export type NavigationTarget = { + connectionId: string; + database: string; + schema?: string; + tableName: string; + columnName?: string; + whereInput?: string; +}; + +async function openTableTarget(target: NavigationTarget) { + const connectionStore = useConnectionStore(); + const queryStore = useQueryStore(); + + connectionStore.activeConnectionId = target.connectionId; + const config = connectionStore.getConfig(target.connectionId); + const tabTitle = target.schema ? `${target.schema}.${target.tableName}` : target.tableName; + const tabId = queryStore.createTab(target.connectionId, target.database, tabTitle, "data"); + queryStore.setExecuting(tabId, true); + + try { + await connectionStore.ensureConnected(target.connectionId); + if (!config) throw new Error("Connection config not found"); + const querySchema = target.schema || target.database; + const columns = await api.getColumns(target.connectionId, target.database, querySchema, target.tableName); + const primaryKeys = columns.filter((c) => c.is_primary_key).map((c) => c.name); + const sql = buildTableSelectSql({ + databaseType: config.db_type, + schema: target.schema, + tableName: target.tableName, + primaryKeys, + whereInput: target.whereInput, + }); + queryStore.updateSql(tabId, sql); + queryStore.setTableMeta(tabId, { schema: target.schema, tableName: target.tableName, columns, primaryKeys }); + await queryStore.executeTabSql(tabId, sql); + } catch (e: any) { + queryStore.setErrorResult(tabId, e); + } +} + +export function useNavigationTargets(dialogs: { + showFieldLineageDialog: { value: boolean }; + showDatabaseSearchDialog: { value: boolean }; + structurePrefillTable: { value: string }; +}) { + const queryStore = useQueryStore(); + + async function openLineageTarget(target: NavigationTarget) { + dialogs.showFieldLineageDialog.value = false; + await openTableTarget(target); + } + + async function openDatabaseSearchTarget(target: NavigationTarget) { + dialogs.showDatabaseSearchDialog.value = false; + await openTableTarget(target); + } + + async function onStructureEditorSaved(reloadData: () => Promise, toast: (msg: string, duration?: number) => void) { + const activeTab = queryStore.tabs.find((t) => t.id === queryStore.activeTabId); + if (activeTab?.mode === "data" && activeTab.tableMeta?.tableName === dialogs.structurePrefillTable.value) { + try { + const columns = await api.getColumns( + activeTab.connectionId, activeTab.database, + activeTab.tableMeta.schema || activeTab.database, activeTab.tableMeta.tableName, + ); + queryStore.setTableMeta(activeTab.id, { + ...activeTab.tableMeta, + columns, + primaryKeys: columns.filter((c) => c.is_primary_key).map((c) => c.name), + }); + await reloadData(); + } catch (e: any) { + toast(e?.message || String(e), 5000); + } + } + } + + return { openLineageTarget, openDatabaseSearchTarget, onStructureEditorSaved, openTableTarget }; +} diff --git a/src/composables/usePanelResize.ts b/src/composables/usePanelResize.ts new file mode 100644 index 000000000..8045a0e17 --- /dev/null +++ b/src/composables/usePanelResize.ts @@ -0,0 +1,42 @@ +import { ref, type Ref } from "vue"; + +export function usePanelResize() { + const sidebarWidth = ref(Number(localStorage.getItem("dbx-sidebar-width")) || 260); + const aiPanelWidth = ref(Number(localStorage.getItem("dbx-ai-panel-width")) || 360); + const historyWidth = ref(Number(localStorage.getItem("dbx-history-width")) || 288); + + function startPanelResize(widthRef: Ref, storageKey: string, direction: 'left' | 'right') { + return (e: MouseEvent) => { + e.preventDefault(); + const startX = e.clientX; + const startWidth = widthRef.value; + + const onMouseMove = (ev: MouseEvent) => { + const delta = ev.clientX - startX; + widthRef.value = Math.max(180, Math.min(800, startWidth + (direction === 'right' ? delta : -delta))); + }; + + const onMouseUp = () => { + document.removeEventListener("mousemove", onMouseMove); + document.removeEventListener("mouseup", onMouseUp); + localStorage.setItem(storageKey, String(widthRef.value)); + }; + + document.addEventListener("mousemove", onMouseMove); + document.addEventListener("mouseup", onMouseUp); + }; + } + + const startSidebarResize = startPanelResize(sidebarWidth, "dbx-sidebar-width", 'right'); + const startAiPanelResize = startPanelResize(aiPanelWidth, "dbx-ai-panel-width", 'left'); + const startHistoryResize = startPanelResize(historyWidth, "dbx-history-width", 'left'); + + return { + sidebarWidth, + aiPanelWidth, + historyWidth, + startSidebarResize, + startAiPanelResize, + startHistoryResize, + }; +} diff --git a/src/composables/useSqlExecution.ts b/src/composables/useSqlExecution.ts new file mode 100644 index 000000000..b4a039b71 --- /dev/null +++ b/src/composables/useSqlExecution.ts @@ -0,0 +1,118 @@ +import { ref, type Ref, type ComputedRef } from "vue"; +import { useI18n } from "vue-i18n"; +import { useQueryStore } from "@/stores/queryStore"; +import { useHistoryStore } from "@/stores/historyStore"; +import { useConnectionStore } from "@/stores/connectionStore"; +import { useToast } from "@/composables/useToast"; +import type { ConnectionConfig, QueryTab } from "@/types/database"; + +const DANGER_RE = /\b(DROP|DELETE|TRUNCATE|ALTER|UPDATE|MERGE|REPLACE)\b/i; + +export function stripSqlComments(sql: string): string { + return sql + .replace(/\/\*[\s\S]*?\*\//g, " ") + .replace(/--.*$/gm, " ") + .replace(/#.*$/gm, " "); +} + +export function isDangerousSql(sql: string): boolean { + return DANGER_RE.test(stripSqlComments(sql)); +} + +export function useSqlExecution(deps: { + activeTab: ComputedRef; + activeConnection: ComputedRef; + executableSql: ComputedRef; + activeOutputView: Ref<"result" | "explain">; +}) { + const { t } = useI18n(); + const queryStore = useQueryStore(); + const historyStore = useHistoryStore(); + const connectionStore = useConnectionStore(); + const { toast } = useToast(); + + const dangerSql = ref(""); + const pendingDangerSql = ref(""); + const showDangerDialog = ref(false); + + function tryExecute(sqlOverride?: string) { + const tab = deps.activeTab.value; + const sql = sqlOverride ?? deps.executableSql.value; + if (!tab || !sql.trim()) return; + if (isDangerousSql(sql)) { + dangerSql.value = sql; + pendingDangerSql.value = sql; + showDangerDialog.value = true; + } else { + doExecute(sql); + } + } + + async function doExecute(sql = deps.executableSql.value) { + const tab = deps.activeTab.value; + if (!tab || !sql.trim()) return; + deps.activeOutputView.value = "result"; + const connName = connectionStore.getConfig(tab.connectionId)?.name || ""; + const start = Date.now(); + await queryStore.executeCurrentSql(sql); + const elapsed = Date.now() - start; + const success = !tab.result?.columns.includes("Error"); + historyStore.add({ + connection_name: connName, + database: tab.database, + sql, + execution_time_ms: elapsed, + success, + error: success ? undefined : String(tab.result?.rows?.[0]?.[0] ?? ""), + }); + } + + function cancelActiveExecution() { + const tab = deps.activeTab.value; + if (!tab) return; + if (tab.isExecuting) void queryStore.cancelTabExecution(tab.id); + else if (tab.isExplaining) void queryStore.cancelTabExplain(tab.id); + } + + function explainReasonMessage(reason: string): string { + if (reason === "unsupported") return t("explain.unsupported"); + if (reason === "unsafe") return t("explain.unsafe"); + return t("explain.emptySql"); + } + + async function tryExplain(sqlOverride?: string) { + const tab = deps.activeTab.value; + const sql = sqlOverride ?? deps.executableSql.value; + if (!tab || !sql.trim()) { + toast(t("explain.emptySql")); + return; + } + + deps.activeOutputView.value = "explain"; + const result = await queryStore.explainTabSql(tab.id, sql, deps.activeConnection.value?.db_type); + if (!result.ok) { + toast(explainReasonMessage(result.reason), 5000); + return; + } + + const current = deps.activeTab.value; + if (current?.explainError) toast(current.explainError, 5000); + } + + function onDangerConfirm() { + const sql = pendingDangerSql.value || deps.executableSql.value; + pendingDangerSql.value = ""; + doExecute(sql); + } + + return { + dangerSql, + pendingDangerSql, + showDangerDialog, + tryExecute, + doExecute, + cancelActiveExecution, + tryExplain, + onDangerConfirm, + }; +} diff --git a/src/composables/useTabScroll.ts b/src/composables/useTabScroll.ts new file mode 100644 index 000000000..6bf4c4108 --- /dev/null +++ b/src/composables/useTabScroll.ts @@ -0,0 +1,26 @@ +import { ref, type Ref } from "vue"; + +export function useTabScroll(tabsContainerRef: Ref) { + const canScrollLeft = ref(false); + const canScrollRight = ref(false); + + function updateScrollButtons() { + const el = tabsContainerRef.value; + if (!el) { + canScrollLeft.value = false; + canScrollRight.value = false; + return; + } + canScrollLeft.value = el.scrollLeft > 0; + canScrollRight.value = el.scrollLeft < el.scrollWidth - el.clientWidth - 1; + } + + function scrollTabs(direction: "left" | "right") { + const el = tabsContainerRef.value; + if (!el) return; + const scrollAmount = el.clientWidth * 0.8; + el.scrollBy({ left: direction === "left" ? -scrollAmount : scrollAmount, behavior: "smooth" }); + } + + return { canScrollLeft, canScrollRight, updateScrollButtons, scrollTabs }; +} diff --git a/src/composables/useTauriEvents.ts b/src/composables/useTauriEvents.ts new file mode 100644 index 000000000..316e82462 --- /dev/null +++ b/src/composables/useTauriEvents.ts @@ -0,0 +1,55 @@ +import { useConnectionStore } from "@/stores/connectionStore"; +import { useQueryStore } from "@/stores/queryStore"; +import type { NavigationTarget } from "@/composables/useNavigationTargets"; + +export function useTauriEvents(deps: { + openTableTarget: (target: NavigationTarget) => Promise; +}) { + const connectionStore = useConnectionStore(); + const queryStore = useQueryStore(); + + function setupTauriListeners() { + import("@tauri-apps/api/event").then(({ listen }) => { + listen<{ connection_id: string; database: string; schema?: string; table: string }>( + "mcp-open-table", + async (event) => { + const { connection_id, database, schema, table } = event.payload; + if (!connectionStore.connections.length) await connectionStore.initFromDisk(); + const config = connectionStore.getConfig(connection_id); + if (!config) return; + connectionStore.activeConnectionId = connection_id; + await connectionStore.ensureConnected(connection_id); + if (config.db_type === "redis") { + queryStore.createTab(connection_id, database || "0", `db${database || "0"}`, "redis"); + } else if (config.db_type === "mongodb") { + queryStore.createTab(connection_id, database, table, "mongo"); + } else { + deps.openTableTarget({ connectionId: connection_id, database, schema, tableName: table }); + } + import("@tauri-apps/api/window").then(({ getCurrentWindow }) => + getCurrentWindow().setFocus().catch(() => {}), + ); + }, + ); + listen<{ connection_id: string; database: string; sql: string }>( + "mcp-execute-query", + async (event) => { + const { connection_id, database, sql } = event.payload; + if (!connectionStore.connections.length) await connectionStore.initFromDisk(); + const config = connectionStore.getConfig(connection_id); + if (!config) return; + connectionStore.activeConnectionId = connection_id; + await connectionStore.ensureConnected(connection_id); + const tabId = queryStore.createTab(connection_id, database, undefined, "query"); + queryStore.updateSql(tabId, sql); + await queryStore.executeTabSql(tabId, sql); + import("@tauri-apps/api/window").then(({ getCurrentWindow }) => + getCurrentWindow().setFocus().catch(() => {}), + ); + }, + ); + }).catch(() => {}); + } + + return { setupTauriListeners }; +} diff --git a/src/composables/useTheme.ts b/src/composables/useTheme.ts new file mode 100644 index 000000000..07efb624d --- /dev/null +++ b/src/composables/useTheme.ts @@ -0,0 +1,25 @@ +import { ref } from "vue"; +import { isTauriRuntime } from "@/lib/tauriRuntime"; +import type { Theme } from "@tauri-apps/api/window"; + +export function useTheme() { + const isDark = ref(localStorage.getItem("dbx-theme") === "dark"); + + function applyTheme() { + document.documentElement.classList.toggle("dark", isDark.value); + if (!isTauriRuntime()) return; + import("@tauri-apps/api/window").then(({ getCurrentWindow }) => { + getCurrentWindow() + .setTheme(isDark.value ? "dark" as Theme : "light" as Theme) + .catch(() => {}); + }); + } + + function toggleTheme() { + isDark.value = !isDark.value; + localStorage.setItem("dbx-theme", isDark.value ? "dark" : "light"); + applyTheme(); + } + + return { isDark, applyTheme, toggleTheme }; +} diff --git a/src/lib/tabPresentation.ts b/src/lib/tabPresentation.ts new file mode 100644 index 000000000..7d0b6cee6 --- /dev/null +++ b/src/lib/tabPresentation.ts @@ -0,0 +1,71 @@ +import { useI18n } from "vue-i18n"; +import { useConnectionStore } from "@/stores/connectionStore"; +import type { QueryTab } from "@/types/database"; + +export function connectionDisplayName(connectionId: string): string { + const connectionStore = useConnectionStore(); + return connectionStore.getConfig(connectionId)?.name || connectionId; +} + +export function connectionColor(connectionId: string): string { + const connectionStore = useConnectionStore(); + return connectionStore.getConfig(connectionId)?.color || ""; +} + +export function databaseDisplayNameForTab(connectionId: string, database: string): string { + const { t } = useI18n(); + const connectionStore = useConnectionStore(); + const connection = connectionStore.getConfig(connectionId); + if (connection?.db_type === "redis" && database !== "") return `db${database}`; + return database || t("editor.noDatabase"); +} + +export function isPreviewTab(tab: QueryTab): boolean { + const connectionStore = useConnectionStore(); + const config = connectionStore.getConfig(tab.connectionId); + return !!config?.name.startsWith("[Preview]"); +} + +export function tabDisplayTitle(tab: QueryTab): string { + const database = databaseDisplayNameForTab(tab.connectionId, tab.database); + if (isPreviewTab(tab)) return tab.title; + if (tab.mode === "data" && tab.tableMeta?.tableName) { + return tab.tableMeta.tableName; + } + if (tab.mode === "query") { + return `${connectionDisplayName(tab.connectionId)} | ${database}`; + } + if (tab.mode === "mongo" && tab.sql) { + return `${database} | ${tab.sql}`; + } + if (tab.mode === "redis") { + return `${connectionDisplayName(tab.connectionId)} | ${database}`; + } + return tab.title; +} + +export function tabTooltipLines(tab: QueryTab): { label: string; value: string }[] { + const { t } = useI18n(); + const connName = connectionDisplayName(tab.connectionId); + const database = databaseDisplayNameForTab(tab.connectionId, tab.database); + const lines: { label: string; value: string }[] = [ + { label: t("tabs.tooltipConnection"), value: connName }, + { label: t("tabs.tooltipDatabase"), value: database }, + ]; + if (tab.mode === "data" && tab.tableMeta?.tableName) { + lines.push({ label: t("tabs.tooltipTable"), value: tab.tableMeta.tableName }); + } + if (tab.mode === "mongo" && tab.sql) { + lines.push({ label: t("tabs.tooltipCollection"), value: tab.sql }); + } + return lines; +} + +export function tabModeLabel(tab: QueryTab): string { + const { t } = useI18n(); + if (tab.mode === "data") return t("tabs.table"); + if (tab.mode === "query") return t("tabs.sql"); + if (tab.mode === "mongo") return t("tabs.mongo"); + if (tab.mode === "redis") return t("tabs.redis"); + return tab.mode; +}
{{ t('welcome.mcpDescription') }}
npx @dbx-app/mcp-server
{{ t('configExport.importLayoutConfirm') }}
- {{ t('updates.availableMessage', { current: updateInfo.current_version, latest: updateInfo.latest_version }) }} -
- {{ updateCheckMessage || t('updates.upToDate', { version: updateInfo?.current_version || '' }) }} -
+ {{ t('updates.availableMessage', { current: updateInfo.current_version, latest: updateInfo.latest_version }) }} +
+ {{ updateCheckMessage || t('updates.upToDate', { version: updateInfo?.current_version || '' }) }} +