refactor: split App.vue into composables and layout components

Extract 11 composables and 5 layout components from App.vue, reducing it
from 1921 to 327 lines. Unify dialog overlay style across DialogContent
and DialogScrollContent for consistent backdrop appearance.
This commit is contained in:
t8y2 2026-05-05 04:14:00 +08:00
parent 794c5375c7
commit bdd8b3fa03
22 changed files with 2217 additions and 1778 deletions

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,152 @@
<script setup lang="ts">
import { computed, watch, defineAsyncComponent } from "vue";
import { useI18n } from "vue-i18n";
import { Button } from "@/components/ui/button";
import {
Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle,
} from "@/components/ui/dialog";
import ConnectionDialog from "@/components/connection/ConnectionDialog.vue";
import EditorSettingsDialog from "@/components/editor/EditorSettingsDialog.vue";
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
const DataTransferDialog = defineAsyncComponent(() => import("@/components/transfer/DataTransferDialog.vue"));
const SchemaDiffDialog = defineAsyncComponent(() => import("@/components/diff/SchemaDiffDialog.vue"));
const SqlFileExecutionDialog = defineAsyncComponent(() => import("@/components/sql-file/SqlFileExecutionDialog.vue"));
const SchemaDiagramDialog = defineAsyncComponent(() => import("@/components/diagram/SchemaDiagramDialog.vue"));
const TableImportDialog = defineAsyncComponent(() => import("@/components/import/TableImportDialog.vue"));
const TableStructureEditorDialog = defineAsyncComponent(() => import("@/components/structure/TableStructureEditorDialog.vue"));
const FieldLineageDialog = defineAsyncComponent(() => import("@/components/lineage/FieldLineageDialog.vue"));
const ConfigPassphraseDialog = defineAsyncComponent(() => import("@/components/config/ConfigPassphraseDialog.vue"));
const DatabaseSearchDialog = defineAsyncComponent(() => import("@/components/search/DatabaseSearchDialog.vue"));
import { useConnectionStore } from "@/stores/connectionStore";
import { useDialogSources } from "@/composables/useDialogSources";
const props = defineProps<{
showConnectionDialog: boolean;
showSettingsDialog: boolean;
showDangerDialog: boolean;
dangerSql: string;
}>();
const emit = defineEmits<{
"update:showConnectionDialog": [value: boolean];
"update:showSettingsDialog": [value: boolean];
"update:showDangerDialog": [value: boolean];
dangerConfirm: [];
connectStarted: [name: string];
connectSucceeded: [name: string];
connectFailed: [message: string];
structureEditorSaved: [];
openLineageTarget: [target: { connectionId: string; database: string; schema?: string; tableName: string; columnName?: string }];
openDatabaseSearchTarget: [target: { connectionId: string; database: string; schema?: string; tableName: string; whereInput?: string }];
}>();
const { t } = useI18n();
const connectionStore = useConnectionStore();
const dialogs = useDialogSources();
const editConfig = computed(() => {
const id = connectionStore.editingConnectionId;
if (!id) return undefined;
return connectionStore.getConfig(id);
});
watch(editConfig, (v) => {
if (v) emit("update:showConnectionDialog", true);
});
watch(() => props.showConnectionDialog, (v) => {
if (!v) connectionStore.stopEditing();
});
</script>
<template>
<ConnectionDialog
:open="showConnectionDialog"
:edit-config="editConfig"
@update:open="emit('update:showConnectionDialog', $event)"
@connect-started="emit('connectStarted', $event)"
@connect-succeeded="emit('connectSucceeded', $event)"
@connect-failed="emit('connectFailed', $event)"
/>
<EditorSettingsDialog
:open="showSettingsDialog"
@update:open="emit('update:showSettingsDialog', $event)"
/>
<DangerConfirmDialog
:open="showDangerDialog"
:sql="dangerSql"
@update:open="emit('update:showDangerDialog', $event)"
@confirm="emit('dangerConfirm')"
/>
<DataTransferDialog
v-model:open="dialogs.showTransferDialog.value"
:prefill-connection-id="dialogs.transferPrefillConnectionId.value"
:prefill-database="dialogs.transferPrefillDatabase.value"
/>
<SchemaDiffDialog
v-model:open="dialogs.showSchemaDiffDialog.value"
:prefill-connection-id="dialogs.schemaDiffPrefillConnectionId.value"
:prefill-database="dialogs.schemaDiffPrefillDatabase.value"
/>
<SqlFileExecutionDialog
v-model:open="dialogs.showSqlFileDialog.value"
:prefill-connection-id="dialogs.sqlFilePrefillConnectionId.value"
:prefill-database="dialogs.sqlFilePrefillDatabase.value"
/>
<SchemaDiagramDialog
v-model:open="dialogs.showDiagramDialog.value"
:prefill-connection-id="dialogs.diagramPrefillConnectionId.value"
:prefill-database="dialogs.diagramPrefillDatabase.value"
:prefill-schema="dialogs.diagramPrefillSchema.value"
:focus-table-name="dialogs.diagramFocusTableName.value"
/>
<TableImportDialog
v-model:open="dialogs.showTableImportDialog.value"
:prefill-connection-id="dialogs.tableImportPrefillConnectionId.value"
:prefill-database="dialogs.tableImportPrefillDatabase.value"
:prefill-schema="dialogs.tableImportPrefillSchema.value"
:prefill-table="dialogs.tableImportPrefillTable.value"
/>
<TableStructureEditorDialog
v-model:open="dialogs.showStructureEditorDialog.value"
:prefill-connection-id="dialogs.structurePrefillConnectionId.value"
:prefill-database="dialogs.structurePrefillDatabase.value"
:prefill-schema="dialogs.structurePrefillSchema.value"
:prefill-table="dialogs.structurePrefillTable.value"
@saved="emit('structureEditorSaved')"
/>
<FieldLineageDialog
v-model:open="dialogs.showFieldLineageDialog.value"
:prefill-connection-id="dialogs.lineagePrefillConnectionId.value"
:prefill-database="dialogs.lineagePrefillDatabase.value"
:prefill-schema="dialogs.lineagePrefillSchema.value"
:prefill-table="dialogs.lineagePrefillTable.value"
:prefill-column="dialogs.lineagePrefillColumn.value"
@open-target="emit('openLineageTarget', $event)"
/>
<DatabaseSearchDialog
v-model:open="dialogs.showDatabaseSearchDialog.value"
:prefill-connection-id="dialogs.databaseSearchPrefillConnectionId.value"
:prefill-database="dialogs.databaseSearchPrefillDatabase.value"
:prefill-schema="dialogs.databaseSearchPrefillSchema.value"
@open-target="emit('openDatabaseSearchTarget', $event)"
/>
<ConfigPassphraseDialog
v-model:open="dialogs.showConfigPassphraseDialog.value"
:mode="dialogs.configPassphraseMode.value"
:external-error="dialogs.configPassphraseError.value"
@confirm="dialogs.configPassphraseMode.value === 'export' ? dialogs.onExportConfirm($event) : dialogs.onImportConfirm($event)"
/>
<Dialog v-model:open="dialogs.showImportLayoutConfirm.value">
<DialogContent class="sm:max-w-[400px]">
<DialogHeader>
<DialogTitle>{{ t('configExport.importLayoutTitle') }}</DialogTitle>
</DialogHeader>
<p class="text-sm text-muted-foreground">{{ t('configExport.importLayoutConfirm') }}</p>
<DialogFooter>
<Button variant="outline" @click="dialogs.showImportLayoutConfirm.value = false">{{ t('dangerDialog.cancel') }}</Button>
<Button @click="dialogs.showImportLayoutConfirm.value = false; dialogs.pendingImportLayout.value && connectionStore.applySidebarLayout(dialogs.pendingImportLayout.value)">{{ t('configExport.importLayoutApply') }}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>

