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:
t8y2
2026-05-12 14:33:52 +08:00
parent 76ff987d00
commit 53a45330a6
9 changed files with 407 additions and 27 deletions
+146 -16
View File
@@ -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>
+101
View File
@@ -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>