diff --git a/src/components/grid/DataGrid.vue b/src/components/grid/DataGrid.vue index 3d0f9da3f..c25f883fe 100644 --- a/src/components/grid/DataGrid.vue +++ b/src/components/grid/DataGrid.vue @@ -7,7 +7,7 @@ const globalDdlOpen = ref(false); import { computed, nextTick, onUnmounted, watch } from "vue"; import { useElementSize } from "@vueuse/core"; import { useI18n } from "vue-i18n"; -import { ArrowUp, ArrowDown, Download, Plus, Trash2, Save, ChevronLeft, ChevronRight, Search, Inbox, SearchX, Code2, Copy, Loader2, X, Undo2, WrapText } from "lucide-vue-next"; +import { ArrowUp, ArrowDown, Download, Plus, Trash2, Save, ChevronLeft, ChevronRight, Search, Inbox, SearchX, Code2, Copy, Loader2, X, Undo2, WrapText, Info } from "lucide-vue-next"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { @@ -23,6 +23,17 @@ import type { QueryResult, ColumnInfo, DatabaseType } from "@/types/database"; import { save as savePath } from "@tauri-apps/plugin-dialog"; import { writeTextFile } from "@tauri-apps/plugin-fs"; import * as api from "@/lib/tauri"; +import { + extractSelection, + formatSelectionAsCsv, + formatSelectionAsJson, + formatSelectionAsSqlInList, + formatSelectionAsTsv, + isCellInSelection, + normalizeSelectionRange, + type CellPosition, + type CellSelectionRange, +} from "@/lib/gridSelection"; import { useToast } from "@/composables/useToast"; @@ -102,7 +113,12 @@ function typeColorClass(t: string): string { if (["bytea", "blob", "binary", "varbinary", "image"].includes(s)) return "text-red-400"; return "text-muted-foreground"; } -const contextCell = ref<{ rowId: number; col: number } | null>(null); +const contextCell = ref<{ rowId: number; rowIndex: number; col: number } | null>(null); +const selectionAnchor = ref(null); +const selectionFocus = ref(null); +const isSelectingCells = ref(false); +const detailCell = ref<{ rowIndex: number; col: number } | null>(null); +const showCellDetail = ref(false); const sortCol = ref(null); const sortDir = ref<"asc" | "desc">("asc"); const searchText = ref(""); @@ -257,6 +273,46 @@ const displayItems = computed(() => { const hasVisibleRows = computed(() => displayItems.value.length > 0); const emptyTitle = computed(() => searchText.value ? t('grid.noSearchResults') : t('grid.noRows')); const emptyDescription = computed(() => searchText.value ? t('grid.noSearchResultsDescription') : t('grid.noRowsDescription')); +const selectedRange = computed(() => { + if (!selectionAnchor.value || !selectionFocus.value) return null; + return normalizeSelectionRange(selectionAnchor.value, selectionFocus.value); +}); +const visibleSelectionRows = computed(() => displayItems.value.map((item) => item.data)); +const selectedCells = computed(() => extractSelection(props.result.columns, visibleSelectionRows.value, selectedRange.value)); +const selectedCellCount = computed(() => selectedCells.value.columns.length * selectedCells.value.rows.length); +const hasCellSelection = computed(() => selectedCellCount.value > 0); +const selectionSummary = computed(() => t("grid.selectedCells", { count: selectedCellCount.value })); +const activeCellDetail = computed(() => { + const cell = detailCell.value; + if (!cell) return null; + const item = displayItems.value[cell.rowIndex]; + const column = props.result.columns[cell.col]; + if (!item || !column) return null; + const value = item.data[cell.col] ?? null; + const rawValue = formatCell(value); + const valueText = value === null ? "" : String(value); + const trimmed = valueText.trim(); + const maybeJson = typeof value === "string" && (trimmed.startsWith("{") || trimmed.startsWith("[")); + let formattedJson = ""; + if (maybeJson) { + try { + formattedJson = JSON.stringify(JSON.parse(value), null, 2); + } catch { + formattedJson = ""; + } + } + return { + rowNumber: cell.rowIndex + 1, + colIndex: cell.col, + column, + type: columnTypeMap.value.get(column) || "", + comment: columnCommentMap.value.get(column) || "", + value, + rawValue, + length: value === null ? 0 : String(value).length, + formattedJson, + }; +}); function toggleSort(colName: string) { if (isResizing) return; @@ -574,16 +630,111 @@ function discardChanges() { editingCell.value = null; } +// --- Cell selection and detail --- +function clearCellSelection() { + selectionAnchor.value = null; + selectionFocus.value = null; + isSelectingCells.value = false; +} + +function selectSingleCell(rowIndex: number, colIndex: number) { + const cell = { rowIndex, colIndex }; + selectionAnchor.value = cell; + selectionFocus.value = cell; +} + +function finishCellSelection() { + isSelectingCells.value = false; + document.removeEventListener("mouseup", finishCellSelection); +} + +function beginCellSelection(rowIndex: number, colIndex: number, event: MouseEvent) { + if (event.button !== 0) return; + if (editingCell.value) return; + event.preventDefault(); + selectSingleCell(rowIndex, colIndex); + isSelectingCells.value = true; + document.addEventListener("mouseup", finishCellSelection); +} + +function extendCellSelection(rowIndex: number, colIndex: number) { + if (!isSelectingCells.value || !selectionAnchor.value) return; + selectionFocus.value = { rowIndex, colIndex }; +} + +function cellIsSelected(rowIndex: number, colIndex: number): boolean { + return isCellInSelection(rowIndex, colIndex, selectedRange.value); +} + +function showCellDetails(rowIndex: number, colIndex: number) { + detailCell.value = { rowIndex, col: colIndex }; + showCellDetail.value = true; +} + +function copyText(text: string) { + navigator.clipboard.writeText(text); + toast(t('grid.copied')); +} + +function copySelectionTsv() { + if (!hasCellSelection.value) return; + copyText(formatSelectionAsTsv(selectedCells.value)); +} + +function copySelectionCsv() { + if (!hasCellSelection.value) return; + copyText(formatSelectionAsCsv(selectedCells.value)); +} + +function copySelectionJson() { + if (!hasCellSelection.value) return; + copyText(formatSelectionAsJson(selectedCells.value)); +} + +function copySelectionSqlInList() { + if (!hasCellSelection.value) return; + copyText(formatSelectionAsSqlInList(selectedCells.value)); +} + +function copyDetailValue() { + if (!activeCellDetail.value) return; + copyText(activeCellDetail.value.rawValue); +} + +function copyDetailColumnName() { + if (!activeCellDetail.value) return; + copyText(activeCellDetail.value.column); +} + +function copyDetailSqlCondition() { + const detail = activeCellDetail.value; + if (!detail) return; + const column = quoteIdent(detail.column); + const condition = detail.value === null + ? `${column} IS NULL` + : `${column} = ${escapeVal(detail.value)}`; + copyText(condition); +} + +watch(() => props.result, () => { + clearCellSelection(); + showCellDetail.value = false; + detailCell.value = null; +}); + // --- Copy/Export --- -function onCellContext(rowId: number, colIdx: number) { - contextCell.value = { rowId, col: colIdx }; +function onCellContext(rowId: number, rowIndex: number, colIdx: number) { + contextCell.value = { rowId, rowIndex, col: colIdx }; + if (!cellIsSelected(rowIndex, colIdx)) { + selectSingleCell(rowIndex, colIdx); + } } function copyCell() { if (!contextCell.value) return; const item = getRowItem(contextCell.value.rowId); const val = item?.data[contextCell.value.col] ?? null; - navigator.clipboard.writeText(formatCell(val)); + copyText(formatCell(val)); } function copyRow() { @@ -592,7 +743,7 @@ function copyRow() { if (!item) return; const obj: Record = {}; props.result.columns.forEach((col, i) => { obj[col] = item.data[i]; }); - navigator.clipboard.writeText(JSON.stringify(obj, null, 2)); + copyText(JSON.stringify(obj, null, 2)); } function copyAll() { @@ -600,7 +751,7 @@ function copyAll() { const body = sortedRows.value .map(({ row, sourceIndex }) => rowDataWithChanges(row, sourceIndex).map((c) => formatCell(c)).join("\t")) .join("\n"); - navigator.clipboard.writeText(`${header}\n${body}`); + copyText(`${header}\n${body}`); } async function exportCsv() { @@ -721,7 +872,10 @@ function onDdlResizeEnd() { window.removeEventListener("mouseup", onDdlResizeEnd); } -onUnmounted(onDdlResizeEnd); +onUnmounted(() => { + onDdlResizeEnd(); + finishCellSelection(); +}); 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; @@ -879,17 +1033,20 @@ function escapeAndHighlightKeywords(s: string): string {