View File

@ -0,0 +1,50 @@
<script setup lang="ts">
import { useI18n } from "vue-i18n";
import { Upload, Download } from "lucide-vue-next";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import ConnectionTree from "@/components/sidebar/ConnectionTree.vue";
defineProps<{
sidebarWidth: number;
}>();
const emit = defineEmits<{
import: [];
export: [];
startResize: [event: MouseEvent];
}>();
const { t } = useI18n();
</script>
<template>
<div class="h-full shrink-0 relative select-none" :style="{ width: sidebarWidth + 'px' }">
<div class="h-full flex flex-col overflow-hidden">
<div class="h-9 flex items-center px-3 text-xs font-medium text-muted-foreground border-b bg-muted/20">
{{ t('sidebar.connections') }}
<span class="flex-1" />
<Tooltip>
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="h-5 w-5" @click="emit('import')">
<Upload class="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ t('sidebar.import') }}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="h-5 w-5" @click="emit('export')">
<Download class="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ t('sidebar.export') }}</TooltipContent>
</Tooltip>
</div>
<div class="flex-1 overflow-y-auto">
<ConnectionTree />
</div>
</div>
<div class="panel-resize-handle panel-resize-handle--right" @mousedown="emit('startResize', $event)" />
</div>
</template>

View File

@ -0,0 +1,144 @@
<script setup lang="ts">
import { ref, watch, nextTick } from "vue";
import { useI18n } from "vue-i18n";
import { X, Pin, ChevronRight } from "lucide-vue-next";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import { useQueryStore } from "@/stores/queryStore";
import { useTabScroll } from "@/composables/useTabScroll";
import {
connectionColor,
tabDisplayTitle,
tabTooltipLines,
tabModeLabel,
} from "@/lib/tabPresentation";
const { t } = useI18n();
const queryStore = useQueryStore();
const tabsContainerRef = ref<HTMLElement | null>(null);
const { canScrollLeft, canScrollRight, updateScrollButtons, scrollTabs } = useTabScroll(tabsContainerRef);
watch(() => queryStore.tabs.length, () => {
nextTick(updateScrollButtons);
});
watch(() => queryStore.activeTabId, () => {
nextTick(() => {
const container = tabsContainerRef.value;
if (!container) return;
const activeEl = container.querySelector('[data-active-tab="true"]');
if (activeEl) {
activeEl.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "nearest" });
}
updateScrollButtons();
});
});
</script>
<template>
<div v-if="queryStore.tabs.length > 0" class="relative h-9 flex items-center border-b bg-muted/20 shrink-0">
<button
v-if="canScrollLeft"
class="absolute left-0 z-10 h-full px-1 bg-linear-to-r from-background via-background/80 to-transparent text-muted-foreground hover:text-foreground"
:aria-label="t('tabs.scrollLeft')"
@click="scrollTabs('left')"
>
<ChevronRight class="h-4 w-4 rotate-180" />
</button>
<div
ref="tabsContainerRef"
class="flex-1 flex items-center overflow-x-auto min-w-0"
style="-ms-overflow-style:none;scrollbar-width:none;-webkit-overflow-scrolling:touch"
@scroll="updateScrollButtons"
>
<ContextMenu
v-for="tab in queryStore.tabs"
:key="tab.id"
>
<ContextMenuTrigger as-child>
<Tooltip>
<TooltipTrigger as-child>
<div
class="group flex min-w-38 items-center gap-1 px-1 h-full text-xs cursor-pointer border-r transition-colors whitespace-nowrap"
:class="tab.id === queryStore.activeTabId ? 'bg-background font-medium' : 'font-normal text-muted-foreground'"
:data-active-tab="tab.id === queryStore.activeTabId"
@click="queryStore.activeTabId = tab.id"
>
<span class="h-4 w-1 rounded-full shrink-0" :style="{ backgroundColor: connectionColor(tab.connectionId) || '#9ca3af' }" />
<span class="min-w-0 truncate flex-1">{{ tabDisplayTitle(tab) }}</span>
<Tooltip>
<TooltipTrigger as-child>
<button
class="inline-flex rounded p-0.5 text-muted-foreground hover:bg-muted-foreground/20 hover:text-foreground focus:opacity-100"
:class="tab.pinned ? 'visible text-primary' : 'invisible group-hover:visible'"
@click.stop="queryStore.togglePinnedTab(tab.id)"
>
<Pin class="h-3 w-3" :class="{ 'fill-current': tab.pinned }" />
</button>
</TooltipTrigger>
<TooltipContent>{{ tab.pinned ? t('contextMenu.unpin') : t('contextMenu.pin') }}</TooltipContent>
</Tooltip>
<span
class="shrink-0 rounded border px-1 text-[10px] leading-4"
:class="tab.mode === 'data' ? 'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900 dark:bg-emerald-950 dark:text-emerald-300' : 'border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-900 dark:bg-blue-950 dark:text-blue-300'"
>
{{ tabModeLabel(tab) }}
</span>
<button
class="rounded hover:bg-muted-foreground/20 p-0.5 shrink-0"
@click.stop="queryStore.closeTab(tab.id)"
>
<X class="h-3 w-3" />
</button>
</div>
</TooltipTrigger>
<TooltipContent side="bottom" class="text-xs grid grid-cols-[auto_1fr] gap-x-2">
<template v-for="line in tabTooltipLines(tab)" :key="line.label">
<span class="text-muted-foreground">{{ line.label }}</span>
<span>{{ line.value }}</span>
</template>
</TooltipContent>
</Tooltip>
</ContextMenuTrigger>
<ContextMenuContent class="w-44">
<ContextMenuItem @click="queryStore.togglePinnedTab(tab.id)">
<Pin class="w-3.5 h-3.5 mr-2" :class="{ 'fill-current': tab.pinned }" />
{{ tab.pinned ? t('contextMenu.unpin') : t('contextMenu.pin') }}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem @click="queryStore.closeTab(tab.id)">
<X class="w-3.5 h-3.5 mr-2" />
{{ t('contextMenu.closeTab') }}
</ContextMenuItem>
<ContextMenuItem
:disabled="queryStore.tabs.length <= 1"
@click="queryStore.closeOtherTabs(tab.id)"
>
<X class="w-3.5 h-3.5 mr-2" />
{{ t('contextMenu.closeOtherTabs') }}
</ContextMenuItem>
<ContextMenuItem variant="destructive" @click="queryStore.closeAllTabs">
<X class="w-3.5 h-3.5 mr-2" />
{{ t('contextMenu.closeAllTabs') }}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
</div>
<button
v-if="canScrollRight"
class="absolute right-0 z-10 h-full px-1 bg-linear-to-l from-background via-background/80 to-transparent text-muted-foreground hover:text-foreground"
:aria-label="t('tabs.scrollRight')"
@click="scrollTabs('right')"
>
<ChevronRight class="h-4 w-4" />
</button>
</div>
</template>

