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.
This commit is contained in:
parent
c2a13f3057
commit
51662c692f
|
|
@ -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<ObjectBrowserRow[]>([]);
|
|||
const rootRef = ref<HTMLElement>();
|
||||
const search = ref("");
|
||||
const objectFilter = ref<ObjectFilter>("all");
|
||||
const userHasSelectedFilter = ref(false);
|
||||
const sortKey = ref<ObjectBrowserSortKey>("name");
|
||||
const sortDirection = ref<ObjectBrowserSortDirection>("asc");
|
||||
const loadingSchemas = ref(false);
|
||||
|
|
@ -104,6 +128,13 @@ const showRenameDialog = ref(false);
|
|||
const renameTarget = ref<ObjectBrowserRow | null>(null);
|
||||
const renameInput = ref("");
|
||||
const renameError = ref("");
|
||||
const showTruncateConfirm = ref(false);
|
||||
const truncateTarget = ref<ObjectBrowserRow | null>(null);
|
||||
const showEmptyConfirm = ref(false);
|
||||
const emptyTarget = ref<ObjectBrowserRow | null>(null);
|
||||
const showDuplicateDialog = ref(false);
|
||||
const duplicateTarget = ref<ObjectBrowserRow | null>(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) }}
|
||||
</button>
|
||||
|
|
@ -747,19 +1065,70 @@ watch(
|
|||
<ContextMenuItem @click="openRow(item)">
|
||||
<Table2 class="w-4 h-4 mr-2" /> {{ t("contextMenu.viewData") }}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem @click="openNewQuery(item)">
|
||||
<TerminalSquare class="w-4 h-4 mr-2" /> {{ t("contextMenu.newQuery") }}
|
||||
<ContextMenuItem v-if="canOpenStructureEditor" @click="openStructureEditor(item)">
|
||||
<PencilRuler class="w-4 h-4 mr-2" /> {{ t("contextMenu.editStructure") }}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem v-if="canRename(item)" @click="requestRename(item)">
|
||||
<Pencil class="w-4 h-4 mr-2" /> {{ t("contextMenu.renameObject") }}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem @click="openNewQuery(item)">
|
||||
<TerminalSquare class="w-4 h-4 mr-2" /> {{ t("contextMenu.newQuery") }}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem v-if="canOpenDiagram" @click="openDiagram(item)">
|
||||
<Network class="w-4 h-4 mr-2" /> {{ t("diagram.open") }}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem v-if="canOpenTableImport" @click="openTableImport(item)">
|
||||
<FileUp class="w-4 h-4 mr-2" /> {{ t("contextMenu.importData") }}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem @click="openDataCompare(item)">
|
||||
<ArrowRightLeft class="w-4 h-4 mr-2" /> {{ t("dataCompare.title") }}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuSub>
|
||||
<ContextMenuSubTrigger>
|
||||
<Download class="w-4 h-4 mr-2" /> {{ t("contextMenu.exportData") }}
|
||||
</ContextMenuSubTrigger>
|
||||
<ContextMenuSubContent>
|
||||
<ContextMenuItem @click="exportData(item, 'csv')">CSV</ContextMenuItem>
|
||||
<ContextMenuItem @click="exportData(item, 'json')">JSON</ContextMenuItem>
|
||||
<ContextMenuItem @click="exportData(item, 'sql')">SQL INSERT</ContextMenuItem>
|
||||
<ContextMenuItem @click="exportDataXlsx(item)">XLSX</ContextMenuItem>
|
||||
</ContextMenuSubContent>
|
||||
</ContextMenuSub>
|
||||
<ContextMenuItem @click="openDatabaseExport(item)">
|
||||
<Download class="w-4 h-4 mr-2" /> {{ t("contextMenu.exportDatabase") }}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem @click="exportStructure(item)">
|
||||
<FileCode class="w-4 h-4 mr-2" /> {{ t("contextMenu.exportStructure") }}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem @click="requestDuplicateStructure(item)">
|
||||
<CopyPlus class="w-4 h-4 mr-2" /> {{ t("contextMenu.duplicateStructure") }}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
v-if="supportsTruncateTable"
|
||||
class="text-destructive"
|
||||
@click="requestTruncateTable(item)"
|
||||
>
|
||||
<Scissors class="w-4 h-4 mr-2" /> {{ t("contextMenu.truncateTable") }}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem class="text-destructive" @click="requestEmptyTable(item)">
|
||||
<Eraser class="w-4 h-4 mr-2" /> {{ t("contextMenu.emptyTable") }}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem class="text-destructive" @click="requestDrop(item)">
|
||||
<Trash2 class="w-4 h-4 mr-2" /> {{ t("contextMenu.dropTable") }}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem @click="copyName(item)">
|
||||
<Copy class="w-4 h-4 mr-2" /> {{ t("contextMenu.copyName") }}
|
||||
</ContextMenuItem>
|
||||
</template>
|
||||
<!-- VIEW -->
|
||||
<template v-else-if="item.type === 'VIEW'">
|
||||
<ContextMenuItem @click="openViewData(item)">
|
||||
<Table2 class="w-4 h-4 mr-2" /> {{ t("contextMenu.viewData") }}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem @click="openSource(item)">
|
||||
<Code2 class="w-4 h-4 mr-2" /> {{ t("contextMenu.viewSource") }}
|
||||
</ContextMenuItem>
|
||||
|
|
@ -769,10 +1138,38 @@ watch(
|
|||
<ContextMenuItem v-if="canRename(item)" @click="requestRename(item)">
|
||||
<Pencil class="w-4 h-4 mr-2" /> {{ t("contextMenu.renameObject") }}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem @click="openNewQuery(item)">
|
||||
<TerminalSquare class="w-4 h-4 mr-2" /> {{ t("contextMenu.newQuery") }}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem v-if="canOpenDiagram" @click="openDiagram(item)">
|
||||
<Network class="w-4 h-4 mr-2" /> {{ t("diagram.open") }}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuSub>
|
||||
<ContextMenuSubTrigger>
|
||||
<Download class="w-4 h-4 mr-2" /> {{ t("contextMenu.exportData") }}
|
||||
</ContextMenuSubTrigger>
|
||||
<ContextMenuSubContent>
|
||||
<ContextMenuItem @click="exportData(item, 'csv')">CSV</ContextMenuItem>
|
||||
<ContextMenuItem @click="exportData(item, 'json')">JSON</ContextMenuItem>
|
||||
<ContextMenuItem @click="exportData(item, 'sql')">SQL INSERT</ContextMenuItem>
|
||||
<ContextMenuItem @click="exportDataXlsx(item)">XLSX</ContextMenuItem>
|
||||
</ContextMenuSubContent>
|
||||
</ContextMenuSub>
|
||||
<ContextMenuItem @click="openDatabaseExport(item)">
|
||||
<Download class="w-4 h-4 mr-2" /> {{ t("contextMenu.exportDatabase") }}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem @click="exportStructure(item)">
|
||||
<FileCode class="w-4 h-4 mr-2" /> {{ t("contextMenu.exportStructure") }}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem class="text-destructive" @click="requestDrop(item)">
|
||||
<Trash2 class="w-4 h-4 mr-2" /> {{ t("contextMenu.dropView") }}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem @click="copyName(item)">
|
||||
<Copy class="w-4 h-4 mr-2" /> {{ t("contextMenu.copyName") }}
|
||||
</ContextMenuItem>
|
||||
</template>
|
||||
<!-- PROCEDURE / FUNCTION -->
|
||||
<template v-else>
|
||||
|
|
@ -787,6 +1184,10 @@ watch(
|
|||
<Trash2 class="w-4 h-4 mr-2" />
|
||||
{{ item.type === "PROCEDURE" ? t("contextMenu.dropProcedure") : t("contextMenu.dropFunction") }}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem @click="copyName(item)">
|
||||
<Copy class="w-4 h-4 mr-2" /> {{ t("contextMenu.copyName") }}
|
||||
</ContextMenuItem>
|
||||
</template>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
|
|
@ -913,6 +1314,43 @@ watch(
|
|||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<DangerConfirmDialog
|
||||
v-model:open="showTruncateConfirm"
|
||||
:title="t('contextMenu.confirmTruncateTableTitle')"
|
||||
:message="t('contextMenu.confirmTruncateTableMessage', { name: truncateTarget?.name ?? '' })"
|
||||
:sql="truncateTarget ? buildTruncateTableSql(truncateTarget) : ''"
|
||||
:confirm-label="t('contextMenu.truncateTable')"
|
||||
@confirm="confirmTruncateTable"
|
||||
/>
|
||||
|
||||
<DangerConfirmDialog
|
||||
v-model:open="showEmptyConfirm"
|
||||
:title="t('contextMenu.confirmEmptyTableTitle')"
|
||||
:message="t('contextMenu.confirmEmptyTableMessage', { name: emptyTarget?.name ?? '' })"
|
||||
:sql="emptyTarget ? buildEmptyTableSql(emptyTarget) : ''"
|
||||
:confirm-label="t('contextMenu.emptyTable')"
|
||||
@confirm="confirmEmptyTable"
|
||||
/>
|
||||
|
||||
<Dialog v-model:open="showDuplicateDialog">
|
||||
<DialogContent class="sm:max-w-[400px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ t("contextMenu.duplicateNameTitle") }}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Input
|
||||
v-model="duplicateTableName"
|
||||
:placeholder="t('contextMenu.duplicateNamePlaceholder')"
|
||||
@keydown.enter.prevent="confirmDuplicateStructure"
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="showDuplicateDialog = false">{{ t("dangerDialog.cancel") }}</Button>
|
||||
<Button :disabled="!duplicateTableName.trim()" @click="confirmDuplicateStructure">
|
||||
{{ t("dangerDialog.confirm") }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
|
|
|||
|
|
@ -90,6 +90,28 @@ fn mysql_datetime_to_string(value: DateTime<Utc>) -> String {
|
|||
value.naive_utc().to_string()
|
||||
}
|
||||
|
||||
fn is_mysql_lossless_integer_type(type_name: &str) -> bool {
|
||||
let upper_type = type_name.to_uppercase();
|
||||
upper_type.contains("BIGINT") || upper_type.contains("LARGEINT")
|
||||
}
|
||||
|
||||
fn mysql_lossless_integer_to_json(row: &MySqlRow, idx: usize) -> serde_json::Value {
|
||||
row.try_get::<String, _>(idx)
|
||||
.map(serde_json::Value::String)
|
||||
.or_else(|_| row.try_get::<Decimal, _>(idx).map(|v: Decimal| serde_json::Value::String(v.to_string())))
|
||||
.or_else(|_| row.try_get::<i64, _>(idx).map(|v| serde_json::Value::String(v.to_string())))
|
||||
.or_else(|_| row.try_get::<u64, _>(idx).map(|v| serde_json::Value::String(v.to_string())))
|
||||
.or_else(|_| {
|
||||
row.try_get::<Vec<u8>, _>(idx).map(|b| serde_json::Value::String(String::from_utf8_lossy(&b).to_string()))
|
||||
})
|
||||
.or_else(|_| row.try_get_unchecked::<String, _>(idx).map(serde_json::Value::String))
|
||||
.or_else(|_| {
|
||||
row.try_get_unchecked::<Vec<u8>, _>(idx)
|
||||
.map(|b| serde_json::Value::String(String::from_utf8_lossy(&b).to_string()))
|
||||
})
|
||||
.unwrap_or(serde_json::Value::Null)
|
||||
}
|
||||
|
||||
fn mysql_value_to_json(row: &MySqlRow, idx: usize, type_name: &str) -> serde_json::Value {
|
||||
if row.try_get_raw(idx).map(|v| v.is_null()).unwrap_or(true) {
|
||||
return serde_json::Value::Null;
|
||||
|
|
@ -116,12 +138,8 @@ fn mysql_value_to_json(row: &MySqlRow, idx: usize, type_name: &str) -> serde_jso
|
|||
.unwrap_or(serde_json::Value::Null);
|
||||
}
|
||||
|
||||
if upper_type.contains("BIGINT") {
|
||||
return row
|
||||
.try_get::<i64, _>(idx)
|
||||
.map(|v| serde_json::Value::String(v.to_string()))
|
||||
.or_else(|_| row.try_get::<u64, _>(idx).map(|v| serde_json::Value::String(v.to_string())))
|
||||
.unwrap_or(serde_json::Value::Null);
|
||||
if is_mysql_lossless_integer_type(&upper_type) {
|
||||
return mysql_lossless_integer_to_json(row, idx);
|
||||
}
|
||||
|
||||
if upper_type == "DECIMAL" {
|
||||
|
|
@ -678,6 +696,11 @@ mod tests {
|
|||
assert!(sql.contains("c.COLUMN_KEY"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mysql_largeint_uses_lossless_integer_decoding() {
|
||||
assert!(is_mysql_lossless_integer_type("LARGEINT"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mysql_column_key_marks_primary_when_key_column_usage_is_unavailable() {
|
||||
let primary_key_columns = HashSet::new();
|
||||
|
|
|
|||
Loading…
Reference in New Issue