feat(structure): search and paginate source table picker for copy columns

This commit is contained in:
二丫讲梵 authored and GitHub committed 2026-08-24 18:47:13 +08:00
1 parent 5bafb68919
commit ff4667b93f
14 files changed
+460 -60

No files matched your search

@@ -7,7 +7,7 @@ import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { AlertTriangle, Check, ChevronDown, ChevronUp, Copy, Database, Info, KeyRound, ListChevronsUpDown, Loader2, Maximize2, Plus, RefreshCw, Save, Search, Settings, SlidersHorizontal, Trash2, UserRound, X } from "@lucide/vue";
import { AlertTriangle, Check, ChevronDown, ChevronLeft, ChevronRight, ChevronUp, Copy, Database, Info, KeyRound, ListChevronsUpDown, Loader2, Maximize2, Plus, RefreshCw, Save, Search, Settings, SlidersHorizontal, Trash2, UserRound, 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";
@@ -205,13 +205,21 @@ const columns = ref<EditableStructureColumn[]>([]);
const copyColumnsDialogOpen = ref(false);
const copySourceTables = ref<TableInfo[]>([]);
const copySourceTableName = ref("");
const copySourceTableSearch = ref("");
const copySourceColumns = ref<ColumnInfo[]>([]);
const copySourceColumnSearch = ref("");
const selectedCopySourceColumnNames = ref<string[]>([]);
const copySourceTablesLoading = ref(false);
const copySourceTablesOffset = ref(0);
const copySourceTablesHasMore = ref(false);
const copySourceColumnsLoading = ref(false);
const copySourceError = ref("");
const COPY_SOURCE_TABLE_PAGE_SIZE = 100;
const COPY_SOURCE_TABLE_PAGE_PROBE_SIZE = COPY_SOURCE_TABLE_PAGE_SIZE + 2;
const COPY_SOURCE_TABLE_SEARCH_DEBOUNCE_MS = 250;
let copySourceTablesRequestId = 0;
let copySourceColumnsRequestId = 0;
let copySourceTableSearchTimer: ReturnType<typeof setTimeout> | undefined;
const indexes = ref<EditableStructureIndex[]>([]);
/** PostgreSQL partitioned parent (`relkind = 'p'`): `CREATE INDEX CONCURRENTLY`
* is rejected by the server on such tables, so the option is disabled here and
@@ -1907,11 +1915,75 @@ const selectedCopySourceColumns = computed(() => {
return copyableSourceColumns.value.filter(({ column, alreadyExists }) => !alreadyExists && selected.has(column.name)).map(({ column }) => column);
});
const allCopyableSourceColumnsSelected = computed(() => copyableSourceColumnNames.value.length > 0 && copyableSourceColumnNames.value.every((name) => selectedCopySourceColumnNames.value.includes(name)));
const copySourceTablesHasPreviousPage = computed(() => copySourceTablesOffset.value > 0);
function clearCopySourceTableSearchTimer() {
if (copySourceTableSearchTimer === undefined) return;
clearTimeout(copySourceTableSearchTimer);
copySourceTableSearchTimer = undefined;
}
function isCopySourceTable(table: TableInfo): boolean {
const databaseInfo = connection.value?.database_info;
return isCreateMode.value || tableStructureIdentifierComparisonKey(table.name, databaseType.value, databaseInfo) !== tableStructureIdentifierComparisonKey(props.tableName, databaseType.value, databaseInfo);
}
function clearCopySourceTableSelection() {
copySourceTableName.value = "";
copySourceColumns.value = [];
copySourceColumnSearch.value = "";
selectedCopySourceColumnNames.value = [];
copySourceColumnsRequestId++;
copySourceColumnsLoading.value = false;
}
async function loadCopySourceTables(offset = 0) {
if (!props.connectionId || !props.database) return;
clearCopySourceTableSelection();
const requestId = ++copySourceTablesRequestId;
copySourceTablesLoading.value = true;
copySourceError.value = "";
try {
await store.ensureConnected(props.connectionId);
const tables = await api.listTables(props.connectionId, props.database, metadataSchema.value, copySourceTableSearch.value.trim() || undefined, COPY_SOURCE_TABLE_PAGE_PROBE_SIZE, offset, ["TABLE"], props.catalog);
if (requestId !== copySourceTablesRequestId) return;
copySourceTables.value = tables.slice(0, COPY_SOURCE_TABLE_PAGE_SIZE).filter(isCopySourceTable);
copySourceTablesOffset.value = offset;
// The current table is excluded locally. Probe the next two rows so its
// presence immediately after a full page does not create an empty next page.
copySourceTablesHasMore.value = tables.slice(COPY_SOURCE_TABLE_PAGE_SIZE).some(isCopySourceTable);
} catch (error: any) {
if (requestId !== copySourceTablesRequestId) return;
copySourceTables.value = [];
copySourceTablesHasMore.value = false;
copySourceError.value = error?.message || String(error);
} finally {
if (requestId === copySourceTablesRequestId) copySourceTablesLoading.value = false;
}
}
function updateCopySourceTableSearch(value: string | number) {
copySourceTableSearch.value = String(value);
clearCopySourceTableSelection();
clearCopySourceTableSearchTimer();
copySourceTableSearchTimer = setTimeout(() => {
copySourceTableSearchTimer = undefined;
void loadCopySourceTables();
}, COPY_SOURCE_TABLE_SEARCH_DEBOUNCE_MS);
}
watch(copyColumnsDialogOpen, (open) => {
if (open) return;
clearCopySourceTableSearchTimer();
copySourceTablesRequestId++;
copySourceColumnsRequestId++;
});
async function openCopyColumnsDialog() {
if (!canAddColumn.value || !props.connectionId || !props.database) return;
copyColumnsDialogOpen.value = true;
copySourceTableName.value = "";
copySourceTableSearch.value = "";
copySourceColumns.value = [];
copySourceColumnSearch.value = "";
selectedCopySourceColumnNames.value = [];
@@ -1919,21 +1991,10 @@ async function openCopyColumnsDialog() {
copySourceColumnsRequestId++;
copySourceColumnsLoading.value = false;
copySourceTables.value = [];
copySourceTablesLoading.value = true;
try {
await store.ensureConnected(props.connectionId);
const databaseInfo = connection.value?.database_info;
const currentTableIdentifierKey = tableStructureIdentifierComparisonKey(props.tableName, databaseType.value, databaseInfo);
copySourceTables.value = (await api.listTables(props.connectionId, props.database, metadataSchema.value, undefined, undefined, undefined, undefined, props.catalog)).filter((table) => {
const tableType = table.table_type.toUpperCase();
return tableType !== "VIEW" && tableType !== "MATERIALIZED_VIEW" && (isCreateMode.value || tableStructureIdentifierComparisonKey(table.name, databaseType.value, databaseInfo) !== currentTableIdentifierKey);
});
} catch (error: any) {
copySourceTables.value = [];
copySourceError.value = error?.message || String(error);
} finally {
copySourceTablesLoading.value = false;
}
copySourceTablesOffset.value = 0;
copySourceTablesHasMore.value = false;
clearCopySourceTableSearchTimer();
await loadCopySourceTables();
}
async function loadCopySourceColumns(tableName: string) {
@@ -3265,6 +3326,7 @@ onDeactivated(() => {
stopStructureHorizontalScrollbarDrag();
});
onBeforeUnmount(() => {
clearCopySourceTableSearchTimer();
stopColumnDragTracking();
stopStructureHorizontalScrollbarDrag();
structureHorizontalScrollbarObserverGeneration += 1;
@@ -4335,18 +4397,10 @@ watch([activeTab, ddlLoading], ([tab, loading]) => {
</DialogHeader>
<div class="space-y-3 overflow-hidden">
<label class="grid gap-1.5 text-sm font-medium">
{{ t("structureEditor.copyColumnsSourceTable") }}
<select
:value="copySourceTableName"
class="h-9 w-full rounded-md border border-input bg-background px-3 text-sm outline-none focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/30 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="copySourceTablesLoading || copySourceTables.length === 0"
@change="loadCopySourceColumns(($event.target as HTMLSelectElement).value)"
>
<option value="" disabled>{{ t("structureEditor.copyColumnsSelectSourceTable") }}</option>
<option v-for="table in copySourceTables" :key="table.name" :value="table.name">{{ table.name }}</option>
</select>
</label>
<div class="grid gap-1.5 text-sm font-medium">
<label for="copy-source-table-search">{{ t("structureEditor.copyColumnsSourceTable") }}</label>
<Input id="copy-source-table-search" :model-value="copySourceTableSearch" :placeholder="t('structureEditor.copyColumnsSearchSourceTables')" @update:model-value="updateCopySourceTableSearch" />
</div>
<div v-if="copySourceTablesLoading" class="flex items-center gap-2 py-5 text-sm text-muted-foreground">
<Loader2 class="h-4 w-4 animate-spin" />
@@ -4356,9 +4410,47 @@ watch([activeTab, ddlLoading], ([tab, loading]) => {
{{ copySourceError }}
</div>
<div v-else-if="copySourceTables.length === 0" class="rounded-md border border-dashed px-3 py-5 text-center text-sm text-muted-foreground">
{{ t("structureEditor.copyColumnsNoSourceTables") }}
{{ copySourceTableSearch ? t("structureEditor.copyColumnsNoMatchingSourceTables") : t("structureEditor.copyColumnsNoSourceTables") }}
</div>
<template v-else-if="copySourceTableName">
<template v-else>
<div class="max-h-52 overflow-y-auto rounded-md border" :aria-label="t('structureEditor.copyColumnsSourceTable')">
<button
v-for="table in copySourceTables"
:key="table.name"
type="button"
:aria-pressed="table.name === copySourceTableName"
:class="['flex h-9 w-full items-center px-3 text-left font-mono text-sm hover:bg-muted/50 focus-visible:bg-muted focus-visible:outline-none', table.name === copySourceTableName ? 'bg-muted' : '']"
@click="loadCopySourceColumns(table.name)"
>
<span class="truncate" :title="table.name">{{ table.name }}</span>
</button>
</div>
<div v-if="copySourceTablesHasPreviousPage || copySourceTablesHasMore" class="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
:disabled="copySourceTablesLoading || !copySourceTablesHasPreviousPage"
:title="t('structureEditor.copyColumnsPreviousSourceTablePage')"
:aria-label="t('structureEditor.copyColumnsPreviousSourceTablePage')"
@click="loadCopySourceTables(Math.max(0, copySourceTablesOffset - COPY_SOURCE_TABLE_PAGE_SIZE))"
>
<ChevronLeft class="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
:disabled="copySourceTablesLoading || !copySourceTablesHasMore"
:title="t('structureEditor.copyColumnsNextSourceTablePage')"
:aria-label="t('structureEditor.copyColumnsNextSourceTablePage')"
@click="loadCopySourceTables(copySourceTablesOffset + COPY_SOURCE_TABLE_PAGE_SIZE)"
>
<ChevronRight class="h-4 w-4" />
</Button>
</div>
</template>
<template v-if="copySourceTableName">
<div class="flex items-center justify-between gap-2">
<span class="text-sm font-medium">{{ t("structureEditor.copyColumnsSelectFields") }}</span>
<Button variant="ghost" size="sm" class="h-7 px-2 text-xs" :disabled="copySourceColumnsLoading || copyableSourceColumnNames.length === 0" @click="toggleCopySourceColumns">
+4 -1
View File
@@ -3671,12 +3671,15 @@ export default {
copyColumn: "Copy Column",
copyColumnsTitle: "Copy Columns from Another Table",
copyColumnsSourceTable: "Source table",
copyColumnsSelectSourceTable: "Select a source table",
copyColumnsSearchSourceTables: "Search source tables",
copyColumnsSelectFields: "Select fields",
copyColumnsSearchFields: "Search fields or types",
copyColumnsSelectAll: "Select all",
copyColumnsClearSelection: "Clear selection",
copyColumnsNoSourceTables: "No other tables are available in this schema.",
copyColumnsNoMatchingSourceTables: "No source tables match the search.",
copyColumnsPreviousSourceTablePage: "Previous source table page",
copyColumnsNextSourceTablePage: "Next source table page",
copyColumnsNoFields: "The selected table has no fields.",
copyColumnsNoMatchingFields: "No fields match the search.",
copyColumnsAlreadyExists: "Already exists",
+4 -1
View File
@@ -3592,12 +3592,15 @@ export default withEnglishFallback({
copyColumn: "Copiar columna",
copyColumnsTitle: "Copiar columnas de otra tabla",
copyColumnsSourceTable: "Tabla de origen",
copyColumnsSelectSourceTable: "Selecciona una tabla de origen",
copyColumnsSearchSourceTables: "Buscar tablas de origen",
copyColumnsSelectFields: "Selecciona campos",
copyColumnsSearchFields: "Buscar campos o tipos",
copyColumnsSelectAll: "Seleccionar todo",
copyColumnsClearSelection: "Borrar selección",
copyColumnsNoSourceTables: "No hay otras tablas disponibles en este esquema.",
copyColumnsNoMatchingSourceTables: "Ninguna tabla de origen coincide con la búsqueda.",
copyColumnsPreviousSourceTablePage: "Página anterior de tablas de origen",
copyColumnsNextSourceTablePage: "Página siguiente de tablas de origen",
copyColumnsNoFields: "La tabla seleccionada no tiene campos.",
copyColumnsNoMatchingFields: "Ningún campo coincide con la búsqueda.",
copyColumnsAlreadyExists: "Ya existe",
+4 -1
View File
@@ -3590,12 +3590,15 @@ export default withEnglishFallback({
copyColumn: "Copia colonna",
copyColumnsTitle: "Copia colonne da un'altra tabella",
copyColumnsSourceTable: "Tabella di origine",
copyColumnsSelectSourceTable: "Seleziona una tabella di origine",
copyColumnsSearchSourceTables: "Cerca tabelle di origine",
copyColumnsSelectFields: "Seleziona campi",
copyColumnsSearchFields: "Cerca campi o tipi",
copyColumnsSelectAll: "Seleziona tutto",
copyColumnsClearSelection: "Cancella selezione",
copyColumnsNoSourceTables: "Non sono disponibili altre tabelle in questo schema.",
copyColumnsNoMatchingSourceTables: "Nessuna tabella di origine corrisponde alla ricerca.",
copyColumnsPreviousSourceTablePage: "Pagina precedente delle tabelle di origine",
copyColumnsNextSourceTablePage: "Pagina successiva delle tabelle di origine",
copyColumnsNoFields: "La tabella selezionata non ha campi.",
copyColumnsNoMatchingFields: "Nessun campo corrisponde alla ricerca.",
copyColumnsAlreadyExists: "Già esistente",
+4 -1
View File
@@ -3652,12 +3652,15 @@ export default withEnglishFallback({
copyColumn: "列をコピー",
copyColumnsTitle: "別のテーブルから列をコピー",
copyColumnsSourceTable: "コピー元テーブル",
copyColumnsSelectSourceTable: "コピー元テーブルを選択",
copyColumnsSearchSourceTables: "コピー元テーブルを検索",
copyColumnsSelectFields: "フィールドを選択",
copyColumnsSearchFields: "フィールドまたは型を検索",
copyColumnsSelectAll: "すべて選択",
copyColumnsClearSelection: "選択を解除",
copyColumnsNoSourceTables: "このスキーマには他に選択可能なテーブルがありません。",
copyColumnsNoMatchingSourceTables: "検索に一致するコピー元テーブルはありません。",
copyColumnsPreviousSourceTablePage: "コピー元テーブルの前のページ",
copyColumnsNextSourceTablePage: "コピー元テーブルの次のページ",
copyColumnsNoFields: "選択したテーブルにはフィールドがありません。",
copyColumnsNoMatchingFields: "検索に一致するフィールドはありません。",
copyColumnsAlreadyExists: "既に存在します",
+4 -1
View File
@@ -3441,12 +3441,15 @@ export default withEnglishFallback({
copyColumn: "컬럼 복사",
copyColumnsTitle: "다른 테이블에서 컬럼 복사",
copyColumnsSourceTable: "원본 테이블",
copyColumnsSelectSourceTable: "원본 테이블 선택",
copyColumnsSearchSourceTables: "원본 테이블 검색",
copyColumnsSelectFields: "필드 선택",
copyColumnsSearchFields: "필드 또는 유형 검색",
copyColumnsSelectAll: "모두 선택",
copyColumnsClearSelection: "선택 해제",
copyColumnsNoSourceTables: "이 스키마에 선택할 수 있는 다른 테이블이 없습니다.",
copyColumnsNoMatchingSourceTables: "검색과 일치하는 원본 테이블이 없습니다.",
copyColumnsPreviousSourceTablePage: "원본 테이블 이전 페이지",
copyColumnsNextSourceTablePage: "원본 테이블 다음 페이지",
copyColumnsNoFields: "선택한 테이블에 필드가 없습니다.",
copyColumnsNoMatchingFields: "검색과 일치하는 필드가 없습니다.",
copyColumnsAlreadyExists: "이미 존재함",
+4 -1
View File
@@ -3592,12 +3592,15 @@ export default withEnglishFallback({
copyColumn: "Copiar coluna",
copyColumnsTitle: "Copiar colunas de outra tabela",
copyColumnsSourceTable: "Tabela de origem",
copyColumnsSelectSourceTable: "Selecione uma tabela de origem",
copyColumnsSearchSourceTables: "Pesquisar tabelas de origem",
copyColumnsSelectFields: "Selecione campos",
copyColumnsSearchFields: "Pesquisar campos ou tipos",
copyColumnsSelectAll: "Selecionar tudo",
copyColumnsClearSelection: "Limpar seleção",
copyColumnsNoSourceTables: "Não há outras tabelas disponíveis neste esquema.",
copyColumnsNoMatchingSourceTables: "Nenhuma tabela de origem corresponde à pesquisa.",
copyColumnsPreviousSourceTablePage: "Página anterior de tabelas de origem",
copyColumnsNextSourceTablePage: "Próxima página de tabelas de origem",
copyColumnsNoFields: "A tabela selecionada não tem campos.",
copyColumnsNoMatchingFields: "Nenhum campo corresponde à pesquisa.",
copyColumnsAlreadyExists: "Já existe",
+4 -1
View File
@@ -3657,12 +3657,15 @@ export default withEnglishFallback({
copyColumn: "复制字段",
copyColumnsTitle: "从其他表复制字段",
copyColumnsSourceTable: "源表",
copyColumnsSelectSourceTable: "请选择源表",
copyColumnsSearchSourceTables: "搜索源表",
copyColumnsSelectFields: "选择字段",
copyColumnsSearchFields: "搜索字段或类型",
copyColumnsSelectAll: "全选",
copyColumnsClearSelection: "取消全选",
copyColumnsNoSourceTables: "当前模式下没有其他可选表。",
copyColumnsNoMatchingSourceTables: "没有匹配的源表。",
copyColumnsPreviousSourceTablePage: "上一页源表",
copyColumnsNextSourceTablePage: "下一页源表",
copyColumnsNoFields: "所选表没有字段。",
copyColumnsNoMatchingFields: "没有匹配的字段。",
copyColumnsAlreadyExists: "已存在",
+4 -1
View File
@@ -3337,12 +3337,15 @@ export default withEnglishFallback({
copyColumn: "複製欄位",
copyColumnsTitle: "從其他資料表複製欄位",
copyColumnsSourceTable: "來源資料表",
copyColumnsSelectSourceTable: "請選擇來源資料表",
copyColumnsSearchSourceTables: "搜尋來源資料表",
copyColumnsSelectFields: "選擇欄位",
copyColumnsSearchFields: "搜尋欄位或類型",
copyColumnsSelectAll: "全選",
copyColumnsClearSelection: "取消全選",
copyColumnsNoSourceTables: "目前結構描述中沒有其他可選資料表。",
copyColumnsNoMatchingSourceTables: "沒有符合搜尋的來源資料表。",
copyColumnsPreviousSourceTablePage: "上一頁來源資料表",
copyColumnsNextSourceTablePage: "下一頁來源資料表",
copyColumnsNoFields: "所選資料表沒有欄位。",
copyColumnsNoMatchingFields: "沒有符合搜尋的欄位。",
copyColumnsAlreadyExists: "已存在",