View File

@ -0,0 +1,122 @@
<script setup lang="ts">
import { useI18n } from "vue-i18n";
import { DatabaseZap, FilePlus2, Loader2, Globe, Moon, Sun, History, Bot, ArrowLeftRight, FileCode, Settings, CloudDownload } from "lucide-vue-next";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
defineProps<{
isDark: boolean
showAiPanel: boolean
showHistory: boolean
checkingUpdates: boolean
hasConnections: boolean
hasSqlFileConnections: boolean
}>();
const emit = defineEmits<{
'new-connection': []
'new-query': []
'toggle-theme': []
'toggle-locale': []
'toggle-ai': []
'toggle-history': []
'open-github': []
'open-settings': []
'check-updates': []
'open-transfer': []
'open-sql-file': []
}>();
const { t } = useI18n();
</script>
<template>
<div class="h-10 flex items-center gap-1 px-2 border-b bg-muted/30 shrink-0">
<Button variant="ghost" size="sm" class="h-7 px-2 text-xs gap-1" @click="emit('new-connection')">
<DatabaseZap class="h-3.5 w-3.5" />
{{ t('toolbar.newConnection') }}
</Button>
<Button variant="ghost" size="sm" class="h-7 px-2 text-xs gap-1" @click="emit('new-query')" :disabled="!hasConnections">
<FilePlus2 class="h-3.5 w-3.5" />
{{ t('toolbar.newQuery') }}
</Button>
<Button variant="ghost" size="sm" class="h-7 px-2 text-xs gap-1" @click="emit('open-transfer')" :disabled="!hasConnections">
<ArrowLeftRight class="h-3.5 w-3.5" />
{{ t('transfer.dataTransfer') }}
</Button>
<Button variant="ghost" size="sm" class="h-7 px-2 text-xs gap-1" @click="emit('open-sql-file')" :disabled="!hasSqlFileConnections">
<FileCode class="h-3.5 w-3.5" />
{{ t('sqlFile.title') }}
</Button>
<div class="flex-1" />
<Tooltip>
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="h-7 w-7" :disabled="checkingUpdates" @click="emit('check-updates')">
<Loader2 v-if="checkingUpdates" class="h-4 w-4 animate-spin" />
<CloudDownload v-else class="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ t('updates.check') }}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="h-7 w-7" :class="{ 'bg-accent': showHistory }" @click="emit('toggle-history')">
<History class="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ t('history.title') }}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="h-7 w-7" :class="{ 'bg-accent': showAiPanel }" @click="emit('toggle-ai')">
<Bot class="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>AI</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="h-7 w-7" @click="emit('toggle-theme')">
<Moon v-if="!isDark" class="h-4 w-4" />
<Sun v-else class="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ isDark ? 'Light' : 'Dark' }}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="h-7 w-7" @click="emit('toggle-locale')">
<Globe class="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ t('common.language') }}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="h-7 w-7" @click="emit('open-github')">
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="currentColor"><path d="M12 0C5.37 0 0 5.37 0 12c0 5.3 3.438 9.8 8.205 11.387.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61-.546-1.387-1.333-1.756-1.333-1.756-1.09-.745.083-.729.083-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 21.795 24 17.295 24 12 24 5.37 18.627 0 12 0z"/></svg>
</Button>
</TooltipTrigger>
<TooltipContent>GitHub</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="h-7 w-7" @click="emit('open-settings')">
<Settings class="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ t('settings.title') }}</TooltipContent>
</Tooltip>
</div>
</template>

