feat(objects): add checkbox multi-select and grid view for object browser
* feat(objects): add checkbox column toggle for object browser - Add objectBrowserShowCheckbox setting (default: false) to EditorSettings - Checkbox column hidden by default; auto-shows when any tables are selected - Toolbar button toggles multi-select mode (active state highlighted in primary color) - Clearing the toggle also clears all selections - Add i18n key objects.toggleCheckbox in en/zh-CN Closes #2748 (part 2) * feat(objects): add grid view mode for object browser - Add objectBrowserViewMode setting (list | grid, default: list) to EditorSettings - Toolbar segmented control toggles between list and grid views, persisted across sessions - Grid view renders cards (icon + name + type + row count) in a responsive auto-fill layout - Reuses existing sort, search, filter, multi-select and context menu - Partition children not expandable in grid mode (minimal version) - Add i18n keys objects.viewList/objects.viewGrid across all locales - Backfill objects.toggleCheckbox in es/it/ja/pt-BR/zh-TW (missed in prior commit) Refs #2748 * fix(objects): redesign grid view cards for visual polish - Center-align content (icon + name + metadata) for a proper tile look - Enlarge type icons (h-7) so color coding is visible at a glance - Merge type label and row count on one line with separator - Reduce internal spacing (gap-1.5) and tighten padding - Replace background hover with border glow + shadow for cleaner feedback - Narrow min card width (150px) for denser grid, add align-content:start - Softer rounded-lg corners Refs #2748 * fix(objects): polish grid view card visual details - Add colored circle background behind icons (type-matched opacity-10) - Increase name font from text-xs to text-sm for better legibility - Separate row count into a primary-tinted badge (only shown when > 0) - Keep type label clean without mixing row count inline Refs #2748 * fix(objects): refine grid card min-width, icon shadow, and type label size * @ feat(object-browser): align tile/grid card metadata with list view columns Add the metadata categories present in the list view but missing from the tile/grid card view (objectBrowserViewMode === "grid"): - Size pill (totalBytes) next to the existing rows pill in the stats row - Timestamps row showing created_at · updated_at (per-row conditional) - Comment row (truncated, per-row conditional, with hover tooltip) Each card now shows: icon, name, type label, rows pill, size pill, timestamps (when present), and comment (when present) — matching the same metadata categories the list view displays across its 8 columns. Also fix a pre-existing bug in the list view where the totalBytes column tooltip (:title) used formatObjectBrowserCount instead of formatObjectBrowserBytes, showing a count-formatted number instead of a byte-formatted size string. @ * @ feat(object-browser): add sort selector in toolbar for grid/list views The list view has column header click-to-sort but the grid/tile view had no way to change sort order. Add a compact sort selector in the toolbar that works identically in both views: - Native <select> dropdown with all 7 sort keys (name, type, rows, size, created, updated, comment) using existing column i18n labels - ArrowUp/ArrowDown direction toggle button with localized tooltip - Positioned between schema selector and view toggle in the toolbar - Shares the existing sortKey/sortDirection/toggleSort logic — list view column headers continue to work as before - Accessible: aria-label on select, title on direction button i18n: added sortAsc/sortDesc/sortBy to en.ts and zh-CN.ts @ * @ fix(object-browser): make sort key options reactive to available data sortKeyOptions was a static array that always included created_at and updated_at even when no row had data for those fields. Sorting by an empty field is a no-op that silently confuses users. Convert to a computed that mirrors the list view column visibility logic (hasCreatedAt / hasUpdatedAt): the two time-based sort options only appear when at least one loaded row has that field populated. Follows design decision: sort selector remains visible in both list and grid views (plan B), acting as a shared sort state indicator. @ * @ fix(object-browser): reset sortKey when selected option disappears from options When sortKeyOptions loses created_at or updated_at (e.g. after a schema switch clears all rows), sortKey could remain set to a key no longer in the dropdown, causing the <select> to show a blank selection. Add a watch on sortKeyOptions that resets sortKey to "name" and sortDirection to "asc" whenever the current key is no longer present. @ * chore(i18n): autofill new translations * perf(object-browser): virtualize tile/grid view with RecycleScroller Replaces the flat v-for over filteredRows with a row-chunked RecycleScroller, matching the list view. Previously, switching to tile mode on a schema with thousands of tables mounted every card and its CustomContextMenu at once, stalling the UI for several seconds. Changes: - gridRows computed: chunks filteredRows into rows of gridColumns cards - ResizeObserver on the grid container drives gridColumns; initial read uses getComputedStyle to match contentRect.width and avoid a 1-frame column jump on mount - objectGridRowHeight computed: item-size adapts to whether the dataset has timestamp/comment rows, minimising wasted row space - watch(gridContainerRef) re-attaches the observer across list<->grid toggles; onBeforeUnmount cleans up - scrollObjectsToTop() resets scroll position on sort, search, object-type filter and view-mode changes - CSS: new .object-browser-grid-wrapper / -scroller / -row classes with will-change / contain parity with the list scroller --------- Co-authored-by: dbx-i18n-bot <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
parent
65a92afce8
commit
2c2d9d7fef
|
|
@ -1,5 +1,5 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref, watch } from "vue";
|
||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from "vue";
|
||||
import { RecycleScroller } from "vue-virtual-scroller";
|
||||
import { useSqlHighlighter } from "@/composables/useSqlHighlighter";
|
||||
import {
|
||||
|
|
@ -19,6 +19,8 @@ import {
|
|||
Eye,
|
||||
FileCode,
|
||||
GripVertical,
|
||||
LayoutGrid,
|
||||
List,
|
||||
ListTree,
|
||||
Upload,
|
||||
Loader2,
|
||||
|
|
@ -217,8 +219,52 @@ 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 hasAnyComment = computed(() => rows.value.some((row) => row.comment?.trim()));
|
||||
const isListView = computed(() => settingsStore.editorSettings.objectBrowserViewMode !== "grid");
|
||||
|
||||
// RecycleScroller exposes scrollToItem on its component instance (see
|
||||
// vue-virtual-scroller). Typed loosely to match the DataGrid usage pattern.
|
||||
const listScrollerRef = ref<{ scrollToItem?: (index: number) => void } | null>(null);
|
||||
const gridScrollerRef = ref<{ scrollToItem?: (index: number) => void } | null>(null);
|
||||
|
||||
function scrollObjectsToTop() {
|
||||
// Read the active scroller inside nextTick so that after a list <-> grid
|
||||
// switch the (re)mounted scroller is the one we reset.
|
||||
nextTick(() => {
|
||||
const scroller = isListView.value ? listScrollerRef.value : gridScrollerRef.value;
|
||||
scroller?.scrollToItem?.(0);
|
||||
});
|
||||
}
|
||||
|
||||
function setViewMode(mode: "list" | "grid") {
|
||||
settingsStore.updateEditorSettings({ objectBrowserViewMode: mode });
|
||||
scrollObjectsToTop();
|
||||
}
|
||||
|
||||
// Re-sorting reorders the rows; jump to the top so the new head is visible
|
||||
// instead of leaving the view parked at a stale mid-scroll position.
|
||||
// Note: watch(sortKeyOptions) may reset sortKey during setup when the persisted key
|
||||
// is no longer valid, triggering this watcher before any scroller is mounted —
|
||||
// scrollObjectsToTop() handles that safely via optional chaining.
|
||||
watch([sortKey, sortDirection], () => scrollObjectsToTop());
|
||||
|
||||
// Also jump to the top when the search query or object-type filter changes —
|
||||
// filtered results bear no relation to the previous scroll position.
|
||||
watch(search, () => scrollObjectsToTop());
|
||||
watch(objectFilter, () => scrollObjectsToTop());
|
||||
|
||||
const showCheckboxColumn = computed(() => settingsStore.editorSettings.objectBrowserShowCheckbox || selectedTableCount.value > 0);
|
||||
|
||||
function toggleCheckboxColumn() {
|
||||
const next = !settingsStore.editorSettings.objectBrowserShowCheckbox;
|
||||
settingsStore.updateEditorSettings({ objectBrowserShowCheckbox: next });
|
||||
if (!next) clearTableSelection();
|
||||
}
|
||||
|
||||
const objectBrowserColumns = computed<ObjectBrowserColumnKey[]>(() => {
|
||||
const columns: ObjectBrowserColumnKey[] = ["select", "name", "type", "estimatedRows", "totalBytes"];
|
||||
const columns: ObjectBrowserColumnKey[] = [];
|
||||
if (showCheckboxColumn.value) columns.push("select");
|
||||
columns.push("name", "type", "estimatedRows", "totalBytes");
|
||||
if (hasCreatedAt.value) columns.push("created_at");
|
||||
if (hasUpdatedAt.value) columns.push("updated_at");
|
||||
columns.push("comment");
|
||||
|
|
@ -249,6 +295,78 @@ const partitionRowsByParentId = computed(() => {
|
|||
});
|
||||
const filteredRows = computed(() => groupedFilteredRows());
|
||||
const selectableRows = computed(() => rows.value.filter((row) => row.type === "TABLE"));
|
||||
|
||||
// ---- Grid (tile) view virtualization ----
|
||||
// The grid view chunks filteredRows into fixed-height rows and hands them to
|
||||
// RecycleScroller, mirroring the list view so only visible rows are mounted.
|
||||
// The previous flat `v-for` rendered every card (plus its CustomContextMenu)
|
||||
// at once, which stalls the UI on schemas with thousands of objects.
|
||||
const OBJECT_GRID_MIN_CARD_WIDTH = 160; // former `minmax(160px, 1fr)` floor
|
||||
const OBJECT_GRID_GAP = 12; // 0.75rem, former grid gap (both axes)
|
||||
// Card height is variable: only timestamp/comment rows that actually appear in
|
||||
// the current dataset contribute. objectGridRowHeight (below) adapts accordingly
|
||||
// so the row slot is as tight as possible instead of always using the worst case.
|
||||
// Base: p-3 top+bottom(24) + icon h-11(44) + name(18) + type/bytes(18) + gap-1×2(8) = 112
|
||||
// + optional timestamp row: text-[10px](15) + gap-1(4) = 19
|
||||
// + optional comment row: text-[10px](15) + gap-1(4) = 19
|
||||
// If the card gains a new metadata row, add a matching constant and include it below.
|
||||
const OBJECT_GRID_CARD_BASE_H = 112;
|
||||
const OBJECT_GRID_CARD_TIMESTAMP_H = 19;
|
||||
const OBJECT_GRID_CARD_COMMENT_H = 19;
|
||||
const OBJECT_GRID_CARD_SAFETY = 6; // buffer for sub-pixel font differences
|
||||
|
||||
const objectGridRowHeight = computed(() => {
|
||||
let cardH = OBJECT_GRID_CARD_BASE_H;
|
||||
if (hasCreatedAt.value || hasUpdatedAt.value) cardH += OBJECT_GRID_CARD_TIMESTAMP_H;
|
||||
if (hasAnyComment.value) cardH += OBJECT_GRID_CARD_COMMENT_H;
|
||||
return cardH + OBJECT_GRID_GAP + OBJECT_GRID_CARD_SAFETY;
|
||||
});
|
||||
const gridContainerRef = ref<HTMLElement | null>(null);
|
||||
const gridColumns = ref(1);
|
||||
let gridResizeObserver: ResizeObserver | null = null;
|
||||
|
||||
function recomputeGridColumns(width: number) {
|
||||
gridColumns.value = Math.max(1, Math.floor((width + OBJECT_GRID_GAP) / (OBJECT_GRID_MIN_CARD_WIDTH + OBJECT_GRID_GAP)));
|
||||
}
|
||||
|
||||
// The grid container lives inside a v-else, so it mounts/unmounts when the user
|
||||
// toggles list <-> grid. Watch the template ref to (re)attach the observer each
|
||||
// time the node appears, instead of once in onMounted (which would miss the
|
||||
// case where the browser starts in list mode).
|
||||
watch(
|
||||
gridContainerRef,
|
||||
(el, prevEl) => {
|
||||
if (prevEl) {
|
||||
gridResizeObserver?.disconnect();
|
||||
gridResizeObserver = null;
|
||||
}
|
||||
if (!el) return;
|
||||
// Use the content-box width (excluding padding) to match what ResizeObserver
|
||||
// delivers via entry.contentRect.width, avoiding a 1-frame column jump on mount.
|
||||
const style = getComputedStyle(el);
|
||||
recomputeGridColumns(el.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight));
|
||||
gridResizeObserver = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) recomputeGridColumns(entry.contentRect.width);
|
||||
});
|
||||
gridResizeObserver.observe(el);
|
||||
},
|
||||
{ flush: "post" },
|
||||
);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
gridResizeObserver?.disconnect();
|
||||
gridResizeObserver = null;
|
||||
});
|
||||
|
||||
const gridRows = computed(() => {
|
||||
const cols = gridColumns.value;
|
||||
const cards = filteredRows.value;
|
||||
const rows: Array<{ key: string; cards: ObjectBrowserRow[] }> = [];
|
||||
for (let i = 0; i < cards.length; i += cols) {
|
||||
rows.push({ key: `row-${i}`, cards: cards.slice(i, i + cols) });
|
||||
}
|
||||
return rows;
|
||||
});
|
||||
const visibleSelectableRows = computed(() => filteredRows.value.filter((row) => row.type === "TABLE"));
|
||||
const selectedTableRows = computed(() => {
|
||||
const ids = selectedTableIds.value;
|
||||
|
|
@ -293,6 +411,36 @@ function toggleSort(key: ObjectBrowserSortKey) {
|
|||
sortDirection.value = initialObjectBrowserSortDirection(key);
|
||||
}
|
||||
|
||||
const sortKeyOptions = computed<ObjectBrowserSortKey[]>(() => {
|
||||
const options: ObjectBrowserSortKey[] = ["name", "type", "estimatedRows", "totalBytes"];
|
||||
if (hasCreatedAt.value) options.push("created_at");
|
||||
if (hasUpdatedAt.value) options.push("updated_at");
|
||||
options.push("comment");
|
||||
return options;
|
||||
});
|
||||
|
||||
watch(sortKeyOptions, (options) => {
|
||||
if (!options.includes(sortKey.value)) {
|
||||
sortKey.value = "name";
|
||||
sortDirection.value = "asc";
|
||||
}
|
||||
});
|
||||
|
||||
function sortKeyLabel(key: ObjectBrowserSortKey): string {
|
||||
if (key === "name") return t("objects.name");
|
||||
if (key === "type") return t("objects.type");
|
||||
if (key === "estimatedRows") return t("objects.rows");
|
||||
if (key === "totalBytes") return t("objects.size");
|
||||
if (key === "created_at") return t("objects.createdAt");
|
||||
if (key === "updated_at") return t("objects.updatedAt");
|
||||
if (key === "comment") return t("objects.comment");
|
||||
return key;
|
||||
}
|
||||
|
||||
function onSortKeyChange(key: ObjectBrowserSortKey) {
|
||||
toggleSort(key);
|
||||
}
|
||||
|
||||
function minimumColumnWidth(key: ObjectBrowserColumnKey) {
|
||||
if (key === "select") return 34;
|
||||
if (key === "name" || key === "comment") return 120;
|
||||
|
|
@ -385,6 +533,15 @@ function iconClass(type: ObjectBrowserRow["type"]) {
|
|||
return "text-green-500";
|
||||
}
|
||||
|
||||
function iconBgClass(type: ObjectBrowserRow["type"]) {
|
||||
if (type === "VIEW" || type === "MATERIALIZED_VIEW") return "bg-purple-500/10";
|
||||
if (type === "PROCEDURE") return "bg-blue-500/10";
|
||||
if (type === "FUNCTION") return "bg-amber-500/10";
|
||||
if (type === "SEQUENCE") return "bg-emerald-500/10";
|
||||
if (type === "PACKAGE" || type === "PACKAGE_BODY") return "bg-cyan-500/10";
|
||||
return "bg-green-500/10";
|
||||
}
|
||||
|
||||
function isPartitionParentExpanded(row: ObjectBrowserRow) {
|
||||
return expandedPartitionParentIds.value.has(row.id);
|
||||
}
|
||||
|
|
@ -1645,6 +1802,35 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
|
|||
content-class="w-56"
|
||||
@update:model-value="onSchemaChange"
|
||||
/>
|
||||
<!-- Sort selector -->
|
||||
<div class="flex h-7 shrink-0 items-center rounded border bg-muted/20 p-0.5">
|
||||
<select
|
||||
class="h-6 cursor-pointer appearance-none rounded-sm bg-transparent px-1.5 text-xs text-muted-foreground outline-none hover:text-foreground focus:text-foreground"
|
||||
:value="sortKey"
|
||||
:aria-label="t('objects.sortBy')"
|
||||
@change="onSortKeyChange(($event.target as HTMLSelectElement).value as ObjectBrowserSortKey)"
|
||||
>
|
||||
<option v-for="key in sortKeyOptions" :key="key" :value="key" class="bg-background text-foreground">
|
||||
{{ sortKeyLabel(key) }}
|
||||
</option>
|
||||
</select>
|
||||
<button type="button" class="flex h-6 w-6 items-center justify-center rounded-sm text-muted-foreground transition-colors hover:text-foreground" :title="sortDirection === 'asc' ? t('objects.sortAsc') : t('objects.sortDesc')" @click="sortDirection = sortDirection === 'asc' ? 'desc' : 'asc'">
|
||||
<ArrowUp v-if="sortDirection === 'asc'" class="h-3 w-3" />
|
||||
<ArrowDown v-else class="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex h-7 shrink-0 items-center rounded border bg-muted/20 p-0.5">
|
||||
<button type="button" class="flex h-6 w-6 items-center justify-center rounded-sm text-muted-foreground transition-colors hover:text-foreground" :class="{ 'bg-background text-foreground shadow-sm': isListView }" :title="t('objects.viewList')" @click="setViewMode('list')">
|
||||
<List class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button type="button" class="flex h-6 w-6 items-center justify-center rounded-sm text-muted-foreground transition-colors hover:text-foreground" :class="{ 'bg-background text-foreground shadow-sm': !isListView }" :title="t('objects.viewGrid')" @click="setViewMode('grid')">
|
||||
<LayoutGrid class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7" :class="{ 'text-primary': settingsStore.editorSettings.objectBrowserShowCheckbox }" :title="t('objects.toggleCheckbox')" @click="toggleCheckboxColumn">
|
||||
<CheckSquare v-if="settingsStore.editorSettings.objectBrowserShowCheckbox" class="h-3.5 w-3.5" />
|
||||
<Square v-else class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7" :disabled="loadingObjects" @click="reload">
|
||||
<RefreshCw class="h-3.5 w-3.5" :class="{ 'animate-spin': loadingObjects }" />
|
||||
</Button>
|
||||
|
|
@ -1690,9 +1876,9 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
|
|||
{{ t("objects.empty") }}
|
||||
</div>
|
||||
<div v-else class="flex min-h-0 min-w-0 flex-1 flex-col">
|
||||
<div class="object-browser-table flex min-h-0 min-w-0 flex-1 flex-col overflow-x-auto overflow-y-hidden">
|
||||
<div v-if="isListView" class="object-browser-table flex min-h-0 min-w-0 flex-1 flex-col overflow-x-auto overflow-y-hidden">
|
||||
<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, minWidth: `${objectGridMinWidth}px` }">
|
||||
<div class="relative flex min-w-0 items-center">
|
||||
<div v-if="showCheckboxColumn" 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" />
|
||||
|
|
@ -1781,7 +1967,7 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<RecycleScroller class="object-browser-scroller min-h-0 flex-1" :style="{ minWidth: `${objectGridMinWidth}px` }" :items="filteredRows" :item-size="34" :buffer="600" :skip-hover="true" key-field="id">
|
||||
<RecycleScroller ref="listScrollerRef" class="object-browser-scroller min-h-0 flex-1" :style="{ minWidth: `${objectGridMinWidth}px` }" :items="filteredRows" :item-size="34" :buffer="600" :skip-hover="true" key-field="id">
|
||||
<template #default="{ item }">
|
||||
<CustomContextMenu :items="getObjectBrowserMenuItems(item)" v-slot="{ onContextMenu }">
|
||||
<div
|
||||
|
|
@ -1794,7 +1980,7 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
|
|||
@click="onRowClick(item, $event)"
|
||||
@contextmenu="onContextMenu"
|
||||
>
|
||||
<button class="flex h-6 w-6 items-center justify-center rounded-sm text-muted-foreground hover:bg-accent hover:text-foreground" type="button" :class="{ invisible: item.type !== 'TABLE' }" @click.stop="toggleTableSelection(item)">
|
||||
<button v-if="showCheckboxColumn" class="flex h-6 w-6 items-center justify-center rounded-sm text-muted-foreground hover:bg-accent hover:text-foreground" type="button" :class="{ invisible: item.type !== 'TABLE' }" @click.stop="toggleTableSelection(item)">
|
||||
<CheckSquare v-if="selectedTableIds.has(item.id)" class="h-3.5 w-3.5 text-primary" />
|
||||
<Square v-else class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
|
|
@ -1820,7 +2006,7 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
|
|||
<div class="truncate text-xs tabular-nums text-muted-foreground" :title="item.estimatedRows == null ? '' : formatObjectBrowserCount(item.estimatedRows)">
|
||||
{{ formatObjectBrowserCount(item.estimatedRows) }}
|
||||
</div>
|
||||
<div class="truncate text-xs tabular-nums text-muted-foreground" :title="item.totalBytes == null ? '' : formatObjectBrowserCount(item.totalBytes)">
|
||||
<div class="truncate text-xs tabular-nums text-muted-foreground" :title="item.totalBytes == null ? '' : formatObjectBrowserBytes(item.totalBytes)">
|
||||
{{ formatObjectBrowserBytes(item.totalBytes) }}
|
||||
</div>
|
||||
<div v-if="hasCreatedAt" class="truncate text-xs tabular-nums text-muted-foreground" :title="formatObjectBrowserTimestamp(item.created_at)">
|
||||
|
|
@ -1837,6 +2023,48 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
|
|||
</template>
|
||||
</RecycleScroller>
|
||||
</div>
|
||||
<div v-else ref="gridContainerRef" class="object-browser-grid-wrapper min-h-0 flex-1 p-2">
|
||||
<RecycleScroller ref="gridScrollerRef" v-if="gridRows.length > 0" class="object-browser-grid-scroller h-full" :items="gridRows" :item-size="objectGridRowHeight" :buffer="600" :skip-hover="true" key-field="key">
|
||||
<template #default="{ item: row }">
|
||||
<div class="object-browser-grid-row" :style="{ gridTemplateColumns: `repeat(${gridColumns}, minmax(0, 1fr))`, height: `${objectGridRowHeight - OBJECT_GRID_GAP}px` }">
|
||||
<CustomContextMenu v-for="item in row.cards" :key="item.id" :items="getObjectBrowserMenuItems(item)" v-slot="{ onContextMenu }">
|
||||
<div
|
||||
class="relative flex cursor-pointer flex-col items-center gap-1 rounded-lg border bg-card p-3 text-center transition-all hover:border-primary/40 hover:shadow-sm"
|
||||
:class="{
|
||||
'border-primary bg-primary/5': selectedTableIds.has(item.id),
|
||||
'border-primary/60': sourceRow?.id === item.id && !selectedTableIds.has(item.id),
|
||||
}"
|
||||
:title="item.displayName"
|
||||
@click="onRowClick(item, $event)"
|
||||
@contextmenu="onContextMenu"
|
||||
>
|
||||
<button v-if="showCheckboxColumn" class="absolute right-1 top-1 flex h-5 w-5 items-center justify-center rounded-sm text-muted-foreground hover:bg-accent hover:text-foreground" type="button" :class="{ invisible: item.type !== 'TABLE' }" @click.stop="toggleTableSelection(item)">
|
||||
<CheckSquare v-if="selectedTableIds.has(item.id)" class="h-3.5 w-3.5 text-primary" />
|
||||
<Square v-else class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<div class="flex h-11 w-11 shrink-0 items-center justify-center rounded-full shadow-sm" :class="iconBgClass(item.type)">
|
||||
<component :is="iconFor(item)" class="h-6 w-6" :class="iconClass(item.type)" />
|
||||
</div>
|
||||
<span class="w-full truncate text-sm font-medium leading-tight text-foreground">{{ item.displayName }}</span>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-xs text-muted-foreground">{{ typeLabel(item.type) }}</span>
|
||||
<span v-if="item.estimatedRows != null && item.estimatedRows > 0" class="rounded-full bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium tabular-nums text-primary">{{ formatObjectBrowserCount(item.estimatedRows) }}</span>
|
||||
<span v-if="item.totalBytes != null && item.totalBytes > 0" class="rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-medium tabular-nums text-muted-foreground">{{ formatObjectBrowserBytes(item.totalBytes) }}</span>
|
||||
</div>
|
||||
<div v-if="item.created_at?.trim() || item.updated_at?.trim()" class="flex items-center gap-1 text-[10px] text-muted-foreground/70">
|
||||
<span v-if="item.created_at?.trim()">{{ formatObjectBrowserTimestamp(item.created_at) }}</span>
|
||||
<span v-if="item.created_at?.trim() && item.updated_at?.trim()">·</span>
|
||||
<span v-if="item.updated_at?.trim()">{{ formatObjectBrowserTimestamp(item.updated_at) }}</span>
|
||||
</div>
|
||||
<div v-if="item.comment?.trim()" class="w-full truncate text-[10px] text-muted-foreground/60" :title="item.comment">
|
||||
{{ item.comment }}
|
||||
</div>
|
||||
</div>
|
||||
</CustomContextMenu>
|
||||
</div>
|
||||
</template>
|
||||
</RecycleScroller>
|
||||
</div>
|
||||
<div v-if="sourceRow" class="flex h-[42%] min-h-44 shrink-0 flex-col border-t bg-background">
|
||||
<div class="flex h-8 shrink-0 items-center gap-2 border-b bg-muted/20 px-3">
|
||||
<Code2 class="h-3.5 w-3.5 text-muted-foreground" />
|
||||
|
|
@ -2060,4 +2288,24 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
|
|||
.object-browser-scroller :deep(.vue-recycle-scroller__item-view) {
|
||||
contain: layout style paint;
|
||||
}
|
||||
|
||||
.object-browser-grid-wrapper {
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.object-browser-grid-scroller {
|
||||
will-change: scroll-position;
|
||||
contain: content;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.object-browser-grid-scroller :deep(.vue-recycle-scroller__item-view) {
|
||||
contain: layout style paint;
|
||||
}
|
||||
|
||||
.object-browser-grid-row {
|
||||
display: grid;
|
||||
column-gap: 12px;
|
||||
align-items: start;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1783,6 +1783,12 @@ export default {
|
|||
batchTruncateSuccess: "Truncated {count} tables",
|
||||
copyTableSelected: "Copy",
|
||||
pasteTableSelected: "Paste",
|
||||
toggleCheckbox: "Toggle multi-select",
|
||||
viewList: "List view",
|
||||
viewGrid: "Grid view",
|
||||
sortAsc: "Ascending",
|
||||
sortDesc: "Descending",
|
||||
sortBy: "Sort by",
|
||||
},
|
||||
structureEditor: {
|
||||
title: "Edit Table Structure",
|
||||
|
|
|
|||
|
|
@ -1714,7 +1714,13 @@ export default withEnglishFallback({
|
|||
batchTruncateSuccess: "{count} tablas truncadas",
|
||||
copyTableSelected: "Copiar",
|
||||
pasteTableSelected: "Pegar",
|
||||
toggleCheckbox: "Alternar selección múltiple",
|
||||
viewList: "Vista de lista",
|
||||
viewGrid: "Vista de cuadrícula",
|
||||
sourceReadOnly: "Este código fuente es de solo lectura y no se puede editar.",
|
||||
sortAsc: "ascendente",
|
||||
sortDesc: "descendente",
|
||||
sortBy: "ordenar por",
|
||||
},
|
||||
structureEditor: {
|
||||
title: "Editar estructura de tabla",
|
||||
|
|
|
|||
|
|
@ -1712,7 +1712,13 @@ export default withEnglishFallback({
|
|||
batchTruncateSuccess: "Troncate {count} tabelle",
|
||||
copyTableSelected: "Copia",
|
||||
pasteTableSelected: "Incolla",
|
||||
toggleCheckbox: "Attiva/disattiva selezione multipla",
|
||||
viewList: "Vista elenco",
|
||||
viewGrid: "Vista griglia",
|
||||
sourceReadOnly: "Il codice sorgente è di sola lettura, non modificabile.",
|
||||
sortAsc: "Crescente",
|
||||
sortDesc: "Decrescente",
|
||||
sortBy: "Ordina per",
|
||||
},
|
||||
structureEditor: {
|
||||
title: "Modifica Struttura Tabella",
|
||||
|
|
|
|||
|
|
@ -1746,7 +1746,13 @@ export default withEnglishFallback({
|
|||
batchTruncateSuccess: "{count}テーブルをトランケートしました",
|
||||
copyTableSelected: "コピー",
|
||||
pasteTableSelected: "貼り付け",
|
||||
toggleCheckbox: "複数選択の切り替え",
|
||||
viewList: "リスト表示",
|
||||
viewGrid: "グリッド表示",
|
||||
sourceReadOnly: "このソースは読み取り専用で、編集できません。",
|
||||
sortAsc: "昇順",
|
||||
sortDesc: "降順",
|
||||
sortBy: "並べ替え",
|
||||
},
|
||||
structureEditor: {
|
||||
title: "テーブル構造を編集",
|
||||
|
|
|
|||
|
|
@ -1713,7 +1713,13 @@ export default withEnglishFallback({
|
|||
batchTruncateSuccess: "{count} tabelas truncadas",
|
||||
copyTableSelected: "Copiar",
|
||||
pasteTableSelected: "Colar",
|
||||
toggleCheckbox: "Alternar seleção múltipla",
|
||||
viewList: "Visualização em lista",
|
||||
viewGrid: "Visualização em grade",
|
||||
sourceReadOnly: "O código fonte é somente leitura e não pode ser editado.",
|
||||
sortAsc: "Crescente",
|
||||
sortDesc: "Decrescente",
|
||||
sortBy: "Ordenar por",
|
||||
},
|
||||
structureEditor: {
|
||||
title: "Editar estrutura da tabela",
|
||||
|
|
|
|||
|
|
@ -1783,6 +1783,12 @@ export default withEnglishFallback({
|
|||
batchTruncateSuccess: "已截断 {count} 张表",
|
||||
copyTableSelected: "复制",
|
||||
pasteTableSelected: "粘贴",
|
||||
toggleCheckbox: "切换多选模式",
|
||||
viewList: "列表视图",
|
||||
viewGrid: "平铺视图",
|
||||
sortAsc: "升序",
|
||||
sortDesc: "降序",
|
||||
sortBy: "排序方式",
|
||||
},
|
||||
structureEditor: {
|
||||
title: "编辑表结构",
|
||||
|
|
|
|||
|
|
@ -1623,6 +1623,12 @@ export default withEnglishFallback({
|
|||
batchTruncateSuccess: "已截斷 {count} 張資料表",
|
||||
copyTableSelected: "複製",
|
||||
pasteTableSelected: "貼上",
|
||||
toggleCheckbox: "切換多選模式",
|
||||
viewList: "列表檢視",
|
||||
viewGrid: "平鋪檢視",
|
||||
sortAsc: "升序",
|
||||
sortDesc: "降序",
|
||||
sortBy: "排序方式",
|
||||
},
|
||||
structureEditor: {
|
||||
title: "編輯資料表結構",
|
||||
|
|
|
|||
|
|
@ -421,6 +421,8 @@ export interface EditorSettings {
|
|||
queryExportKeysetOptimizationEnabled: boolean;
|
||||
updateDownloadSource: UpdateDownloadSource;
|
||||
toolbarItems: ToolbarItems;
|
||||
objectBrowserShowCheckbox: boolean;
|
||||
objectBrowserViewMode: "list" | "grid";
|
||||
}
|
||||
|
||||
export interface ToolbarItems {
|
||||
|
|
@ -550,6 +552,8 @@ export const DEFAULT_EDITOR_SETTINGS: EditorSettings = {
|
|||
queryExportKeysetOptimizationEnabled: true,
|
||||
updateDownloadSource: "official",
|
||||
toolbarItems: { ...DEFAULT_TOOLBAR_ITEMS },
|
||||
objectBrowserShowCheckbox: false,
|
||||
objectBrowserViewMode: "list",
|
||||
};
|
||||
|
||||
export const STORAGE_KEY = "dbx-editor-settings";
|
||||
|
|
@ -776,6 +780,8 @@ export function normalizeEditorSettings(settings: Partial<EditorSettings>, exist
|
|||
queryExportKeysetOptimizationEnabled: typeof settings.queryExportKeysetOptimizationEnabled === "boolean" ? settings.queryExportKeysetOptimizationEnabled : DEFAULT_EDITOR_SETTINGS.queryExportKeysetOptimizationEnabled,
|
||||
updateDownloadSource: normalizeUpdateDownloadSource(settings.updateDownloadSource),
|
||||
toolbarItems: normalizeToolbarItems(settings.toolbarItems),
|
||||
objectBrowserShowCheckbox: typeof settings.objectBrowserShowCheckbox === "boolean" ? settings.objectBrowserShowCheckbox : DEFAULT_EDITOR_SETTINGS.objectBrowserShowCheckbox,
|
||||
objectBrowserViewMode: settings.objectBrowserViewMode === "grid" ? "grid" : DEFAULT_EDITOR_SETTINGS.objectBrowserViewMode,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -1016,6 +1022,8 @@ export const useSettingsStore = defineStore("settings", () => {
|
|||
if (partial.queryExportKeysetOptimizationEnabled !== undefined) editorSettings.value.queryExportKeysetOptimizationEnabled = partial.queryExportKeysetOptimizationEnabled;
|
||||
if (partial.updateDownloadSource !== undefined) editorSettings.value.updateDownloadSource = normalizeUpdateDownloadSource(partial.updateDownloadSource);
|
||||
if (partial.toolbarItems !== undefined) editorSettings.value.toolbarItems = normalizeToolbarItems(partial.toolbarItems);
|
||||
if (partial.objectBrowserShowCheckbox !== undefined) editorSettings.value.objectBrowserShowCheckbox = partial.objectBrowserShowCheckbox === true;
|
||||
if (partial.objectBrowserViewMode !== undefined) editorSettings.value.objectBrowserViewMode = partial.objectBrowserViewMode === "grid" ? "grid" : "list";
|
||||
saveEditorSettings(editorSettings.value);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue