diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index d6c9d06961..e0dcbfebc1 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -1933,7 +1933,8 @@ const { filteredColumnLayoutOptions, isColumnVisible, toggleColumnVisibility, - showAllColumns, + hideColumns: hideColumnsInLayout, + showAllColumns: showAllColumnsInLayout, invertColumnVisibility, showColumn, persistColumnOrder, @@ -2294,6 +2295,47 @@ function unfreezeAllColumns() { applyColumnOrderChange(unfreezeAllColumnsInLayout); } +// 隐藏列后列宽/滚动范围都会缩短:让滚动条贴回新的最大偏移,避免右侧出现空白槽。 +function clampGridHorizontalScroll() { + const scroller = gridScrollerElement(); + if (!scroller) return; + const previousLeft = scroller.scrollLeft; + clampGridScrollerBounds(scroller); // 复用既有边界收敛,保持单一事实来源 + if (scroller.scrollLeft === previousLeft) return; // 未越界,无需同步 + updateGridHorizontalViewport(scroller); + if (headerRef.value) headerRef.value.scrollLeft = scroller.scrollLeft; +} + +// 批量隐藏(表头右键菜单):一次布局提交 + 一次持久化,并把可见索引变化交给 +// applyColumnOrderChange 处理,随后清空单元格选区、重测列宽并收敛横向滚动。 +function hideColumns(columnIndexes: number[]) { + if (columnIndexes.length === 0) return; + applyColumnOrderChange(() => hideColumnsInLayout(columnIndexes)); + clearCellSelection(); + void nextTick(() => { + scheduleColumnLayoutRefresh(); + clampGridHorizontalScroll(); + }); +} + +function hideContextColumn() { + const columnIndex = contextHeaderColumnIndex.value; + if (columnIndex === null || columnIndex < 0) return; + hideColumns([columnIndex]); +} + +function hideSelectedColumns() { + const actualColumnIndexes = selectedVisibleColumnIndexes() + .map((visibleColIdx) => visibleColumnIndexes.value[visibleColIdx]) + .filter((index): index is number => index !== undefined); + hideColumns(actualColumnIndexes); +} + +function showAllColumns() { + applyColumnOrderChange(showAllColumnsInLayout); + void nextTick(scheduleColumnLayoutRefresh); +} + // --- 表头拖拽进 SQL 编辑器:目标导向模式切换的控制器 --- // 按选择顺序(Set 插入序)取列名;被拖列未选中时仅拖该列。 function columnReferenceDragNames(draggedVisibleColIdx: number): string[] | null { @@ -11104,6 +11146,7 @@ defineExpose({ filteredColumnLayoutOptions, isColumnVisible, toggleColumnVisibility, + hideColumns, showAllColumns, invertColumnVisibility, hasCustomColumnOrder, @@ -11289,6 +11332,7 @@ const gridContextMenuItems = computed(() => { const row = contextRowItem.value; const rowLabels = rowActionLabels(); const hasEditableSelection = selectionHasEditableCells(); + const selectedColumnCount = selectedVisibleColumnIndexes().length; const gridSnapshotContext = contextHeaderColumn.value && hasColumnSelection.value ? "columns" : contextCell.value?.col === -1 && affectedRowIds().length > 0 ? "rows" : contextCell.value && hasCellSelection.value && selectedCellMatrix.value ? "cells" : null; const previewItems: ContextMenuItem[] = []; if (!contextHeaderColumn.value && contextCell.value) { @@ -11320,6 +11364,9 @@ const gridContextMenuItems = computed(() => { frozenColumnCount: frozenColumnCount.value, contextVisibleColIdx: contextHeaderVisibleColIdx.value ?? undefined, hasColumnSelection: hasColumnSelection.value, + selectedColumnCount, + visibleColumnCount: visibleColumnCount.value, + hiddenColumnCount: hiddenColumnCount.value, labels: { copyName: selectedColumnNamesForCopy.value.length > 1 @@ -11338,6 +11385,9 @@ const gridContextMenuItems = computed(() => { freezeToColumn: t("grid.freezeToColumn"), freezeSelectedColumns: t("grid.freezeSelectedColumns"), unfreezeColumns: t("grid.unfreezeColumns"), + hideColumn: t("grid.hideColumn"), + hideSelectedColumns: t("grid.hideSelectedColumns", { count: selectedColumnCount }), + showAllColumnsMenu: t("grid.showAllColumnsMenu"), }, icons: { copy: Copy, @@ -11368,6 +11418,9 @@ const gridContextMenuItems = computed(() => { unfreezeAllColumns(); clearCellSelection(); }, + hideColumn: hideContextColumn, + hideSelectedColumns, + showAllColumnsMenu: showAllColumns, }, filterSubmenu: filterSubmenu(), }), diff --git a/apps/desktop/src/components/grid/__tests__/dataGridColumnLayoutPopover.spec.ts b/apps/desktop/src/components/grid/__tests__/dataGridColumnLayoutPopover.spec.ts index a9e1b17529..597db006b8 100644 --- a/apps/desktop/src/components/grid/__tests__/dataGridColumnLayoutPopover.spec.ts +++ b/apps/desktop/src/components/grid/__tests__/dataGridColumnLayoutPopover.spec.ts @@ -70,6 +70,7 @@ function createGrid(itemCount: number) { return normalizedSearch ? options.filter((option) => option.column.toLowerCase().includes(normalizedSearch)) : options; }, toggleColumnVisibility, + hideColumns: vi.fn(), showAllColumns: vi.fn(), invertColumnVisibility: vi.fn(), hasCustomColumnOrder: false, diff --git a/apps/desktop/src/components/grid/dataGridColumnLayoutPopover.ts b/apps/desktop/src/components/grid/dataGridColumnLayoutPopover.ts index e72957ce8a..021dcaefa6 100644 --- a/apps/desktop/src/components/grid/dataGridColumnLayoutPopover.ts +++ b/apps/desktop/src/components/grid/dataGridColumnLayoutPopover.ts @@ -13,6 +13,7 @@ export interface DataGridColumnLayoutHandle { orderedColumnLayoutOptions: readonly DataGridColumnLayoutOption[]; filteredColumnLayoutOptions: (search: string) => DataGridColumnLayoutOption[]; toggleColumnVisibility: (columnIndex: number) => void; + hideColumns: (columnIndexes: number[]) => void; showAllColumns: () => void; invertColumnVisibility: () => void; hasCustomColumnOrder: boolean; diff --git a/apps/desktop/src/composables/__tests__/useDataGridColumnLayout.spec.ts b/apps/desktop/src/composables/__tests__/useDataGridColumnLayout.spec.ts index d7d5663a8e..5d05a62f1f 100644 --- a/apps/desktop/src/composables/__tests__/useDataGridColumnLayout.spec.ts +++ b/apps/desktop/src/composables/__tests__/useDataGridColumnLayout.spec.ts @@ -232,6 +232,83 @@ describe("useDataGridColumnLayout", () => { expect(JSON.parse(localStorage.getItem("dbx-data-grid-column-layout:visibility-null-column-layout")!)).toMatchObject({ hiddenKeys: ["empty\0\0"] }); }); + it("hides a batch of columns in one commit and persists the hidden keys", () => { + const scope = effectScope(); + const state = scope.run(() => + useDataGridColumnLayoutState({ + columns: ref(["id", "name", "email", "phone"]), + sourceColumns: ref(undefined), + commentByColumn: ref(new Map()), + displayableColumnIndexes: ref([0, 1, 2, 3]), + allNullColumnIndexes: ref([]), + columnOrderKeys: ref(["id\0\0", "name\0\0", "email\0\0", "phone\0\0"]), + layoutScopeKey: ref("batch-hide-layout"), + tableScopeKey: ref(""), + }), + )!; + + state.hideColumns([1, 2]); + expect(state.visibleColumnIndexes.value).toEqual([0, 3]); + expect(state.hiddenColumnCount.value).toBe(2); + + // 重复索引与已隐藏索引不会改变结果(幂等) + state.hideColumns([1, 2]); + expect(state.visibleColumnIndexes.value).toEqual([0, 3]); + + state.showAllColumns(); + expect(state.visibleColumnIndexes.value).toEqual([0, 1, 2, 3]); + + state.hideColumns([3]); + state.toggleColumnVisibility(0); + scope.stop(); + + expect(loadDataGridColumnLayout("batch-hide-layout")?.hiddenKeys).toEqual(["id\0\0", "phone\0\0"]); + }); + + it("keeps at least one column visible when a batch would hide every column", () => { + const scope = effectScope(); + const state = scope.run(() => + useDataGridColumnLayoutState({ + columns: ref(["id", "name", "email"]), + sourceColumns: ref(undefined), + commentByColumn: ref(new Map()), + displayableColumnIndexes: ref([0, 1, 2]), + allNullColumnIndexes: ref([]), + columnOrderKeys: ref(["id\0\0", "name\0\0", "email\0\0"]), + layoutScopeKey: ref("batch-hide-last-layout"), + tableScopeKey: ref(""), + }), + )!; + + state.hideColumns([1, 2]); + expect(state.visibleColumnIndexes.value).toEqual([0]); + + // 只剩一列可见时再隐藏它:保持不变,不会变成空网格 + state.hideColumns([0]); + expect(state.visibleColumnIndexes.value).toEqual([0]); + scope.stop(); + }); + + it("treats an empty batch hide as a no-op", () => { + const scope = effectScope(); + const state = scope.run(() => + useDataGridColumnLayoutState({ + columns: ref(["id", "name", "email"]), + sourceColumns: ref(undefined), + commentByColumn: ref(new Map()), + displayableColumnIndexes: ref([0, 1, 2]), + allNullColumnIndexes: ref([]), + columnOrderKeys: ref(["id\0\0", "name\0\0", "email\0\0"]), + layoutScopeKey: ref("batch-hide-empty-layout"), + tableScopeKey: ref(""), + }), + )!; + + state.hideColumns([]); + expect(state.visibleColumnIndexes.value).toEqual([0, 1, 2]); + scope.stop(); + }); + it("returns ordered layout options with visibility state and reorders hidden fields", () => { const scope = effectScope(); const state = scope.run(() => diff --git a/apps/desktop/src/composables/__tests__/useDataGridSelection.spec.ts b/apps/desktop/src/composables/__tests__/useDataGridSelection.spec.ts index c2754e7439..f8e15ff77a 100644 --- a/apps/desktop/src/composables/__tests__/useDataGridSelection.spec.ts +++ b/apps/desktop/src/composables/__tests__/useDataGridSelection.spec.ts @@ -9,19 +9,23 @@ function createSelection(options?: { onUserCellSelection?: () => void; shouldUpdateDraggedRowsImmediately?: () => boolean; onDraggedRowSelectionChange?: () => void; + displayItemCount?: number; }) { const columns = computed(() => ["id", "name", "email"]); const displayItems = computed(() => - [1, 2, 3, 4].map((id, index) => ({ - id, - sourceIndex: index, - data: [id, `name-${id}`, `user-${id}@example.com`], - isNew: false, - isDraft: false, - isDeleted: false, - isDirtyCol: [false, false, false], - status: "clean", - })), + Array.from({ length: options?.displayItemCount ?? 4 }, (_, index) => { + const id = index + 1; + return { + id, + sourceIndex: index, + data: [id, `name-${id}`, `user-${id}@example.com`], + isNew: false, + isDraft: false, + isDeleted: false, + isDirtyCol: [false, false, false], + status: "clean", + }; + }), ); return useDataGridSelection({ @@ -685,4 +689,40 @@ describe("useDataGridSelection", () => { Object.defineProperty(globalThis, "document", { configurable: true, value: originalDocument }); } }); + + it("selects a header column on a zero-row result", () => { + const selection = createSelection({ displayItemCount: 0 }); + + selection.selectColumn(1); + + expect(selection.hasColumnSelection.value).toBe(true); + expect(selection.selectedColumnIndexes.value).toEqual(new Set([1])); + }); + + it("selects a header column range on a zero-row result", () => { + const selection = createSelection({ displayItemCount: 0 }); + + selection.selectColumns(0, 2); + + expect(selection.hasColumnSelection.value).toBe(true); + expect(selection.selectedColumnIndexes.value).toEqual(new Set([0, 1, 2])); + }); + + it("merges a shift-click header selection on a zero-row result", () => { + const selection = createSelection({ displayItemCount: 0 }); + + selection.selectColumn(1); + selection.selectColumn(3, rowEvent({ shift: true })); + + expect(selection.selectedColumnIndexes.value).toEqual(new Set([1, 2, 3])); + }); + + it("keeps discrete cell selection empty when selecting header columns on a zero-row result", () => { + const selection = createSelection({ displayItemCount: 0 }); + + selection.selectColumn(1); + + expect(selection.selectedCellKeys.value).toEqual(new Set()); + expect(selection.hasCellSelection.value).toBe(false); + }); }); diff --git a/apps/desktop/src/composables/useDataGridColumnLayout.ts b/apps/desktop/src/composables/useDataGridColumnLayout.ts index c024ab6674..dc5d572add 100644 --- a/apps/desktop/src/composables/useDataGridColumnLayout.ts +++ b/apps/desktop/src/composables/useDataGridColumnLayout.ts @@ -14,7 +14,7 @@ import { type TableDataGridColumnOrderChangedDetail, } from "@/lib/dataGrid/dataGridColumnLayoutStorage"; import { buildDataGridColumnLookupItems, filterDataGridColumnLookupItems, type DataGridColumnLookupItem } from "@/lib/dataGrid/dataGridColumnLookup"; -import { hiddenColumnIndexesForKeys, hiddenColumnIndexesWithAllNullColumns, hiddenColumnKeysForIndexes, invertedHiddenColumnIndexes, nextHiddenColumnIndexes, removeAutoHiddenColumnIndexes, visibleColumnIndexesForFilter } from "@/lib/dataGrid/dataGridColumnVisibility"; +import { hiddenColumnIndexesAfterHiding, hiddenColumnIndexesForKeys, hiddenColumnIndexesWithAllNullColumns, hiddenColumnKeysForIndexes, invertedHiddenColumnIndexes, nextHiddenColumnIndexes, removeAutoHiddenColumnIndexes, visibleColumnIndexesForFilter } from "@/lib/dataGrid/dataGridColumnVisibility"; export type RenderedDataGridColumn = { visibleColIdx: number; @@ -212,6 +212,24 @@ export function useDataGridColumnLayoutState(options: { persistHiddenColumnKeys(); } + // 批量隐藏:一次 hiddenColumnIndexes 提交、一次持久化,供表头右键菜单使用。 + function hideColumns(columnIndexes: Iterable) { + const requestedIndexes = [...columnIndexes].filter((index) => Number.isInteger(index) && index >= 0); + if (requestedIndexes.length === 0) return; + hiddenColumnIndexes.value = hiddenColumnIndexesAfterHiding({ + columnIndexes: requestedIndexes, + hiddenIndexes: hiddenColumnIndexes.value, + availableIndexes: toValue(options.displayableColumnIndexes), + }); + // hiddenColumnIndexesAfterHiding 只做加法(绝不删列),且 invariant + // autoHiddenNullColumnIndexes 始终是 hiddenColumnIndexes 的子集,由 + // applyNullColumnVisibility / showColumn / toggleColumnVisibility 共同维护, + // 所以这里不需要清理 autoHiddenNullColumnIndexes(与之等价的剪枝循环恒不可达)。 + // 将来若允许传入「已隐藏」的列,正确做法与剪枝相反:该列属于手动隐藏, + // 必须从 autoHiddenNullColumnIndexes 中移除,而不是保留。 + persistHiddenColumnKeys(); + } + function showAllColumns() { hiddenColumnIndexes.value = new Set(); autoHiddenNullColumnIndexes.value = new Set(); @@ -410,6 +428,7 @@ export function useDataGridColumnLayoutState(options: { filteredColumnLayoutOptions, isColumnVisible, toggleColumnVisibility, + hideColumns, showAllColumns, invertColumnVisibility, showColumn, diff --git a/apps/desktop/src/composables/useDataGridSelection.ts b/apps/desktop/src/composables/useDataGridSelection.ts index 4db53eb97d..a2f60ebe6b 100644 --- a/apps/desktop/src/composables/useDataGridSelection.ts +++ b/apps/desktop/src/composables/useDataGridSelection.ts @@ -224,7 +224,9 @@ export function useDataGridSelection(options: UseDataGridSelectionOptions) { function selectColumns(startCol: number, endCol: number, options?: { merge?: boolean }) { const normalizedColumns = normalizeSelectedColumnIndexes(Array.from({ length: Math.abs(endCol - startCol) + 1 }, (_, index) => Math.min(startCol, endCol) + index)); - if (normalizedColumns.length === 0 || displayItems.value.length <= 0) return; + // Column selection is column-based and deliberately independent of the row count, so an + // empty result (0 rows, N columns) keeps header multi-select working. + if (normalizedColumns.length === 0) return; clearRowSelection(); selectedCellKeys.value = new Set(); selectionAnchor.value = null; diff --git a/apps/desktop/src/i18n/locales/az.ts b/apps/desktop/src/i18n/locales/az.ts index 327de5e3c3..02d95b605e 100644 --- a/apps/desktop/src/i18n/locales/az.ts +++ b/apps/desktop/src/i18n/locales/az.ts @@ -2019,6 +2019,9 @@ export default withEnglishFallback({ freezeToColumn: "Bu sütuna qədər sabitlə", freezeSelectedColumns: "Seçilmiş sütunları sabitlə", unfreezeColumns: "Sütunların sabitlənməsini ləğv et", + hideColumn: "Bu sütunu gizlət", + hideSelectedColumns: "Seçilmiş sütunları gizlət ({count})", + showAllColumnsMenu: "Bütün sütunları göstər", pasted: "Yerləşdirildi!", batchAppendPasteNotEditable: "Bu nəticəni redaktə etmək mümkün deyil.", batchAppendPasteInvalidTarget: "Yerləşdirməzdən əvvəl boş yeni sətrin nömrəsini seçin.", diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index d05280d40e..222235e8c7 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -2136,6 +2136,9 @@ export default { freezeToColumn: "Freeze to This Column", freezeSelectedColumns: "Freeze Selected Columns", unfreezeColumns: "Unfreeze Columns", + hideColumn: "Hide This Column", + hideSelectedColumns: "Hide Selected Columns ({count})", + showAllColumnsMenu: "Show All Columns", pasted: "Pasted!", batchAppendPasteNotEditable: "This result cannot be edited.", batchAppendPasteInvalidTarget: "Select the row number of an empty new row before pasting.", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index de4ce7ed96..13a5d4d19b 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -1987,6 +1987,9 @@ export default withEnglishFallback({ freezeToColumn: "Inmovilizar hasta esta columna", freezeSelectedColumns: "Inmovilizar columnas seleccionadas", unfreezeColumns: "Desinmovilizar columnas", + hideColumn: "Ocultar esta columna", + hideSelectedColumns: "Ocultar columnas seleccionadas ({count})", + showAllColumnsMenu: "Mostrar todas las columnas", pasted: "¡Pegado!", search: "Buscar...", searchTruncatedValuesHint: "Algunos valores largos de texto/JSON están truncados para su visualización; la búsqueda solo cubre el contenido cargado y puede encontrar menos coincidencias que la consulta SQL.", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index fa7af4a3f0..d6dcfab8e4 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -1985,6 +1985,9 @@ export default withEnglishFallback({ freezeToColumn: "Blocca fino a questa colonna", freezeSelectedColumns: "Blocca colonne selezionate", unfreezeColumns: "Sblocca colonne", + hideColumn: "Nascondi questa colonna", + hideSelectedColumns: "Nascondi colonne selezionate ({count})", + showAllColumnsMenu: "Mostra tutte le colonne", pasted: "Incollato!", search: "Cerca...", searchTruncatedValuesHint: "Alcuni valori di testo/JSON lunghi sono troncati per la visualizzazione; la ricerca copre solo il contenuto caricato e può trovare meno corrispondenze della query SQL.", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index e295241348..c45b812ff1 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -1997,6 +1997,9 @@ export default withEnglishFallback({ freezeToColumn: "この列まで固定", freezeSelectedColumns: "選択列を固定", unfreezeColumns: "列の固定を解除", + hideColumn: "この列を非表示", + hideSelectedColumns: "選択した列を非表示 ({count})", + showAllColumnsMenu: "すべての列を表示", pasted: "貼り付けました!", search: "検索...", searchTruncatedValuesHint: "一部の長いテキスト/JSON 値は表示用に切り詰められています。検索は読み込み済みの表示内容のみが対象で、SQL クエリより一致数が少なくなることがあります。", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index 1949e7d5a7..0908695f05 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -1977,6 +1977,9 @@ export default withEnglishFallback({ freezeToColumn: "이 컬럼까지 고정", freezeSelectedColumns: "선택한 컬럼 고정", unfreezeColumns: "컬럼 고정 해제", + hideColumn: "이 컬럼 숨기기", + hideSelectedColumns: "선택한 컬럼 숨기기 ({count})", + showAllColumnsMenu: "모든 컬럼 표시", pasted: "붙여넣었습니다!", search: "검색...", searchTruncatedValuesHint: "일부 긴 텍스트/JSON 값은 표시를 위해 잘렸습니다. 검색은 로드된 표시 내용만 대상으로 하며 SQL 쿼리보다 일치 수가 적을 수 있습니다.", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index aa466c7ef0..e4aa8e93db 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -1987,6 +1987,9 @@ export default withEnglishFallback({ freezeToColumn: "Congelar até esta coluna", freezeSelectedColumns: "Congelar colunas selecionadas", unfreezeColumns: "Descongelar colunas", + hideColumn: "Ocultar esta coluna", + hideSelectedColumns: "Ocultar colunas selecionadas ({count})", + showAllColumnsMenu: "Mostrar todas as colunas", pasted: "Colado!", search: "Pesquisar...", searchTruncatedValuesHint: "Alguns valores longos de texto/JSON estão truncados para exibição; a busca cobre apenas o conteúdo carregado e pode encontrar menos correspondências que a consulta SQL.", diff --git a/apps/desktop/src/i18n/locales/tr.ts b/apps/desktop/src/i18n/locales/tr.ts index 188cfca265..0df0d30334 100644 --- a/apps/desktop/src/i18n/locales/tr.ts +++ b/apps/desktop/src/i18n/locales/tr.ts @@ -2014,6 +2014,9 @@ export default withEnglishFallback({ freezeToColumn: "Bu Sütuna Kadar Dondur", freezeSelectedColumns: "Seçili Sütunları Dondur", unfreezeColumns: "Sütun Dondurmayı Kaldır", + hideColumn: "Bu Sütunu Gizle", + hideSelectedColumns: "Seçili Sütunları Gizle ({count})", + showAllColumnsMenu: "Tüm Sütunları Göster", pasted: "Yapıştırıldı!", batchAppendPasteNotEditable: "Bu sonuç düzenlenemez.", batchAppendPasteInvalidTarget: "Yapıştırmadan önce boş yeni bir satırın satır numarasını seçin.", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index bee63d979b..2e35a496dd 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -2051,6 +2051,9 @@ export default withEnglishFallback({ freezeToColumn: "冻结到此列", freezeSelectedColumns: "冻结选中列", unfreezeColumns: "取消冻结", + hideColumn: "隐藏此列", + hideSelectedColumns: "隐藏选中列({count} 列)", + showAllColumnsMenu: "显示全部列", pasted: "已粘贴!", batchAppendPasteNotEditable: "当前结果不可编辑。", batchAppendPasteInvalidTarget: "请先选中一个空新增行的行号单元格,再进行粘贴。", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 84a2abebaf..c7627d0889 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -1985,6 +1985,9 @@ export default withEnglishFallback({ freezeToColumn: "凍結到此欄", freezeSelectedColumns: "凍結選取欄", unfreezeColumns: "取消凍結", + hideColumn: "隱藏此欄", + hideSelectedColumns: "隱藏選取欄({count} 欄)", + showAllColumnsMenu: "顯示全部欄", pasted: "已貼上!", search: "搜尋……", searchTruncatedValuesHint: "部分長文字/JSON 值已截斷顯示,搜尋僅涵蓋已載入的顯示內容,符合數可能少於 SQL 查詢。", diff --git a/apps/desktop/src/lib/__tests__/dataGrid/dataGridContextMenu.spec.ts b/apps/desktop/src/lib/__tests__/dataGrid/dataGridContextMenu.spec.ts index 84c257e36d..7e978e088e 100644 --- a/apps/desktop/src/lib/__tests__/dataGrid/dataGridContextMenu.spec.ts +++ b/apps/desktop/src/lib/__tests__/dataGrid/dataGridContextMenu.spec.ts @@ -138,4 +138,87 @@ describe("dataGridContextMenu", () => { }); expect(rowItems.find((item) => item.label === "delete")?.variant).toBe("destructive"); }); + + function columnMenuItems(overrides: { hasColumnSelection?: boolean; selectedColumnCount?: number; visibleColumnCount?: number; hiddenColumnCount?: number }) { + const action = vi.fn(); + const filter = createDataGridFilterSubmenu({ + label: "filter", + icon, + labels: { equals: "equals", notEquals: "not equals", like: "like", notLike: "not like", lessThan: "less", greaterThan: "greater", isNull: "null", isNotNull: "not null", clear: "clear" }, + apply: action, + clear: action, + }); + return createDataGridColumnContextMenuItems({ + headerColumn: true, + contextColumn: false, + canCopyAlterSql: false, + canFilter: false, + hasSort: false, + sortMode: "database", + contextVisibleColIdx: 0, + hasColumnSelection: false, + selectedColumnCount: 1, + visibleColumnCount: 3, + hiddenColumnCount: 0, + labels: { + copyName: "copy name", + copyNames: "copy names", + details: "details", + copyAlterSql: "alter", + databaseAscending: "db asc", + databaseDescending: "db desc", + localAscending: "local asc", + localDescending: "local desc", + clearSort: "clear sort", + freezeToColumn: "freeze to column", + freezeSelectedColumns: "freeze selected", + unfreezeColumns: "unfreeze", + hideColumn: "hide column", + hideSelectedColumns: "hide selected (3)", + showAllColumnsMenu: "show all columns", + }, + icons: { copy: icon, columnDetails: icon, database: icon, ascending: icon, descending: icon, clearSort: icon }, + actions: { copyName: action, copyNames: action, details: action, copyAlterSql: action, sort: action, freezeToColumn: action, freezeSelectedColumns: action, unfreezeColumns: action, hideColumn: action, hideSelectedColumns: action, showAllColumnsMenu: action }, + filterSubmenu: filter, + ...overrides, + }); + } + + it("offers hide-this-column on a header and hides the recovery entry until a column is hidden", () => { + const labels = columnMenuItems({}).map((item) => item.label); + + expect(labels).toContain("hide column"); + expect(labels).not.toContain("hide selected (3)"); + expect(labels).not.toContain("show all columns"); + }); + + it("offers batch hiding for a multi-column selection and shows the recovery entry when columns are hidden", () => { + const items = columnMenuItems({ hasColumnSelection: true, selectedColumnCount: 3, visibleColumnCount: 5, hiddenColumnCount: 2 }); + const hideSelected = items.find((item) => item.label === "hide selected (3)"); + + expect(hideSelected?.disabled).toBe(false); + expect(items.map((item) => item.label)).toContain("show all columns"); + }); + + it("disables batch hiding when the selection covers every visible column", () => { + const items = columnMenuItems({ hasColumnSelection: true, selectedColumnCount: 3, visibleColumnCount: 3 }); + + expect(items.find((item) => item.label === "hide selected (3)")?.disabled).toBe(true); + }); + + it("disables hiding this column when only one column is visible", () => { + const items = columnMenuItems({ visibleColumnCount: 1 }); + const hideColumn = items.find((item) => item.label === "hide column"); + + expect(hideColumn).toBeDefined(); + expect(hideColumn?.disabled).toBe(true); + }); + + it("keeps hiding this column enabled for callers that omit the visible column count", () => { + const items = columnMenuItems({ visibleColumnCount: undefined }); + const hideColumn = items.find((item) => item.label === "hide column"); + + expect(hideColumn).toBeDefined(); + expect(hideColumn?.disabled).toBe(false); + }); }); diff --git a/apps/desktop/src/lib/dataGrid/dataGridColumnVisibility.ts b/apps/desktop/src/lib/dataGrid/dataGridColumnVisibility.ts index e31fc44b3e..948cba37ac 100644 --- a/apps/desktop/src/lib/dataGrid/dataGridColumnVisibility.ts +++ b/apps/desktop/src/lib/dataGrid/dataGridColumnVisibility.ts @@ -65,6 +65,34 @@ export function nextHiddenColumnIndexes(options: { columnIndex: number; hiddenIn return next; } +/** + * Batch counterpart of {@link nextHiddenColumnIndexes}: hides a whole set of + * columns in one deterministic step and keeps at least one column visible. + * When the batch would hide every visible column, the first currently visible + * column is kept instead (same rule as the single-column toggle, expressed once + * for a set). Duplicate or already-hidden indexes are ignored, and an empty + * batch is a no-op. + */ +export function hiddenColumnIndexesAfterHiding(options: { columnIndexes: Iterable; hiddenIndexes: ReadonlySet; availableIndexes: readonly number[] }): Set { + const next = new Set(options.hiddenIndexes); + const available = new Set(options.availableIndexes); + const visibleIndexes = options.availableIndexes.filter((index) => !next.has(index)); + const requestedIndexes = [...new Set(options.columnIndexes)].filter((index) => available.has(index) && !next.has(index)); + if (requestedIndexes.length === 0) return next; + + if (visibleIndexes.length - requestedIndexes.length >= 1) { + for (const index of requestedIndexes) next.add(index); + return next; + } + + // 会隐藏全部可见列:保留最前面的一个可见列,其余全部隐藏。 + const keptIndex = visibleIndexes[0]; + for (const index of visibleIndexes) { + if (index !== keptIndex) next.add(index); + } + return next; +} + export function invertedHiddenColumnIndexes(availableIndexes: number[], hiddenIndexes: ReadonlySet): Set { const next = new Set(availableIndexes.filter((index) => !hiddenIndexes.has(index))); if (next.size === availableIndexes.length && availableIndexes.length > 0) { diff --git a/apps/desktop/src/lib/dataGrid/dataGridContextMenu.ts b/apps/desktop/src/lib/dataGrid/dataGridContextMenu.ts index 18ff420c7c..8a281f91b0 100644 --- a/apps/desktop/src/lib/dataGrid/dataGridContextMenu.ts +++ b/apps/desktop/src/lib/dataGrid/dataGridContextMenu.ts @@ -81,9 +81,30 @@ export function createDataGridColumnContextMenuItems(options: { frozenColumnCount?: number; contextVisibleColIdx?: number; hasColumnSelection?: boolean; - labels: Record<"copyName" | "copyNames" | "details" | "copyAlterSql" | "databaseAscending" | "databaseDescending" | "localAscending" | "localDescending" | "clearSort" | "freezeToColumn" | "freezeSelectedColumns" | "unfreezeColumns", string>; + /** 显式多选的可见列数量;为 undefined 时按 0 处理。 */ + selectedColumnCount?: number; + /** 当前可见列总数,用于判断「选中列覆盖全部可见列」而禁用批量隐藏。 */ + visibleColumnCount?: number; + /** 当前被隐藏的列数量,用于决定是否显示「显示全部列」。 */ + hiddenColumnCount?: number; + labels: Record< + "copyName" | "copyNames" | "details" | "copyAlterSql" | "databaseAscending" | "databaseDescending" | "localAscending" | "localDescending" | "clearSort" | "freezeToColumn" | "freezeSelectedColumns" | "unfreezeColumns" | "hideColumn" | "hideSelectedColumns" | "showAllColumnsMenu", + string + >; icons: Pick; - actions: { copyName: () => void; copyNames: () => void; details: () => void; copyAlterSql: () => void; sort: (direction: "asc" | "desc" | null, mode: "database" | "local") => void; freezeToColumn: () => void; freezeSelectedColumns: () => void; unfreezeColumns: () => void }; + actions: { + copyName: () => void; + copyNames: () => void; + details: () => void; + copyAlterSql: () => void; + sort: (direction: "asc" | "desc" | null, mode: "database" | "local") => void; + freezeToColumn: () => void; + freezeSelectedColumns: () => void; + unfreezeColumns: () => void; + hideColumn: () => void; + hideSelectedColumns: () => void; + showAllColumnsMenu: () => void; + }; filterSubmenu: DataGridContextMenuItem; }): DataGridContextMenuItem[] { const items: DataGridContextMenuItem[] = []; @@ -108,6 +129,18 @@ export function createDataGridColumnContextMenuItems(options: { } if (options.contextVisibleColIdx !== undefined) { items.push({ label: "", separator: true }); + const selectedColumnCount = options.selectedColumnCount ?? 0; + const visibleColumnCount = options.visibleColumnCount ?? 0; + // 只剩一列时隐藏会被 helper 拒绝;用 disabled 明确表达,而不是静默无效。 + // 这里读 options.visibleColumnCount 而非本地默认值:字段可选,未传的调用方不应被误禁用。 + items.push({ label: options.labels.hideColumn, action: options.actions.hideColumn, disabled: options.visibleColumnCount !== undefined && options.visibleColumnCount <= 1 }); + // 仅在多选(>1)时提供批量隐藏;选中列覆盖全部可见列时禁用,而不是悄悄少隐藏一列。 + if (options.hasColumnSelection && selectedColumnCount > 1) { + items.push({ label: options.labels.hideSelectedColumns, action: options.actions.hideSelectedColumns, disabled: visibleColumnCount > 0 && selectedColumnCount >= visibleColumnCount }); + } + if ((options.hiddenColumnCount ?? 0) > 0) { + items.push({ label: options.labels.showAllColumnsMenu, action: options.actions.showAllColumnsMenu }); + } if ((options.frozenColumnCount ?? 0) > 0) { items.push({ label: options.labels.unfreezeColumns, action: options.actions.unfreezeColumns }); } diff --git a/packages/app-tests/dataGridColumnVisibility.test.ts b/packages/app-tests/dataGridColumnVisibility.test.ts index 41b475e4b8..336d9a1080 100644 --- a/packages/app-tests/dataGridColumnVisibility.test.ts +++ b/packages/app-tests/dataGridColumnVisibility.test.ts @@ -1,7 +1,18 @@ import { strict as assert } from "node:assert"; import { test } from "vitest"; import { buildDataGridColumnLookupItems, filterDataGridColumnLookupItems } from "../../apps/desktop/src/lib/dataGrid/dataGridColumnLookup.ts"; -import { allNullColumnIndexes, filterColumnVisibilityOptions, hiddenColumnIndexesForKeys, hiddenColumnIndexesWithAllNullColumns, hiddenColumnKeysForIndexes, invertedHiddenColumnIndexes, nextHiddenColumnIndexes, removeAutoHiddenColumnIndexes, visibleColumnIndexesForFilter } from "../../apps/desktop/src/lib/dataGrid/dataGridColumnVisibility.ts"; +import { + allNullColumnIndexes, + filterColumnVisibilityOptions, + hiddenColumnIndexesAfterHiding, + hiddenColumnIndexesForKeys, + hiddenColumnIndexesWithAllNullColumns, + hiddenColumnKeysForIndexes, + invertedHiddenColumnIndexes, + nextHiddenColumnIndexes, + removeAutoHiddenColumnIndexes, + visibleColumnIndexesForFilter, +} from "../../apps/desktop/src/lib/dataGrid/dataGridColumnVisibility.ts"; test("filters column visibility options by trimmed case-insensitive text", () => { const options = filterColumnVisibilityOptions(["id", "created_at", "CustomerName"], " NAME "); @@ -93,6 +104,54 @@ test("inverts hidden column indexes", () => { assert.deepEqual([...hidden].sort(), [0, 2]); }); +test("hides a batch of columns in one step", () => { + const hidden = hiddenColumnIndexesAfterHiding({ + columnIndexes: [1, 2], + hiddenIndexes: new Set(), + availableIndexes: [0, 1, 2, 3], + }); + + assert.deepEqual([...hidden].sort(), [1, 2]); +}); + +test("batch hiding is idempotent for duplicates and already hidden columns", () => { + const hidden = hiddenColumnIndexesAfterHiding({ + columnIndexes: [1, 1, 2, 2], + hiddenIndexes: new Set([2]), + availableIndexes: [0, 1, 2, 3], + }); + + assert.deepEqual([...hidden].sort(), [1, 2]); +}); + +test("batch hiding ignores empty input and unavailable indexes", () => { + const empty = hiddenColumnIndexesAfterHiding({ columnIndexes: [], hiddenIndexes: new Set([1]), availableIndexes: [0, 1, 2] }); + assert.deepEqual([...empty], [1]); + + const unavailable = hiddenColumnIndexesAfterHiding({ columnIndexes: [9], hiddenIndexes: new Set(), availableIndexes: [0, 1, 2] }); + assert.deepEqual([...unavailable], []); +}); + +test("batch hiding keeps the last visible column", () => { + const noneLeft = hiddenColumnIndexesAfterHiding({ + columnIndexes: [2], + hiddenIndexes: new Set([0, 1]), + availableIndexes: [0, 1, 2], + }); + + assert.deepEqual([...noneLeft].sort(), [0, 1]); +}); + +test("batch hiding every visible column keeps the first one visible", () => { + const hidden = hiddenColumnIndexesAfterHiding({ + columnIndexes: [0, 1, 2], + hiddenIndexes: new Set(), + availableIndexes: [0, 1, 2], + }); + + assert.deepEqual([...hidden].sort(), [1, 2]); +}); + test("keeps one column visible when inverting all visible columns", () => { const hidden = invertedHiddenColumnIndexes([0, 1, 2], new Set()); diff --git a/packages/app-tests/dataGridContextMenu.test.ts b/packages/app-tests/dataGridContextMenu.test.ts index 6914913259..819817a165 100644 --- a/packages/app-tests/dataGridContextMenu.test.ts +++ b/packages/app-tests/dataGridContextMenu.test.ts @@ -45,6 +45,24 @@ test("context-menu invalidation clears cell and header targets together", () => assert.match(handler, /contextHeaderVisibleColIdx\.value = null;/); }); +test("header context menu wires batch hide and show-all to the column layout pipeline", () => { + assert.match(dataGridSource, /hideColumn: hideContextColumn,/); + assert.match(dataGridSource, /hideSelectedColumns,/); + assert.match(dataGridSource, /showAllColumnsMenu: showAllColumns,/); + assert.match(dataGridSource, /hiddenColumnCount: hiddenColumnCount\.value,/); + + const hideWrapper = dataGridSource.match(/function hideColumns\(columnIndexes: number\[\]\) \{[^]*?\n\}/)?.[0] ?? ""; + + assert.match(hideWrapper, /applyColumnOrderChange\(\(\) => hideColumnsInLayout\(columnIndexes\)\)/); + assert.match(hideWrapper, /clearCellSelection\(\);/); + assert.match(hideWrapper, /clampGridHorizontalScroll\(\)/); + + // 显示全部列也走 applyColumnOrderChange,隐藏期间产生的可见索引偏移会被重新对齐。 + const showAllWrapper = dataGridSource.match(/function showAllColumns\(\) \{[^]*?\n\}/)?.[0] ?? ""; + + assert.match(showAllWrapper, /applyColumnOrderChange\(showAllColumnsInLayout\)/); +}); + test("select-all context menus invalidate a stale specialized target", () => { const header = dataGridSource.match(//u)?.[0] ?? "";