View File

@ -0,0 +1,241 @@
<script setup lang="ts">
import { computed, defineAsyncComponent } from "vue";
import { useI18n } from "vue-i18n";
import { Loader2, Square, Bot, Table2, GitBranch } from "lucide-vue-next";
import { Splitpanes, Pane } from "splitpanes";
import "splitpanes/dist/splitpanes.css";
import { Button } from "@/components/ui/button";
import QueryEditor from "@/components/editor/QueryEditor.vue";
import DataGrid from "@/components/grid/DataGrid.vue";
import RedisKeyBrowser from "@/components/redis/RedisKeyBrowser.vue";
import MongoDocBrowser from "@/components/mongo/MongoDocBrowser.vue";
const ExplainPlanViewer = defineAsyncComponent(() => import("@/components/explain/ExplainPlanViewer.vue"));
import { useQueryStore } from "@/stores/queryStore";
import { canCancelQueryExecution, queryExecutionLabelKey } from "@/lib/queryExecutionState";
import { databaseDisplayNameForTab } from "@/lib/tabPresentation";
import type { QueryTab, ConnectionConfig } from "@/types/database";
import type { SqlFormatDialect } from "@/lib/sqlFormatter";
const props = defineProps<{
activeTab: QueryTab;
activeConnection?: ConnectionConfig;
executableSql: string;
activeOutputView: "result" | "explain";
formatSqlRequestId: number;
selectedSql: string;
cursorPos: number;
}>();
const emit = defineEmits<{
"update:activeOutputView": [value: "result" | "explain"];
fixWithAi: [errorMessage: string];
execute: [sqlOverride?: string];
cancel: [];
explain: [];
editorUpdate: [value: string];
editorSelectionChange: [value: string];
editorCursorChange: [pos: number];
formatError: [];
reload: [];
paginate: [offset: number, limit: number, whereInput?: string];
sort: [column: string, direction: "asc" | "desc" | null, whereInput?: string];
executeSql: [sql: string];
}>();
const { t } = useI18n();
const queryStore = useQueryStore();
const activeSqlFormatDialect = computed<SqlFormatDialect>(() => {
switch (props.activeConnection?.db_type) {
case "mysql":
return "mysql";
case "postgres":
return "postgres";
case "sqlite":
return "sqlite";
case "sqlserver":
return "sqlserver";
default:
return "generic";
}
});
const editorDialect = computed<"mysql" | "postgres">(() =>
props.activeConnection?.db_type === "postgres" ? "postgres" : "mysql"
);
</script>
<template>
<div class="flex flex-col flex-1 min-h-0">
<!-- Query mode: editor + results -->
<template v-if="activeTab.mode === 'query'">
<Splitpanes horizontal class="flex-1">
<Pane :size="40" :min-size="15">
<div class="h-full flex flex-col">
<QueryEditor
class="flex-1"
:model-value="activeTab.sql"
:connection-id="activeTab.connectionId"
:database="activeTab.database"
:dialect="editorDialect"
:format-dialect="activeSqlFormatDialect"
:format-request-id="formatSqlRequestId"
@update:model-value="emit('editorUpdate', $event)"
@selection-change="emit('editorSelectionChange', $event)"
@cursor-change="emit('editorCursorChange', $event)"
@format-error="emit('formatError')"
@execute="emit('execute')"
/>
</div>
</Pane>
<Pane :size="60" :min-size="20">
<div class="h-full flex flex-col">
<div
v-if="activeTab.result || activeTab.explainPlan || activeTab.explainError || activeTab.isExecuting || activeTab.isExplaining"
class="h-8 shrink-0 border-b bg-muted/20 px-2 flex items-center gap-1"
>
<Button
size="sm"
:variant="activeOutputView === 'result' ? 'secondary' : 'ghost'"
class="h-6 px-2 text-xs"
:disabled="!activeTab.result && !activeTab.isExecuting"
@click="emit('update:activeOutputView', 'result')"
>
{{ t('tabs.tableData') }}
</Button>
<template v-if="activeOutputView === 'result' && activeTab.results && activeTab.results.length > 1">
<span class="mx-1 h-4 w-px bg-border" />
<Button
v-for="(_, rIdx) in activeTab.results"
:key="rIdx"
size="sm"
:variant="activeTab.activeResultIndex === rIdx ? 'default' : 'ghost'"
class="h-6 px-2 text-xs"
@click="queryStore.setActiveResultIndex(activeTab.id, rIdx)"
>
{{ t('tabs.resultN', { n: rIdx + 1 }) }}
</Button>
</template>
<Button
size="sm"
:variant="activeOutputView === 'explain' ? 'secondary' : 'ghost'"
class="h-6 px-2 text-xs gap-1"
:disabled="!activeTab.explainPlan && !activeTab.explainError && !activeTab.isExplaining"
@click="emit('update:activeOutputView', 'explain')"
>
<GitBranch class="h-3.5 w-3.5" />
{{ t('explain.title') }}
</Button>
</div>
<ExplainPlanViewer
v-if="activeOutputView === 'explain'"
class="flex-1 min-h-0"
:plan="activeTab.explainPlan"
:error="activeTab.explainError"
:loading="activeTab.isExplaining"
:source-sql="activeTab.lastExplainedSql"
:explain-sql="activeTab.explainSql"
/>
<template v-else>
<DataGrid v-if="activeTab.result" :key="`${activeTab.id}-${activeTab.activeResultIndex ?? 0}`" class="flex-1 min-h-0" :result="activeTab.result" :sql="activeTab.lastExecutedSql || activeTab.sql" :loading="activeTab.isExecuting" />
<div v-if="activeTab.result?.columns.includes('Error')" class="flex items-center gap-2 px-3 py-1.5 border-t bg-destructive/5">
<Bot class="h-3.5 w-3.5 text-destructive" />
<button class="text-xs text-destructive hover:underline" @click="emit('fixWithAi', String(activeTab.result?.rows?.[0]?.[0] ?? ''))">
{{ t('ai.fixWithAi') }}
</button>
</div>
<div v-else-if="!activeTab.result && activeTab.isExecuting" class="flex-1 min-h-0 flex flex-col items-center justify-center gap-3 text-muted-foreground text-sm">
<div class="flex items-center">
<Loader2 class="h-5 w-5 animate-spin mr-2" />
{{ t(queryExecutionLabelKey(activeTab)) }}
</div>
</div>
<div v-else-if="!activeTab.result" class="flex-1 min-h-0 flex items-center justify-center text-muted-foreground text-sm">
{{ t('editor.pressToExecute') }}
</div>
</template>
</div>
</Pane>
</Splitpanes>
</template>
<!-- Data mode: full-height grid -->
<template v-else-if="activeTab.mode === 'data'">
<div class="flex-1 min-h-0 flex flex-col">
<div class="h-9 shrink-0 border-b bg-background/80 px-3 flex items-center gap-2 text-xs">
<span class="inline-flex items-center gap-1 rounded border border-emerald-200 bg-emerald-50 px-2 py-0.5 font-medium text-emerald-700 dark:border-emerald-900 dark:bg-emerald-950 dark:text-emerald-300">
<Table2 class="h-3.5 w-3.5" />
{{ t('tabs.tableData') }}
</span>
<span class="font-medium truncate">{{ activeTab.tableMeta?.tableName || activeTab.title }}</span>
<span class="text-muted-foreground truncate">
{{ databaseDisplayNameForTab(activeTab.connectionId, activeTab.database) }}
<template v-if="activeTab.tableMeta?.schema"> &middot; {{ activeTab.tableMeta.schema }}</template>
</span>
<span v-if="activeTab.tableMeta" class="ml-auto text-muted-foreground">
{{ activeTab.tableMeta.columns.length }} {{ t('tree.columns') }}
</span>
</div>
<DataGrid
v-if="activeTab.result"
class="flex-1 min-h-0"
:key="activeTab.id"
:result="activeTab.result"
:sql="activeTab.sql"
:loading="activeTab.isExecuting"
:editable="!!activeTab.tableMeta?.primaryKeys?.length"
:database-type="activeConnection?.db_type"
:connection-id="activeTab.connectionId"
:database="activeTab.database"
:table-meta="activeTab.tableMeta"
:on-execute-sql="async (sql: string) => emit('executeSql', sql)"
@reload="emit('reload')"
@paginate="(offset: number, limit: number, whereInput?: string) => emit('paginate', offset, limit, whereInput)"
@sort="(column: string, direction: 'asc' | 'desc' | null, whereInput?: string) => emit('sort', column, direction, whereInput)"
/>
<div v-else-if="activeTab.isExecuting" class="h-full flex flex-col items-center justify-center gap-3 text-muted-foreground text-sm">
<div class="flex items-center">
<Loader2 class="h-5 w-5 animate-spin mr-2" />
{{ t(queryExecutionLabelKey(activeTab)) }}
</div>
<Button
variant="destructive"
size="sm"
class="h-7 gap-1.5"
:disabled="!canCancelQueryExecution(activeTab)"
@click="emit('cancel')"
>
<Loader2 v-if="activeTab.isCancelling" class="h-3.5 w-3.5 animate-spin" />
<Square v-else class="h-3.5 w-3.5 fill-current" />
{{ t('toolbar.stopQuery') }}
</Button>
</div>
</div>
</template>
<!-- Redis mode: key browser -->
<template v-else-if="activeTab.mode === 'redis'">
<div class="flex-1 min-h-0">
<RedisKeyBrowser
:key="activeTab.id"
:connection-id="activeTab.connectionId"
:db="Number(activeTab.database)"
/>
</div>
</template>
<!-- MongoDB mode: document browser -->
<template v-else-if="activeTab.mode === 'mongo'">
<div class="flex-1 min-h-0">
<MongoDocBrowser
:key="activeTab.id"
:connection-id="activeTab.connectionId"
:database="activeTab.database"
:collection="activeTab.sql"
/>
</div>
</template>
</div>
</template>

