mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-31 08:23:34 +00:00
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:
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user