From 51662c692f725d3e9ee5c7b11d0310df1dc7111b Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Fri, 22 May 2026 19:15:22 +0800 Subject: [PATCH] feat(objects): default filter to tables and align context menu with sidebar (#416) Object browser now defaults to showing tables when tables exist. Table/view context menus match sidebar tree with structure editing, export, import, diagram, data compare, truncate, empty, duplicate, and copy name support. --- .../src/components/objects/ObjectBrowser.vue | 448 +++++++++++++++++- crates/dbx-core/src/db/mysql.rs | 35 +- 2 files changed, 472 insertions(+), 11 deletions(-) diff --git a/apps/desktop/src/components/objects/ObjectBrowser.vue b/apps/desktop/src/components/objects/ObjectBrowser.vue index eeb8a615ce..b47d1cdd9e 100644 --- a/apps/desktop/src/components/objects/ObjectBrowser.vue +++ b/apps/desktop/src/components/objects/ObjectBrowser.vue @@ -3,15 +3,24 @@ import { computed, ref, watch } from "vue"; import { RecycleScroller } from "vue-virtual-scroller"; import { ArrowDown, + ArrowRightLeft, ArrowUp, Braces, Code2, Copy, + CopyPlus, + Download, + Eraser, Eye, + FileCode, + FileUp, Loader2, + Network, Pencil, PencilLine, + PencilRuler, RefreshCw, + Scissors, Search, ScrollText, Table2, @@ -28,13 +37,24 @@ import { ContextMenuContent, ContextMenuItem, ContextMenuSeparator, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, ContextMenuTrigger, } from "@/components/ui/context-menu"; import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue"; import * as api from "@/lib/api"; import type { ConnectionConfig, ObjectInfo, ObjectSourceKind } from "@/types/database"; import { isSchemaAware } from "@/lib/databaseCapabilities"; -import { buildTableSelectSql, qualifiedTableName } from "@/lib/tableSelectSql"; +import { + supportsSchemaDiagram, + supportsTableImport, + supportsTableStructureEditing, + supportsTableTruncate, + usesPostgresLikeStructureCopy, + usesFetchFirst, +} from "@/lib/databaseFeatureSupport"; +import { buildTableSelectSql, qualifiedTableName, quoteTableIdentifier } from "@/lib/tableSelectSql"; import { useToast } from "@/composables/useToast"; import { buildExecutableObjectSourceStatements, @@ -44,6 +64,9 @@ import { } from "@/lib/objectSourceEditor"; import { buildRenameObjectSql, supportsObjectRename } from "@/lib/objectRenameSql"; import { buildViewDdl } from "@/lib/viewDdl"; +import { isTauriRuntime } from "@/lib/tauriRuntime"; +import { formatSqlInsert } from "@/lib/exportFormats"; +import { fetchTableDataForExport } from "@/lib/tableDataExport"; import { useConnectionStore } from "@/stores/connectionStore"; import { useQueryStore } from "@/stores/queryStore"; import QueryEditor from "@/components/editor/QueryEditor.vue"; @@ -85,6 +108,7 @@ const rows = ref([]); const rootRef = ref(); const search = ref(""); const objectFilter = ref("all"); +const userHasSelectedFilter = ref(false); const sortKey = ref("name"); const sortDirection = ref("asc"); const loadingSchemas = ref(false); @@ -104,6 +128,13 @@ const showRenameDialog = ref(false); const renameTarget = ref(null); const renameInput = ref(""); const renameError = ref(""); +const showTruncateConfirm = ref(false); +const truncateTarget = ref(null); +const showEmptyConfirm = ref(false); +const emptyTarget = ref(null); +const showDuplicateDialog = ref(false); +const duplicateTarget = ref(null); +const duplicateTableName = ref(""); let loadId = 0; const needsSchema = computed(() => isSchemaAware(props.connection.db_type)); @@ -111,6 +142,10 @@ const tableCount = computed(() => rows.value.filter((row) => row.type === "TABLE const viewCount = computed(() => rows.value.filter((row) => row.type === "VIEW").length); const procedureCount = computed(() => rows.value.filter((row) => row.type === "PROCEDURE").length); const functionCount = computed(() => rows.value.filter((row) => row.type === "FUNCTION").length); +const canOpenStructureEditor = computed(() => supportsTableStructureEditing(props.connection.db_type)); +const canOpenDiagram = computed(() => !!props.database && supportsSchemaDiagram(props.connection.db_type)); +const canOpenTableImport = computed(() => !!props.database && supportsTableImport(props.connection.db_type)); +const supportsTruncateTable = computed(() => supportsTableTruncate(props.connection.db_type)); const sourceDialect = computed<"mysql" | "postgres" | "sqlserver">(() => { if (props.connection.db_type === "postgres" || props.connection.db_type === "gaussdb") return "postgres"; if (props.connection.db_type === "sqlserver") return "sqlserver"; @@ -439,6 +474,277 @@ function closeSource() { sourceSaveError.value = ""; } +function quoteIdent(name: string): string { + return quoteTableIdentifier(props.connection.db_type, name); +} + +async function saveFileContent(content: string, defaultFileName: string, filterName: string, filterExt: string) { + if (isTauriRuntime()) { + const { save } = await import("@tauri-apps/plugin-dialog"); + const { writeTextFile } = await import("@tauri-apps/plugin-fs"); + const path = await save({ + defaultPath: defaultFileName, + filters: [{ name: filterName, extensions: [filterExt] }], + }); + if (path) await writeTextFile(path, content); + } else { + const blob = new Blob([content], { type: "text/plain" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = defaultFileName; + a.click(); + URL.revokeObjectURL(url); + } +} + +function openViewData(row: ObjectBrowserRow) { + emit("openTable", { tableName: row.name, schema: row.schema }); +} + +function openStructureEditor(row: ObjectBrowserRow) { + if (row.type !== "TABLE") return; + connectionStore.structureEditorSource = { + connectionId: props.connection.id, + database: props.database, + schema: row.schema || selectedSchema.value, + tableName: row.name, + }; +} + +function openDiagram(row: ObjectBrowserRow) { + connectionStore.diagramSource = { + connectionId: props.connection.id, + database: props.database, + schema: row.schema || selectedSchema.value, + tableName: row.type === "TABLE" ? row.name : undefined, + }; +} + +function openTableImport(row: ObjectBrowserRow) { + if (row.type !== "TABLE") return; + connectionStore.tableImportSource = { + connectionId: props.connection.id, + database: props.database, + schema: row.schema || selectedSchema.value, + tableName: row.name, + }; +} + +function openDataCompare(row: ObjectBrowserRow) { + connectionStore.dataCompareSource = { + connectionId: props.connection.id, + database: props.database, + schema: row.schema || selectedSchema.value, + tableName: row.type === "TABLE" ? row.name : undefined, + }; +} + +function openDatabaseExport(row: ObjectBrowserRow) { + connectionStore.databaseExportSource = { + connectionId: props.connection.id, + database: props.database, + schema: row.schema || selectedSchema.value, + tableName: row.type === "TABLE" || row.type === "VIEW" ? row.name : undefined, + }; +} + +async function exportStructure(row: ObjectBrowserRow) { + try { + const schema = row.schema || selectedSchema.value || props.database; + const ddl = await api.getTableDdl(props.connection.id, props.database, schema, row.name); + await saveFileContent(ddl + "\n", `${row.name}.sql`, "SQL", "sql"); + } catch (e: any) { + console.error("Export structure failed:", e); + } +} + +async function exportData(row: ObjectBrowserRow, format: "csv" | "json" | "sql") { + try { + const schema = row.schema || selectedSchema.value; + const qualName = + isSchemaAware(props.connection.db_type) && schema + ? `${quoteIdent(schema)}.${quoteIdent(row.name)}` + : quoteIdent(row.name); + const queryColumns = + props.connection.db_type === "neo4j" + ? (await api.getColumns(props.connection.id, props.database, schema || props.database, row.name)).map( + (column) => column.name, + ) + : undefined; + const result = await fetchTableDataForExport({ + databaseType: props.connection.db_type, + schema, + tableName: row.name, + columns: queryColumns, + executePage: (sql) => api.executeQuery(props.connection.id, props.database, sql), + }); + + if (format === "csv") { + let outputPath = `${row.name}.csv`; + if (isTauriRuntime()) { + const { save } = await import("@tauri-apps/plugin-dialog"); + const path = await save({ + defaultPath: outputPath, + filters: [{ name: "CSV", extensions: ["csv"] }], + }); + if (!path) return; + outputPath = path as string; + } + await api.exportQueryResultCsv(outputPath, result.columns, result.rows); + toast(t("grid.exported")); + return; + } + + if (format === "json") { + let outputPath = `${row.name}.json`; + if (isTauriRuntime()) { + const { save } = await import("@tauri-apps/plugin-dialog"); + const path = await save({ + defaultPath: outputPath, + filters: [{ name: "JSON", extensions: ["json"] }], + }); + if (!path) return; + outputPath = path as string; + } + await api.exportQueryResultJson(outputPath, result.columns, result.rows); + toast(t("grid.exported")); + return; + } + + const content = formatSqlInsert(qualName, result.columns, result.rows, quoteIdent); + await saveFileContent(content, `${row.name}.sql`, "SQL", "sql"); + toast(t("grid.exported")); + } catch (e: any) { + toast(t("grid.exportFailed", { message: e?.message || String(e) }), 5000); + } +} + +async function exportDataXlsx(row: ObjectBrowserRow) { + try { + const schema = row.schema || selectedSchema.value; + const queryColumns = + props.connection.db_type === "neo4j" + ? (await api.getColumns(props.connection.id, props.database, schema || props.database, row.name)).map( + (column) => column.name, + ) + : undefined; + const result = await fetchTableDataForExport({ + databaseType: props.connection.db_type, + schema, + tableName: row.name, + columns: queryColumns, + executePage: (sql) => api.executeQuery(props.connection.id, props.database, sql), + }); + + let outputPath = `${row.name}.xlsx`; + if (isTauriRuntime()) { + const { save } = await import("@tauri-apps/plugin-dialog"); + const path = await save({ + defaultPath: outputPath, + filters: [{ name: "Excel", extensions: ["xlsx"] }], + }); + if (!path) return; + outputPath = path as string; + } + await api.exportQueryResultXlsx(outputPath, row.name, result.columns, result.rows); + toast(t("grid.exported")); + } catch (e: any) { + toast(t("grid.exportFailed", { message: e?.message || String(e) }), 5000); + } +} + +function requestDuplicateStructure(row: ObjectBrowserRow) { + duplicateTarget.value = row; + duplicateTableName.value = `${row.name}_copy`; + showDuplicateDialog.value = true; +} + +async function confirmDuplicateStructure() { + const row = duplicateTarget.value; + const newName = duplicateTableName.value.trim(); + if (!row || !newName) return; + showDuplicateDialog.value = false; + try { + const schema = row.schema || selectedSchema.value; + const source = qualifiedName(row); + const target = qualifiedTableName({ + databaseType: props.connection.db_type, + schema, + tableName: newName, + }); + let sql: string; + const dbType = props.connection.db_type; + if (dbType === "mysql") { + sql = `CREATE TABLE ${target} LIKE ${source};`; + } else if (usesPostgresLikeStructureCopy(dbType)) { + sql = `CREATE TABLE ${target} (LIKE ${source} INCLUDING ALL);`; + } else if (dbType === "sqlserver") { + sql = `SELECT TOP 0 * INTO ${target} FROM ${source};`; + } else if (usesFetchFirst(dbType)) { + sql = `CREATE TABLE ${target} AS SELECT * FROM ${source} WHERE 1=0`; + } else { + sql = `CREATE TABLE ${target} AS SELECT * FROM ${source} WHERE 0;`; + } + await api.executeQuery(props.connection.id, props.database, sql, schema); + toast(t("contextMenu.duplicateStructureSuccess", { name: newName })); + await reload(); + await connectionStore.refreshObjectListTreeNode(props.connection.id, props.database, schema); + } catch (e: any) { + toast(t("contextMenu.tableOperationFailed", { message: e?.message || String(e) }), 5000); + } +} + +function buildTruncateTableSql(row: ObjectBrowserRow): string { + const name = qualifiedName(row); + const dbType = props.connection.db_type; + if (dbType === "sqlite" || dbType === "duckdb") return `DELETE FROM ${name};`; + return `TRUNCATE TABLE ${name};`; +} + +function requestTruncateTable(row: ObjectBrowserRow) { + truncateTarget.value = row; + showTruncateConfirm.value = true; +} + +async function confirmTruncateTable() { + const row = truncateTarget.value; + if (!row) return; + try { + await api.executeQuery(props.connection.id, props.database, buildTruncateTableSql(row)); + toast(t("contextMenu.truncateTableSuccess", { name: row.name })); + } catch (e: any) { + toast(t("contextMenu.tableOperationFailed", { message: e?.message || String(e) }), 5000); + } + truncateTarget.value = null; +} + +function buildEmptyTableSql(row: ObjectBrowserRow): string { + return `DELETE FROM ${qualifiedName(row)};`; +} + +function requestEmptyTable(row: ObjectBrowserRow) { + emptyTarget.value = row; + showEmptyConfirm.value = true; +} + +async function confirmEmptyTable() { + const row = emptyTarget.value; + if (!row) return; + try { + await api.executeQuery(props.connection.id, props.database, buildEmptyTableSql(row)); + toast(t("contextMenu.emptyTableSuccess", { name: row.name })); + } catch (e: any) { + toast(t("contextMenu.tableOperationFailed", { message: e?.message || String(e) }), 5000); + } + emptyTarget.value = null; +} + +function copyName(row: ObjectBrowserRow) { + navigator.clipboard.writeText(row.name); + toast(t("connection.copied"), 2000); +} + function copySource() { if (!sourceContent.value) return; navigator.clipboard.writeText(sourceContent.value); @@ -527,7 +833,12 @@ async function loadObjects() { if (id !== loadId) return; error.value = e?.message || String(e); } finally { - if (id === loadId) loadingObjects.value = false; + if (id === loadId) { + loadingObjects.value = false; + if (!userHasSelectedFilter.value && tableCount.value > 0) { + objectFilter.value = "tables"; + } + } } } @@ -539,6 +850,8 @@ async function reload() { function onSchemaChange(value: any) { selectedSchema.value = typeof value === "string" && value ? value : undefined; emit("schemaChange", selectedSchema.value); + userHasSelectedFilter.value = false; + objectFilter.value = "all"; void loadObjects(); } @@ -588,6 +901,8 @@ watch( () => [props.connection.id, props.database, props.schema] as const, () => { selectedSchema.value = props.schema; + userHasSelectedFilter.value = false; + objectFilter.value = "all"; void reload(); }, { immediate: true }, @@ -622,7 +937,10 @@ watch( type="button" class="h-6 rounded-sm px-2 text-xs text-muted-foreground transition-colors hover:text-foreground" :class="{ 'bg-background text-foreground shadow-sm': objectFilter === filter }" - @click="objectFilter = filter" + @click=" + userHasSelectedFilter = true; + objectFilter = filter; + " > {{ filterLabel(filter) }} @@ -747,19 +1065,70 @@ watch( {{ t("contextMenu.viewData") }} - - {{ t("contextMenu.newQuery") }} + + {{ t("contextMenu.editStructure") }} {{ t("contextMenu.renameObject") }} + + {{ t("contextMenu.newQuery") }} + + + {{ t("diagram.open") }} + + + {{ t("contextMenu.importData") }} + + + {{ t("dataCompare.title") }} + + + + {{ t("contextMenu.exportData") }} + + + CSV + JSON + SQL INSERT + XLSX + + + + {{ t("contextMenu.exportDatabase") }} + + + {{ t("contextMenu.exportStructure") }} + + + + {{ t("contextMenu.duplicateStructure") }} + + + + {{ t("contextMenu.truncateTable") }} + + + {{ t("contextMenu.emptyTable") }} + {{ t("contextMenu.dropTable") }} + + + {{ t("contextMenu.copyName") }} + @@ -913,6 +1314,43 @@ watch( + + + + + + + + + {{ t("contextMenu.duplicateNameTitle") }} + + + + + + + +