diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index 9c8a69d06..93b16583f 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -159,7 +159,19 @@ import { MAX_RESULT_PAGE_SIZE, MIN_RESULT_PAGE_SIZE, normalizeResultPageSize, re import { allNullColumnIndexes, filterColumnVisibilityOptions, hiddenColumnIndexesWithAllNullColumns, invertedHiddenColumnIndexes, nextHiddenColumnIndexes, removeAutoHiddenColumnIndexes, visibleColumnIndexesForFilter } from "@/lib/dataGrid/dataGridColumnVisibility"; import { buildDataGridColumnLookupItems, filterDataGridColumnLookupItems } from "@/lib/dataGrid/dataGridColumnLookup"; import { columnOrderKeysForIndexes, isDefaultColumnOrder, moveVisibleColumnIndex, orderedColumnIndexes, uniqueDataGridColumnOrderKeys } from "@/lib/dataGrid/dataGridColumnOrder"; -import { dataGridColumnLayoutScopeKey, loadDataGridColumnOrder, removeDataGridColumnOrder, saveDataGridColumnOrder } from "@/lib/dataGrid/dataGridColumnLayoutStorage"; +import { + dataGridColumnLayoutScopeKey, + loadDataGridColumnOrder, + loadTableDataGridColumnOrder, + notifyTableDataGridColumnOrderChanged, + removeDataGridColumnOrder, + removeTableDataGridColumnOrder, + saveDataGridColumnOrder, + saveTableDataGridColumnOrder, + TABLE_DATA_GRID_COLUMN_ORDER_CHANGED_EVENT, + tableDataGridColumnOrderScopeKey, + type TableDataGridColumnOrderChangedDetail, +} from "@/lib/dataGrid/dataGridColumnLayoutStorage"; import { parseClipboardTable, summarizeSelection } from "@/lib/dataGrid/gridSelection"; import { columnHeaderCanvasPointerDisabled, columnHeaderClickShouldBeSuppressed, columnHeaderPreviewOffsetForColumn, columnHeaderTooltipDisabled } from "@/lib/dataGrid/dataGridColumnHeaderInteraction"; @@ -2554,6 +2566,15 @@ const columnLayoutScopeKey = computed(() => sourceColumns: props.sourceColumns, }), ); +const tableColumnOrderScopeKey = computed(() => { + if (props.context !== "table-data" || !props.connectionId || !props.database || !props.tableMeta?.tableName) return ""; + return tableDataGridColumnOrderScopeKey({ + connectionId: props.connectionId, + database: props.database, + schema: props.tableMeta.schema, + tableName: props.tableMeta.tableName, + }); +}); const persistedColumnOrderKeys = ref([]); const displayableColumnIndexes = computed(() => props.result.columns @@ -2633,20 +2654,42 @@ function invertColumnVisibility() { hiddenColumnIndexes.value = invertedHiddenColumnIndexes(displayableColumnIndexes.value, hiddenColumnIndexes.value); } function loadColumnOrder() { - persistedColumnOrderKeys.value = loadDataGridColumnOrder(columnLayoutScopeKey.value, columnOrderKeys.value); + const tableOrder = tableColumnOrderScopeKey.value ? loadTableDataGridColumnOrder(tableColumnOrderScopeKey.value) : []; + persistedColumnOrderKeys.value = tableOrder.length ? tableOrder : loadDataGridColumnOrder(columnLayoutScopeKey.value, columnOrderKeys.value); +} +function onTableDataGridColumnOrderChanged(event: Event) { + if (!(event instanceof CustomEvent)) return; + const detail = event.detail as TableDataGridColumnOrderChangedDetail | undefined; + if (!detail || detail.scopeKey !== tableColumnOrderScopeKey.value) return; + loadColumnOrder(); + nextTick(refreshGridScrollerMetrics); } function persistColumnOrder(indexes: number[]) { + const tableScopeKey = tableColumnOrderScopeKey.value; if (isDefaultColumnOrder(displayableColumnIndexes.value, indexes)) { removeDataGridColumnOrder(columnLayoutScopeKey.value); + if (tableScopeKey) { + removeTableDataGridColumnOrder(tableScopeKey); + notifyTableDataGridColumnOrderChanged(tableScopeKey); + } persistedColumnOrderKeys.value = []; return; } const keys = columnOrderKeysForIndexes(indexes, columnOrderKeys.value); persistedColumnOrderKeys.value = keys; saveDataGridColumnOrder(columnLayoutScopeKey.value, columnOrderKeys.value, keys); + if (tableScopeKey) { + saveTableDataGridColumnOrder(tableScopeKey, keys); + notifyTableDataGridColumnOrderChanged(tableScopeKey); + } } function resetColumnOrder() { removeDataGridColumnOrder(columnLayoutScopeKey.value); + const tableScopeKey = tableColumnOrderScopeKey.value; + if (tableScopeKey) { + removeTableDataGridColumnOrder(tableScopeKey); + notifyTableDataGridColumnOrderChanged(tableScopeKey); + } persistedColumnOrderKeys.value = []; nextTick(refreshGridScrollerMetrics); } @@ -2678,7 +2721,7 @@ watch(allNullColumnIndexesForResult, () => { autoHiddenNullColumnIndexes.value = new Set(); hideAllNullColumns(); }); -watch(() => columnLayoutScopeKey.value, loadColumnOrder, { immediate: true }); +watch([columnLayoutScopeKey, tableColumnOrderScopeKey], loadColumnOrder, { immediate: true }); const firstVisibleColumnIndex = computed(() => visibleColumnIndexes.value[0] ?? 0); function actualColumnIndex(visibleColumnIndex: number): number { return visibleColumnIndexes.value[visibleColumnIndex] ?? visibleColumnIndex; @@ -6266,6 +6309,7 @@ onMounted(() => { window.visualViewport?.addEventListener("resize", resizeFocusedConditionInputs); window.addEventListener("dbx:ui-scale-applied", scheduleCanvasPixelRatioRefresh); window.addEventListener("dbx:ui-scale-applied", resizeFocusedConditionInputs); + window.addEventListener(TABLE_DATA_GRID_COLUMN_ORDER_CHANGED_EVENT, onTableDataGridColumnOrderChanged); document.addEventListener("pointerdown", onConditionSuggestionDocumentPointerDown, true); }); onDeactivated(pauseCanvasGridWork); @@ -6292,6 +6336,7 @@ onUnmounted(() => { window.visualViewport?.removeEventListener("resize", resizeFocusedConditionInputs); window.removeEventListener("dbx:ui-scale-applied", scheduleCanvasPixelRatioRefresh); window.removeEventListener("dbx:ui-scale-applied", resizeFocusedConditionInputs); + window.removeEventListener(TABLE_DATA_GRID_COLUMN_ORDER_CHANGED_EVENT, onTableDataGridColumnOrderChanged); document.removeEventListener("pointerdown", onConditionSuggestionDocumentPointerDown, true); }); diff --git a/apps/desktop/src/components/structure/TableStructureEditor.vue b/apps/desktop/src/components/structure/TableStructureEditor.vue index 7d1f337db..ba57da5fa 100644 --- a/apps/desktop/src/components/structure/TableStructureEditor.vue +++ b/apps/desktop/src/components/structure/TableStructureEditor.vue @@ -28,7 +28,9 @@ import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/backend/safeStor import { type BuildTableStructureChangeSqlOptions, type EditableStructureColumn, type EditableStructureForeignKey, type EditableStructureIndex, type EditableStructureTrigger } from "@/lib/table/tableStructureEditorSql"; import { PRESET_FIELDS_TEMPLATE_ID, createTableColumnTemplateDrafts } from "@/lib/table/tableColumnTemplates"; import { getTableMetadataCapabilities, firstStructureMetadataTab, isStructureMetadataTabSupported } from "@/lib/table/tableMetadataCapabilities"; -import { canAddTableStructureColumn, getTableStructureCapabilities } from "@/lib/table/tableStructureCapabilities"; +import { canAddTableStructureColumn, getTableStructureCapabilities, hasLocalTableColumnOrderChange, isPhysicalTableColumnOrderChange, supportsLocalTableColumnReorder } from "@/lib/table/tableStructureCapabilities"; +import { orderedColumnIndexes, uniqueDataGridColumnOrderKeys } from "@/lib/dataGrid/dataGridColumnOrder"; +import { loadTableDataGridColumnOrder, notifyTableDataGridColumnOrderChanged, removeTableDataGridColumnOrder, saveTableDataGridColumnOrder, tableDataGridColumnOrderScopeKey } from "@/lib/dataGrid/dataGridColumnLayoutStorage"; import { connectionObjectTreeQuerySchema, tableStructureDatabaseTypeForConnection } from "@/lib/database/jdbcDialect"; import type { TableInfoTab, TableStructureEditorDraft, TableStructureEditorTarget, TableStructureEditorViewport } from "@/types/database"; import { @@ -191,7 +193,7 @@ function columnChanged(column: EditableStructureColumn, index: number): boolean if (!column.original || column.markedForDrop) return true; const original = column.original; return ( - column.originalPosition !== index || + isPhysicalTableColumnOrderChange(databaseType.value, connection.value?.db_type, column.originalPosition, index) || column.name !== original.name || column.dataType !== original.data_type || column.isNullable !== original.is_nullable || @@ -1152,6 +1154,7 @@ function resetState() { highlightedIndexId.value = null; appliedInitialTargetSearchKey = ""; appliedInitialTargetScrollKey = ""; + localColumnOrderNoticeShown.value = false; } async function reloadStructureFromDatabase() { @@ -1216,7 +1219,7 @@ async function loadStructure(silent = false, scope: StructureRefreshScope = FULL // Load live charset/collation metadata from the MySQL server so the column // editor shows the correct options for the server version. void loadCharsetMetadata(); - columns.value = createColumnDrafts(nextColumns, databaseType.value); + columns.value = applyStoredLocalColumnOrder(createColumnDrafts(nextColumns, databaseType.value)); } const nextTableComment = await tableCommentPromise; @@ -1325,6 +1328,7 @@ type ColumnDragState = { }; const columnDragState = ref(null); +const localColumnOrderNoticeShown = ref(false); let columnDragPreviousBodyUserSelect = ""; let columnDragPreviousBodyCursor = ""; let columnDragTracking = false; @@ -1350,7 +1354,53 @@ function canDropColumnAt(sourceIndex: number, insertionIndex: number): boolean { return crossedColumns.every((column) => !column.original); } -const canShowColumnDragControls = computed(() => isCreateMode.value || structureCapabilities.value.reorderColumn); +const usesLocalTableColumnOrder = computed(() => !isCreateMode.value && supportsLocalTableColumnReorder(databaseType.value, connection.value?.db_type)); +const canShowColumnDragControls = computed(() => isCreateMode.value || structureCapabilities.value.reorderColumn || usesLocalTableColumnOrder.value); + +function localTableColumnOrderScopeKey(): string { + return tableDataGridColumnOrderScopeKey({ + connectionId: props.connectionId, + database: props.database, + schema: props.schema, + tableName: props.tableName, + }); +} + +function localColumnOrderKeys(items: readonly EditableStructureColumn[]): string[] { + return uniqueDataGridColumnOrderKeys(items.map((column) => column.name)); +} + +const hasLocalColumnOrderChange = computed(() => { + if (!usesLocalTableColumnOrder.value) return false; + return hasLocalTableColumnOrderChange(columns.value); +}); + +function applyStoredLocalColumnOrder(items: EditableStructureColumn[]): EditableStructureColumn[] { + if (!usesLocalTableColumnOrder.value) return items; + const orderedKeys = loadTableDataGridColumnOrder(localTableColumnOrderScopeKey()); + if (!orderedKeys.length) return items; + const columnKeys = uniqueDataGridColumnOrderKeys(items.map((column) => column.name)); + const indexes = orderedColumnIndexes({ + availableIndexes: items.map((_, index) => index), + columnKeys, + orderedKeys, + }); + return indexes.map((index) => items[index]).filter((column): column is EditableStructureColumn => !!column); +} + +function persistLocalColumnOrder(showNotice = true) { + if (!usesLocalTableColumnOrder.value) return; + const scopeKey = localTableColumnOrderScopeKey(); + if (hasLocalColumnOrderChange.value) { + saveTableDataGridColumnOrder(scopeKey, localColumnOrderKeys(columns.value)); + } else { + removeTableDataGridColumnOrder(scopeKey); + } + notifyTableDataGridColumnOrderChanged(scopeKey); + if (!showNotice || localColumnOrderNoticeShown.value) return; + localColumnOrderNoticeShown.value = true; + toast(t("structureEditor.localColumnOrderNotice"), 4000); +} function isSqlServerIdentityChecked(column: EditableStructureColumn): boolean { return !!column.extra.autoIncrement || !!column.extra.identity; @@ -1510,6 +1560,7 @@ function moveColumnTo(index: number, insertionIndex: number) { const adjustedInsertionIndex = insertionIndex > index ? insertionIndex - 1 : insertionIndex; nextColumns.splice(adjustedInsertionIndex, 0, column); columns.value = nextColumns; + persistLocalColumnOrder(); } function onColumnDragPointerDown(index: number, event: PointerEvent) { @@ -2097,6 +2148,8 @@ async function applyChanges() { emit("saved", tableComment.value !== originalTableComment.value); emit("close"); } else { + // Refresh persisted keys after successful renames/additions before metadata reloads. + persistLocalColumnOrder(false); saving.value = false; postSaveRefreshing.value = true; skipNextRefreshVersion = true; @@ -2788,7 +2841,7 @@ watch([activeTab, ddlLoading], ([tab, loading]) => { type="button" variant="ghost" size="icon" - :class="[structureActionButtonClass, canDragColumn(index) ? 'cursor-grab active:cursor-grabbing' : 'cursor-not-allowed']" + :class="[structureActionButtonClass, canDragColumn(index) ? 'cursor-grab active:cursor-grabbing' : 'cursor-not-allowed', hasLocalColumnOrderChange ? 'border-primary/30 bg-primary/10 text-primary hover:bg-primary/15 hover:text-primary' : '']" :disabled="!canDragColumn(index)" :title="t('structureEditor.dragColumn')" :aria-label="t('structureEditor.dragColumn')" diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index d36e49994..9bfe7eaaf 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -1892,6 +1892,7 @@ export default { moveColumnUp: "Move column up", moveColumnDown: "Move column down", dragColumn: "Drag to reorder column", + localColumnOrderNotice: "Column order is saved locally and does not change the database table structure.", yes: "Yes", no: "No", emptyReadonly: "No records", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 0d1a17709..033f13bce 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -1817,6 +1817,7 @@ export default withEnglishFallback({ moveColumnUp: "Mover columna arriba", moveColumnDown: "Mover columna abajo", dragColumn: "Arrastra para reordenar columnas", + localColumnOrderNotice: "El orden de las columnas se guarda solo localmente y no modifica la estructura de la base de datos.", yes: "Sí", no: "No", emptyReadonly: "Sin registros", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 585280e90..2172f7b1f 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -1815,6 +1815,7 @@ export default withEnglishFallback({ moveColumnUp: "Sposta colonna su", moveColumnDown: "Sposta colonna giù", dragColumn: "Trascina per riordinare la colonna", + localColumnOrderNotice: "L'ordine delle colonne viene salvato solo localmente e non modifica la struttura del database.", yes: "Sì", no: "No", emptyReadonly: "Nessun record", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 5699f8d42..ae22084a4 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -1850,6 +1850,7 @@ export default withEnglishFallback({ moveColumnUp: "列を上に移動", moveColumnDown: "列を下に移動", dragColumn: "ドラッグして列の順序を変更", + localColumnOrderNotice: "列の順序はローカルにのみ保存され、データベースのテーブル構造は変更されません。", yes: "はい", no: "いいえ", emptyReadonly: "レコードなし", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 01e72d4c8..e1e3d720d 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -1817,6 +1817,7 @@ export default withEnglishFallback({ moveColumnUp: "Mover coluna para cima", moveColumnDown: "Mover coluna para baixo", dragColumn: "Arraste para reordenar colunas", + localColumnOrderNotice: "A ordem das colunas é salva apenas localmente e não altera a estrutura do banco de dados.", yes: "Sim", no: "Não", emptyReadonly: "Nenhum registro", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index fe5c5398f..cf3054707 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -1891,6 +1891,7 @@ export default withEnglishFallback({ moveColumnUp: "上移字段", moveColumnDown: "下移字段", dragColumn: "拖拽调整字段顺序", + localColumnOrderNotice: "字段顺序仅保存在本地,不会修改数据库表结构。", yes: "是", no: "否", emptyReadonly: "暂无记录", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 7c5ed5c07..950312ece 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -1721,6 +1721,7 @@ export default withEnglishFallback({ moveColumnUp: "上移欄位", moveColumnDown: "下移欄位", dragColumn: "拖曳調整欄位順序", + localColumnOrderNotice: "欄位順序僅儲存在本機,不會修改資料庫資料表結構。", yes: "是", no: "否", emptyReadonly: "暫無記錄", diff --git a/apps/desktop/src/lib/__tests__/dataGrid/dataGridColumnLayoutStorage.spec.ts b/apps/desktop/src/lib/__tests__/dataGrid/dataGridColumnLayoutStorage.spec.ts new file mode 100644 index 000000000..b5aa47ce5 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/dataGrid/dataGridColumnLayoutStorage.spec.ts @@ -0,0 +1,59 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { loadTableDataGridColumnOrder, notifyTableDataGridColumnOrderChanged, removeTableDataGridColumnOrder, saveTableDataGridColumnOrder, TABLE_DATA_GRID_COLUMN_ORDER_CHANGED_EVENT, tableDataGridColumnOrderScopeKey } from "@/lib/dataGrid/dataGridColumnLayoutStorage"; + +function installLocalStorage() { + const data = new Map(); + vi.stubGlobal("localStorage", { + getItem: (key: string) => data.get(key) ?? null, + setItem: (key: string, value: string) => data.set(key, value), + removeItem: (key: string) => data.delete(key), + }); +} + +describe("table data grid column order storage", () => { + beforeEach(installLocalStorage); + afterEach(() => vi.unstubAllGlobals()); + + it("stores an order independently from the current result column signature", () => { + const scopeKey = tableDataGridColumnOrderScopeKey({ + connectionId: "sqlserver-1", + database: "sales", + schema: "core", + tableName: "products", + }); + const order = ["name\u00000", "id\u00000"]; + + saveTableDataGridColumnOrder(scopeKey, order); + + expect(loadTableDataGridColumnOrder(scopeKey)).toEqual(order); + }); + + it("isolates tables and removes a saved order", () => { + const products = tableDataGridColumnOrderScopeKey({ connectionId: "sqlserver-1", database: "sales", schema: "core", tableName: "products" }); + const orders = tableDataGridColumnOrderScopeKey({ connectionId: "sqlserver-1", database: "sales", schema: "core", tableName: "orders" }); + saveTableDataGridColumnOrder(products, ["name\u00000", "id\u00000"]); + + expect(loadTableDataGridColumnOrder(orders)).toEqual([]); + removeTableDataGridColumnOrder(products); + expect(loadTableDataGridColumnOrder(products)).toEqual([]); + }); + + it("normalizes a missing schema to the database namespace", () => { + const withoutSchema = tableDataGridColumnOrderScopeKey({ connectionId: "sqlite-1", database: "main", tableName: "products" }); + const explicitMainSchema = tableDataGridColumnOrderScopeKey({ connectionId: "sqlite-1", database: "main", schema: "main", tableName: "products" }); + + expect(withoutSchema).toBe(explicitMainSchema); + }); + + it("notifies other open views when a table order changes", () => { + const dispatchEvent = vi.fn(); + vi.stubGlobal("window", { dispatchEvent }); + + notifyTableDataGridColumnOrderChanged("table-scope"); + + expect(dispatchEvent).toHaveBeenCalledOnce(); + const event = dispatchEvent.mock.calls[0]?.[0] as CustomEvent; + expect(event.type).toBe(TABLE_DATA_GRID_COLUMN_ORDER_CHANGED_EVENT); + expect(event.detail).toEqual({ scopeKey: "table-scope" }); + }); +}); diff --git a/apps/desktop/src/lib/__tests__/table/tableStructureCapabilities.spec.ts b/apps/desktop/src/lib/__tests__/table/tableStructureCapabilities.spec.ts index 6e22ce24b..a0867c949 100644 --- a/apps/desktop/src/lib/__tests__/table/tableStructureCapabilities.spec.ts +++ b/apps/desktop/src/lib/__tests__/table/tableStructureCapabilities.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { getTableStructureCapabilities } from "@/lib/table/tableStructureCapabilities"; +import { getTableStructureCapabilities, hasLocalTableColumnOrderChange, isPhysicalTableColumnOrderChange, supportsLocalTableColumnReorder } from "@/lib/table/tableStructureCapabilities"; describe("tableStructureCapabilities", () => { it("uses table rebuilds only for native SQLite connections", () => { @@ -27,4 +27,39 @@ describe("tableStructureCapabilities", () => { expect(getTableStructureCapabilities("mysql", "mysql").alterStrategy).toBe("direct"); expect(getTableStructureCapabilities("postgres", "postgres").alterStrategy).toBe("direct"); }); + + it("uses local-only column reordering for editable databases without physical reorder support", () => { + for (const databaseType of ["sqlserver", "postgres", "sqlite", "oracle", "dameng", "duckdb", "informix"] as const) { + expect(supportsLocalTableColumnReorder(databaseType, databaseType)).toBe(true); + } + + for (const databaseType of ["mysql", "gbase", "clickhouse"] as const) { + expect(supportsLocalTableColumnReorder(databaseType, databaseType)).toBe(false); + } + expect(supportsLocalTableColumnReorder("influxdb", "influxdb")).toBe(false); + }); + + it("does not treat local-only reordering as a database structure change", () => { + expect(isPhysicalTableColumnOrderChange("sqlserver", "sqlserver", 0, 2)).toBe(false); + expect(isPhysicalTableColumnOrderChange("postgres", "postgres", 0, 2)).toBe(false); + expect(isPhysicalTableColumnOrderChange("mysql", "mysql", 0, 2)).toBe(true); + }); + + it("detects local order changes including newly added columns", () => { + const first = { original: {}, originalPosition: 0 }; + const second = { original: {}, originalPosition: 1 }; + const added = {}; + + expect(hasLocalTableColumnOrderChange([first, second, added])).toBe(false); + expect(hasLocalTableColumnOrderChange([first, added, second])).toBe(true); + expect(hasLocalTableColumnOrderChange([second, first, added])).toBe(true); + }); + + it("ignores dropped columns when comparing local order", () => { + const first = { original: {}, originalPosition: 0 }; + const dropped = { original: {}, originalPosition: 1, markedForDrop: true }; + const third = { original: {}, originalPosition: 2 }; + + expect(hasLocalTableColumnOrderChange([first, dropped, third])).toBe(false); + }); }); diff --git a/apps/desktop/src/lib/dataGrid/dataGridColumnLayoutStorage.ts b/apps/desktop/src/lib/dataGrid/dataGridColumnLayoutStorage.ts index 85329e7dc..438522e5c 100644 --- a/apps/desktop/src/lib/dataGrid/dataGridColumnLayoutStorage.ts +++ b/apps/desktop/src/lib/dataGrid/dataGridColumnLayoutStorage.ts @@ -1,8 +1,15 @@ import { safeLocalStorageGet, safeLocalStorageRemove, safeLocalStorageSet } from "@/lib/backend/safeStorage"; const STORAGE_PREFIX = "dbx-data-grid-column-layout:"; +const TABLE_STORAGE_PREFIX = "dbx-data-grid-table-column-order:"; const STORAGE_VERSION = 1; +export const TABLE_DATA_GRID_COLUMN_ORDER_CHANGED_EVENT = "dbx:table-data-grid-column-order-changed"; + +export interface TableDataGridColumnOrderChangedDetail { + scopeKey: string; +} + export interface DataGridColumnLayoutScope { connectionId?: string; database?: string; @@ -21,6 +28,18 @@ interface StoredDataGridColumnLayout { order: string[]; } +export interface TableDataGridColumnOrderScope { + connectionId: string; + database: string; + schema?: string; + tableName: string; +} + +interface StoredTableDataGridColumnOrder { + version: number; + order: string[]; +} + function normalizeSql(sql?: string): string { return (sql ?? "").replace(/\s+/g, " ").trim(); } @@ -56,3 +75,37 @@ export function saveDataGridColumnOrder(scopeKey: string, columnKeys: readonly s export function removeDataGridColumnOrder(scopeKey: string) { safeLocalStorageRemove(`${STORAGE_PREFIX}${scopeKey}`); } + +export function tableDataGridColumnOrderScopeKey(scope: TableDataGridColumnOrderScope): string { + const namespace = scope.schema?.trim() || scope.database; + return [scope.connectionId, scope.database, namespace, scope.tableName].join("\u0001"); +} + +export function loadTableDataGridColumnOrder(scopeKey: string): string[] { + const raw = safeLocalStorageGet(`${TABLE_STORAGE_PREFIX}${scopeKey}`); + if (!raw) return []; + try { + const parsed = JSON.parse(raw) as Partial; + if (parsed.version !== STORAGE_VERSION || !Array.isArray(parsed.order)) return []; + return parsed.order.filter((key): key is string => typeof key === "string"); + } catch { + return []; + } +} + +export function saveTableDataGridColumnOrder(scopeKey: string, order: readonly string[]) { + const payload: StoredTableDataGridColumnOrder = { + version: STORAGE_VERSION, + order: [...order], + }; + safeLocalStorageSet(`${TABLE_STORAGE_PREFIX}${scopeKey}`, JSON.stringify(payload)); +} + +export function removeTableDataGridColumnOrder(scopeKey: string) { + safeLocalStorageRemove(`${TABLE_STORAGE_PREFIX}${scopeKey}`); +} + +export function notifyTableDataGridColumnOrderChanged(scopeKey: string) { + if (typeof window === "undefined") return; + window.dispatchEvent(new CustomEvent(TABLE_DATA_GRID_COLUMN_ORDER_CHANGED_EVENT, { detail: { scopeKey } })); +} diff --git a/apps/desktop/src/lib/table/tableStructureCapabilities.ts b/apps/desktop/src/lib/table/tableStructureCapabilities.ts index 32283564f..656142e44 100644 --- a/apps/desktop/src/lib/table/tableStructureCapabilities.ts +++ b/apps/desktop/src/lib/table/tableStructureCapabilities.ts @@ -339,6 +339,23 @@ export function canEditTableStructure(dbType?: DatabaseType): boolean { return caps.createTable || caps.addColumn || caps.alterExistingColumn || caps.createIndex || caps.dropIndex; } +export function supportsLocalTableColumnReorder(dbType?: DatabaseType, connectionDbType?: DatabaseType): boolean { + const caps = getTableStructureCapabilities(dbType, connectionDbType); + return canEditTableStructure(dbType) && !caps.reorderColumn; +} + +export function isPhysicalTableColumnOrderChange(dbType: DatabaseType | undefined, connectionDbType: DatabaseType | undefined, originalPosition: number | undefined, currentPosition: number): boolean { + return getTableStructureCapabilities(dbType, connectionDbType).reorderColumn && originalPosition !== currentPosition; +} + +export function hasLocalTableColumnOrderChange(columns: readonly { originalPosition?: number; original?: unknown; markedForDrop?: boolean }[]): boolean { + const activeColumns = columns.filter((column) => !column.markedForDrop); + // Databases without physical reorder support keep existing columns in ordinal order + // and append newly added columns, so compare against that post-save layout. + const databaseOrder = [...activeColumns.filter((column) => column.original).sort((left, right) => (left.originalPosition ?? Number.MAX_SAFE_INTEGER) - (right.originalPosition ?? Number.MAX_SAFE_INTEGER)), ...activeColumns.filter((column) => !column.original)]; + return activeColumns.some((column, index) => column !== databaseOrder[index]); +} + export function canAddTableStructureColumn(dbType: DatabaseType | undefined, isCreateMode: boolean): boolean { const caps = getTableStructureCapabilities(dbType); return isCreateMode ? caps.createTable : caps.addColumn;