diff --git a/apps/desktop/src/components/structure/TableStructureEditor.vue b/apps/desktop/src/components/structure/TableStructureEditor.vue index ab0e24124a..a5ce8285ef 100644 --- a/apps/desktop/src/components/structure/TableStructureEditor.vue +++ b/apps/desktop/src/components/structure/TableStructureEditor.vue @@ -12,6 +12,7 @@ import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMe import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { SearchableSelect } from "@/components/ui/searchable-select"; +import CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomContextMenu.vue"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { useConnectionStore } from "@/stores/connectionStore"; import { useProductionSafetyStore } from "@/stores/productionSafetyStore"; @@ -81,6 +82,9 @@ import { resolveInsertColumnIndex, restoreCharacterLengthUnitsAfterSave, sameStructureIndexType, + structureColumnSelectionRange, + isSyntheticContextMenuClick, + resolveColumnSelectionActiveId, tableStructureIdentifierComparisonKey, toColumnNames, } from "@/lib/table/tableStructureEditorState"; @@ -600,6 +604,11 @@ const resizing = ref<{ col: number; startX: number; startW: number } | null>(nul const columnSearchInputRef = ref>(); const columnSearchText = ref(""); const selectedColumnId = ref(null); +// Multi-selection set (ctrl/shift-click) plus the shift-range anchor. The +// legacy `selectedColumnId` stays as the "active" column: it decides where +// new rows are inserted and which row copy/add operations anchor to. +const selectedColumnIds = ref>(new Set()); +const columnSelectionAnchorId = ref(null); const highlightedColumnId = ref(null); const indexSearchInputRef = ref>(); const indexSearchText = ref(""); @@ -1481,7 +1490,7 @@ function resetState() { constraintsLoaded.value = false; triggers.value = []; triggersLoaded.value = false; - selectedColumnId.value = null; + clearColumnSelection(); ddlContent.value = ""; ddlFetched.value = false; loadedMetadataFacets.clear(); @@ -1692,7 +1701,7 @@ async function loadStructure( const hydratedColumnDrafts = supportsCharacterLengthUnits.value && options.characterLengthUnitsAfterSave ? restoreCharacterLengthUnitsAfterSave(databaseType.value, nextColumnDrafts, options.characterLengthUnitsAfterSave) : nextColumnDrafts; columns.value = applyStoredLocalColumnOrder(hydratedColumnDrafts); loadedMetadataFacets.add("columns"); - if (!options.preserveDraft) selectedColumnId.value = null; + if (!options.preserveDraft) clearColumnSelection(); } const nextTableComment = await tableCommentPromise; @@ -1788,9 +1797,89 @@ async function focusColumnNameInput(columnId: string) { input?.select(); } +function columnIsSelectable(column: EditableStructureColumn): boolean { + return !column.markedForDrop && columns.value.some((item) => item.id === column.id); +} + +/** Replace the whole selection state (set + active + shift anchor) atomically. */ +function setColumnSelection(ids: Iterable, activeId: string | null, anchorId: string | null) { + selectedColumnIds.value = new Set(ids); + selectedColumnId.value = activeId; + columnSelectionAnchorId.value = anchorId; +} + +function clearColumnSelection() { + setColumnSelection([], null, null); +} + +function selectSingleColumn(column: EditableStructureColumn) { + setColumnSelection([column.id], column.id, column.id); +} + +/** Ctrl/Cmd-click: toggle the row in the set and move the anchor to it. */ +function toggleColumnSelection(column: EditableStructureColumn) { + const next = new Set(selectedColumnIds.value); + if (next.has(column.id)) next.delete(column.id); + else next.add(column.id); + setColumnSelection(next, resolveColumnSelectionActiveId(columns.value, next, column.id), column.id); +} + +/** Shift-click: select the visible range between the anchor and this row; the anchor stays put. */ +function selectColumnRangeFromAnchor(column: EditableStructureColumn) { + const anchorId = columnSelectionAnchorId.value && columns.value.some((item) => item.id === columnSelectionAnchorId.value && !item.markedForDrop) ? columnSelectionAnchorId.value : column.id; + setColumnSelection(structureColumnSelectionRange(columns.value, anchorId, column.id), column.id, anchorId); +} + +// A mouse-driven click first triggers focusin (focus moves into the row's +// inputs) before click. While a pointer selection is in flight, focusin must +// not reset an in-progress ctrl/shift multi-selection; the flag is cleared on +// click and on any mouseup as a fallback. +let columnPointerSelectionActive = false; +let columnContextMenuButton: number | null = null; +let columnContextMenuCtrlKey = false; + +function onColumnRowMouseDown(event: MouseEvent) { + columnPointerSelectionActive = true; + if (event.button === 0) { + columnContextMenuButton = null; + columnContextMenuCtrlKey = false; + } else { + columnContextMenuButton = event.button; + columnContextMenuCtrlKey = event.ctrlKey; + } +} + +function onColumnSelectionPointerUp() { + columnPointerSelectionActive = false; +} + +function onColumnRowClick(column: EditableStructureColumn, event: MouseEvent) { + if (isSyntheticContextMenuClick(columnContextMenuButton, columnContextMenuCtrlKey, event.button)) { + columnContextMenuButton = null; + columnContextMenuCtrlKey = false; + columnPointerSelectionActive = false; + return; + } + columnContextMenuButton = null; + columnContextMenuCtrlKey = false; + columnPointerSelectionActive = false; + if (!columnIsSelectable(column)) return; + if (event.shiftKey) { + selectColumnRangeFromAnchor(column); + return; + } + if (event.metaKey || event.ctrlKey) { + toggleColumnSelection(column); + return; + } + selectSingleColumn(column); +} + function onColumnRowActivate(column: EditableStructureColumn) { - if (column.markedForDrop || !columns.value.some((item) => item.id === column.id)) return; - selectedColumnId.value = column.id; + // focusin path (keyboard Tab into row inputs); mouse clicks are handled by onColumnRowClick. + if (columnPointerSelectionActive) return; + if (!columnIsSelectable(column)) return; + selectSingleColumn(column); } function normalizedColumnSearch(value: string): string { @@ -1878,7 +1967,8 @@ function applyCopiedColumns() { if (!copiedColumns.length) return; const insertAt = resolveInsertColumnIndex(columns.value, selectedColumnId.value); columns.value.splice(insertAt, 0, ...copiedColumns); - selectedColumnId.value = copiedColumns[copiedColumns.length - 1]?.id ?? selectedColumnId.value; + const lastCopiedColumn = copiedColumns[copiedColumns.length - 1]; + if (lastCopiedColumn) selectSingleColumn(lastCopiedColumn); if (usesLocalTableColumnOrder.value) persistLocalColumnOrder(false); copyColumnsDialogOpen.value = false; } @@ -1889,7 +1979,7 @@ async function copyColumn(column: EditableStructureColumn) { if (sourceIndex < 0) return; const copiedColumn = cloneColumnDraftAsNew(column, uuid); columns.value.splice(sourceIndex + 1, 0, copiedColumn); - selectedColumnId.value = copiedColumn.id; + selectSingleColumn(copiedColumn); if (usesLocalTableColumnOrder.value) persistLocalColumnOrder(false); await focusColumnNameInput(copiedColumn.id); } @@ -1914,7 +2004,7 @@ async function addColumn() { }; const insertAt = resolveInsertColumnIndex(columns.value, selectedColumnId.value); columns.value.splice(insertAt, 0, column); - selectedColumnId.value = column.id; + selectSingleColumn(column); if (usesLocalTableColumnOrder.value) persistLocalColumnOrder(false); await focusColumnNameInput(column.id); } @@ -1932,13 +2022,20 @@ function applyColumnTemplate(templateId: string) { if (!templateColumns.length) return; const insertAt = resolveInsertColumnIndex(columns.value, selectedColumnId.value); columns.value.splice(insertAt, 0, ...templateColumns); - selectedColumnId.value = templateColumns[templateColumns.length - 1]?.id ?? selectedColumnId.value; + const lastTemplateColumn = templateColumns[templateColumns.length - 1]; + if (lastTemplateColumn) selectSingleColumn(lastTemplateColumn); if (usesLocalTableColumnOrder.value) persistLocalColumnOrder(false); } function removeNewColumn(column: EditableStructureColumn) { columns.value = columns.value.filter((item) => item.id !== column.id); + if (selectedColumnIds.value.has(column.id)) { + const next = new Set(selectedColumnIds.value); + next.delete(column.id); + selectedColumnIds.value = next; + } if (selectedColumnId.value === column.id) selectedColumnId.value = null; + if (columnSelectionAnchorId.value === column.id) columnSelectionAnchorId.value = null; } type ColumnDragState = { @@ -2320,7 +2417,7 @@ function onColumnDragEnd() { function columnRowClass(column: EditableStructureColumn, index: number) { const dragState = columnDragState.value; const isSearchMatch = filteredColumnRowIds.value.has(column.id); - const isSelected = selectedColumnId.value === column.id && !column.markedForDrop; + const isSelected = selectedColumnIds.value.has(column.id) && !column.markedForDrop; return { "bg-destructive/5 opacity-60": column.markedForDrop, "structure-column-search-match": isSearchMatch, @@ -2475,11 +2572,91 @@ function columnDragInsertionIndex(index: number, event: DragEvent): number { function toggleDropColumn(column: EditableStructureColumn) { if (!canDropColumn(column)) return; column.markedForDrop = !column.markedForDrop; - if (column.markedForDrop && selectedColumnId.value === column.id) { - selectedColumnId.value = null; + if (column.markedForDrop) { + // A dropped row is no longer selectable: keep the multi-selection consistent. + if (selectedColumnIds.value.has(column.id)) { + const next = new Set(selectedColumnIds.value); + next.delete(column.id); + selectedColumnIds.value = next; + } + if (selectedColumnId.value === column.id) selectedColumnId.value = null; + if (columnSelectionAnchorId.value === column.id) columnSelectionAnchorId.value = null; } } +/** Selected columns in visible row order (dropped rows are not selectable). */ +function selectedColumnsInOrder(): EditableStructureColumn[] { + const ids = selectedColumnIds.value; + if (!ids.size) return []; + return columns.value.filter((column) => ids.has(column.id) && !column.markedForDrop); +} + +/** + * Batch copy: clone each source row and insert every copy right after its own + * source, preserving relative order (same behavior as the row copy button, + * applied to each target). + */ +async function copyColumnRows(targets: EditableStructureColumn[]) { + if (!canAddColumn.value) return; + const sources = targets.filter((column) => !column.markedForDrop); + if (!sources.length) return; + const copiedIds: string[] = []; + // Insert from bottom to top so earlier inserts do not shift later source indexes. + for (let index = sources.length - 1; index >= 0; index--) { + const sourceIndex = columns.value.findIndex((item) => item.id === sources[index].id); + if (sourceIndex < 0) continue; + const copiedColumn = cloneColumnDraftAsNew(sources[index], uuid); + columns.value.splice(sourceIndex + 1, 0, copiedColumn); + copiedIds.unshift(copiedColumn.id); + } + const lastCopiedId = copiedIds[copiedIds.length - 1]; + if (!lastCopiedId) return; + setColumnSelection(copiedIds, lastCopiedId, lastCopiedId); + if (usesLocalTableColumnOrder.value) persistLocalColumnOrder(false); + await focusColumnNameInput(lastCopiedId); +} + +/** Batch drop: new rows are removed outright, existing rows are marked for drop. */ +function dropOrRemoveColumns(targets: EditableStructureColumn[]) { + for (const column of [...targets]) { + if (column.original) { + if (!column.markedForDrop) toggleDropColumn(column); + } else { + removeNewColumn(column); + } + } +} + +/** + * Context menu for a column row. When the right-clicked row is part of the + * multi-selection the actions apply to the whole selection; otherwise they + * apply to that row only (same convention as the object browser). + */ +function columnContextMenuItems(column: EditableStructureColumn): ContextMenuItem[] { + if (column.markedForDrop) { + return [{ label: t("structureEditor.restore"), icon: RefreshCw, disabled: !canDropColumn(column), action: () => toggleDropColumn(column) }]; + } + const isBatchContext = selectedColumnIds.value.has(column.id) && selectedColumnIds.value.size > 1; + const targets = isBatchContext ? selectedColumnsInOrder() : [column]; + const count = targets.length; + const allDroppable = targets.every((item) => !item.original || canDropColumn(item)); + return [ + { + label: isBatchContext ? t("structureEditor.copySelectedColumns", { count }) : t("structureEditor.copyColumn"), + icon: Copy, + disabled: !canAddColumn.value, + action: () => void copyColumnRows(targets), + }, + { + label: isBatchContext ? t("structureEditor.dropSelectedColumns", { count }) : column.original ? t("structureEditor.drop") : t("structureEditor.remove"), + icon: Trash2, + variant: "destructive", + disabled: !allDroppable, + action: () => dropOrRemoveColumns(targets), + }, + ]; +} + function isColumnNameDisabled(column: EditableStructureColumn): boolean { return column.markedForDrop || (!!column.original && !structureCapabilities.value.renameColumn); } @@ -3023,6 +3200,7 @@ function registerStructureEditorShortcuts() { keydownListenerRegistered = true; window.addEventListener("keydown", onStructureEditorKeydown); document.addEventListener("pointerdown", onStructureDensityDocumentPointerdown, true); + document.addEventListener("mouseup", onColumnSelectionPointerUp); } function unregisterStructureEditorShortcuts() { @@ -3030,6 +3208,7 @@ function unregisterStructureEditorShortcuts() { keydownListenerRegistered = false; window.removeEventListener("keydown", onStructureEditorKeydown); document.removeEventListener("pointerdown", onStructureDensityDocumentPointerdown, true); + document.removeEventListener("mouseup", onColumnSelectionPointerUp); } onMounted(() => { @@ -3191,7 +3370,7 @@ watch( watch(activeTab, () => { stopStructureHorizontalScrollbarDrag(); - selectedColumnId.value = null; + clearColumnSelection(); highlightedColumnId.value = null; highlightedIndexId.value = null; restoreStructureScrollPosition(); @@ -3203,9 +3382,17 @@ watch([activeTab, loading, indexesLoading, visibleColWidths, indexColWidths], ob watch( columns, (items) => { - if (selectedColumnId.value && !items.some((column) => column.id === selectedColumnId.value)) { + const existingIds = new Set(items.map((column) => column.id)); + if (selectedColumnId.value && !existingIds.has(selectedColumnId.value)) { selectedColumnId.value = null; } + if (columnSelectionAnchorId.value && !existingIds.has(columnSelectionAnchorId.value)) { + columnSelectionAnchorId.value = null; + } + const prunedIds = [...selectedColumnIds.value].filter((id) => existingIds.has(id)); + if (prunedIds.length !== selectedColumnIds.value.size) { + selectedColumnIds.value = new Set(prunedIds); + } }, { deep: false }, ); @@ -3461,361 +3648,363 @@ watch([activeTab, ddlLoading], ([tab, loading]) => { - - -
- {{ index + 1 }} - -
- - - - - - - - - - - - - - -
- {{ t("structureEditor.enumValues") }} - -
-
-
- - -
-
-
-
-
- - -
- - - - - - - - -
- - - - - - - - {{ preset.label }} - - - -
- - -
- - + + + +
+ {{ index + 1 }} + +
+ + + + + + + + + + - - +
- - {{ t("structureEditor.editComment") }} - - - {{ column.name || t("structureEditor.columnName") }} - + {{ t("structureEditor.enumValues") }} + +
+
+
+ + +
-