feat(objects): support object browser column resizing

This commit is contained in:
林剑凛
2026-06-26 13:23:17 +08:00
committed by GitHub
parent 0e1d2dd32e
commit 41f2468313
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { computed, onBeforeUnmount, ref, watch } from "vue";
import { RecycleScroller } from "vue-virtual-scroller";
import { useSqlHighlighter } from "@/composables/useSqlHighlighter";
import {
@@ -17,6 +17,7 @@ import {
Eraser,
Eye,
FileCode,
GripVertical,
ListTree,
Upload,
Loader2,
@@ -83,6 +84,7 @@ import {
} from "@/lib/objectBrowserRows";
type ObjectFilter = "all" | "tables" | "views" | "procedures" | "functions" | "sequences" | "packages";
type ObjectBrowserColumnKey = "select" | "name" | "type" | "estimatedRows" | "totalBytes" | "created_at" | "updated_at" | "comment";
const props = defineProps<{
connection: ConnectionConfig;
@@ -148,7 +150,18 @@ const selectedTableIds = ref<Set<string>>(new Set());
const expandedPartitionParentIds = ref<Set<string>>(new Set());
const showBatchDropConfirm = ref(false);
const batchDropPreviewSql = ref("");
const objectColumnWidths = ref<Record<ObjectBrowserColumnKey, number>>({
select: 34,
name: 360,
type: 110,
estimatedRows: 110,
totalBytes: 100,
created_at: 150,
updated_at: 150,
comment: 260,
});
let loadId = 0;
let stopColumnResize: (() => void) | null = null;
// Export via background tracker
const { addTask: addExportTask } = useExportTracker();
@@ -202,12 +215,22 @@ const objectFilters = computed<ObjectFilter[]>(() =>
const showObjectFilter = computed(() => objectFilters.value.length > 2);
const hasCreatedAt = computed(() => rows.value.some((row) => row.created_at?.trim()));
const hasUpdatedAt = computed(() => rows.value.some((row) => row.updated_at?.trim()));
const objectBrowserColumns = computed<ObjectBrowserColumnKey[]>(() => {
const columns: ObjectBrowserColumnKey[] = ["select", "name", "type", "estimatedRows", "totalBytes"];
if (hasCreatedAt.value) columns.push("created_at");
if (hasUpdatedAt.value) columns.push("updated_at");
columns.push("comment");
return columns;
});
const gridTemplateColumns = computed(() => {
const columns = ["34px", "minmax(160px,1fr)", "110px", "110px", "100px"];
if (hasCreatedAt.value) columns.push("150px");
if (hasUpdatedAt.value) columns.push("150px");
columns.push("minmax(160px,0.7fr)");
return columns.join(" ");
return objectBrowserColumns.value
.map((key, index, columns) => {
const width = objectColumnWidths.value[key];
if (key === "select") return `${width}px`;
if (index === columns.length - 1) return `minmax(${width}px,1fr)`;
return `${width}px`;
})
.join(" ");
});
const partitionRowsByParentId = computed(() => {
const groups = new Map<string, ObjectBrowserRow[]>();
@@ -262,6 +285,49 @@ function toggleSort(key: ObjectBrowserSortKey) {
sortDirection.value = initialObjectBrowserSortDirection(key);
}
function minimumColumnWidth(key: ObjectBrowserColumnKey) {
if (key === "select") return 34;
if (key === "name" || key === "comment") return 120;
return 72;
}
function onObjectColumnResizeStart(key: ObjectBrowserColumnKey, event: MouseEvent) {
event.preventDefault();
event.stopPropagation();
stopColumnResize?.();
const startX = event.clientX;
const startWidth = objectColumnWidths.value[key];
const minWidth = minimumColumnWidth(key);
document.body.classList.add("select-none", "cursor-col-resize");
const onMove = (moveEvent: MouseEvent) => {
objectColumnWidths.value = {
...objectColumnWidths.value,
[key]: Math.max(minWidth, startWidth + moveEvent.clientX - startX),
};
};
const onUp = () => {
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
document.body.classList.remove("select-none", "cursor-col-resize");
stopColumnResize = null;
};
stopColumnResize = onUp;
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
}
function resetObjectColumnWidth(key: ObjectBrowserColumnKey, width: number, event: MouseEvent) {
event.preventDefault();
event.stopPropagation();
objectColumnWidths.value = {
...objectColumnWidths.value,
[key]: width,
};
}
function rowMatchesObjectFilter(row: ObjectBrowserRow) {
if (objectFilter.value === "tables") return row.type === "TABLE";
if (objectFilter.value === "views") return row.type === "VIEW" || row.type === "MATERIALIZED_VIEW";
@@ -1149,6 +1215,10 @@ function onSearchKeydown(event: KeyboardEvent) {
defineExpose({ focusSearch });
onBeforeUnmount(() => {
stopColumnResize?.();
});
watch(
() => [props.connection.id, props.database, props.schema] as const,
() => {
@@ -1357,38 +1427,94 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
</div>
<div v-else class="flex min-h-0 flex-1 flex-col">
<div class="grid h-7 shrink-0 items-center gap-3 border-b bg-muted/40 px-3 text-xs font-medium text-muted-foreground" :style="{ gridTemplateColumns }">
<button class="flex h-6 w-6 items-center justify-center rounded-sm hover:bg-accent" type="button" :disabled="visibleSelectableRows.length === 0" @click="toggleVisibleTableSelection">
<CheckSquare v-if="allVisibleTablesSelected" class="h-3.5 w-3.5 text-primary" />
<Square v-else class="h-3.5 w-3.5" />
</button>
<button class="flex min-w-0 items-center gap-1 truncate text-left" type="button" @click="toggleSort('name')">
<span class="truncate">{{ t("objects.name") }}</span>
<component :is="sortIconFor('name')" v-if="sortIconFor('name')" class="h-3 w-3 shrink-0" />
</button>
<button class="flex min-w-0 items-center gap-1 truncate text-left" type="button" @click="toggleSort('type')">
<span class="truncate">{{ t("objects.type") }}</span>
<component :is="sortIconFor('type')" v-if="sortIconFor('type')" class="h-3 w-3 shrink-0" />
</button>
<button class="flex min-w-0 items-center justify-end gap-1 truncate text-right" type="button" :title="t('objects.statisticsHint')" @click="toggleSort('estimatedRows')">
<span class="truncate">{{ t("objects.rows") }}</span>
<component :is="sortIconFor('estimatedRows')" v-if="sortIconFor('estimatedRows')" class="h-3 w-3 shrink-0" />
</button>
<button class="flex min-w-0 items-center justify-end gap-1 truncate text-right" type="button" :title="t('objects.statisticsHint')" @click="toggleSort('totalBytes')">
<span class="truncate">{{ t("objects.size") }}</span>
<component :is="sortIconFor('totalBytes')" v-if="sortIconFor('totalBytes')" class="h-3 w-3 shrink-0" />
</button>
<button v-if="hasCreatedAt" class="flex min-w-0 items-center gap-1 truncate text-left" type="button" @click="toggleSort('created_at')">
<span class="truncate">{{ t("objects.createdAt") }}</span>
<component :is="sortIconFor('created_at')" v-if="sortIconFor('created_at')" class="h-3 w-3 shrink-0" />
</button>
<button v-if="hasUpdatedAt" class="flex min-w-0 items-center gap-1 truncate text-left" type="button" @click="toggleSort('updated_at')">
<span class="truncate">{{ t("objects.updatedAt") }}</span>
<component :is="sortIconFor('updated_at')" v-if="sortIconFor('updated_at')" class="h-3 w-3 shrink-0" />
</button>
<button class="flex min-w-0 items-center gap-1 truncate text-left" type="button" @click="toggleSort('comment')">
<span class="truncate">{{ t("objects.comment") }}</span>
<component :is="sortIconFor('comment')" v-if="sortIconFor('comment')" class="h-3 w-3 shrink-0" />
</button>
<div class="relative flex min-w-0 items-center">
<button class="flex h-6 w-6 items-center justify-center rounded-sm hover:bg-accent" type="button" :disabled="visibleSelectableRows.length === 0" @click="toggleVisibleTableSelection">
<CheckSquare v-if="allVisibleTablesSelected" class="h-3.5 w-3.5 text-primary" />
<Square v-else class="h-3.5 w-3.5" />
</button>
<div class="absolute -right-2 top-0 bottom-0 z-10 flex w-3 cursor-col-resize items-center justify-center text-muted-foreground/70 hover:bg-primary/30 hover:text-primary" @mousedown="onObjectColumnResizeStart('select', $event)" @dblclick="resetObjectColumnWidth('select', 34, $event)">
<GripVertical class="h-3 w-3" />
</div>
</div>
<div class="relative flex min-w-0 items-center">
<button class="flex min-w-0 items-center gap-1 truncate pr-4 text-left" type="button" @click="toggleSort('name')">
<span class="truncate">{{ t("objects.name") }}</span>
<component :is="sortIconFor('name')" v-if="sortIconFor('name')" class="h-3 w-3 shrink-0" />
</button>
<div class="absolute -right-2 top-0 bottom-0 z-10 flex w-3 cursor-col-resize items-center justify-center text-muted-foreground/70 hover:bg-primary/30 hover:text-primary" @mousedown="onObjectColumnResizeStart('name', $event)" @dblclick="resetObjectColumnWidth('name', 360, $event)">
<GripVertical class="h-3 w-3" />
</div>
</div>
<div class="relative flex min-w-0 items-center">
<button class="flex min-w-0 items-center gap-1 truncate pr-4 text-left" type="button" @click="toggleSort('type')">
<span class="truncate">{{ t("objects.type") }}</span>
<component :is="sortIconFor('type')" v-if="sortIconFor('type')" class="h-3 w-3 shrink-0" />
</button>
<div class="absolute -right-2 top-0 bottom-0 z-10 flex w-3 cursor-col-resize items-center justify-center text-muted-foreground/70 hover:bg-primary/30 hover:text-primary" @mousedown="onObjectColumnResizeStart('type', $event)" @dblclick="resetObjectColumnWidth('type', 110, $event)">
<GripVertical class="h-3 w-3" />
</div>
</div>
<div class="relative flex min-w-0 items-center justify-end">
<button class="flex min-w-0 items-center justify-end gap-1 truncate pr-4 text-right" type="button" :title="t('objects.statisticsHint')" @click="toggleSort('estimatedRows')">
<span class="truncate">{{ t("objects.rows") }}</span>
<component :is="sortIconFor('estimatedRows')" v-if="sortIconFor('estimatedRows')" class="h-3 w-3 shrink-0" />
</button>
<div
class="absolute -right-2 top-0 bottom-0 z-10 flex w-3 cursor-col-resize items-center justify-center text-muted-foreground/70 hover:bg-primary/30 hover:text-primary"
@mousedown="onObjectColumnResizeStart('estimatedRows', $event)"
@dblclick="resetObjectColumnWidth('estimatedRows', 110, $event)"
>
<GripVertical class="h-3 w-3" />
</div>
</div>
<div class="relative flex min-w-0 items-center justify-end">
<button class="flex min-w-0 items-center justify-end gap-1 truncate pr-4 text-right" type="button" :title="t('objects.statisticsHint')" @click="toggleSort('totalBytes')">
<span class="truncate">{{ t("objects.size") }}</span>
<component :is="sortIconFor('totalBytes')" v-if="sortIconFor('totalBytes')" class="h-3 w-3 shrink-0" />
</button>
<div
class="absolute -right-2 top-0 bottom-0 z-10 flex w-3 cursor-col-resize items-center justify-center text-muted-foreground/70 hover:bg-primary/30 hover:text-primary"
@mousedown="onObjectColumnResizeStart('totalBytes', $event)"
@dblclick="resetObjectColumnWidth('totalBytes', 100, $event)"
>
<GripVertical class="h-3 w-3" />
</div>
</div>
<div v-if="hasCreatedAt" class="relative flex min-w-0 items-center">
<button class="flex min-w-0 items-center gap-1 truncate pr-4 text-left" type="button" @click="toggleSort('created_at')">
<span class="truncate">{{ t("objects.createdAt") }}</span>
<component :is="sortIconFor('created_at')" v-if="sortIconFor('created_at')" class="h-3 w-3 shrink-0" />
</button>
<div
class="absolute -right-2 top-0 bottom-0 z-10 flex w-3 cursor-col-resize items-center justify-center text-muted-foreground/70 hover:bg-primary/30 hover:text-primary"
@mousedown="onObjectColumnResizeStart('created_at', $event)"
@dblclick="resetObjectColumnWidth('created_at', 150, $event)"
>
<GripVertical class="h-3 w-3" />
</div>
</div>
<div v-if="hasUpdatedAt" class="relative flex min-w-0 items-center">
<button class="flex min-w-0 items-center gap-1 truncate pr-4 text-left" type="button" @click="toggleSort('updated_at')">
<span class="truncate">{{ t("objects.updatedAt") }}</span>
<component :is="sortIconFor('updated_at')" v-if="sortIconFor('updated_at')" class="h-3 w-3 shrink-0" />
</button>
<div
class="absolute -right-2 top-0 bottom-0 z-10 flex w-3 cursor-col-resize items-center justify-center text-muted-foreground/70 hover:bg-primary/30 hover:text-primary"
@mousedown="onObjectColumnResizeStart('updated_at', $event)"
@dblclick="resetObjectColumnWidth('updated_at', 150, $event)"
>
<GripVertical class="h-3 w-3" />
</div>
</div>
<div class="relative flex min-w-0 items-center">
<button class="flex min-w-0 items-center gap-1 truncate pr-4 text-left" type="button" @click="toggleSort('comment')">
<span class="truncate">{{ t("objects.comment") }}</span>
<component :is="sortIconFor('comment')" v-if="sortIconFor('comment')" class="h-3 w-3 shrink-0" />
</button>
<div class="absolute -right-2 top-0 bottom-0 z-10 flex w-3 cursor-col-resize items-center justify-center text-muted-foreground/70 hover:bg-primary/30 hover:text-primary" @mousedown="onObjectColumnResizeStart('comment', $event)" @dblclick="resetObjectColumnWidth('comment', 260, $event)">
<GripVertical class="h-3 w-3" />
</div>
</div>
</div>
<RecycleScroller class="object-browser-scroller min-h-0 flex-1" :items="filteredRows" :item-size="34" :buffer="600" :skip-hover="true" key-field="id">
<template #default="{ item }">