View File

@ -0,0 +1,159 @@
<script setup lang="ts">
import { computed } from "vue";
import { useI18n } from "vue-i18n";
import { Play, Loader2, Square, Database, Table2, AlignLeft, GitBranch } from "lucide-vue-next";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
import { useConnectionStore } from "@/stores/connectionStore";
import { useDatabaseOptions } from "@/composables/useDatabaseOptions";
import { connectionIconType } from "@/lib/connectionPresentation";
import { connectionDisplayName } from "@/lib/tabPresentation";
import type { QueryTab, ConnectionConfig } from "@/types/database";
const props = defineProps<{
activeTab: QueryTab;
activeConnection?: ConnectionConfig;
executableSql: string;
}>();
const emit = defineEmits<{
execute: [];
cancel: [];
explain: [];
formatSql: [];
changeConnection: [connectionId: string];
changeDatabase: [database: string];
}>();
const { t } = useI18n();
const connectionStore = useConnectionStore();
const { databaseOptions, loadingDatabaseOptions, loadDatabaseOptions } = useDatabaseOptions();
const activeDatabaseOptions = computed(() => {
const connection = props.activeConnection;
return connection ? databaseOptions.value[connection.id] ?? [] : [];
});
const activeDatabaseValue = computed(() => props.activeTab.database || "");
const activeConnectionValue = computed(() => props.activeConnection?.id || "");
function databaseDisplayName(database: string): string {
const connection = props.activeConnection;
if (connection?.db_type === "redis" && database !== "") return `db${database}`;
return database || t("editor.noDatabase");
}
</script>
<template>
<div class="h-9 shrink-0 border-b bg-background/80 px-3 flex items-center gap-1 text-xs text-muted-foreground relative z-10">
<div class="flex items-center gap-0.5">
<Tooltip>
<TooltipTrigger as-child>
<Button
:variant="activeTab.isExecuting ? 'destructive' : 'ghost'"
size="icon"
class="h-6 w-6"
:disabled="activeTab.isCancelling || activeTab.isExplaining || (!activeTab.isExecuting && !executableSql.trim())"
@click="activeTab.isExecuting ? emit('cancel') : emit('execute')"
>
<Loader2 v-if="activeTab.isCancelling" class="h-3.5 w-3.5 animate-spin" />
<Square v-else-if="activeTab.isExecuting" class="h-3.5 w-3.5 fill-current" />
<Play v-else class="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ activeTab.isExecuting ? t('toolbar.stopQuery') : t('toolbar.executeShortcut') }}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<Button
:variant="activeTab.isExplaining ? 'destructive' : 'ghost'"
size="icon"
class="h-6 w-6"
:disabled="activeTab.isExecuting || (!activeTab.isExplaining && !executableSql.trim())"
@click="activeTab.isExplaining ? emit('cancel') : emit('explain')"
>
<Square v-if="activeTab.isExplaining" class="h-3.5 w-3.5 fill-current" />
<GitBranch v-else class="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ activeTab.isExplaining ? t('toolbar.stopExplain') : t('toolbar.explainPlan') }}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="h-6 w-6" :disabled="activeTab.isExecuting || activeTab.isExplaining || !activeTab.sql.trim()" @click="emit('formatSql')">
<AlignLeft class="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ t('toolbar.formatSql') }}</TooltipContent>
</Tooltip>
</div>
<span class="flex-1 min-w-0" />
<div class="flex items-center gap-2 shrink-0">
<div class="flex items-center gap-1">
<span v-if="activeConnection?.color" class="h-4 w-1 rounded-full shrink-0" :style="{ backgroundColor: activeConnection.color }" />
<Select
:model-value="activeConnectionValue"
@update:model-value="(v: any) => emit('changeConnection', v)"
>
<SelectTrigger class="h-6 w-auto max-w-56 border-0 bg-transparent px-1 text-xs font-medium text-foreground shadow-none focus:ring-0">
<div v-if="activeConnection" class="flex min-w-0 items-center gap-1.5">
<DatabaseIcon :db-type="connectionIconType(activeConnection)" class="h-3.5 w-3.5 shrink-0" />
<span class="truncate">{{ connectionDisplayName(activeConnectionValue) }}</span>
</div>
<SelectValue v-else :placeholder="t('editor.selectConnection')" />
</SelectTrigger>
<SelectContent position="popper">
<SelectItem
v-for="connection in connectionStore.connections"
:key="connection.id"
:value="connection.id"
>
<div class="flex min-w-0 items-center gap-2">
<DatabaseIcon :db-type="connectionIconType(connection)" class="h-3.5 w-3.5 shrink-0" />
<span class="truncate">{{ connection.name }}</span>
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
<div class="flex items-center gap-1">
<Database class="h-3.5 w-3.5 shrink-0" />
<Select
:model-value="activeDatabaseValue"
@update:model-value="(v: any) => emit('changeDatabase', v)"
@update:open="(open: boolean) => { if (open && activeConnection) loadDatabaseOptions(activeConnection.id).catch(() => {}) }"
>
<SelectTrigger class="h-6 w-auto max-w-56 border-0 bg-transparent px-1 text-xs shadow-none focus:ring-0">
<SelectValue :placeholder="loadingDatabaseOptions[activeConnection?.id || ''] ? t('common.loading') : t('editor.selectDatabase')">
{{ databaseDisplayName(activeDatabaseValue) }}
</SelectValue>
</SelectTrigger>
<SelectContent position="popper">
<SelectItem
v-for="database in activeDatabaseOptions"
:key="database"
:value="database"
>
{{ databaseDisplayName(database) }}
</SelectItem>
<SelectItem v-if="!activeDatabaseOptions.length && activeDatabaseValue" :value="activeDatabaseValue">
{{ databaseDisplayName(activeDatabaseValue) }}
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div v-if="activeTab.tableMeta" class="flex min-w-0 items-center gap-1 ml-2">
<Table2 class="h-3.5 w-3.5 shrink-0" />
<span class="truncate">{{ activeTab.tableMeta.columns.length }} {{ t('tree.columns') }}</span>
</div>
</div>
</template>

