mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-31 16:24:33 +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>
|
||||
|
||||
Reference in New Issue
Block a user