feat(structure): add DDL edit entry and drag reorder

This commit is contained in:
zipg 2026-06-29 12:26:43 +08:00 committed by GitHub
parent a65a5881d5
commit 3b457c13ec
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 166 additions and 29 deletions

View File

@ -64,6 +64,7 @@ import {
TableProperties,
Database,
Columns3,
PencilRuler,
} from "@lucide/vue";
import { Button } from "@/components/ui/button";
import QueryLoadingState from "@/components/common/QueryLoadingState.vue";
@ -144,9 +145,11 @@ import { useSqlHighlighter } from "@/composables/useSqlHighlighter";
import { useCellDetailEditor, type UseCellDetailEditorReturn } from "@/composables/useCellDetailEditor";
import { useTheme } from "@/composables/useTheme";
import { useConnectionStore } from "@/stores/connectionStore";
import { useQueryStore } from "@/stores/queryStore";
import { useSettingsStore } from "@/stores/settingsStore";
import type { DataGridSortDirection, DataGridSortMode } from "@/lib/dataGridSort";
import { getTableMetadataCapabilities } from "@/lib/tableMetadataCapabilities";
import { supportsTableStructureEditing } from "@/lib/databaseCapabilities";
import { forgetDataGridConditionHistory, loadDataGridConditionHistory, rememberDataGridConditionHistory } from "@/lib/dataGridConditionHistory";
import { caretPositionInsideInsertedSqlSingleQuotes, insertedSqlSingleQuoteAtCaret } from "@/lib/sqlQuoteCaret";
import { effectiveDatabaseTypeForConnection } from "@/lib/jdbcDialect";
@ -159,6 +162,7 @@ const FORMATTED_JSON_EDIT_WARNING_STORAGE_KEY = "dbx-cell-detail-formatted-json-
const { t } = useI18n();
const slots = useSlots();
const connectionStore = useConnectionStore();
const queryStore = useQueryStore();
const settingsStore = useSettingsStore();
const { isDark } = useTheme();
const { toast } = useToast();
@ -6566,6 +6570,7 @@ function toggleCellDetailPanelLayout() {
}
const tableMetadataCapabilities = computed(() => getTableMetadataCapabilities(props.databaseType));
const canOpenTableStructureEditor = computed(() => !!props.connectionId && !!props.database && !!props.tableMeta?.tableName && supportsTableStructureEditing(resolvedDatabaseType.value));
const tableInfoTabs = computed(() => {
const tabs: TableInfoTabItem[] = [];
if (tableMetadataCapabilities.value.columns) {
@ -6706,6 +6711,11 @@ function copyDdl() {
copyText(ddlContent.value);
}
function openTableStructureEditor() {
if (!props.connectionId || !props.database || !props.tableMeta?.tableName || !canOpenTableStructureEditor.value) return;
queryStore.openTableStructure(props.connectionId, props.database, props.tableMeta.schema, props.tableMeta.tableName);
}
function toggleDdlWrap() {
ddlWrap.value = !ddlWrap.value;
}
@ -8422,15 +8432,21 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
</template>
</div>
<!-- Table Info Drawer -->
<div v-if="showTableInfo" class="relative col-start-2 row-start-1 border-l flex flex-col bg-background min-w-0" :class="[{ 'row-span-2': cellDetailPanelIsBottom }, { 'ddl-drawer-resizing': isResizingDdl }]" :style="ddlDrawerStyle" @contextmenu="onDrawerContextMenu">
<div v-if="showTableInfo" class="table-info-drawer relative col-start-2 row-start-1 border-l flex flex-col bg-background min-w-0" :class="[{ 'row-span-2': cellDetailPanelIsBottom }, { 'ddl-drawer-resizing': isResizingDdl }]" :style="ddlDrawerStyle" @contextmenu="onDrawerContextMenu">
<div class="absolute left-0 top-0 bottom-0 z-20 w-1.5 -translate-x-1/2 cursor-col-resize hover:bg-primary/30" @mousedown.prevent="onDdlResizeStart" />
<div class="flex items-center gap-2 px-3 py-1.5 border-b shrink-0 bg-muted/20 h-9">
<TableProperties class="w-3.5 h-3.5 text-muted-foreground" />
<span class="text-xs font-medium flex-1 min-w-0 truncate">{{ tableMeta?.tableName }}</span>
<Button v-if="activeTableInfoTab === 'ddl'" variant="ghost" size="sm" class="h-6 px-2 text-xs" :title="t('grid.copyDdl')" :aria-label="t('grid.copyDdl')" @click="copyDdl">
<Copy class="w-3 h-3" />
<span>{{ t("grid.copyDdl") }}</span>
</Button>
<div v-if="activeTableInfoTab === 'ddl'" class="table-info-ddl-actions flex min-w-0 shrink-0 items-center gap-1">
<Button v-if="canOpenTableStructureEditor" variant="ghost" size="sm" class="table-info-ddl-action-button h-6 px-2 text-xs" :title="t('contextMenu.editStructure')" :aria-label="t('contextMenu.editStructure')" @click="openTableStructureEditor">
<PencilRuler class="w-3 h-3" />
<span class="table-info-ddl-action-label">{{ t("contextMenu.editStructure") }}</span>
</Button>
<Button variant="ghost" size="sm" class="table-info-ddl-action-button h-6 px-2 text-xs" :title="t('grid.copyDdl')" :aria-label="t('grid.copyDdl')" @click="copyDdl">
<Copy class="w-3 h-3" />
<span class="table-info-ddl-action-label">{{ t("grid.copyDdl") }}</span>
</Button>
</div>
<Button v-if="activeTableInfoTab === 'ddl'" variant="ghost" size="icon" class="h-6 w-6" :class="{ 'bg-accent': ddlWrap }" @click="toggleDdlWrap">
<WrapText class="w-3 h-3" />
</Button>
@ -9662,6 +9678,43 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
transition: none;
}
.table-info-drawer {
container-type: inline-size;
}
.table-info-ddl-action-button {
gap: 0.25rem;
max-width: 8rem;
overflow: hidden;
transition:
max-width 180ms ease,
padding-inline 180ms ease;
}
.table-info-ddl-action-label {
min-width: 0;
max-width: 6rem;
overflow: hidden;
white-space: nowrap;
opacity: 1;
transition:
max-width 180ms ease,
opacity 120ms ease;
}
@container (max-width: 360px) {
.table-info-ddl-action-button {
width: 1.5rem;
max-width: 1.5rem;
padding-inline: 0;
}
.table-info-ddl-action-label {
max-width: 0;
opacity: 0;
}
}
.detail-drawer-resizing {
transition: none;
}

View File

@ -6,7 +6,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { AlertTriangle, Check, ChevronDown, ChevronUp, Copy, Database, Info, KeyRound, Loader2, Maximize2, Plus, RefreshCw, Save, Settings, SlidersHorizontal, Trash2, X } from "@lucide/vue";
import { AlertTriangle, Check, ChevronDown, Copy, Database, Info, KeyRound, ListChevronsUpDown, Loader2, Maximize2, Plus, RefreshCw, Save, Settings, SlidersHorizontal, Trash2, X } from "@lucide/vue";
import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
@ -966,30 +966,105 @@ function removeNewColumn(column: EditableStructureColumn) {
columns.value = columns.value.filter((item) => item.id !== column.id);
}
function canMoveColumn(index: number, direction: -1 | 1): boolean {
type ColumnDragState = {
columnId: string;
sourceIndex: number;
insertionIndex: number;
};
const columnDragState = ref<ColumnDragState | null>(null);
function canDragColumn(index: number): boolean {
if (loading.value || saving.value) return false;
if (!Number.isInteger(index) || (direction !== -1 && direction !== 1)) return false;
const targetIndex = index + direction;
if (targetIndex < 0 || targetIndex >= columns.value.length) return false;
if (columns.value[index]?.markedForDrop || columns.value[targetIndex]?.markedForDrop) return false;
// Draft columns can always swap order among themselves
// ADD COLUMN statement order determines their final physical placement.
if (!columns.value[index]?.original && !columns.value[targetIndex]?.original) return true;
return canShowColumnMoveControls.value;
if (!Number.isInteger(index) || index < 0 || index >= columns.value.length) return false;
const column = columns.value[index];
if (!column || column.markedForDrop) return false;
if (!column.original) return true;
return canShowColumnDragControls.value;
}
const canShowColumnMoveControls = computed(() => isCreateMode.value || structureCapabilities.value.reorderColumn);
function canDropColumnAt(sourceIndex: number, insertionIndex: number): boolean {
if (!canDragColumn(sourceIndex)) return false;
if (!Number.isInteger(insertionIndex) || insertionIndex < 0 || insertionIndex > columns.value.length) return false;
if (insertionIndex === sourceIndex || insertionIndex === sourceIndex + 1) return false;
const sourceColumn = columns.value[sourceIndex];
if (!sourceColumn) return false;
const crossedColumns = insertionIndex < sourceIndex ? columns.value.slice(insertionIndex, sourceIndex) : columns.value.slice(sourceIndex + 1, insertionIndex);
if (crossedColumns.some((column) => column.markedForDrop)) return false;
if (canShowColumnDragControls.value) return true;
if (sourceColumn.original) return false;
return crossedColumns.every((column) => !column.original);
}
function moveColumn(index: number, direction: -1 | 1) {
if (!canMoveColumn(index, direction)) return;
const targetIndex = index + direction;
const canShowColumnDragControls = computed(() => isCreateMode.value || structureCapabilities.value.reorderColumn);
function moveColumnTo(index: number, insertionIndex: number) {
if (!canDropColumnAt(index, insertionIndex)) return;
const nextColumns = [...columns.value];
const [column] = nextColumns.splice(index, 1);
if (!column) return;
nextColumns.splice(targetIndex, 0, column);
const adjustedInsertionIndex = insertionIndex > index ? insertionIndex - 1 : insertionIndex;
nextColumns.splice(adjustedInsertionIndex, 0, column);
columns.value = nextColumns;
}
function onColumnDragStart(index: number, event: DragEvent) {
if (!canDragColumn(index)) {
event.preventDefault();
return;
}
const column = columns.value[index];
if (!column) return;
columnDragState.value = {
columnId: column.id,
sourceIndex: index,
insertionIndex: index,
};
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/plain", column.name || column.id);
}
}
function onColumnDragOver(index: number, event: DragEvent) {
const state = columnDragState.value;
if (!state || columns.value[index]?.markedForDrop) return;
const insertionIndex = columnDragInsertionIndex(index, event);
if (!canDropColumnAt(state.sourceIndex, insertionIndex)) return;
event.preventDefault();
state.insertionIndex = insertionIndex;
if (event.dataTransfer) event.dataTransfer.dropEffect = "move";
}
function onColumnDrop(index: number, event: DragEvent) {
const state = columnDragState.value;
if (!state) return;
event.preventDefault();
moveColumnTo(state.sourceIndex, columnDragInsertionIndex(index, event));
columnDragState.value = null;
}
function onColumnDragEnd() {
columnDragState.value = null;
}
function columnRowClass(column: EditableStructureColumn, index: number) {
const dragState = columnDragState.value;
return {
"bg-destructive/5 opacity-60": column.markedForDrop,
"opacity-55": dragState?.columnId === column.id,
"bg-primary/5 shadow-[inset_0_2px_0_var(--primary)]": dragState && canDropColumnAt(dragState.sourceIndex, index) && dragState.insertionIndex === index,
"bg-primary/5 shadow-[inset_0_-2px_0_var(--primary)]": dragState && canDropColumnAt(dragState.sourceIndex, index + 1) && dragState.insertionIndex === index + 1,
};
}
function columnDragInsertionIndex(index: number, event: DragEvent): number {
const target = event.currentTarget;
if (!(target instanceof HTMLElement)) return index;
const rect = target.getBoundingClientRect();
return event.clientY > rect.top + rect.height / 2 ? index + 1 : index;
}
function toggleDropColumn(column: EditableStructureColumn) {
if (!canDropColumn(column)) return;
column.markedForDrop = !column.markedForDrop;
@ -1562,7 +1637,7 @@ watch(activeTab, (tab) => {
</tr>
</thead>
<tbody>
<tr v-for="(column, index) in columns" :key="column.id" :class="column.markedForDrop ? 'bg-destructive/5 opacity-60' : ''" :data-new-column-row="!column.original ? 'true' : undefined">
<tr v-for="(column, index) in columns" :key="column.id" :class="columnRowClass(column, index)" :data-new-column-row="!column.original ? 'true' : undefined" @dragover="onColumnDragOver(index, $event)" @drop="onColumnDrop(index, $event)">
<td :class="[structureCellClass, 'text-muted-foreground']">
<div class="flex items-center gap-1">
<span>{{ index + 1 }}</span>
@ -1788,14 +1863,21 @@ watch(activeTab, (tab) => {
</td>
<td :class="structureLastCellClass">
<div class="flex min-w-0 items-center justify-start gap-0.5 overflow-hidden">
<template v-if="canShowColumnMoveControls || !column.original">
<Button type="button" variant="ghost" size="icon" :class="structureActionButtonClass" :disabled="!canMoveColumn(index, -1)" :title="t('structureEditor.moveColumnUp')" :aria-label="t('structureEditor.moveColumnUp')" @click.stop.prevent="moveColumn(index, -1)">
<ChevronUp :class="structureIconClass" />
</Button>
<Button type="button" variant="ghost" size="icon" :class="structureActionButtonClass" :disabled="!canMoveColumn(index, 1)" :title="t('structureEditor.moveColumnDown')" :aria-label="t('structureEditor.moveColumnDown')" @click.stop.prevent="moveColumn(index, 1)">
<ChevronDown :class="structureIconClass" />
</Button>
</template>
<Button
v-if="canShowColumnDragControls || !column.original"
type="button"
variant="ghost"
size="icon"
:class="[structureActionButtonClass, canDragColumn(index) ? 'cursor-grab active:cursor-grabbing' : 'cursor-not-allowed']"
:disabled="!canDragColumn(index)"
:title="t('structureEditor.dragColumn')"
:aria-label="t('structureEditor.dragColumn')"
draggable="true"
@dragstart="onColumnDragStart(index, $event)"
@dragend="onColumnDragEnd"
>
<ListChevronsUpDown :class="structureIconClass" />
</Button>
<Button
v-if="column.original"
variant="ghost"

View File

@ -1554,6 +1554,7 @@ export default {
remove: "Remove",
moveColumnUp: "Move column up",
moveColumnDown: "Move column down",
dragColumn: "Drag to reorder column",
yes: "Yes",
no: "No",
emptyReadonly: "No records",

View File

@ -1557,6 +1557,7 @@ export default withEnglishFallback({
remove: "移除",
moveColumnUp: "上移字段",
moveColumnDown: "下移字段",
dragColumn: "拖拽调整字段顺序",
yes: "是",
no: "否",
emptyReadonly: "暂无记录",