+
{{ t("grid.columnVisibilityHint") }}
+
+
diff --git a/apps/desktop/src/composables/useDataGridColumnResize.ts b/apps/desktop/src/composables/useDataGridColumnResize.ts
index 92f36458c..a10b52380 100644
--- a/apps/desktop/src/composables/useDataGridColumnResize.ts
+++ b/apps/desktop/src/composables/useDataGridColumnResize.ts
@@ -1,4 +1,4 @@
-import { ref, computed, type ComputedRef, type Ref } from "vue";
+import { ref, computed, watch, type ComputedRef, type Ref } from "vue";
import { useElementSize } from "@vueuse/core";
import { calculateDataGridColumnWidth, DATA_GRID_COL_MIN_WIDTH, DATA_GRID_SAMPLE_ROWS } from "@/lib/dataGridColumnWidth";
@@ -20,6 +20,7 @@ export function useDataGridColumnResize(options: UseDataGridColumnResizeOptions)
const columnWidths = ref([]);
const { width: gridWidth } = useElementSize(gridRef);
let isResizing = false;
+ let previousColumnIndexes: number[] = [];
function sampleColumnValues(visibleColIdx: number): CellValue[] {
const actualColIdx = columnIndexes.value[visibleColIdx];
@@ -33,14 +34,23 @@ export function useDataGridColumnResize(options: UseDataGridColumnResizeOptions)
}
function initColumnWidths() {
- if (columnWidths.value.length !== columns.value.length) {
+ const previousWidthsByColumnIndex = new Map();
+ previousColumnIndexes.forEach((columnIndex, visibleIndex) => {
+ const width = columnWidths.value[visibleIndex];
+ if (width !== undefined) previousWidthsByColumnIndex.set(columnIndex, width);
+ });
+ const nextColumnIndexes = [...columnIndexes.value];
+ if (columnWidths.value.length !== columns.value.length || previousColumnIndexes.join("\0") !== nextColumnIndexes.join("\0")) {
columnWidths.value = columns.value.map((colName, colIdx) => {
+ const existingWidth = previousWidthsByColumnIndex.get(nextColumnIndexes[colIdx]);
+ if (existingWidth !== undefined) return existingWidth;
return calculateDataGridColumnWidth({
columnName: colName,
sampleValues: sampleColumnValues(colIdx),
});
});
}
+ previousColumnIndexes = nextColumnIndexes;
}
function onResizeStart(colIdx: number, event: MouseEvent) {
@@ -101,6 +111,8 @@ export function useDataGridColumnResize(options: UseDataGridColumnResizeOptions)
return isResizing;
}
+ watch(() => columnIndexes.value.join("\0"), initColumnWidths);
+
return {
columnWidths,
initColumnWidths,
diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts
index 153cad066..9751d0e4f 100644
--- a/apps/desktop/src/i18n/locales/en.ts
+++ b/apps/desktop/src/i18n/locales/en.ts
@@ -615,7 +615,9 @@ export default {
columnVisibility: "Columns",
columnVisibilityHint: "At least one column stays visible.",
searchColumns: "Search columns...",
+ dragColumnToReorder: "Drag column header to reorder",
invertColumnVisibility: "Invert",
+ resetColumnOrder: "Reset order",
showAllColumns: "Show all",
viewOptions: "View options",
hideNullColumns: "Hide NULL",
diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts
index 9b2c435c4..d19d3ae64 100644
--- a/apps/desktop/src/i18n/locales/es.ts
+++ b/apps/desktop/src/i18n/locales/es.ts
@@ -532,7 +532,9 @@ export default {
columnVisibility: "Columnas",
columnVisibilityHint: "Al menos una columna permanece visible.",
searchColumns: "Buscar columnas...",
+ dragColumnToReorder: "Arrastra el encabezado para reordenar",
invertColumnVisibility: "Invertir",
+ resetColumnOrder: "Restablecer orden",
showAllColumns: "Mostrar todo",
viewOptions: "Opciones de vista",
hideNullColumns: "Ocultar NULL",
diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts
index 8c476c5d7..79bf0e1f7 100644
--- a/apps/desktop/src/i18n/locales/it.ts
+++ b/apps/desktop/src/i18n/locales/it.ts
@@ -561,7 +561,9 @@ export default {
columnVisibility: "Colonne",
columnVisibilityHint: "Almeno una colonna deve rimanere visibile.",
searchColumns: "Cerca colonne...",
+ dragColumnToReorder: "Trascina l'intestazione per riordinare",
invertColumnVisibility: "Inverti",
+ resetColumnOrder: "Reimposta ordine",
showAllColumns: "Mostra tutte",
viewOptions: "Opzioni visualizzazione",
hideNullColumns: "Nascondi NULL",
diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts
index d0c83e464..001408877 100644
--- a/apps/desktop/src/i18n/locales/ja.ts
+++ b/apps/desktop/src/i18n/locales/ja.ts
@@ -611,7 +611,9 @@ export default {
columnVisibility: "列",
columnVisibilityHint: "少なくとも1列は表示されます。",
searchColumns: "列を検索...",
+ dragColumnToReorder: "列ヘッダーをドラッグして並べ替え",
invertColumnVisibility: "反転",
+ resetColumnOrder: "順序をリセット",
showAllColumns: "すべて表示",
viewOptions: "表示オプション",
hideNullColumns: "NULL列を非表示",
diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts
index 2ee991eff..bcb05643d 100644
--- a/apps/desktop/src/i18n/locales/pt-BR.ts
+++ b/apps/desktop/src/i18n/locales/pt-BR.ts
@@ -561,7 +561,9 @@ export default {
columnVisibility: "Colunas",
columnVisibilityHint: "Pelo menos uma coluna permanece visível.",
searchColumns: "Pesquisar colunas...",
+ dragColumnToReorder: "Arraste o cabeçalho para reordenar",
invertColumnVisibility: "Inverter",
+ resetColumnOrder: "Redefinir ordem",
showAllColumns: "Mostrar todas",
viewOptions: "Opções de visualização",
hideNullColumns: "Ocultar NULL",
diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts
index 94622f302..f3b0583c7 100644
--- a/apps/desktop/src/i18n/locales/zh-CN.ts
+++ b/apps/desktop/src/i18n/locales/zh-CN.ts
@@ -616,7 +616,9 @@ export default {
columnVisibility: "字段筛选",
columnVisibilityHint: "至少保留一列可见。",
searchColumns: "搜索字段...",
+ dragColumnToReorder: "拖拽列头调整顺序",
invertColumnVisibility: "反选",
+ resetColumnOrder: "重置顺序",
showAllColumns: "显示全部",
viewOptions: "视图选项",
hideNullColumns: "隐藏 NULL 列",
diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts
index ae4b5a1de..b5127f95d 100644
--- a/apps/desktop/src/i18n/locales/zh-TW.ts
+++ b/apps/desktop/src/i18n/locales/zh-TW.ts
@@ -562,7 +562,9 @@ export default {
columnVisibility: "欄位篩選",
columnVisibilityHint: "至少保留一欄可見。",
searchColumns: "搜尋欄位……",
+ dragColumnToReorder: "拖曳欄位標題調整順序",
invertColumnVisibility: "反選",
+ resetColumnOrder: "重設順序",
showAllColumns: "顯示全部",
viewOptions: "檢視選項",
hideNullColumns: "隱藏 NULL 欄",
diff --git a/apps/desktop/src/lib/dataGridColumnLayoutStorage.ts b/apps/desktop/src/lib/dataGridColumnLayoutStorage.ts
new file mode 100644
index 000000000..21d8955a4
--- /dev/null
+++ b/apps/desktop/src/lib/dataGridColumnLayoutStorage.ts
@@ -0,0 +1,68 @@
+import { safeLocalStorageGet, safeLocalStorageRemove, safeLocalStorageSet } from "@/lib/safeStorage";
+
+const STORAGE_PREFIX = "dbx-data-grid-column-layout:";
+const STORAGE_VERSION = 1;
+
+export interface DataGridColumnLayoutScope {
+ connectionId?: string;
+ database?: string;
+ schema?: string;
+ context?: string;
+ tableSchema?: string;
+ tableName?: string;
+ sql?: string;
+ columns: readonly string[];
+ sourceColumns?: readonly (string | undefined)[];
+}
+
+interface StoredDataGridColumnLayout {
+ version: number;
+ columnSignature: string;
+ order: string[];
+}
+
+function normalizeSql(sql?: string): string {
+ return (sql ?? "").replace(/\s+/g, " ").trim();
+}
+
+export function dataGridColumnLayoutScopeKey(scope: DataGridColumnLayoutScope): string {
+ const columnSignature = scope.columns.join("\0");
+ const sourceSignature = (scope.sourceColumns ?? []).map((column) => column ?? "").join("\0");
+ return [
+ scope.connectionId ?? "",
+ scope.database ?? "",
+ scope.schema ?? "",
+ scope.context ?? "",
+ scope.tableSchema ?? "",
+ scope.tableName ?? "",
+ scope.tableName ? "" : normalizeSql(scope.sql),
+ columnSignature,
+ sourceSignature,
+ ].join("\u0001");
+}
+
+export function loadDataGridColumnOrder(scopeKey: string, columnKeys: readonly string[]): string[] {
+ const raw = safeLocalStorageGet(`${STORAGE_PREFIX}${scopeKey}`);
+ if (!raw) return [];
+ try {
+ const parsed = JSON.parse(raw) as Partial;
+ if (parsed.version !== STORAGE_VERSION || !Array.isArray(parsed.order)) return [];
+ if (parsed.columnSignature && parsed.columnSignature !== columnKeys.join("\0")) return [];
+ return parsed.order.filter((key): key is string => typeof key === "string");
+ } catch {
+ return [];
+ }
+}
+
+export function saveDataGridColumnOrder(scopeKey: string, columnKeys: readonly string[], order: readonly string[]) {
+ const payload: StoredDataGridColumnLayout = {
+ version: STORAGE_VERSION,
+ columnSignature: columnKeys.join("\0"),
+ order: [...order],
+ };
+ safeLocalStorageSet(`${STORAGE_PREFIX}${scopeKey}`, JSON.stringify(payload));
+}
+
+export function removeDataGridColumnOrder(scopeKey: string) {
+ safeLocalStorageRemove(`${STORAGE_PREFIX}${scopeKey}`);
+}
diff --git a/apps/desktop/src/lib/dataGridColumnOrder.ts b/apps/desktop/src/lib/dataGridColumnOrder.ts
new file mode 100644
index 000000000..12ee0fa1c
--- /dev/null
+++ b/apps/desktop/src/lib/dataGridColumnOrder.ts
@@ -0,0 +1,67 @@
+export function uniqueDataGridColumnOrderKeys(columns: readonly string[], sourceColumns?: readonly (string | undefined)[]): string[] {
+ const counts = new Map();
+ return columns.map((column, index) => {
+ const base = sourceColumns?.[index] || column || `#${index + 1}`;
+ const count = counts.get(base) ?? 0;
+ counts.set(base, count + 1);
+ return `${base}\u0000${count}`;
+ });
+}
+
+export function orderedColumnIndexes(options: { availableIndexes: readonly number[]; columnKeys: readonly string[]; orderedKeys: readonly string[] }): number[] {
+ const available = new Set(options.availableIndexes);
+ const indexByKey = new Map();
+ for (const index of options.availableIndexes) {
+ const key = options.columnKeys[index];
+ if (key) indexByKey.set(key, index);
+ }
+
+ const used = new Set();
+ const ordered: number[] = [];
+ for (const key of options.orderedKeys) {
+ const index = indexByKey.get(key);
+ if (index === undefined || !available.has(index) || used.has(index)) continue;
+ ordered.push(index);
+ used.add(index);
+ }
+
+ for (const index of options.availableIndexes) {
+ if (!used.has(index)) ordered.push(index);
+ }
+
+ return ordered;
+}
+
+export function moveVisibleColumnIndex(options: { orderedIndexes: readonly number[]; hiddenIndexes: ReadonlySet; fromVisibleIndex: number; toVisibleIndex: number }): number[] {
+ const visibleIndexes = options.orderedIndexes.filter((index) => !options.hiddenIndexes.has(index));
+ const fromActualIndex = visibleIndexes[options.fromVisibleIndex];
+ if (fromActualIndex === undefined) return [...options.orderedIndexes];
+
+ const withoutSource = options.orderedIndexes.filter((index) => index !== fromActualIndex);
+ const visibleWithoutSource = visibleIndexes.filter((index) => index !== fromActualIndex);
+ const targetVisibleIndex = Math.max(0, Math.min(options.toVisibleIndex, visibleWithoutSource.length));
+ if (options.fromVisibleIndex === targetVisibleIndex) return [...options.orderedIndexes];
+
+ const next = [...withoutSource];
+ if (targetVisibleIndex >= visibleWithoutSource.length) {
+ const lastVisibleIndex = visibleWithoutSource[visibleWithoutSource.length - 1];
+ const insertAfterIndex = lastVisibleIndex === undefined ? next.length - 1 : next.indexOf(lastVisibleIndex);
+ next.splice(insertAfterIndex + 1, 0, fromActualIndex);
+ return next;
+ }
+
+ const targetActualIndex = visibleWithoutSource[targetVisibleIndex];
+ const insertIndex = targetActualIndex === undefined ? next.length : next.indexOf(targetActualIndex);
+ if (insertIndex < 0) return [...options.orderedIndexes];
+ next.splice(insertIndex, 0, fromActualIndex);
+ return next;
+}
+
+export function columnOrderKeysForIndexes(indexes: readonly number[], columnKeys: readonly string[]): string[] {
+ return indexes.map((index) => columnKeys[index]).filter((key): key is string => !!key);
+}
+
+export function isDefaultColumnOrder(availableIndexes: readonly number[], orderedIndexes: readonly number[]): boolean {
+ if (availableIndexes.length !== orderedIndexes.length) return false;
+ return availableIndexes.every((index, position) => orderedIndexes[position] === index);
+}
diff --git a/packages/app-tests/dataGridColumnOrder.test.ts b/packages/app-tests/dataGridColumnOrder.test.ts
new file mode 100644
index 000000000..05bcbe4b3
--- /dev/null
+++ b/packages/app-tests/dataGridColumnOrder.test.ts
@@ -0,0 +1,99 @@
+import { strict as assert } from "node:assert";
+import { test } from "vitest";
+import { columnOrderKeysForIndexes, isDefaultColumnOrder, moveVisibleColumnIndex, orderedColumnIndexes, uniqueDataGridColumnOrderKeys } from "../../apps/desktop/src/lib/dataGridColumnOrder";
+
+test("creates stable keys for duplicate column names", () => {
+ assert.deepEqual(uniqueDataGridColumnOrderKeys(["id", "name", "name"]), [`id\u00000`, `name\u00000`, `name\u00001`]);
+});
+
+test("uses source columns when available", () => {
+ assert.deepEqual(uniqueDataGridColumnOrderKeys(["id", "display_name"], ["id", "name"]), [`id\u00000`, `name\u00000`]);
+});
+
+test("orders available indexes from persisted keys and appends new columns", () => {
+ const keys = uniqueDataGridColumnOrderKeys(["id", "name", "email", "created_at"]);
+ assert.deepEqual(
+ orderedColumnIndexes({
+ availableIndexes: [0, 1, 2, 3],
+ columnKeys: keys,
+ orderedKeys: [keys[2], keys[0], "missing"],
+ }),
+ [2, 0, 1, 3],
+ );
+});
+
+test("ignores unavailable indexes while preserving displayable columns", () => {
+ const keys = uniqueDataGridColumnOrderKeys(["id", "name", "email"]);
+ assert.deepEqual(
+ orderedColumnIndexes({
+ availableIndexes: [1, 2],
+ columnKeys: keys,
+ orderedKeys: [keys[0], keys[2], keys[1]],
+ }),
+ [2, 1],
+ );
+});
+
+test("moves a visible column forward", () => {
+ assert.deepEqual(
+ moveVisibleColumnIndex({
+ orderedIndexes: [0, 1, 2, 3],
+ hiddenIndexes: new Set(),
+ fromVisibleIndex: 3,
+ toVisibleIndex: 1,
+ }),
+ [0, 3, 1, 2],
+ );
+});
+
+test("moves a visible column backward", () => {
+ assert.deepEqual(
+ moveVisibleColumnIndex({
+ orderedIndexes: [0, 1, 2, 3],
+ hiddenIndexes: new Set(),
+ fromVisibleIndex: 1,
+ toVisibleIndex: 3,
+ }),
+ [0, 2, 3, 1],
+ );
+});
+
+test("moves a visible column to an adjacent later position", () => {
+ assert.deepEqual(
+ moveVisibleColumnIndex({
+ orderedIndexes: [0, 1, 2, 3],
+ hiddenIndexes: new Set(),
+ fromVisibleIndex: 1,
+ toVisibleIndex: 2,
+ }),
+ [0, 2, 1, 3],
+ );
+});
+
+test("moves visible columns without disturbing hidden column identity", () => {
+ assert.deepEqual(
+ moveVisibleColumnIndex({
+ orderedIndexes: [0, 1, 2, 3],
+ hiddenIndexes: new Set([1]),
+ fromVisibleIndex: 2,
+ toVisibleIndex: 0,
+ }),
+ [3, 0, 1, 2],
+ );
+});
+
+test("returns no-op for invalid or same visible indexes", () => {
+ const orderedIndexes = [0, 1, 2];
+ assert.deepEqual(moveVisibleColumnIndex({ orderedIndexes, hiddenIndexes: new Set(), fromVisibleIndex: 1, toVisibleIndex: 1 }), orderedIndexes);
+ assert.deepEqual(moveVisibleColumnIndex({ orderedIndexes, hiddenIndexes: new Set(), fromVisibleIndex: -1, toVisibleIndex: 1 }), orderedIndexes);
+});
+
+test("converts indexes back to persisted keys", () => {
+ const keys = uniqueDataGridColumnOrderKeys(["id", "name", "email"]);
+ assert.deepEqual(columnOrderKeysForIndexes([2, 0, 1], keys), [keys[2], keys[0], keys[1]]);
+});
+
+test("detects default order", () => {
+ assert.equal(isDefaultColumnOrder([0, 1, 2], [0, 1, 2]), true);
+ assert.equal(isDefaultColumnOrder([0, 1, 2], [1, 0, 2]), false);
+});