diff --git a/apps/desktop/src/components/structure/TableStructureEditor.vue b/apps/desktop/src/components/structure/TableStructureEditor.vue index a5ce8285ef..aacb729736 100644 --- a/apps/desktop/src/components/structure/TableStructureEditor.vue +++ b/apps/desktop/src/components/structure/TableStructureEditor.vue @@ -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([]); const copyColumnsDialogOpen = ref(false); const copySourceTables = ref([]); const copySourceTableName = ref(""); +const copySourceTableSearch = ref(""); const copySourceColumns = ref([]); const copySourceColumnSearch = ref(""); const selectedCopySourceColumnNames = ref([]); 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 | undefined; const indexes = ref([]); /** 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]) => {
- +
+ + +
@@ -4356,9 +4410,47 @@ watch([activeTab, ddlLoading], ([tab, loading]) => { {{ copySourceError }}
- {{ t("structureEditor.copyColumnsNoSourceTables") }} + {{ copySourceTableSearch ? t("structureEditor.copyColumnsNoMatchingSourceTables") : t("structureEditor.copyColumnsNoSourceTables") }}
-