feat(structure): open editor from field and index nodes

This commit is contained in:
t8y2 committed 2026-07-06 14:16:43 +08:00
1 parent 564c5beeff
commit 92ef1ac3cf
13 files changed
+258 -25

No files matched your search

@@ -28,7 +28,7 @@ import { PRESET_FIELDS_TEMPLATE_ID, createTableColumnTemplateDrafts } from "@/li
import { getTableMetadataCapabilities } from "@/lib/table/tableMetadataCapabilities";
import { canAddTableStructureColumn, getTableStructureCapabilities } from "@/lib/table/tableStructureCapabilities";
import { connectionObjectTreeQuerySchema, tableStructureDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
import type { TableInfoTab, TableStructureEditorDraft } from "@/types/database";
import type { TableInfoTab, TableStructureEditorDraft, TableStructureEditorTarget } from "@/types/database";
import {
buildStructureTargetLabel,
combineDataTypeForDatabase,
@@ -85,6 +85,7 @@ const props = defineProps<{
tableName: string;
initialTab?: TableInfoTab;
initialTabRequestId?: number;
initialTarget?: TableStructureEditorTarget;
draft?: TableStructureEditorDraft;
}>();
@@ -494,8 +495,12 @@ const resizing = ref<{ col: number; startX: number; startW: number } | null>(nul
const columnSearchInputRef = ref<InstanceType<typeof Input>>();
const columnSearchText = ref("");
const highlightedColumnId = ref<string | null>(null);
const indexSearchInputRef = ref<InstanceType<typeof Input>>();
const indexSearchText = ref("");
const highlightedIndexId = ref<string | null>(null);
const sqlPreviewCollapsed = ref(loadSqlPreviewCollapsed());
let columnHighlightTimer: ReturnType<typeof window.setTimeout> | undefined;
let indexHighlightTimer: ReturnType<typeof window.setTimeout> | undefined;
watch(
structureDensity,
@@ -689,6 +694,12 @@ const filteredColumnRowIds = computed(() => {
);
});
const columnSearchMatchCount = computed(() => (columnSearchText.value.trim() ? filteredColumnRowIds.value.size : 0));
const filteredIndexRowIds = computed(() => {
const query = indexSearchText.value.trim().toLowerCase();
if (!query) return new Set<string>();
return new Set(indexes.value.filter((index) => indexMatchesSearch(index, query)).map((index) => index.id));
});
const indexSearchMatchCount = computed(() => (indexSearchText.value.trim() ? filteredIndexRowIds.value.size : 0));
const foreignKeyActionOptions = ["", "CASCADE", "SET NULL", "RESTRICT", "NO ACTION"];
const triggerTimingOptions = ["BEFORE", "AFTER"];
const triggerEventOptions = ["INSERT", "UPDATE", "DELETE"];
@@ -723,6 +734,10 @@ let restoringDraft = false;
let syncingDraft = false;
let draftHydrated = false;
let hydratingRestoredDraft = false;
// A context-menu target may arrive before metadata rows render, so search text
// and row scrolling are tracked separately for each request.
let appliedInitialTargetSearchKey = "";
let appliedInitialTargetScrollKey = "";
function cloneDraftValue<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T;
@@ -977,6 +992,12 @@ function resetState() {
newTableName.value = "";
tableComment.value = "";
originalTableComment.value = "";
columnSearchText.value = "";
highlightedColumnId.value = null;
indexSearchText.value = "";
highlightedIndexId.value = null;
appliedInitialTargetSearchKey = "";
appliedInitialTargetScrollKey = "";
}
async function reloadStructureFromDatabase() {
@@ -1441,6 +1462,76 @@ function onColumnSearchKeydown(event: KeyboardEvent) {
scrollToColumnSearchMatch(event.shiftKey ? -1 : 1);
}
function indexMatchesSearch(index: EditableStructureIndex, searchQuery = indexSearchText.value.trim().toLowerCase()): boolean {
if (!searchQuery) return false;
return [index.name, toColumnNames(index.columns), index.includedColumns.join(", "), index.indexType, index.filter, index.comment].some((value) =>
String(value ?? "")
.toLowerCase()
.includes(searchQuery),
);
}
function indexFieldMatchesSearch(value: string | null | undefined): boolean {
const query = indexSearchText.value.trim().toLowerCase();
return (
!!query &&
String(value ?? "")
.toLowerCase()
.includes(query)
);
}
function indexRowClass(index: EditableStructureIndex) {
const isSearchMatch = filteredIndexRowIds.value.has(index.id);
return {
"bg-destructive/5 opacity-60": index.markedForDrop,
"structure-column-search-match": isSearchMatch,
"structure-column-search-current": highlightedIndexId.value === index.id,
};
}
function indexSearchFieldClass(index: EditableStructureIndex, value: string | null | undefined) {
const matches = indexFieldMatchesSearch(value);
return {
"!border-primary/60 !bg-primary/10": matches,
"!border-primary !ring-2 !ring-primary/30": matches && highlightedIndexId.value === index.id,
};
}
function focusIndexSearch() {
activeTab.value = "indexes";
void nextTick(() => {
const input = indexSearchInputRef.value?.$el as HTMLInputElement | undefined;
input?.focus();
input?.select();
});
}
function scrollToIndexSearchMatch(direction: 1 | -1 = 1) {
const query = indexSearchText.value.trim();
if (!query) {
focusIndexSearch();
return;
}
const rows = Array.from(rootRef.value?.querySelectorAll<HTMLElement>("[data-index-row-index]") ?? []);
const matches = indexes.value.map((index, rowIndex) => ({ index, rowIndex })).filter(({ index }) => indexMatchesSearch(index));
if (!matches.length) return;
const currentIndex = highlightedIndexId.value ? matches.findIndex(({ index }) => index.id === highlightedIndexId.value) : -1;
const nextMatch = matches[(currentIndex + direction + matches.length) % matches.length] ?? matches[0];
highlightedIndexId.value = nextMatch.index.id;
rows[nextMatch.rowIndex]?.scrollIntoView({ block: "center", inline: "nearest" });
if (indexHighlightTimer) window.clearTimeout(indexHighlightTimer);
indexHighlightTimer = window.setTimeout(() => {
highlightedIndexId.value = null;
}, 1800);
}
function onIndexSearchKeydown(event: KeyboardEvent) {
if (event.key !== "Enter") return;
event.preventDefault();
scrollToIndexSearchMatch(event.shiftKey ? -1 : 1);
}
function columnDragInsertionIndex(index: number, event: DragEvent): number {
const target = event.currentTarget;
if (!(target instanceof HTMLElement)) return index;
@@ -1816,16 +1907,18 @@ function unregisterStructureEditorShortcuts() {
onMounted(() => {
resetState();
applyInitialStructureTab();
applyInitialStructureTarget();
registerStructureEditorShortcuts();
void loadDynamicDataTypeOptions();
if (props.draft?.initialized) {
restoreDraft(props.draft);
applyInitialStructureTab();
void hydrateRestoredDraftFromDatabase();
applyInitialStructureTarget();
void hydrateRestoredDraftFromDatabase().then(() => applyInitialStructureTarget());
} else if (isCreateMode.value) {
markDraftHydratedAndSync();
} else {
void loadStructure(false, FULL_STRUCTURE_REFRESH_SCOPE, true, { blockSecondaryMetadata: true });
void loadStructure(false, FULL_STRUCTURE_REFRESH_SCOPE, true, { blockSecondaryMetadata: true }).then(() => applyInitialStructureTarget());
}
});
@@ -1834,7 +1927,8 @@ onActivated(() => {
void loadDynamicDataTypeOptions();
if (props.draft?.initialized && !draftHydrated) {
restoreDraft(props.draft);
void hydrateRestoredDraftFromDatabase();
applyInitialStructureTarget();
void hydrateRestoredDraftFromDatabase().then(() => applyInitialStructureTarget());
}
});
onDeactivated(unregisterStructureEditorShortcuts);
@@ -1843,6 +1937,7 @@ onBeforeUnmount(() => {
unregisterStructureEditorShortcuts();
clearSqlPreviewState();
if (columnHighlightTimer) window.clearTimeout(columnHighlightTimer);
if (indexHighlightTimer) window.clearTimeout(indexHighlightTimer);
persistStructureDensity();
});
@@ -1869,13 +1964,53 @@ function applyInitialStructureTab() {
activeTab.value = resolveStructureMetadataTab(props.initialTab);
}
function initialTargetKey(target: TableStructureEditorTarget): string {
return `${props.initialTabRequestId ?? 0}:${target.kind}:${target.name}`;
}
function applyInitialStructureTarget() {
const target = props.initialTarget;
const targetName = target?.name.trim();
if (!target || !targetName) return;
const key = initialTargetKey(target);
if (appliedInitialTargetSearchKey !== key) {
if (target.kind === "column") {
activeTab.value = resolveStructureMetadataTab("columns");
columnSearchText.value = targetName;
highlightedColumnId.value = null;
} else {
activeTab.value = resolveStructureMetadataTab("indexes");
indexSearchText.value = targetName;
highlightedIndexId.value = null;
}
appliedInitialTargetSearchKey = key;
}
if (appliedInitialTargetScrollKey === key) return;
const hasMatch = target.kind === "column" ? columns.value.some((column) => columnMatchesSearch(column)) : indexes.value.some((index) => indexMatchesSearch(index));
if (!hasMatch) return;
appliedInitialTargetScrollKey = key;
void nextTick(() => {
if (target.kind === "column") {
scrollToColumnSearchMatch(1);
} else {
scrollToIndexSearchMatch(1);
}
});
}
watch(tableMetadataCapabilities, (capabilities) => {
if (!isStructureMetadataTabSupported(activeTab.value, capabilities)) activeTab.value = firstStructureMetadataTab(capabilities);
});
watch([() => props.initialTab, () => props.initialTabRequestId], () => {
if (!props.initialTab) return;
applyInitialStructureTab();
watch([() => props.initialTab, () => props.initialTabRequestId, () => props.initialTarget], () => {
if (props.initialTab) applyInitialStructureTab();
applyInitialStructureTarget();
});
watch([columns, indexes], () => {
applyInitialStructureTarget();
});
watch([() => props.connectionId, () => props.database, databaseType], () => {
@@ -1893,6 +2028,7 @@ watch(
watch(activeTab, () => {
highlightedColumnId.value = null;
highlightedIndexId.value = null;
syncDraftToParent();
});
@@ -2036,6 +2172,19 @@ watch(activeTab, (tab) => {
</TooltipTrigger>
<TooltipContent>{{ t("structureEditor.configureColumnTemplates") }}</TooltipContent>
</Tooltip>
<div v-if="activeTab === 'indexes'" class="relative flex w-40 shrink-0 items-center">
<Search :class="[structureIconClass, 'pointer-events-none absolute left-2 text-muted-foreground']" />
<Input ref="indexSearchInputRef" v-model="indexSearchText" :placeholder="t('structureEditor.searchIndexes')" :class="[structureControlClass, 'pl-7 pr-14 text-[length:var(--structure-font-size)] placeholder:text-[length:var(--structure-font-size)]']" @keydown="onIndexSearchKeydown" />
<button
v-if="indexSearchText"
type="button"
class="absolute right-1.5 top-1/2 -translate-y-1/2 rounded px-1 text-[length:var(--structure-font-size)] text-muted-foreground hover:bg-muted hover:text-foreground"
:title="t('structureEditor.nextIndexMatch')"
@click="scrollToIndexSearchMatch(1)"
>
{{ indexSearchMatchCount }}
</button>
</div>
<Button v-if="activeTab === 'indexes'" size="sm" :class="structureToolbarButtonClass" :disabled="!structureCapabilities.createIndex || indexesLoading" @click="addIndex">
<Plus :class="structureIconClass" />
{{ t("structureEditor.addIndex") }}
@@ -2345,9 +2494,9 @@ watch(activeTab, (tab) => {
</tr>
</thead>
<tbody>
<tr v-for="index in indexes" :key="index.id" :class="index.markedForDrop ? 'bg-destructive/5 opacity-60' : ''" :data-new-index-row="!index.original ? 'true' : undefined">
<tr v-for="(index, rowIndex) in indexes" :key="index.id" :class="indexRowClass(index)" :data-new-index-row="!index.original ? 'true' : undefined" :data-index-row-index="rowIndex">
<td :class="structureCellClass">
<Input :model-value="index.name" :class="structureControlClass" :disabled="!canEditIndexDraft(index)" data-index-name-input @update:model-value="(value: string | number) => onIndexNameInput(index, value)" />
<Input :model-value="index.name" :class="[structureControlClass, indexSearchFieldClass(index, index.name)]" :disabled="!canEditIndexDraft(index)" data-index-name-input @update:model-value="(value: string | number) => onIndexNameInput(index, value)" />
</td>
<td :class="[structureCellClass, 'overflow-hidden']">
<DropdownMenu v-if="canEditIndexDraft(index)">
@@ -2405,10 +2554,10 @@ watch(activeTab, (tab) => {
<span v-else class="text-[length:var(--structure-font-size)] text-muted-foreground">{{ index.includedColumns.join(", ") }}</span>
</td>
<td :class="structureCellClass">
<Input v-model="index.filter" :class="structureMonoControlClass" :placeholder="index.original?.filter || ''" :disabled="!canEditIndexFilter(index)" />
<Input v-model="index.filter" :class="[structureMonoControlClass, indexSearchFieldClass(index, index.filter)]" :placeholder="index.original?.filter || ''" :disabled="!canEditIndexFilter(index)" />
</td>
<td :class="structureCellClass">
<Input v-model="index.comment" :class="structureControlClass" :disabled="!canEditIndexComment(index)" />
<Input v-model="index.comment" :class="[structureControlClass, indexSearchFieldClass(index, index.comment)]" :disabled="!canEditIndexComment(index)" />
</td>
<td :class="structureLastCellClass">
<Badge v-if="index.isPrimary" variant="outline">{{ t("structureEditor.primary") }}</Badge>