View File

@ -0,0 +1,61 @@
<script setup lang="ts">
import { useI18n } from "vue-i18n";
import { Loader2 } from "lucide-vue-next";
import { Button } from "@/components/ui/button";
import {
Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle,
} from "@/components/ui/dialog";
import type { UpdateInfo } from "@/lib/api";
const open = defineModel<boolean>("open", { required: true });
defineProps<{
updateInfo: UpdateInfo | null
updateCheckMessage: string
isDownloadingUpdate: boolean
downloadProgress: number
updateReady: boolean
}>();
const emit = defineEmits<{
'open-latest-release': []
'download-and-install': []
'restart': []
}>();
const { t } = useI18n();
</script>
<template>
<Dialog v-model:open="open">
<DialogContent class="sm:max-w-[520px]">
<DialogHeader>
<DialogTitle>{{ updateInfo?.update_available ? t('updates.availableTitle') : t('updates.title') }}</DialogTitle>
</DialogHeader>
<div class="space-y-3 text-sm">
<p v-if="updateInfo?.update_available">
{{ t('updates.availableMessage', { current: updateInfo.current_version, latest: updateInfo.latest_version }) }}
</p>
<p v-else class="text-muted-foreground">
{{ updateCheckMessage || t('updates.upToDate', { version: updateInfo?.current_version || '' }) }}
</p>
<div v-if="updateInfo?.update_available && updateInfo.release_notes" class="max-h-48 overflow-auto rounded-md border bg-muted/30 p-3 text-xs whitespace-pre-wrap">
{{ updateInfo.release_notes }}
</div>
</div>
<DialogFooter>
<Button variant="outline" @click="open = false">{{ t('dangerDialog.cancel') }}</Button>
<template v-if="updateInfo?.update_available">
<Button variant="outline" @click="emit('open-latest-release')">{{ t('updates.openRelease') }}</Button>
<Button v-if="updateReady" @click="emit('restart')">{{ t('updates.restart') }}</Button>
<Button v-else-if="isDownloadingUpdate" disabled>
<Loader2 class="h-4 w-4 animate-spin" />
{{ t('updates.downloading', { progress: downloadProgress }) }}
</Button>
<Button v-else @click="emit('download-and-install')">{{ t('updates.downloadAndInstall') }}</Button>
</template>
<Button v-else-if="updateCheckMessage" @click="emit('open-latest-release')">{{ t('updates.openRelease') }}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>

