diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index 8af49af690..f480ed44fa 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -1925,9 +1925,14 @@ const { hasCellSelection, clearCellSelection, selectSingleCell, + selectRow, + selectColumn, + selectAllCells, + extendCellSelectionTo, finishCellSelection, extendCellSelection, cellIsSelected, + columnIsSelected, selectedRangeStart, selectedRowIds, hasRowSelection, @@ -1951,6 +1956,12 @@ const multiRowCount = computed(() => { }); const isMultiRow = computed(() => multiRowCount.value > 1); +const copyRowJsonLabel = computed(() => { + if (isTransposeMode.value) { + return isMultiRow.value ? t("grid.copyColumnsJson", { count: multiRowCount.value }) : t("grid.copyColumnJson"); + } + return isMultiRow.value ? t("grid.copyRows", { count: multiRowCount.value }) : t("grid.copyRow"); +}); function onCellMouseenter(rowIndex: number, visibleColIdx: number, actualColIdx: number) { hoveredDetailCell.value = { rowIndex, col: actualColIdx }; @@ -2002,6 +2013,9 @@ function isRowActive(index: number): boolean { if (item && isRowSelected(item.id)) return true; const range = selectedRange.value; if (!range) return false; + const coversAllVisibleRows = range.startRow === 0 && range.endRow >= displayItems.value.length - 1; + const coversAllVisibleColumns = range.startCol === 0 && range.endCol >= visibleColumnCount.value - 1; + if (coversAllVisibleRows && !coversAllVisibleColumns) return false; return index >= range.startRow && index <= range.endRow; } @@ -2495,6 +2509,7 @@ const { copySelectionCsv, copySelectionJson, copySelectionSqlInList, + copySelectedRowsTsv, exportCsv, exportJson, exportMarkdown, @@ -2530,6 +2545,37 @@ function showCellDetailsForVisibleCell(rowIndex: number, visibleColIdx: number, showCellDetails(rowIndex, actualColIdx); } +function transposeCellIsSelected(rowIndex: number, actualColIdx: number) { + const visibleColIdx = visibleColumnIndexes.value.indexOf(actualColIdx); + return visibleColIdx >= 0 && cellIsSelected(rowIndex, visibleColIdx); +} + +function onTransposeCellMouseenter(rowIndex: number, actualColIdx: number) { + hoveredDetailCell.value = { rowIndex, col: actualColIdx }; +} + +function selectTransposeCell(rowIndex: number, actualColIdx: number, event: MouseEvent) { + const visibleColIdx = visibleColumnIndexes.value.indexOf(actualColIdx); + if (visibleColIdx < 0) return; + contextHeaderColumn.value = null; + clearRowSelection(); + if (event.shiftKey || event.metaKey || event.ctrlKey) { + extendCellSelectionTo(rowIndex, visibleColIdx); + } else { + selectSingleCell(rowIndex, visibleColIdx); + } + transposeRowIndex.value = rowIndex; + showCellDetails(rowIndex, actualColIdx); + gridRef.value?.focus({ preventScroll: true }); +} + +function onTransposeCellContext(rowIndex: number, actualColIdx: number, event: MouseEvent) { + selectTransposeCell(rowIndex, actualColIdx, event); + const item = displayItems.value[rowIndex]; + contextCell.value = item ? { rowId: item.id, rowIndex, col: actualColIdx } : null; + void prefetchCopyStatements(); +} + watch([selectedRange, showCellDetail, isEditingDetail], () => { const selectedCell = currentSelectedCellPosition(); const target = linkedCellDetailTarget({ @@ -2609,6 +2655,23 @@ function scrollCellIntoView(rowIndex: number, colIndex: number) { }); } +function scrollGridRowIntoView(rowIndex: number) { + const target = Math.max(0, Math.min(displayItems.value.length - 1, rowIndex)); + nextTick(() => { + const scroller = scrollerRef.value; + if (scroller && !(scroller instanceof HTMLElement)) { + scroller.scrollToItem?.(target); + scroller.scrollToPosition?.(target * 26); + } else if (scroller instanceof HTMLElement) { + scroller.scrollTop = target * 26; + } + requestAnimationFrame(() => { + const rowEl = gridRef.value?.querySelector(`[data-row-index="${target}"]`); + rowEl?.scrollIntoView({ block: "nearest", inline: "nearest" }); + }); + }); +} + function currentTransposeRequestedRowIndex(): number { const position = currentSelectedCellPosition(); if (position) return position.rowIndex; @@ -2618,10 +2681,11 @@ function currentTransposeRequestedRowIndex(): number { function toggleKeyboardTranspose(): boolean { if (displayItems.value.length === 0) return false; + const requestedRowIndex = currentTransposeRequestedRowIndex(); const next = nextKeyboardTransposeState({ showTranspose: showTranspose.value, transposeRowIndex: transposeRowIndex.value, - requestedRowIndex: currentTransposeRequestedRowIndex(), + requestedRowIndex, rowIds: displayItems.value.map((item) => item.id), selectedRowIds: selectedRowIds.value, selectedRange: selectedRange.value, @@ -2632,6 +2696,8 @@ function toggleKeyboardTranspose(): boolean { closeCellDetails(); nextTick(updateTransposeViewport); if (next.transposeRowIndex !== null) scrollTransposeRecordIntoView(next.transposeRowIndex); + } else { + scrollGridRowIntoView(requestedRowIndex); } return true; } @@ -2746,9 +2812,23 @@ async function onGridKeydown(event: KeyboardEvent) { return; } if (clipboardShortcut(event, "c")) { - if (!hasCellSelection.value) return; + if (!hasCellSelection.value && !hasRowSelection.value) return; event.preventDefault(); - copySelectionTsv(); + if (isTransposeMode.value && hasRowSelection.value) { + copyRow(); + return; + } + if (hasCellSelection.value) { + copySelectionTsv(); + } else { + copySelectedRowsTsv(); + } + return; + } + if (clipboardShortcut(event, "a")) { + if (!hasData.value) return; + event.preventDefault(); + selectAllCells(); return; } if (clipboardShortcut(event, "x")) { @@ -2905,9 +2985,17 @@ function onTransposeRecordResizeStart(event: MouseEvent) { document.addEventListener("mouseup", onUp); } -function closeTranspose() { +function currentTransposeViewportRowIndex(): number { + if (displayItems.value.length === 0) return 0; + const rowIndex = transposeRowIndex.value ?? transposeRecordWindow.value.start; + return Math.max(0, Math.min(displayItems.value.length - 1, rowIndex)); +} + +function closeTranspose(scrollToCurrentRecord = true) { + const rowIndex = currentTransposeViewportRowIndex(); showTranspose.value = false; transposeRowIndex.value = null; + if (scrollToCurrentRecord) scrollGridRowIntoView(rowIndex); } function openContextTranspose() { @@ -2941,12 +3029,27 @@ function toggleTranspose(rowIndex: number) { closeCellDetails(); nextTick(updateTransposeViewport); if (next.transposeRowIndex !== null) scrollTransposeRecordIntoView(next.transposeRowIndex); + } else { + scrollGridRowIntoView(rowIndex); } } -function selectTransposeRecord(rowIndex: number) { +function selectTransposeRecord(rowIndex: number, event?: MouseEvent) { if (rowIndex < 0 || rowIndex >= displayItems.value.length) return; transposeRowIndex.value = rowIndex; + contextHeaderColumn.value = null; + const item = displayItems.value[rowIndex]; + if (item) { + if (event) { + handleRowClick(rowIndex, item.id, event); + } else { + selectedRowIds.value = new Set([item.id]); + selection.lastClickedRowIndex.value = rowIndex; + selectRow(rowIndex); + } + contextCell.value = { rowId: item.id, rowIndex, col: -1 }; + void prefetchCopyStatements(); + } gridRef.value?.focus({ preventScroll: true }); } @@ -2984,7 +3087,7 @@ watch( nextTransposeStateForRecordCount(showTranspose.value, transposeRowIndex.value, displayItems.value.length), ); } else { - closeTranspose(); + closeTranspose(false); } exitTransaction(); }, @@ -3329,37 +3432,8 @@ onUnmounted(() => { clearInterval(_loadingTimer); }); -const SQL_KEYWORDS = - /\b(CREATE|TABLE|INDEX|UNIQUE|PRIMARY|KEY|FOREIGN|REFERENCES|CONSTRAINT|NOT|NULL|DEFAULT|INT|INTEGER|BIGINT|SMALLINT|VARCHAR|CHARACTER|VARYING|TEXT|BOOLEAN|DOUBLE|PRECISION|REAL|FLOAT|NUMERIC|DECIMAL|TIMESTAMP|DATE|TIME|SERIAL|AUTOINCREMENT|AUTO_INCREMENT|IF|EXISTS|ON|SET|CASCADE|RESTRICT|CHECK|WITH|WITHOUT|ZONE)\b/gi; - const highlightedDdlContent = computed(() => highlight(ddlContent.value)); -function highlightSql(sql: string): string { - const tokens: string[] = []; - let rest = sql; - const re = /("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')/g; - let match: RegExpExecArray | null; - let last = 0; - while ((match = re.exec(rest)) !== null) { - if (match.index > last) tokens.push(escapeAndHighlightKeywords(rest.slice(last, match.index))); - const q = match[1]; - const escaped = q.replace(/&/g, "&").replace(//g, ">"); - const cls = q.startsWith('"') ? "ddl-ident" : "ddl-str"; - tokens.push(`${escaped}`); - last = re.lastIndex; - } - if (last < rest.length) tokens.push(escapeAndHighlightKeywords(rest.slice(last))); - return tokens.join(""); -} - -function escapeAndHighlightKeywords(s: string): string { - return s - .replace(/&/g, "&") - .replace(//g, ">") - .replace(SQL_KEYWORDS, '$1'); -} - defineExpose({ useTransaction, transactionActive, @@ -3920,7 +3994,8 @@ defineExpose({ : 'bg-[rgb(239_239_239)] dark:bg-muted' " :style="{ width: `${transposeRecordWidth}px` }" - @click="selectTransposeRecord(recordIndex)" + @click="selectTransposeRecord(recordIndex, $event)" + @contextmenu="selectTransposeRecord(recordIndex, $event)" > {{ recordIndex + 1 }}
+ +
@@ -4007,8 +4100,9 @@ defineExpose({ >
#
@@ -4018,18 +4112,16 @@ defineExpose({ class="shrink-0 px-2 py-1.5 border-r border-border whitespace-nowrap hover:bg-accent/60 select-none relative overflow-hidden" :class="{ 'bg-primary/15 ring-1 ring-inset ring-primary/40': - highlightedColumnIndex === actualColumnIndex(colIdx), + highlightedColumnIndex === actualColumnIndex(colIdx) || columnIsSelected(colIdx), }" :style="{ width: `var(--col-w-${colIdx})` }" :data-grid-column-index="actualColumnIndex(colIdx)" + @click="selectColumn(colIdx, $event)" @contextmenu="onHeaderContext(col)" > - + {{ col }} {{ t("grid.copyCell") }} - {{ isMultiRow ? t("grid.copyRows", { count: multiRowCount }) : t("grid.copyRow") }} + {{ copyRowJsonLabel }} {{ isMultiRow ? t("grid.copyRowsInsert", { count: multiRowCount }) : t("grid.copyRowInsert") }} diff --git a/apps/desktop/src/composables/useDataGridExport.ts b/apps/desktop/src/composables/useDataGridExport.ts index cfd683a1ac..d79dbc2611 100644 --- a/apps/desktop/src/composables/useDataGridExport.ts +++ b/apps/desktop/src/composables/useDataGridExport.ts @@ -320,6 +320,26 @@ export function useDataGridExport(options: UseDataGridExportOptions) { await copyText(formatSelectionAsSqlInList(selectedCells.value)); } + async function copySelectedRowsTsv() { + if (!hasRowSelection.value || selectedRowIds.value.size === 0) return; + const rows = displayItems.value.filter((item) => selectedRowIds.value.has(item.id)).map((item) => item.data); + await copyText(formatSelectionAsTsv({ columns: columns.value, rows })); + } + + function rowToJsonObject(item: RowItem): Record { + const obj: Record = {}; + columns.value.forEach((col, i) => { + obj[col] = item.data[i]; + }); + return obj; + } + + async function copyRowsAsJson(items: RowItem[]) { + if (items.length === 0) return; + const value = items.length === 1 ? rowToJsonObject(items[0]) : items.map(rowToJsonObject); + await copyText(JSON.stringify(value, null, 2)); + } + // --- Cell/row copy --- async function copyCell() { if (!contextCell.value || contextCell.value.col < 0) return; @@ -331,37 +351,19 @@ export function useDataGridExport(options: UseDataGridExportOptions) { async function copyRow() { if (hasRowSelection.value && selectedRowIds.value.size > 0) { const items = displayItems.value.filter((item) => selectedRowIds.value.has(item.id)); - const objects = items.map((item) => { - const obj: Record = {}; - columns.value.forEach((col, i) => { - obj[col] = item.data[i]; - }); - return obj; - }); - await copyText(JSON.stringify(objects, null, 2)); + await copyRowsAsJson(items); return; } const range = selectedRange.value; if (range && range.startRow !== range.endRow) { const items = displayItems.value.slice(range.startRow, range.endRow + 1); - const objects = items.map((item) => { - const obj: Record = {}; - columns.value.forEach((col, i) => { - obj[col] = item.data[i]; - }); - return obj; - }); - await copyText(JSON.stringify(objects, null, 2)); + await copyRowsAsJson(items); return; } if (!contextCell.value) return; const item = getRowItem(contextCell.value.rowId); if (!item) return; - const obj: Record = {}; - columns.value.forEach((col, i) => { - obj[col] = item.data[i]; - }); - await copyText(JSON.stringify(obj, null, 2)); + await copyRowsAsJson([item]); } function insertEligibleRows(): RowItem[] { @@ -545,6 +547,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) { copySelectionCsv, copySelectionJson, copySelectionSqlInList, + copySelectedRowsTsv, exportCsv, exportJson, exportMarkdown, diff --git a/apps/desktop/src/composables/useDataGridSelection.ts b/apps/desktop/src/composables/useDataGridSelection.ts index 564a904971..79b5f1ed3f 100644 --- a/apps/desktop/src/composables/useDataGridSelection.ts +++ b/apps/desktop/src/composables/useDataGridSelection.ts @@ -1,8 +1,11 @@ import { ref, computed, type ComputedRef, type Ref } from "vue"; import { + allCellsSelectionRange, + columnSelectionRange, extractSelection, isCellInSelection, normalizeSelectionRange, + rowSelectionRange, type CellPosition, type CellSelectionRange, type SelectionData, @@ -39,6 +42,7 @@ export function useDataGridSelection(options: UseDataGridSelectionOptions) { const selectedRowIds = ref>(new Set()); const lastClickedRowIndex = ref(null); + const lastClickedColumnIndex = ref(null); const hasRowSelection = computed(() => selectedRowIds.value.size > 0); const selectedRowCount = computed(() => selectedRowIds.value.size); @@ -74,9 +78,57 @@ export function useDataGridSelection(options: UseDataGridSelectionOptions) { } function selectRow(rowIndex: number) { - if (columns.value.length === 0) return; - selectionAnchor.value = { rowIndex, colIndex: 0 }; - selectionFocus.value = { rowIndex, colIndex: columns.value.length - 1 }; + const range = rowSelectionRange(rowIndex, columns.value.length); + if (!range) return; + selectionAnchor.value = { rowIndex: range.startRow, colIndex: range.startCol }; + selectionFocus.value = { rowIndex: range.endRow, colIndex: range.endCol }; + } + + function selectRows(startRow: number, endRow: number) { + const range = rowSelectionRange(startRow, columns.value.length, endRow); + if (!range) return; + selectionAnchor.value = { rowIndex: range.startRow, colIndex: range.startCol }; + selectionFocus.value = { rowIndex: range.endRow, colIndex: range.endCol }; + } + + function selectColumns(startCol: number, endCol: number) { + const range = columnSelectionRange(displayItems.value.length, startCol, endCol); + if (!range) return; + clearRowSelection(); + selectionAnchor.value = { rowIndex: range.startRow, colIndex: range.startCol }; + selectionFocus.value = { rowIndex: range.endRow, colIndex: range.endCol }; + focusGridWithoutScrolling(); + } + + function selectColumn(colIndex: number, event?: MouseEvent) { + const isShift = Boolean(event?.shiftKey); + const isMeta = Boolean(event?.metaKey || event?.ctrlKey); + if (isShift && lastClickedColumnIndex.value !== null) { + selectColumns(lastClickedColumnIndex.value, colIndex); + } else if (isMeta && selectedRange.value) { + selectColumns(Math.min(selectedRange.value.startCol, colIndex), Math.max(selectedRange.value.endCol, colIndex)); + } else { + selectColumns(colIndex, colIndex); + } + lastClickedColumnIndex.value = colIndex; + } + + function selectAllCells() { + const range = allCellsSelectionRange(displayItems.value.length, columns.value.length); + if (!range) return; + clearRowSelection(); + lastClickedColumnIndex.value = null; + selectionAnchor.value = { rowIndex: range.startRow, colIndex: range.startCol }; + selectionFocus.value = { rowIndex: range.endRow, colIndex: range.endCol }; + focusGridWithoutScrolling(); + } + + function extendCellSelectionTo(rowIndex: number, colIndex: number) { + if (!selectionAnchor.value) { + selectSingleCell(rowIndex, colIndex); + return; + } + selectionFocus.value = { rowIndex, colIndex }; } function handleRowClick(rowIndex: number, rowId: number, event: MouseEvent) { @@ -103,8 +155,10 @@ export function useDataGridSelection(options: UseDataGridSelectionOptions) { if (item) next.add(item.id); } selectedRowIds.value = next; + selectRows(start, end); } else { selectedRowIds.value = new Set([rowId]); + selectRow(rowIndex); lastClickedRowIndex.value = rowIndex; } } @@ -125,6 +179,7 @@ export function useDataGridSelection(options: UseDataGridSelectionOptions) { focusGridWithoutScrolling(); selectSingleCell(rowIndex, colIndex); isSelectingCells.value = true; + lastClickedColumnIndex.value = colIndex; if (showTranspose.value) transposeRowIndex.value = rowIndex; document.addEventListener("mouseup", finishCellSelection); } @@ -134,7 +189,7 @@ export function useDataGridSelection(options: UseDataGridSelectionOptions) { selectionFocus.value = { rowIndex, colIndex }; } - function handleDataCellMousedown(rowIndex: number, colIndex: number, rowId: number, event: MouseEvent) { + function handleDataCellMousedown(rowIndex: number, colIndex: number, _rowId: number, event: MouseEvent) { if (event.button !== 0) return; if (editingCell.value) return; @@ -144,7 +199,8 @@ export function useDataGridSelection(options: UseDataGridSelectionOptions) { if (isMeta || isShift) { event.preventDefault(); focusGridWithoutScrolling(); - handleRowClick(rowIndex, rowId, event); + clearRowSelection(); + extendCellSelectionTo(rowIndex, colIndex); return; } @@ -160,6 +216,17 @@ export function useDataGridSelection(options: UseDataGridSelectionOptions) { return isCellInSelection(rowIndex, colIndex, selectedRange.value); } + function columnIsSelected(colIndex: number): boolean { + const range = selectedRange.value; + if (!range) return false; + return ( + range.startCol <= colIndex && + range.endCol >= colIndex && + range.startRow === 0 && + range.endRow >= displayItems.value.length - 1 + ); + } + function selectedRangeStart(): CellPosition | null { const range = selectedRange.value; if (!range) return null; @@ -177,10 +244,15 @@ export function useDataGridSelection(options: UseDataGridSelectionOptions) { clearCellSelection, selectSingleCell, selectRow, + selectColumn, + selectColumns, + selectAllCells, + extendCellSelectionTo, finishCellSelection, beginCellSelection, extendCellSelection, cellIsSelected, + columnIsSelected, selectedRangeStart, selectedRowIds, lastClickedRowIndex, diff --git a/apps/desktop/src/composables/useSqlHighlighter.ts b/apps/desktop/src/composables/useSqlHighlighter.ts index da8dac560f..7ad5b248fa 100644 --- a/apps/desktop/src/composables/useSqlHighlighter.ts +++ b/apps/desktop/src/composables/useSqlHighlighter.ts @@ -1,4 +1,4 @@ -import { computed, onMounted, ref } from "vue"; +import { onMounted, ref } from "vue"; import { useTheme } from "@/composables/useTheme"; import { type SqlHighlighter, createShikiSqlHighlighter } from "@/lib/sqlHighlighter"; diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index d57669455e..e39c398030 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -320,6 +320,7 @@ export default { copy: "Copy", copyCell: "Copy Cell", copyRow: "Copy Row (JSON)", + copyColumnJson: "Copy Column (JSON)", copyRowInsert: "Copy as INSERT", copyRowInsertWithoutPrimaryKeys: "Copy as INSERT without Primary Keys", copyRowUpdate: "Copy as UPDATE", @@ -436,6 +437,7 @@ export default { cloneRows: "Clone {count} as New Rows", restoreRows: "Restore {count} Rows", copyRows: "Copy {count} Rows (JSON)", + copyColumnsJson: "Copy {count} Columns (JSON)", copyRowsInsert: "Copy {count} Rows as INSERT", copyRowsInsertWithoutPrimaryKeys: "Copy {count} Rows as INSERT without Primary Keys", copyRowsUpdate: "Copy {count} Rows as UPDATE", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 1790ac9d38..28fc57542d 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -311,6 +311,7 @@ export default { copy: "Copiar", copyCell: "Copiar celda", copyRow: "Copiar fila (JSON)", + copyColumnJson: "Copiar columna (JSON)", copyRowInsert: "Copiar como INSERT", copyRowInsertWithoutPrimaryKeys: "Copiar como INSERT sin claves primarias", copyRowUpdate: "Copiar como UPDATE", @@ -400,6 +401,7 @@ export default { cloneRows: "Clonar {count} como filas nuevas", restoreRows: "Restaurar {count} filas", copyRows: "Copiar {count} filas (JSON)", + copyColumnsJson: "Copiar {count} columnas (JSON)", copyRowsInsert: "Copiar {count} filas como INSERT", copyRowsInsertWithoutPrimaryKeys: "Copiar {count} filas como INSERT sin claves primarias", copyRowsUpdate: "Copiar {count} filas como UPDATE", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 05eff8d5f7..9d00a3db0f 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -318,6 +318,7 @@ export default { copy: "复制", copyCell: "复制单元格", copyRow: "复制行 (JSON)", + copyColumnJson: "复制列 (JSON)", copyRowInsert: "复制为 INSERT 语句", copyRowInsertWithoutPrimaryKeys: "复制为 INSERT 语句(不含主键)", copyRowUpdate: "复制为 UPDATE 语句", @@ -433,6 +434,7 @@ export default { cloneRows: "克隆 {count} 行为新行", restoreRows: "恢复 {count} 行", copyRows: "复制 {count} 行 (JSON)", + copyColumnsJson: "复制 {count} 列 (JSON)", copyRowsInsert: "复制 {count} 行为 INSERT", copyRowsInsertWithoutPrimaryKeys: "复制 {count} 行为 INSERT(不含主键)", copyRowsUpdate: "复制 {count} 行为 UPDATE", diff --git a/apps/desktop/src/lib/gridSelection.ts b/apps/desktop/src/lib/gridSelection.ts index 34cc6d530d..ed722b6bcd 100644 --- a/apps/desktop/src/lib/gridSelection.ts +++ b/apps/desktop/src/lib/gridSelection.ts @@ -26,6 +26,39 @@ export function normalizeSelectionRange(anchor: CellPosition, focus: CellPositio }; } +export function rowSelectionRange( + rowIndex: number, + columnCount: number, + endRowIndex = rowIndex, +): CellSelectionRange | null { + if (rowIndex < 0 || endRowIndex < 0 || columnCount <= 0) return null; + return { + startRow: Math.min(rowIndex, endRowIndex), + endRow: Math.max(rowIndex, endRowIndex), + startCol: 0, + endCol: columnCount - 1, + }; +} + +export function columnSelectionRange( + rowCount: number, + colIndex: number, + endColIndex = colIndex, +): CellSelectionRange | null { + if (rowCount <= 0 || colIndex < 0 || endColIndex < 0) return null; + return { + startRow: 0, + endRow: rowCount - 1, + startCol: Math.min(colIndex, endColIndex), + endCol: Math.max(colIndex, endColIndex), + }; +} + +export function allCellsSelectionRange(rowCount: number, columnCount: number): CellSelectionRange | null { + if (rowCount <= 0 || columnCount <= 0) return null; + return { startRow: 0, endRow: rowCount - 1, startCol: 0, endCol: columnCount - 1 }; +} + export function isCellInSelection(rowIndex: number, colIndex: number, range: CellSelectionRange | null): boolean { if (!range) return false; return ( diff --git a/crates/dbx-core/src/table_structure_sql.rs b/crates/dbx-core/src/table_structure_sql.rs index 628a567d98..c584646a57 100644 --- a/crates/dbx-core/src/table_structure_sql.rs +++ b/crates/dbx-core/src/table_structure_sql.rs @@ -1392,6 +1392,8 @@ mod tests { table_name: "users".to_string(), columns: vec![renamed, email], indexes: vec![old_index, email_index], + table_comment: None, + original_table_comment: None, }); assert_eq!(result.warnings, Vec::::new()); @@ -1418,6 +1420,8 @@ mod tests { table_name: "users".to_string(), columns: vec![created_at], indexes: Vec::new(), + table_comment: None, + original_table_comment: None, }); assert_eq!(result.warnings, Vec::::new()); @@ -1439,6 +1443,8 @@ mod tests { table_name: "users".to_string(), columns: vec![created_at], indexes: Vec::new(), + table_comment: None, + original_table_comment: None, }); assert_eq!(result.warnings, Vec::::new()); @@ -1467,6 +1473,8 @@ mod tests { table_name: "users".to_string(), columns: vec![id, name], indexes: vec![idx], + table_comment: None, + original_table_comment: None, }); assert_eq!(result.warnings, Vec::::new()); @@ -1501,6 +1509,8 @@ mod tests { table_name: "users".to_string(), columns: vec![col], indexes: Vec::new(), + table_comment: None, + original_table_comment: None, }); assert_eq!(result.statements, Vec::::new()); @@ -1559,6 +1569,8 @@ mod tests { table_name: "users".to_string(), columns: vec![id, email, name], indexes: Vec::new(), + table_comment: None, + original_table_comment: None, }); assert_eq!(result.warnings, Vec::::new()); @@ -1583,6 +1595,8 @@ mod tests { table_name: "users".to_string(), columns: vec![email], indexes: vec![index("idx_users_email", &["email"])], + table_comment: None, + original_table_comment: None, }); assert_eq!(result.warnings, Vec::::new()); @@ -1610,6 +1624,8 @@ mod tests { table_name: "events".to_string(), columns: vec![name, created_at], indexes: vec![index("idx_events_name", &["name"])], + table_comment: None, + original_table_comment: None, }); assert_eq!(result.warnings, Vec::::new()); @@ -1648,6 +1664,8 @@ mod tests { table_name: "events".to_string(), columns: vec![source, status], indexes: Vec::new(), + table_comment: None, + original_table_comment: None, }); assert_eq!(result.warnings, Vec::::new()); @@ -1687,6 +1705,8 @@ mod tests { table_name: "USERS".to_string(), columns: vec![name], indexes: vec![index("IDX_USERS_DISPLAY_NAME", &["DISPLAY_NAME"])], + table_comment: None, + original_table_comment: None, }); assert_eq!(result.warnings, Vec::::new()); @@ -1725,6 +1745,8 @@ mod tests { table_name: "users".to_string(), columns: vec![id], indexes: Vec::new(), + table_comment: None, + original_table_comment: None, }); assert_eq!(result.warnings, Vec::::new()); @@ -1753,6 +1775,8 @@ mod tests { table_name: "users".to_string(), columns: vec![id], indexes: Vec::new(), + table_comment: None, + original_table_comment: None, }); assert_eq!(result.warnings, Vec::::new()); @@ -1797,6 +1821,8 @@ mod tests { table_name: "users".to_string(), columns: vec![old_pk, new_pk], indexes: Vec::new(), + table_comment: None, + original_table_comment: None, }); assert_eq!(result.warnings, Vec::::new()); @@ -1828,6 +1854,8 @@ mod tests { table_name: "users".to_string(), columns: vec![id], indexes: Vec::new(), + table_comment: None, + original_table_comment: None, }); assert_eq!(result.warnings, Vec::::new()); @@ -1856,6 +1884,8 @@ mod tests { table_name: "users".to_string(), columns: vec![id], indexes: Vec::new(), + table_comment: None, + original_table_comment: None, }); assert_eq!(result.statements, Vec::::new()); diff --git a/packages/app-tests/dataGridSelectionShortcuts.test.ts b/packages/app-tests/dataGridSelectionShortcuts.test.ts new file mode 100644 index 0000000000..d8c6e2d150 --- /dev/null +++ b/packages/app-tests/dataGridSelectionShortcuts.test.ts @@ -0,0 +1,46 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +const source = readFileSync(new URL("../../apps/desktop/src/components/grid/DataGrid.vue", import.meta.url), "utf8"); +const selectionSource = readFileSync( + new URL("../../apps/desktop/src/composables/useDataGridSelection.ts", import.meta.url), + "utf8", +); + +test("data grid wires whole table, row, and column selection gestures", () => { + assert.match(source, /@click="selectAllCells"/); + assert.match(source, /@click="selectColumn\(colIdx, \$event\)"/); + assert.match(source, /columnIsSelected\(colIdx\)/); + assert.match(selectionSource, /function selectAllCells\(\)/); + assert.match(selectionSource, /function selectColumn\(colIndex: number, event\?: MouseEvent\)/); + assert.match(selectionSource, /lastClickedColumnIndex/); +}); + +test("data grid intercepts copy and select-all shortcuts for grid selections", () => { + assert.match(source, /clipboardShortcut\(event, "a"\)/); + assert.match(source, /selectAllCells\(\)/); + assert.match(source, /isTransposeMode\.value && hasRowSelection\.value/); + assert.match(source, /copyRow\(\);/); + assert.match(source, /if \(hasCellSelection\.value\) \{\s+copySelectionTsv\(\);/); + assert.match(source, /copySelectedRowsTsv\(\)/); +}); + +test("transpose cells reuse grid cell selection and details", () => { + assert.match(source, /function selectTransposeCell\(rowIndex: number, actualColIdx: number, event: MouseEvent\)/); + assert.match(source, /transposeCellIsSelected\(cell\.recordIndex, cell\.valueIndex\)/); + assert.match(source, /@click="selectTransposeCell\(cell\.recordIndex, cell\.valueIndex, \$event\)"/); + assert.match(source, /@contextmenu="onTransposeCellContext\(cell\.recordIndex, cell\.valueIndex, \$event\)"/); + assert.match(source, /showCellDetails\(cell\.recordIndex, cell\.valueIndex\)/); +}); + +test("transpose record headers copy selected records as rows", () => { + assert.match(source, /function selectTransposeRecord\(rowIndex: number, event\?: MouseEvent\)/); + assert.match(source, /handleRowClick\(rowIndex, item\.id, event\)/); + assert.match(source, /@click="selectTransposeRecord\(recordIndex, \$event\)"/); + assert.match(source, /@contextmenu="selectTransposeRecord\(recordIndex, \$event\)"/); + assert.match(source, /copyRowJsonLabel/); + assert.match(source, /if \(isTransposeMode\.value\)/); + assert.match(source, /grid\.copyColumnJson/); + assert.match(source, /grid\.copyColumnsJson/); +}); diff --git a/packages/app-tests/dataGridTransposeSource.test.ts b/packages/app-tests/dataGridTransposeSource.test.ts index 961d624dd8..115dfdbad5 100644 --- a/packages/app-tests/dataGridTransposeSource.test.ts +++ b/packages/app-tests/dataGridTransposeSource.test.ts @@ -27,3 +27,12 @@ test("transpose mode follows appended rows and survives rollback refresh", () => ); assert.match(source, /nextTransposeStateForRecordCount/); }); + +test("closing transpose scrolls the normal grid to the active record", () => { + assert.match(source, /function currentTransposeViewportRowIndex\(\)/); + assert.match(source, /transposeRowIndex\.value \?\? transposeRecordWindow\.value\.start/); + assert.match(source, /function scrollGridRowIntoView\(rowIndex: number\)/); + assert.match(source, /scrollToItem\?\.\(target\)/); + assert.match(source, /function closeTranspose\(scrollToCurrentRecord = true\)/); + assert.match(source, /closeTranspose\(false\)/); +}); diff --git a/packages/app-tests/gridSelection.test.ts b/packages/app-tests/gridSelection.test.ts index 242aa130c4..4b5c43edcf 100644 --- a/packages/app-tests/gridSelection.test.ts +++ b/packages/app-tests/gridSelection.test.ts @@ -1,6 +1,8 @@ import { strict as assert } from "node:assert"; import test from "node:test"; import { + allCellsSelectionRange, + columnSelectionRange, extractSelection, formatSelectionAsCsv, formatSelectionAsJson, @@ -8,13 +10,11 @@ import { formatSelectionAsTsv, isCellInSelection, normalizeSelectionRange, + rowSelectionRange, } from "../../apps/desktop/src/lib/gridSelection.ts"; test("normalizes a dragged cell range in either direction", () => { - const range = normalizeSelectionRange( - { rowIndex: 4, colIndex: 3 }, - { rowIndex: 1, colIndex: 0 }, - ); + const range = normalizeSelectionRange({ rowIndex: 4, colIndex: 3 }, { rowIndex: 1, colIndex: 0 }); assert.deepEqual(range, { startRow: 1, @@ -44,25 +44,43 @@ test("extracts selection rows and columns from a rectangular range", () => { ]); }); +test("builds whole row, column, and table selection ranges", () => { + assert.deepEqual(rowSelectionRange(2, 4), { startRow: 2, endRow: 2, startCol: 0, endCol: 3 }); + assert.deepEqual(rowSelectionRange(4, 4, 2), { startRow: 2, endRow: 4, startCol: 0, endCol: 3 }); + assert.deepEqual(columnSelectionRange(5, 1), { startRow: 0, endRow: 4, startCol: 1, endCol: 1 }); + assert.deepEqual(columnSelectionRange(5, 3, 1), { startRow: 0, endRow: 4, startCol: 1, endCol: 3 }); + assert.deepEqual(allCellsSelectionRange(3, 2), { startRow: 0, endRow: 2, startCol: 0, endCol: 1 }); + assert.equal(rowSelectionRange(0, 0), null); + assert.equal(columnSelectionRange(0, 0), null); + assert.equal(allCellsSelectionRange(0, 2), null); +}); + test("formats selected cells as TSV, CSV, JSON, and SQL values", () => { const selection = { columns: ["name", "note"], rows: [ ["Ada", "math"], - ["Bob", "quote \"here\""], + ["Bob", 'quote "here"'], ["O'Hara", null], ], }; - assert.equal(formatSelectionAsTsv(selection), "Ada\tmath\nBob\tquote \"here\"\nO'Hara\tNULL"); - assert.equal(formatSelectionAsCsv(selection), "\"name\",\"note\"\n\"Ada\",\"math\"\n\"Bob\",\"quote \"\"here\"\"\"\n\"O'Hara\",\"NULL\""); + assert.equal(formatSelectionAsTsv(selection), 'Ada\tmath\nBob\tquote "here"\nO\'Hara\tNULL'); + assert.equal( + formatSelectionAsCsv(selection), + '"name","note"\n"Ada","math"\n"Bob","quote ""here"""\n"O\'Hara","NULL"', + ); assert.equal( formatSelectionAsJson(selection), - JSON.stringify([ - { name: "Ada", note: "math" }, - { name: "Bob", note: "quote \"here\"" }, - { name: "O'Hara", note: null }, - ], null, 2), + JSON.stringify( + [ + { name: "Ada", note: "math" }, + { name: "Bob", note: 'quote "here"' }, + { name: "O'Hara", note: null }, + ], + null, + 2, + ), ); assert.equal(formatSelectionAsSqlInList(selection), "('Ada', 'math', 'Bob', 'quote \"here\"', 'O''Hara', NULL)"); });