mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-30 00:19:35 +00:00
feat(sidebar): group objects by type and add Object Browser context menus (#222)
Sidebar tree now groups database objects into Tables, Views, Procedures, and Functions folders instead of a flat list. Empty groups are hidden and each group shows its item count. Object Browser rows now have right-click context menus with actions like View Data, New Query, View Source, and Drop.
This commit is contained in:
@@ -12,17 +12,29 @@ import {
|
||||
Search,
|
||||
ScrollText,
|
||||
Table2,
|
||||
TerminalSquare,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-vue-next";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
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 { qualifiedTableName } from "@/lib/tableSelectSql";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { buildExecutableObjectSourceSql, objectSourceSaveExecutionMode } from "@/lib/objectSourceEditor";
|
||||
import { useQueryStore } from "@/stores/queryStore";
|
||||
import QueryEditor from "@/components/editor/QueryEditor.vue";
|
||||
import type { SqlFormatDialect } from "@/lib/sqlFormatter";
|
||||
|
||||
@@ -49,6 +61,7 @@ const emit = defineEmits<{
|
||||
|
||||
const { t } = useI18n();
|
||||
const { toast } = useToast();
|
||||
const queryStore = useQueryStore();
|
||||
|
||||
const schemas = ref<string[]>([]);
|
||||
const selectedSchema = ref<string | undefined>(props.schema);
|
||||
@@ -66,6 +79,8 @@ const sourceDraft = ref("");
|
||||
const sourceSaving = ref(false);
|
||||
const sourceSaveError = ref("");
|
||||
const error = ref("");
|
||||
const showDropConfirm = ref(false);
|
||||
const dropTarget = ref<ObjectRow | null>(null);
|
||||
let loadId = 0;
|
||||
|
||||
const needsSchema = computed(() => isSchemaAware(props.connection.db_type));
|
||||
@@ -196,6 +211,73 @@ async function openSource(row: ObjectRow) {
|
||||
}
|
||||
}
|
||||
|
||||
function openNewQuery(row: ObjectRow) {
|
||||
const tabId = queryStore.createTab(props.connection.id, props.database, row.name);
|
||||
queryStore.updateSql(tabId, `SELECT * FROM ${qualifiedName(row)} LIMIT 100;`);
|
||||
}
|
||||
|
||||
function qualifiedName(row: ObjectRow): string {
|
||||
return qualifiedTableName({
|
||||
databaseType: props.connection.db_type,
|
||||
schema: row.schema || selectedSchema.value,
|
||||
tableName: row.name,
|
||||
});
|
||||
}
|
||||
|
||||
function requestDrop(row: ObjectRow) {
|
||||
dropTarget.value = row;
|
||||
showDropConfirm.value = true;
|
||||
}
|
||||
|
||||
async function confirmDrop() {
|
||||
if (!dropTarget.value) return;
|
||||
const row = dropTarget.value;
|
||||
const typeSql =
|
||||
row.type === "VIEW"
|
||||
? "VIEW"
|
||||
: row.type === "PROCEDURE"
|
||||
? "PROCEDURE"
|
||||
: row.type === "FUNCTION"
|
||||
? "FUNCTION"
|
||||
: "TABLE";
|
||||
const name = qualifiedName(row);
|
||||
try {
|
||||
await api.executeQuery(props.connection.id, props.database, `DROP ${typeSql} ${name}`);
|
||||
const successKey =
|
||||
row.type === "VIEW"
|
||||
? "contextMenu.dropViewSuccess"
|
||||
: row.type === "PROCEDURE"
|
||||
? "contextMenu.dropProcedureSuccess"
|
||||
: row.type === "FUNCTION"
|
||||
? "contextMenu.dropFunctionSuccess"
|
||||
: "contextMenu.dropTableSuccess";
|
||||
toast(t(successKey, { name: row.name }));
|
||||
await reload();
|
||||
} catch (e: any) {
|
||||
toast(t("contextMenu.tableOperationFailed", { message: e?.message || String(e) }), 5000);
|
||||
}
|
||||
dropTarget.value = null;
|
||||
}
|
||||
|
||||
function dropConfirmTitle(): string {
|
||||
if (!dropTarget.value) return "";
|
||||
const type = dropTarget.value.type;
|
||||
if (type === "VIEW") return t("contextMenu.confirmDropViewTitle");
|
||||
if (type === "PROCEDURE") return t("contextMenu.confirmDropProcedureTitle");
|
||||
if (type === "FUNCTION") return t("contextMenu.confirmDropFunctionTitle");
|
||||
return t("contextMenu.confirmDropTableTitle");
|
||||
}
|
||||
|
||||
function dropConfirmMessage(): string {
|
||||
if (!dropTarget.value) return "";
|
||||
const name = dropTarget.value.name;
|
||||
const type = dropTarget.value.type;
|
||||
if (type === "VIEW") return t("contextMenu.confirmDropViewMessage", { name });
|
||||
if (type === "PROCEDURE") return t("contextMenu.confirmDropProcedureMessage", { name });
|
||||
if (type === "FUNCTION") return t("contextMenu.confirmDropFunctionMessage", { name });
|
||||
return t("contextMenu.confirmDropTableMessage", { name });
|
||||
}
|
||||
|
||||
function closeSource() {
|
||||
sourceRow.value = null;
|
||||
sourceContent.value = "";
|
||||
@@ -417,22 +499,62 @@ watch(
|
||||
key-field="id"
|
||||
>
|
||||
<template #default="{ item }">
|
||||
<div
|
||||
class="grid h-[38px] cursor-pointer items-center gap-3 border-b px-3 hover:bg-accent/50"
|
||||
:class="{ 'bg-accent/40': sourceRow?.id === item.id }"
|
||||
:style="{ gridTemplateColumns }"
|
||||
@click="openRow(item)"
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<component :is="iconFor(item)" class="h-3.5 w-3.5 shrink-0" :class="iconClass(item.type)" />
|
||||
<span class="truncate text-[13px] font-medium text-foreground">{{ item.name }}</span>
|
||||
</div>
|
||||
<div class="truncate text-xs text-muted-foreground">{{ typeLabel(item.type) }}</div>
|
||||
<div class="truncate text-xs text-muted-foreground">{{ item.schema || props.database }}</div>
|
||||
<div v-if="hasComments" class="truncate text-xs text-muted-foreground" :title="item.comment || ''">
|
||||
{{ item.comment || "" }}
|
||||
</div>
|
||||
</div>
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger as-child>
|
||||
<div
|
||||
class="grid h-[38px] cursor-pointer items-center gap-3 border-b px-3 hover:bg-accent/50"
|
||||
:class="{ 'bg-accent/40': sourceRow?.id === item.id }"
|
||||
:style="{ gridTemplateColumns }"
|
||||
@click="openRow(item)"
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<component :is="iconFor(item)" class="h-3.5 w-3.5 shrink-0" :class="iconClass(item.type)" />
|
||||
<span class="truncate text-[13px] font-medium text-foreground">{{ item.name }}</span>
|
||||
</div>
|
||||
<div class="truncate text-xs text-muted-foreground">{{ typeLabel(item.type) }}</div>
|
||||
<div class="truncate text-xs text-muted-foreground">{{ item.schema || props.database }}</div>
|
||||
<div v-if="hasComments" class="truncate text-xs text-muted-foreground" :title="item.comment || ''">
|
||||
{{ item.comment || "" }}
|
||||
</div>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent class="w-auto min-w-40">
|
||||
<!-- TABLE -->
|
||||
<template v-if="item.type === 'TABLE'">
|
||||
<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>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem class="text-destructive" @click="requestDrop(item)">
|
||||
<Trash2 class="w-4 h-4 mr-2" /> {{ t("contextMenu.dropTable") }}
|
||||
</ContextMenuItem>
|
||||
</template>
|
||||
<!-- VIEW -->
|
||||
<template v-else-if="item.type === 'VIEW'">
|
||||
<ContextMenuItem @click="openSource(item)">
|
||||
<Code2 class="w-4 h-4 mr-2" /> {{ t("contextMenu.viewSource") }}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem class="text-destructive" @click="requestDrop(item)">
|
||||
<Trash2 class="w-4 h-4 mr-2" /> {{ t("contextMenu.dropView") }}
|
||||
</ContextMenuItem>
|
||||
</template>
|
||||
<!-- PROCEDURE / FUNCTION -->
|
||||
<template v-else>
|
||||
<ContextMenuItem @click="openSource(item)">
|
||||
<Code2 class="w-4 h-4 mr-2" /> {{ t("contextMenu.viewSource") }}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem class="text-destructive" @click="requestDrop(item)">
|
||||
<Trash2 class="w-4 h-4 mr-2" />
|
||||
{{ item.type === "PROCEDURE" ? t("contextMenu.dropProcedure") : t("contextMenu.dropFunction") }}
|
||||
</ContextMenuItem>
|
||||
</template>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
</template>
|
||||
</RecycleScroller>
|
||||
<div v-if="sourceRow" class="flex h-[42%] min-h-44 shrink-0 flex-col border-t bg-background">
|
||||
@@ -520,6 +642,14 @@ watch(
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DangerConfirmDialog
|
||||
v-model:open="showDropConfirm"
|
||||
:title="dropConfirmTitle()"
|
||||
:details="dropConfirmMessage()"
|
||||
:confirm-label="t('dangerDialog.deleteConfirm')"
|
||||
@confirm="confirmDrop"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -38,6 +38,9 @@ import {
|
||||
CopyPlus,
|
||||
Plus,
|
||||
FileText,
|
||||
ScrollText,
|
||||
Braces,
|
||||
Code2,
|
||||
} from "lucide-vue-next";
|
||||
import {
|
||||
ContextMenu,
|
||||
@@ -182,6 +185,18 @@ function getIconInfo(node: TreeNode): { icon: any; colorClass: string } | null {
|
||||
return { icon: Database, colorClass: "text-yellow-500" };
|
||||
case "mongo-collection":
|
||||
return { icon: Table, colorClass: "text-green-400" };
|
||||
case "procedure":
|
||||
return { icon: ScrollText, colorClass: "text-blue-500" };
|
||||
case "function":
|
||||
return { icon: Braces, colorClass: "text-amber-500" };
|
||||
case "group-tables":
|
||||
return { icon: Table, colorClass: "text-green-500" };
|
||||
case "group-views":
|
||||
return { icon: Eye, colorClass: "text-purple-500" };
|
||||
case "group-procedures":
|
||||
return { icon: ScrollText, colorClass: "text-blue-500" };
|
||||
case "group-functions":
|
||||
return { icon: Braces, colorClass: "text-amber-500" };
|
||||
default:
|
||||
return { icon: Database, colorClass: "text-muted-foreground" };
|
||||
}
|
||||
@@ -192,6 +207,10 @@ const groupTypes: Set<TreeNodeType> = new Set([
|
||||
"group-indexes",
|
||||
"group-fkeys",
|
||||
"group-triggers",
|
||||
"group-tables",
|
||||
"group-views",
|
||||
"group-procedures",
|
||||
"group-functions",
|
||||
"saved-sql-root",
|
||||
"saved-sql-folder",
|
||||
]);
|
||||
@@ -232,6 +251,16 @@ async function toggle() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
node.type === "group-tables" ||
|
||||
node.type === "group-views" ||
|
||||
node.type === "group-procedures" ||
|
||||
node.type === "group-functions"
|
||||
) {
|
||||
node.isExpanded = !node.isExpanded;
|
||||
return;
|
||||
}
|
||||
|
||||
if (node.isExpanded) {
|
||||
node.isExpanded = false;
|
||||
return;
|
||||
@@ -512,6 +541,38 @@ const showCreateSchemaDialog = ref(false);
|
||||
const createSchemaName = ref("");
|
||||
const showDropSchemaConfirm = ref(false);
|
||||
|
||||
// --- Procedure / Function Management ---
|
||||
const showDropObjectConfirm = ref(false);
|
||||
|
||||
function buildDropObjectSql(): string {
|
||||
const node = props.node;
|
||||
const keyword = node.type === "procedure" ? "PROCEDURE" : "FUNCTION";
|
||||
const name = qualifiedTableName(node.label, node.schema);
|
||||
return `DROP ${keyword} ${name};`;
|
||||
}
|
||||
|
||||
function viewObjectSource() {
|
||||
void openObjectBrowser();
|
||||
}
|
||||
|
||||
function requestDropObject() {
|
||||
showDropObjectConfirm.value = true;
|
||||
}
|
||||
|
||||
async function confirmDropObject() {
|
||||
const node = props.node;
|
||||
if (!node.connectionId || !node.database) return;
|
||||
try {
|
||||
await connectionStore.ensureConnected(node.connectionId);
|
||||
await api.executeQuery(node.connectionId, node.database, buildDropObjectSql(), node.schema);
|
||||
const msgKey = node.type === "procedure" ? "contextMenu.dropProcedureSuccess" : "contextMenu.dropFunctionSuccess";
|
||||
toast(t(msgKey, { name: node.label }), 3000);
|
||||
await refreshTableList(node);
|
||||
} catch (e: any) {
|
||||
toast(t("contextMenu.tableOperationFailed", { message: e?.message || String(e) }), 5000);
|
||||
}
|
||||
}
|
||||
|
||||
const isTableNotView = computed(() => props.node.type === "table");
|
||||
|
||||
const supportsTruncate = computed(() => {
|
||||
@@ -1190,6 +1251,8 @@ const hasTypeMenu = computed(() => {
|
||||
t === "table" ||
|
||||
t === "view" ||
|
||||
t === "column" ||
|
||||
t === "procedure" ||
|
||||
t === "function" ||
|
||||
t === "saved-sql-root" ||
|
||||
t === "saved-sql-folder" ||
|
||||
t === "saved-sql-file" ||
|
||||
@@ -1471,6 +1534,17 @@ const isDragging = computed(() => dragState.active && dragState.draggedId === pr
|
||||
@vue:mounted="($event: any) => $event.el.focus()"
|
||||
/>
|
||||
<span v-else class="min-w-0 flex-1 truncate">{{ displayLabel(node) }}</span>
|
||||
<span
|
||||
v-if="
|
||||
(node.type === 'group-tables' ||
|
||||
node.type === 'group-views' ||
|
||||
node.type === 'group-procedures' ||
|
||||
node.type === 'group-functions') &&
|
||||
node.objectCount != null
|
||||
"
|
||||
class="text-muted-foreground text-[10px] shrink-0"
|
||||
>{{ node.objectCount }}</span
|
||||
>
|
||||
<Badge v-if="isNodeDefaultDatabase" variant="secondary" class="h-4 px-1.5 text-[10px]">
|
||||
{{ t("editor.defaultDatabase") }}
|
||||
</Badge>
|
||||
@@ -1711,6 +1785,18 @@ const isDragging = computed(() => dragState.active && dragState.draggedId === pr
|
||||
</ContextMenuItem>
|
||||
</template>
|
||||
|
||||
<template v-if="node.type === 'procedure' || node.type === 'function'">
|
||||
<ContextMenuItem @click="viewObjectSource">
|
||||
<Code2 class="w-4 h-4 mr-2" />
|
||||
{{ t("contextMenu.viewSource") }}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem class="text-destructive" @click="requestDropObject">
|
||||
<Trash2 class="w-4 h-4 mr-2" />
|
||||
{{ node.type === "procedure" ? t("contextMenu.dropProcedure") : t("contextMenu.dropFunction") }}
|
||||
</ContextMenuItem>
|
||||
</template>
|
||||
|
||||
<template v-if="isGroupLabel(node)">
|
||||
<ContextMenuItem v-if="node.type === 'saved-sql-root'" @click="openCreateSavedSqlFolder">
|
||||
<FolderPlus class="w-4 h-4" /> {{ t("savedSql.newFolder") }}
|
||||
@@ -1887,6 +1973,21 @@ const isDragging = computed(() => dragState.active && dragState.draggedId === pr
|
||||
@confirm="confirmTruncateTable"
|
||||
/>
|
||||
|
||||
<DangerConfirmDialog
|
||||
v-model:open="showDropObjectConfirm"
|
||||
:title="
|
||||
node.type === 'procedure' ? t('contextMenu.confirmDropProcedureTitle') : t('contextMenu.confirmDropFunctionTitle')
|
||||
"
|
||||
:message="
|
||||
node.type === 'procedure'
|
||||
? t('contextMenu.confirmDropProcedureMessage', { name: node.label })
|
||||
: t('contextMenu.confirmDropFunctionMessage', { name: node.label })
|
||||
"
|
||||
:sql="buildDropObjectSql()"
|
||||
:confirm-label="node.type === 'procedure' ? t('contextMenu.dropProcedure') : t('contextMenu.dropFunction')"
|
||||
@confirm="confirmDropObject"
|
||||
/>
|
||||
|
||||
<Dialog v-model:open="showDuplicateDialog">
|
||||
<DialogContent class="sm:max-w-[400px]">
|
||||
<DialogHeader>
|
||||
|
||||
@@ -544,6 +544,19 @@ export default {
|
||||
confirmTruncateTableTitle: "Truncate Table",
|
||||
confirmTruncateTableMessage: 'Are you sure you want to truncate "{name}"? This will remove all rows.',
|
||||
dropTableSuccess: 'Table "{name}" dropped',
|
||||
viewSource: "View Source",
|
||||
dropView: "Drop View",
|
||||
dropProcedure: "Drop Procedure",
|
||||
dropFunction: "Drop Function",
|
||||
confirmDropViewTitle: "Drop View",
|
||||
confirmDropViewMessage: 'Are you sure you want to drop view "{name}"?',
|
||||
confirmDropProcedureTitle: "Drop Procedure",
|
||||
confirmDropProcedureMessage: 'Are you sure you want to drop procedure "{name}"?',
|
||||
confirmDropFunctionTitle: "Drop Function",
|
||||
confirmDropFunctionMessage: 'Are you sure you want to drop function "{name}"?',
|
||||
dropViewSuccess: 'View "{name}" dropped',
|
||||
dropProcedureSuccess: 'Procedure "{name}" dropped',
|
||||
dropFunctionSuccess: 'Function "{name}" dropped',
|
||||
emptyTableSuccess: 'All data deleted from "{name}"',
|
||||
truncateTableSuccess: 'Table "{name}" truncated',
|
||||
duplicateStructureSuccess: 'Table structure duplicated as "{name}"',
|
||||
@@ -573,6 +586,10 @@ export default {
|
||||
indexes: "Indexes",
|
||||
foreignKeys: "Foreign Keys",
|
||||
triggers: "Triggers",
|
||||
tables: "Tables",
|
||||
views: "Views",
|
||||
procedures: "Procedures",
|
||||
functions: "Functions",
|
||||
objectBrowser: "Browse in Object Browser ({count})",
|
||||
},
|
||||
objects: {
|
||||
|
||||
@@ -545,6 +545,19 @@ export default {
|
||||
confirmTruncateTableTitle: "Truncar tabla",
|
||||
confirmTruncateTableMessage: '¿Estás seguro de que deseas truncar "{name}"? Esto eliminará todas las filas.',
|
||||
dropTableSuccess: 'Tabla "{name}" eliminada',
|
||||
viewSource: "Ver código fuente",
|
||||
dropView: "Eliminar vista",
|
||||
dropProcedure: "Eliminar procedimiento",
|
||||
dropFunction: "Eliminar función",
|
||||
confirmDropViewTitle: "Eliminar vista",
|
||||
confirmDropViewMessage: '¿Seguro que deseas eliminar la vista "{name}"?',
|
||||
confirmDropProcedureTitle: "Eliminar procedimiento",
|
||||
confirmDropProcedureMessage: '¿Seguro que deseas eliminar el procedimiento "{name}"?',
|
||||
confirmDropFunctionTitle: "Eliminar función",
|
||||
confirmDropFunctionMessage: '¿Seguro que deseas eliminar la función "{name}"?',
|
||||
dropViewSuccess: 'Vista "{name}" eliminada',
|
||||
dropProcedureSuccess: 'Procedimiento "{name}" eliminado',
|
||||
dropFunctionSuccess: 'Función "{name}" eliminada',
|
||||
emptyTableSuccess: 'Todos los datos eliminados de "{name}"',
|
||||
truncateTableSuccess: 'Tabla "{name}" truncada',
|
||||
duplicateStructureSuccess: 'Estructura de tabla duplicada como "{name}"',
|
||||
@@ -574,6 +587,10 @@ export default {
|
||||
indexes: "Índices",
|
||||
foreignKeys: "Claves foráneas",
|
||||
triggers: "Disparadores",
|
||||
tables: "Tablas",
|
||||
views: "Vistas",
|
||||
procedures: "Procedimientos",
|
||||
functions: "Funciones",
|
||||
objectBrowser: "Explorar en el navegador de objetos ({count})",
|
||||
},
|
||||
objects: {
|
||||
|
||||
@@ -533,6 +533,19 @@ export default {
|
||||
confirmTruncateTableTitle: "截断表",
|
||||
confirmTruncateTableMessage: "确定要截断「{name}」吗?这将删除所有行。",
|
||||
dropTableSuccess: "表「{name}」已删除",
|
||||
viewSource: "查看源码",
|
||||
dropView: "删除视图",
|
||||
dropProcedure: "删除存储过程",
|
||||
dropFunction: "删除函数",
|
||||
confirmDropViewTitle: "删除视图",
|
||||
confirmDropViewMessage: "确定要删除视图「{name}」吗?",
|
||||
confirmDropProcedureTitle: "删除存储过程",
|
||||
confirmDropProcedureMessage: "确定要删除存储过程「{name}」吗?",
|
||||
confirmDropFunctionTitle: "删除函数",
|
||||
confirmDropFunctionMessage: "确定要删除函数「{name}」吗?",
|
||||
dropViewSuccess: "视图「{name}」已删除",
|
||||
dropProcedureSuccess: "存储过程「{name}」已删除",
|
||||
dropFunctionSuccess: "函数「{name}」已删除",
|
||||
emptyTableSuccess: "已清空「{name}」的所有数据",
|
||||
truncateTableSuccess: "表「{name}」已截断",
|
||||
duplicateStructureSuccess: "已复制表结构为「{name}」",
|
||||
@@ -560,6 +573,10 @@ export default {
|
||||
indexes: "索引",
|
||||
foreignKeys: "外键",
|
||||
triggers: "触发器",
|
||||
tables: "表",
|
||||
views: "视图",
|
||||
procedures: "存储过程",
|
||||
functions: "函数",
|
||||
objectBrowser: "在对象浏览器中查看 ({count})",
|
||||
},
|
||||
objects: {
|
||||
|
||||
@@ -5,6 +5,8 @@ const leafTypes: Set<TreeNodeType> = new Set([
|
||||
"index",
|
||||
"fkey",
|
||||
"trigger",
|
||||
"procedure",
|
||||
"function",
|
||||
"object-browser",
|
||||
"redis-db",
|
||||
"mongo-collection",
|
||||
|
||||
+84
-1
@@ -1,4 +1,4 @@
|
||||
import type { TableInfo, TreeNode } from "@/types/database";
|
||||
import type { ObjectInfo, TableInfo, TreeNode, TreeNodeType } from "@/types/database";
|
||||
|
||||
export function buildTableTreeNodes({
|
||||
nodeId,
|
||||
@@ -25,6 +25,89 @@ export function buildTableTreeNodes({
|
||||
}));
|
||||
}
|
||||
|
||||
function normalizeObjectType(type: string): "TABLE" | "VIEW" | "PROCEDURE" | "FUNCTION" {
|
||||
const v = type.toUpperCase();
|
||||
if (v.includes("VIEW")) return "VIEW";
|
||||
if (v.includes("PROC")) return "PROCEDURE";
|
||||
if (v.includes("FUNC")) return "FUNCTION";
|
||||
return "TABLE";
|
||||
}
|
||||
|
||||
const groupDefs: Array<{
|
||||
key: string;
|
||||
label: string;
|
||||
objectType: string;
|
||||
nodeType: TreeNodeType;
|
||||
childType: TreeNodeType;
|
||||
}> = [
|
||||
{ key: "__tables", label: "tree.tables", objectType: "TABLE", nodeType: "group-tables", childType: "table" },
|
||||
{ key: "__views", label: "tree.views", objectType: "VIEW", nodeType: "group-views", childType: "view" },
|
||||
{
|
||||
key: "__procedures",
|
||||
label: "tree.procedures",
|
||||
objectType: "PROCEDURE",
|
||||
nodeType: "group-procedures",
|
||||
childType: "procedure",
|
||||
},
|
||||
{
|
||||
key: "__functions",
|
||||
label: "tree.functions",
|
||||
objectType: "FUNCTION",
|
||||
nodeType: "group-functions",
|
||||
childType: "function",
|
||||
},
|
||||
];
|
||||
|
||||
export function buildGroupedObjectTreeNodes({
|
||||
nodeId,
|
||||
connectionId,
|
||||
database,
|
||||
schema,
|
||||
objects,
|
||||
}: {
|
||||
nodeId: string;
|
||||
connectionId: string;
|
||||
database: string;
|
||||
schema?: string;
|
||||
objects: ObjectInfo[];
|
||||
}): TreeNode[] {
|
||||
const buckets = new Map<string, ObjectInfo[]>();
|
||||
for (const obj of objects) {
|
||||
const t = normalizeObjectType(obj.object_type);
|
||||
const arr = buckets.get(t) ?? [];
|
||||
arr.push(obj);
|
||||
buckets.set(t, arr);
|
||||
}
|
||||
|
||||
const groups: TreeNode[] = [];
|
||||
for (const def of groupDefs) {
|
||||
const items = buckets.get(def.objectType);
|
||||
if (!items?.length) continue;
|
||||
const isExpandable = def.childType === "table" || def.childType === "view";
|
||||
groups.push({
|
||||
id: `${nodeId}:${def.key}`,
|
||||
label: def.label,
|
||||
type: def.nodeType,
|
||||
connectionId,
|
||||
database,
|
||||
schema,
|
||||
objectCount: items.length,
|
||||
isExpanded: false,
|
||||
children: items.map((obj) => ({
|
||||
id: `${nodeId}:${obj.name}`,
|
||||
label: obj.name,
|
||||
type: def.childType,
|
||||
connectionId,
|
||||
database,
|
||||
schema,
|
||||
isExpanded: false,
|
||||
children: isExpandable ? [] : undefined,
|
||||
})),
|
||||
});
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
export function expandCachedObjectBrowserNodes(nodes: TreeNode[]): TreeNode[] {
|
||||
return nodes.flatMap((node) => {
|
||||
if (node.type === "object-browser") return node.hiddenChildren ?? [];
|
||||
|
||||
@@ -23,7 +23,7 @@ import { isTauriRuntime } from "@/lib/tauriRuntime";
|
||||
import { isSchemaAware, TREE_SCHEMA_TYPES } from "@/lib/databaseCapabilities";
|
||||
import { buildDatabaseTreeNodes } from "@/lib/databaseTree";
|
||||
import { buildSqlServerDatabaseTreeNodes, SQLSERVER_DEFAULT_SCHEMA } from "@/lib/sqlServerTree";
|
||||
import { buildTableTreeNodes, expandCachedObjectBrowserNodes } from "@/lib/tableTree";
|
||||
import { buildGroupedObjectTreeNodes, buildTableTreeNodes, expandCachedObjectBrowserNodes } from "@/lib/tableTree";
|
||||
import { useSavedSqlStore } from "@/stores/savedSqlStore";
|
||||
|
||||
const PINNED_TREE_NODES_STORAGE_KEY = "dbx-pinned-tree-nodes";
|
||||
@@ -685,20 +685,20 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
try {
|
||||
await ensureConnected(connectionId);
|
||||
if (useCachedChildren(node, options)) return;
|
||||
const cacheKey = schemaCacheKey(connectionId, database, schema || "", "tables");
|
||||
const cacheKey = schemaCacheKey(connectionId, database, schema || "", "objects");
|
||||
if (!options?.force && (await loadPersistedTreeChildren(node, cacheKey))) return;
|
||||
|
||||
const querySchema = schema || database;
|
||||
const tables = await api.listTables(connectionId, database, querySchema);
|
||||
const config = getConfig(connectionId);
|
||||
const effectiveSchema = schema || (config?.db_type && isSchemaAware(config.db_type) ? database : undefined);
|
||||
const children = buildTableTreeNodes({
|
||||
nodeId,
|
||||
connectionId,
|
||||
database,
|
||||
schema: effectiveSchema,
|
||||
tables,
|
||||
});
|
||||
let children: TreeNode[];
|
||||
try {
|
||||
const objects = await api.listObjects(connectionId, database, querySchema);
|
||||
children = buildGroupedObjectTreeNodes({ nodeId, connectionId, database, schema: effectiveSchema, objects });
|
||||
} catch {
|
||||
const tables = await api.listTables(connectionId, database, querySchema);
|
||||
children = buildTableTreeNodes({ nodeId, connectionId, database, schema: effectiveSchema, tables });
|
||||
}
|
||||
setChildren(node, children);
|
||||
await savePersistedTreeChildren(cacheKey, children);
|
||||
node.isExpanded = true;
|
||||
@@ -919,6 +919,13 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
await loadForeignKeys(node.connectionId, node.database, node.tableName, node.schema);
|
||||
} else if (node.type === "group-triggers" && node.connectionId && node.database && node.tableName) {
|
||||
await loadTriggers(node.connectionId, node.database, node.tableName, node.schema);
|
||||
} else if (
|
||||
node.type === "group-tables" ||
|
||||
node.type === "group-views" ||
|
||||
node.type === "group-procedures" ||
|
||||
node.type === "group-functions"
|
||||
) {
|
||||
node.isExpanded = true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -167,10 +167,16 @@ export type TreeNodeType =
|
||||
| "schema"
|
||||
| "table"
|
||||
| "view"
|
||||
| "procedure"
|
||||
| "function"
|
||||
| "group-columns"
|
||||
| "group-indexes"
|
||||
| "group-fkeys"
|
||||
| "group-triggers"
|
||||
| "group-tables"
|
||||
| "group-views"
|
||||
| "group-procedures"
|
||||
| "group-functions"
|
||||
| "object-browser"
|
||||
| "saved-sql-root"
|
||||
| "saved-sql-folder"
|
||||
|
||||
Reference in New Issue
Block a user