View File

@ -0,0 +1,128 @@
<script setup lang="ts">
import { useI18n } from "vue-i18n";
import { FilePlus2, Plus, History, Upload, Database, Search, ShieldCheck, Sparkles } from "lucide-vue-next";
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
import { connectionDriverLabel, connectionIconType, connectionOptionSubtitle } from "@/lib/connectionPresentation";
import type { ConnectionConfig } from "@/types/database";
defineProps<{
connectionStats: { total: number; connected: number; types: number }
recentConnections: ConnectionConfig[]
appVersion: string
hasConnections: boolean
}>();
const emit = defineEmits<{
'open-connection-query': [connectionId: string]
'new-connection': []
'new-query': []
'show-history': []
'import-config': []
'open-github': []
'open-mcp-guide': []
}>();
const { t } = useI18n();
</script>
<template>
<div class="flex-1 overflow-auto bg-background">
<div class="mx-auto flex min-h-full w-full max-w-5xl flex-col justify-center gap-6 px-8 py-10">
<div class="grid grid-cols-3 gap-3">
<div class="rounded-lg border bg-muted/20 px-4 py-3">
<div class="flex items-center gap-2 text-xs text-muted-foreground">
<Database class="h-3.5 w-3.5" /> {{ t('welcome.connections') }}
</div>
<div class="mt-2 text-2xl font-semibold">{{ connectionStats.total }}</div>
</div>
<div class="rounded-lg border bg-muted/20 px-4 py-3">
<div class="flex items-center gap-2 text-xs text-muted-foreground">
<ShieldCheck class="h-3.5 w-3.5" /> {{ t('welcome.connected') }}
</div>
<div class="mt-2 text-2xl font-semibold">{{ connectionStats.connected }}</div>
</div>
<div class="rounded-lg border bg-muted/20 px-4 py-3">
<div class="flex items-center gap-2 text-xs text-muted-foreground">
<Sparkles class="h-3.5 w-3.5" /> {{ t('welcome.databaseTypes') }}
</div>
<div class="mt-2 text-2xl font-semibold">{{ connectionStats.types }}</div>
</div>
</div>
<div class="grid grid-cols-[1.2fr_0.8fr] gap-4">
<div class="rounded-lg border">
<div class="flex items-center justify-between border-b px-4 py-3">
<div class="text-sm font-medium">{{ t('welcome.quickConnections') }}</div>
</div>
<div class="divide-y">
<button
v-for="connection in recentConnections"
:key="connection.id"
class="flex w-full items-center gap-3 px-4 py-3 text-left hover:bg-muted/40"
@click="emit('open-connection-query', connection.id)"
>
<DatabaseIcon :db-type="connectionIconType(connection)" class="h-4 w-4" />
<span class="h-5 w-1 rounded-full shrink-0" :style="{ backgroundColor: connection.color || '#9ca3af' }" />
<div class="min-w-0 flex-1">
<div class="truncate text-sm font-medium">{{ connection.name }}</div>
<div class="truncate text-xs text-muted-foreground">
{{ connectionOptionSubtitle(connection) || connectionDriverLabel(connection) }}
</div>
</div>
<FilePlus2 class="h-4 w-4 text-muted-foreground" />
</button>
<div v-if="recentConnections.length === 0" class="px-4 py-8 text-sm text-muted-foreground">
{{ t('sidebar.noConnections') }}
</div>
</div>
</div>
<div class="rounded-lg border">
<div class="border-b px-4 py-3">
<div class="text-sm font-medium">{{ t('welcome.shortcuts') }}</div>
</div>
<div class="grid gap-1 p-2">
<button class="flex items-center gap-2 rounded-md px-3 py-2 text-left text-sm hover:bg-muted/50" @click="emit('new-connection')">
<Plus class="h-4 w-4" /> {{ t('toolbar.newConnection') }}
</button>
<button class="flex items-center gap-2 rounded-md px-3 py-2 text-left text-sm hover:bg-muted/50" :disabled="!hasConnections" @click="emit('new-query')">
<FilePlus2 class="h-4 w-4" /> {{ t('toolbar.newQuery') }}
</button>
<button class="flex items-center gap-2 rounded-md px-3 py-2 text-left text-sm hover:bg-muted/50" @click="emit('show-history')">
<History class="h-4 w-4" /> {{ t('history.title') }}
</button>
<button class="flex items-center gap-2 rounded-md px-3 py-2 text-left text-sm hover:bg-muted/50" @click="emit('import-config')">
<Upload class="h-4 w-4" /> {{ t('sidebar.import') }}
</button>
<div class="mt-2 rounded-md bg-muted/30 px-3 py-2 text-xs leading-5 text-muted-foreground">
<Search class="mr-1 inline h-3.5 w-3.5" />
{{ t('welcome.tip') }}
</div>
</div>
</div>
</div>
<!-- MCP Integration Hint -->
<div class="rounded-lg border bg-muted/10 px-5 py-4">
<div class="flex items-start gap-3">
<Sparkles class="h-4 w-4 mt-0.5 text-muted-foreground shrink-0" />
<div class="min-w-0">
<div class="text-sm font-medium">{{ t('welcome.mcpTitle') }}</div>
<p class="mt-1 text-xs leading-5 text-muted-foreground">{{ t('welcome.mcpDescription') }}</p>
<div class="mt-2 flex items-center gap-2">
<code class="rounded bg-muted px-2 py-0.5 text-[11px] select-all">npx @dbx-app/mcp-server</code>
<a href="#" class="text-xs text-primary hover:underline" @click.prevent="emit('open-mcp-guide')">{{ t('welcome.mcpLearnMore') }}</a>
</div>
</div>
</div>
</div>
<!-- Project Info -->
<div class="mt-2 flex items-center justify-center gap-3 text-[11px] text-muted-foreground/60">
<span>DBX {{ appVersion ? 'v' + appVersion : '' }}</span>
<span>·</span>
<a href="#" class="hover:text-foreground transition-colors" @click.prevent="emit('open-github')">GitHub</a>
</div>
</div>
</div>
</template>

