diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index 8125ff3b4..e7c11ddc7 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -179,9 +179,11 @@ import { removeAutoHiddenColumnIndexes, visibleColumnIndexesForFilter, } from "@/lib/dataGridColumnVisibility"; +import { parseClipboardTable } from "@/lib/gridSelection"; import { useToast } from "@/composables/useToast"; import { useDataGridExport } from "@/composables/useDataGridExport"; +import { readTextFromClipboard } from "@/lib/clipboard"; import ExportProgressDialog from "@/components/export/ExportProgressDialog.vue"; import { DATA_GRID_ROW_NUM_WIDTH, useDataGridColumnResize } from "@/composables/useDataGridColumnResize"; import { useDataGridSelection } from "@/composables/useDataGridSelection"; @@ -406,6 +408,8 @@ function typeColorClass(t: string): string { const contextCell = ref<{ rowId: number; rowIndex: number; col: number } | null>(null); const contextHeaderColumn = ref(null); const contextHeaderColumnIndex = ref(null); +const bulkEditDialogOpen = ref(false); +const bulkEditValue = ref(""); const detailCell = ref<{ rowIndex: number; col: number } | null>(null); const hoveredDetailCell = ref<{ rowIndex: number; col: number } | null>(null); const quickDownloadMenuCell = ref<{ rowIndex: number; col: number } | null>(null); @@ -1791,10 +1795,9 @@ const isResultsContext = computed(() => props.context === "results"); const showQueryEditReadyBadge = computed( () => isResultsContext.value && hasData.value && !!props.editable && !!props.tableMeta, ); +const canShowWhereSearch = computed(() => !!props.onExecuteSql && !isResultsContext.value); const canUseWhereSearch = computed(() => !!props.tableMeta && !!props.onExecuteSql && !isResultsContext.value); -const tableUsesSyntheticRowId = computed(() => - usesSyntheticRowIdKey(props.databaseType, props.tableMeta?.primaryKeys ?? []), -); +type DataGridTableMeta = NonNullable; const hiveTableTransactional = ref(undefined); const canEditExistingRows = computed( () => @@ -2026,7 +2029,7 @@ const showDataGridTopbar = computed( () => (useTransaction.value && !!props.editable && (!!props.tableMeta || !!props.customSave)) || hasLocalColumnFilters.value || - canUseWhereSearch.value || + canShowWhereSearch.value || hasSearchBarSlot.value || showQueryEditReadyBadge.value || props.context !== "results" || @@ -2337,8 +2340,10 @@ const { columnIsSelected, selectedRangeStart, selectedRowIds, + selectedColumnIndexes, hasRowSelection, selectedRowCount, + hasColumnSelection, clearRowSelection, handleRowClick, handleDataCellMousedown, @@ -2901,8 +2906,29 @@ async function clearContextFilter() { await clearAllFilters(); } +function waitForTableMeta(timeoutMs = 2500): Promise { + if (props.tableMeta) return Promise.resolve(props.tableMeta); + return new Promise((resolve) => { + let stop: (() => void) | undefined; + const timer = window.setTimeout(() => { + stop?.(); + resolve(null); + }, timeoutMs); + stop = watch( + () => props.tableMeta, + (tableMeta) => { + if (!tableMeta) return; + window.clearTimeout(timer); + stop?.(); + resolve(tableMeta); + }, + { flush: "sync" }, + ); + }); +} + async function applyOrderBySearch() { - if (!props.tableMeta || !props.onExecuteSql) return; + if (!props.onExecuteSql) return; const orderByClause = orderByInput.value.trim() || undefined; emit("update:orderByInput", orderByInput.value); isApplyingWhere.value = true; @@ -2912,16 +2938,18 @@ async function applyOrderBySearch() { sortColIndex.value = null; sortDir.value = "asc"; try { + const tableMeta = await waitForTableMeta(); + if (!tableMeta) return; const sql = await buildTableSelectSql({ databaseType: props.databaseType, - schema: props.tableMeta.schema, - tableName: props.tableMeta.tableName, - columns: props.tableMeta.columns.map((column) => column.name), - primaryKeys: props.tableMeta.primaryKeys, + schema: tableMeta.schema, + tableName: tableMeta.tableName, + columns: tableMeta.columns.map((column) => column.name), + primaryKeys: tableMeta.primaryKeys, orderBy: orderByClause, limit: pageSize.value, whereInput: currentWhereInput(), - includeRowId: tableUsesSyntheticRowId.value, + includeRowId: usesSyntheticRowIdKey(props.databaseType, tableMeta.primaryKeys), }); await props.onExecuteSql(sql); } catch (e: any) { @@ -2932,23 +2960,25 @@ async function applyOrderBySearch() { } async function applyWhereFilter() { - if (!props.tableMeta || !props.onExecuteSql) return; + if (!props.onExecuteSql) return; isApplyingWhere.value = true; saveError.value = ""; currentPage.value = 1; try { + const tableMeta = await waitForTableMeta(); + if (!tableMeta) return; const sql = await buildTableSelectSql({ databaseType: props.databaseType, - schema: props.tableMeta.schema, - tableName: props.tableMeta.tableName, - columns: props.tableMeta.columns.map((column) => column.name), - primaryKeys: props.tableMeta.primaryKeys, + schema: tableMeta.schema, + tableName: tableMeta.tableName, + columns: tableMeta.columns.map((column) => column.name), + primaryKeys: tableMeta.primaryKeys, orderBy: orderByInput.value.trim() || (sortCol.value ? `${queryColumnRef(sortCol.value)} ${sortDir.value.toUpperCase()}` : undefined), limit: pageSize.value, whereInput: currentWhereInput(), - includeRowId: tableUsesSyntheticRowId.value, + includeRowId: usesSyntheticRowIdKey(props.databaseType, tableMeta.primaryKeys), }); await props.onExecuteSql(sql); } catch (e: any) { @@ -3727,29 +3757,125 @@ function clipboardShortcut(event: KeyboardEvent, key: string): boolean { return (event.metaKey || event.ctrlKey) && !event.altKey && event.key.toLowerCase() === key; } -function parseClipboardTable(text: string): string[][] { - const normalized = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").replace(/\n$/, ""); - if (!normalized) return [[""]]; - return normalized.split("\n").map((row) => row.split("\t")); -} +let lastPasteEventAt = 0; async function pasteClipboardIntoSelection() { if (!props.editable) return; - const start = selectedRangeStart(); - if (!start) return; + const text = await readTextFromClipboard(); + pasteTextIntoSelection(text); +} - const text = await navigator.clipboard.readText(); +function pasteTextIntoSelection(text: string): boolean { const rows = parseClipboardTable(text); + + if (rows.length === 1 && rows[0]?.length === 1 && fillSelectionWithValue(rows[0][0])) { + toast(t("grid.pasted")); + return true; + } + + const start = pasteStartCell(); + if (!start) return false; + let applied = false; rows.forEach((row, rowOffset) => { const item = displayItems.value[start.rowIndex + rowOffset]; if (!item) return; row.forEach((value, colOffset) => { const visibleCol = start.colIndex + colOffset; if (visibleCol >= visibleColumns.value.length) return; - applyCellValue(item.id, actualColumnIndex(visibleCol), value); + applied = applyVisibleCellValue(item, visibleCol, value) || applied; }); }); - toast(t("grid.pasted")); + if (applied) toast(t("grid.pasted")); + return applied; +} + +function onGridPaste(event: ClipboardEvent) { + if (!props.editable || (!selectedRange.value && !hasColumnSelection.value)) return; + const text = event.clipboardData?.getData("text/plain"); + if (text === undefined) return; + event.preventDefault(); + lastPasteEventAt = Date.now(); + pasteTextIntoSelection(text); +} + +function pasteStartCell() { + const start = selectedRangeStart(); + if (start) return start; + if (!hasColumnSelection.value) return null; + const firstCol = selectedVisibleColumnIndexes()[0]; + return firstCol === undefined ? null : { rowIndex: 0, colIndex: firstCol }; +} + +function selectedVisibleColumnIndexes(): number[] { + return [...selectedColumnIndexes.value] + .filter((index) => index >= 0 && index < visibleColumns.value.length) + .sort((a, b) => a - b); +} + +function applyVisibleCellValue(item: RowItem, visibleCol: number, value: string | null): boolean { + const actualCol = actualColumnIndex(visibleCol); + if (!canEditCellItem(item, actualCol)) return false; + applyCellValue(item.id, actualCol, value); + return true; +} + +function fillSelectionWithValue(value: string): boolean { + const range = selectedRange.value; + let applied = false; + if (range) { + for (let rowIndex = range.startRow; rowIndex <= range.endRow; rowIndex++) { + const item = displayItems.value[rowIndex]; + if (!item) continue; + for (let visibleCol = range.startCol; visibleCol <= range.endCol; visibleCol++) { + applied = applyVisibleCellValue(item, visibleCol, value) || applied; + } + } + return applied; + } + + if (!hasColumnSelection.value) return false; + const visibleColumnIndexes = selectedVisibleColumnIndexes(); + if (!visibleColumnIndexes.length) return false; + for (const item of displayItems.value) { + for (const visibleCol of visibleColumnIndexes) { + applied = applyVisibleCellValue(item, visibleCol, value) || applied; + } + } + return applied; +} + +function selectionHasEditableCells(): boolean { + const range = selectedRange.value; + if (range) { + for (let rowIndex = range.startRow; rowIndex <= range.endRow; rowIndex++) { + const item = displayItems.value[rowIndex]; + if (!item) continue; + for (let visibleCol = range.startCol; visibleCol <= range.endCol; visibleCol++) { + if (canEditCellItem(item, actualColumnIndex(visibleCol))) return true; + } + } + return false; + } + + if (!hasColumnSelection.value) return false; + const visibleColumnIndexes = selectedVisibleColumnIndexes(); + for (const item of displayItems.value) { + for (const visibleCol of visibleColumnIndexes) { + if (canEditCellItem(item, actualColumnIndex(visibleCol))) return true; + } + } + return false; +} + +function openBulkEditDialog() { + if (!props.editable || !selectionHasEditableCells()) return; + bulkEditValue.value = ""; + bulkEditDialogOpen.value = true; +} + +function applyBulkEditValue() { + if (!fillSelectionWithValue(bulkEditValue.value)) return; + bulkEditDialogOpen.value = false; } function cutSelection() { @@ -4018,9 +4144,15 @@ async function onGridKeydown(event: KeyboardEvent) { return; } if (clipboardShortcut(event, "v")) { - if (!props.editable || !selectedRange.value) return; - event.preventDefault(); - await pasteClipboardIntoSelection(); + if (!props.editable || (!selectedRange.value && !hasColumnSelection.value)) return; + const keydownAt = Date.now(); + window.setTimeout(() => { + if (lastPasteEventAt >= keydownAt) return; + pasteClipboardIntoSelection().catch((e) => + toast(t("grid.copyFailed", { message: e?.message || String(e) }), 5000), + ); + }, 50); + return; } } @@ -4454,8 +4586,10 @@ watch( // --- Context menu handlers --- function onHeaderContext(col: string, columnIndex: number) { contextCell.value = null; - clearCellSelection(); - clearRowSelection(); + const visibleColIdx = visibleColumnIndexes.value.indexOf(columnIndex); + if (visibleColIdx >= 0 && !columnIsSelected(visibleColIdx)) { + selectColumn(visibleColIdx); + } contextHeaderColumn.value = col; contextHeaderColumnIndex.value = columnIndex; } @@ -5167,6 +5301,15 @@ const gridContextMenuItems = computed(() => { items.push(copySubmenu()); } + if (props.editable && hasCellSelection.value) { + items.push({ + label: t("grid.bulkEditSelection"), + action: openBulkEditDialog, + disabled: !selectionHasEditableCells(), + icon: Pencil, + }); + } + // 5. Transpose if (contextCell.value) { items.push({ label: t("grid.transpose"), action: openContextTranspose, icon: Rows3 }); @@ -5219,10 +5362,11 @@ const gridContextMenuItems = computed(() => { :style="gridStyle" tabindex="0" @keydown="onGridKeydown" + @paste="onGridPaste" >
@@ -5273,7 +5417,7 @@ const gridContextMenuItems = computed(() => {
-