feat(structure): insert column after selected

This commit is contained in:
onenewcode authored and GitHub committed 2026-07-15 19:36:25 +08:00
1 parent b6581d3dc9
commit 3b54fa56df
4 files changed
+108 -19

No files matched your search

@@ -66,6 +66,7 @@ import {
mysqlEnumDataType,
parseExtraToColumnExtra,
rehydrateColumnDraftsFromMetadata,
resolveInsertColumnIndex,
restoreDamengLengthUnitsAfterSave,
splitDataType,
toColumnNames,
@@ -548,6 +549,7 @@ const indexColWidths = ref(loadStructureIndexColumnWidths(structureDensity.value
const resizing = ref<{ col: number; startX: number; startW: number } | null>(null);
const columnSearchInputRef = ref<InstanceType<typeof Input>>();
const columnSearchText = ref("");
const selectedColumnId = ref<string | null>(null);
const highlightedColumnId = ref<string | null>(null);
const indexSearchInputRef = ref<InstanceType<typeof Input>>();
const indexSearchText = ref("");
@@ -1191,6 +1193,7 @@ function resetState() {
sqliteSchemaRevision.value = undefined;
foreignKeys.value = [];
triggers.value = [];
selectedColumnId.value = null;
ddlContent.value = "";
ddlFetched.value = false;
newTableName.value = "";
@@ -1270,6 +1273,7 @@ async function loadStructure(silent = false, scope: StructureRefreshScope = FULL
const nextColumnDrafts = createColumnDrafts(nextColumns, databaseType.value);
const hydratedColumnDrafts = databaseType.value === "dameng" && options.damengLengthUnitsAfterSave ? restoreDamengLengthUnitsAfterSave(nextColumnDrafts, options.damengLengthUnitsAfterSave) : nextColumnDrafts;
columns.value = applyStoredLocalColumnOrder(hydratedColumnDrafts);
if (!options.preserveDraft) selectedColumnId.value = null;
}
const nextTableComment = await tableCommentPromise;
@@ -1325,6 +1329,20 @@ async function refreshStructureAfterSave(scope: StructureRefreshScope, damengLen
}
}
async function focusColumnNameInput(columnId: string) {
await nextTick();
const row = Array.from(rootRef.value?.querySelectorAll<HTMLElement>("[data-column-row-index]") ?? []).find((element) => element.dataset.columnId === columnId);
const input = row?.querySelector<HTMLInputElement>("[data-column-name-input]");
row?.scrollIntoView({ block: "nearest" });
input?.focus();
input?.select();
}
function onColumnRowActivate(column: EditableStructureColumn) {
if (column.markedForDrop || !columns.value.some((item) => item.id === column.id)) return;
selectedColumnId.value = column.id;
}
async function addColumn() {
if (!canAddColumn.value) return;
activeTab.value = "columns";
@@ -1343,14 +1361,11 @@ async function addColumn() {
extra: {},
markedForDrop: false,
};
columns.value.push(column);
await nextTick();
const newRows = rootRef.value?.querySelectorAll<HTMLElement>('[data-new-column-row="true"]');
const row = newRows?.[newRows.length - 1];
const input = row?.querySelector<HTMLInputElement>("[data-column-name-input]");
row?.scrollIntoView({ block: "nearest" });
input?.focus();
input?.select();
const insertAt = resolveInsertColumnIndex(columns.value, selectedColumnId.value);
columns.value.splice(insertAt, 0, column);
selectedColumnId.value = column.id;
if (usesLocalTableColumnOrder.value) persistLocalColumnOrder();
await focusColumnNameInput(column.id);
}
function applyColumnTemplate(templateId: string) {
@@ -1364,11 +1379,15 @@ function applyColumnTemplate(templateId: string) {
createId: uuid,
});
if (!templateColumns.length) return;
columns.value.push(...templateColumns);
const insertAt = resolveInsertColumnIndex(columns.value, selectedColumnId.value);
columns.value.splice(insertAt, 0, ...templateColumns);
selectedColumnId.value = templateColumns[templateColumns.length - 1]?.id ?? selectedColumnId.value;
if (usesLocalTableColumnOrder.value) persistLocalColumnOrder();
}
function removeNewColumn(column: EditableStructureColumn) {
columns.value = columns.value.filter((item) => item.id !== column.id);
if (selectedColumnId.value === column.id) selectedColumnId.value = null;
}
type ColumnDragState = {
@@ -1741,10 +1760,12 @@ 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;
return {
"bg-destructive/5 opacity-60": column.markedForDrop,
"structure-column-search-match": isSearchMatch,
"structure-column-search-current": highlightedColumnId.value === column.id,
// Reuse the existing search-current highlight for the active/selected row.
"structure-column-search-current": highlightedColumnId.value === column.id || isSelected,
"opacity-55": dragState?.columnId === column.id,
"bg-primary/5": dragState && (dragState.insertionIndex === index || dragState.insertionIndex === index + 1),
"[&>td]:border-t-2 [&>td]:border-t-primary": dragState?.insertionIndex === index,
@@ -1894,6 +1915,9 @@ 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;
}
}
function isColumnNameDisabled(column: EditableStructureColumn): boolean {
@@ -2431,12 +2455,23 @@ watch(
);
watch(activeTab, () => {
selectedColumnId.value = null;
highlightedColumnId.value = null;
highlightedIndexId.value = null;
restoreStructureScrollPosition();
syncDraftToParent();
});
watch(
columns,
(items) => {
if (selectedColumnId.value && !items.some((column) => column.id === selectedColumnId.value)) {
selectedColumnId.value = null;
}
},
{ deep: false },
);
watch(secondaryMetadataLoading, (value) => {
if (value || !deferredSqlPreviewRefresh) return;
scheduleSqlPreviewRefresh();
@@ -2636,7 +2671,18 @@ watch([activeTab, ddlLoading], ([tab, loading]) => {
</tr>
</thead>
<tbody>
<tr v-for="(column, index) in columns" :key="column.id" :class="columnRowClass(column, index)" :data-new-column-row="!column.original ? 'true' : undefined" :data-column-row-index="index" @dragover="onColumnDragOver(index, $event)" @drop="onColumnDrop(index, $event)">
<tr
v-for="(column, index) in columns"
:key="column.id"
:class="columnRowClass(column, index)"
:data-new-column-row="!column.original ? 'true' : undefined"
:data-column-row-index="index"
:data-column-id="column.id"
@click="onColumnRowActivate(column)"
@focusin="onColumnRowActivate(column)"
@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>
@@ -2965,12 +3011,12 @@ watch([activeTab, ddlLoading], ([tab, loading]) => {
:disabled="!canDropColumn(column)"
:title="column.markedForDrop ? t('structureEditor.restore') : t('structureEditor.drop')"
:aria-label="column.markedForDrop ? t('structureEditor.restore') : t('structureEditor.drop')"
@click="toggleDropColumn(column)"
@click.stop="toggleDropColumn(column)"
>
<RefreshCw v-if="column.markedForDrop" :class="structureIconClass" />
<Trash2 v-else :class="structureIconClass" />
</Button>
<Button v-else variant="ghost" size="icon" :class="structureActionButtonClass" :title="t('structureEditor.remove')" :aria-label="t('structureEditor.remove')" @click="removeNewColumn(column)">
<Button v-else variant="ghost" size="icon" :class="structureActionButtonClass" :title="t('structureEditor.remove')" :aria-label="t('structureEditor.remove')" @click.stop="removeNewColumn(column)">
<X :class="structureIconClass" />
</Button>
</div>
@@ -3263,20 +3309,27 @@ watch([activeTab, ddlLoading], ([tab, loading]) => {
</template>
<style scoped>
/* --primary is rgb/oklch; use color-mix like DataGrid, not hsl(var(--primary)). */
.structure-column-search-match > td:first-child {
box-shadow: inset 3px 0 0 hsl(var(--primary) / 0.55);
box-shadow: inset 3px 0 0 color-mix(in oklab, var(--primary) 55%, transparent);
}
.structure-column-search-current > td {
background-color: color-mix(in oklab, var(--primary) 8%, transparent);
box-shadow:
inset 0 1px 0 hsl(var(--primary) / 0.55),
inset 0 -1px 0 hsl(var(--primary) / 0.55);
inset 0 1px 0 color-mix(in oklab, var(--primary) 55%, transparent),
inset 0 -1px 0 color-mix(in oklab, var(--primary) 55%, transparent);
}
.structure-column-search-current > td:first-child {
box-shadow:
inset 3px 0 0 hsl(var(--primary)),
inset 0 1px 0 hsl(var(--primary) / 0.55),
inset 0 -1px 0 hsl(var(--primary) / 0.55);
inset 3px 0 0 var(--primary),
inset 0 1px 0 color-mix(in oklab, var(--primary) 55%, transparent),
inset 0 -1px 0 color-mix(in oklab, var(--primary) 55%, transparent);
}
/* Inputs are bg-transparent; give them a solid surface on the selected row so fields stay readable. */
.structure-column-search-current :is(input, button, [role="combobox"], [data-slot="select-trigger"]) {
background-color: var(--background);
}
</style>
@@ -18,6 +18,7 @@ import {
mysqlEnumDataType,
parseExtraToColumnExtra,
rehydrateColumnDraftsFromMetadata,
resolveInsertColumnIndex,
restoreDamengLengthUnitsAfterSave,
splitDataType,
} from "@/lib/table/tableStructureEditorState";
@@ -248,6 +249,19 @@ describe("tableStructureEditorState", () => {
expect(hasExistingColumnTypeChange([column])).toBe(false);
});
it("inserts new columns after the selected row or appends when none is selected", () => {
const columns = [{ id: "a" }, { id: "b" }, { id: "c" }];
expect(resolveInsertColumnIndex(columns, null)).toBe(3);
expect(resolveInsertColumnIndex(columns, undefined)).toBe(3);
expect(resolveInsertColumnIndex(columns, "a")).toBe(1);
expect(resolveInsertColumnIndex(columns, "b")).toBe(2);
expect(resolveInsertColumnIndex(columns, "c")).toBe(3);
expect(resolveInsertColumnIndex(columns, "missing")).toBe(3);
expect(resolveInsertColumnIndex([], "a")).toBe(0);
expect(resolveInsertColumnIndex([{ id: "a", markedForDrop: true }, { id: "b" }], "a")).toBe(2);
});
it("strips SQL Server metadata parentheses from editable defaults", () => {
const drafts = createColumnDrafts(
[
@@ -1066,6 +1066,14 @@ export function defaultNewColumnDataType(dbType: DatabaseType | undefined, dataT
return dbType === "sqlite" ? "text" : "varchar(255)";
}
/** Index at which to insert a new column (after the selected row, or append when none). */
export function resolveInsertColumnIndex(columns: readonly { id: string; markedForDrop?: boolean }[], selectedColumnId: string | null | undefined): number {
if (!selectedColumnId) return columns.length;
// Dropped rows are not valid insertion anchors.
const index = columns.findIndex((column) => column.id === selectedColumnId && !column.markedForDrop);
return index >= 0 ? index + 1 : columns.length;
}
function isMysqlDeprecatedDefaultParameterType(baseType: string): boolean {
const typeName = baseType.split(/\s+/)[0];
return ["tinyint", "smallint", "mediumint", "int", "integer", "bigint", "float", "double", "real"].includes(typeName ?? "");