View File

@ -28,7 +28,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
<template>
<DialogPortal>
<DialogOverlay
class="fixed inset-0 z-50 grid place-items-center overflow-y-auto bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
class="fixed inset-0 z-50 grid place-items-center overflow-y-auto bg-black/10 supports-backdrop-filter:backdrop-blur-xs data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
>
<DialogContent
:class="

View File

@ -0,0 +1,114 @@
import { ref } from "vue";
import { useI18n } from "vue-i18n";
import { isTauriRuntime } from "@/lib/tauriRuntime";
import { useToast } from "@/composables/useToast";
import * as api from "@/lib/api";
export function useAppUpdater() {
const { t } = useI18n();
const { toast } = useToast();
const checkingUpdates = ref(false);
const updateInfo = ref<api.UpdateInfo | null>(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,
};
}

View File

@ -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<QueryTab | undefined>) {
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 };
}

View File

@ -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<Record<string, string[]>>({});
const loadingDatabaseOptions = ref<Record<string, boolean>>({});
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<string[]> {
if (!databaseOptions.value[connectionId]) {
await loadDatabaseOptions(connectionId);
}
return databaseOptions.value[connectionId] ?? [];
}
return { databaseOptions, loadingDatabaseOptions, loadDatabaseOptions, getDatabaseOptions };
}

View File

@ -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<SidebarLayout | null>(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,
};
}

View File

@ -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 };
}

View File

@ -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<void>, 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 };
}

View File

@ -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<number>, 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,
};
}

View File

@ -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<QueryTab | undefined>;
activeConnection: ComputedRef<ConnectionConfig | undefined>;
executableSql: ComputedRef<string>;
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,
};
}

View File

@ -0,0 +1,26 @@
import { ref, type Ref } from "vue";
export function useTabScroll(tabsContainerRef: Ref<HTMLElement | null>) {
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 };
}

View File

@ -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<void>;
}) {
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 };
}

View File

@ -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 };
}

View File

@ -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;
}