diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index 59dd5c1652..04a7e51676 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -69,6 +69,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigge import { Input } from "@/components/ui/input"; import QueryLoadingState from "@/components/common/QueryLoadingState.vue"; import DataGridBusyOverlay from "@/components/grid/DataGridBusyOverlay.vue"; +import { DATA_GRID_CLIPBOARD_BATCH_CHARS, DataGridClipboardCapacityError, dataGridPendingRowLimit, dataGridPreparationBatchSize, type DataGridRowPreparationOptions, type DataGridRowPreparationProgress } from "@/lib/dataGrid/dataGridRowPreparation"; import ProductionWatermark from "@/components/common/ProductionWatermark.vue"; import CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomContextMenu.vue"; import LightDropdownMenu from "@/components/ui/LightDropdownMenu.vue"; @@ -332,8 +333,8 @@ import { useNavigationTargets } from "@/composables/useNavigationTargets"; import { useDataGridExport, type MongoCopyUpdateTarget } from "@/composables/useDataGridExport"; import { isTauriRuntime } from "@/lib/backend/tauriRuntime"; import { eventTargetAllowsNativeClipboard, isPlainClipboardShortcut, readTextFromClipboard } from "@/lib/common/clipboard"; -import { claimDataGridPaste, claimDataGridSelectAll, clearDataGridClipboardCopy, parseDataGridClipboard, planDataGridPaste } from "@/lib/dataGrid/dataGridClipboard"; -import { parseInsertStatementPaste } from "@/lib/dataGrid/dataGridInsertPaste"; +import { claimDataGridPaste, claimDataGridSelectAll, clearDataGridClipboardCopy, parseDataGridClipboard, parseDataGridClipboardInBatches, planDataGridPaste } from "@/lib/dataGrid/dataGridClipboard"; +import { parseInsertStatementPaste, parseInsertStatementPasteInBatches } from "@/lib/dataGrid/dataGridInsertPaste"; import { beginDataGridNativeSelectionBlock, finishDataGridNativeSelectionBlock } from "@/lib/dataGrid/dataGridNativeSelection"; import { DATA_GRID_COPY_EXTRACTOR_DESCRIPTORS, DATA_GRID_COPY_EXTRACTOR_IDS, DATA_GRID_DEFAULT_COPY_PREFERENCES, extractorUnavailableForDatabase, type DataGridCopyExtractorId, type DataGridCopyPreference } from "@/lib/dataGrid/dataGridCopyExtractor"; import { columnNamesForCopy } from "@/lib/dataGrid/dataGridColumnNameCopy"; @@ -3577,8 +3578,12 @@ const canCountTableRows = computed(() => props.context !== "results" && !!props. const canJumpLastPage = computed(() => canGoNextPage.value && (hasKnownPaginationTotalRowCount.value || allRowsLoaded.value || canCalculateTotalRowCount.value)); const totalRowCountBusy = computed(() => props.totalRowCountLoading === true || manualTotalRowCountLoading.value); const pageJumpBusy = computed(() => !!props.pageJumpProgress && props.pageJumpProgress.totalRequests > 1); +const rowPreparationProgress = shallowRef(null); +let rowPreparationController: AbortController | null = null; +let gridClipboardLifecycle = 0; +let gridClipboardActive = true; /** Automatic background counts keep rows interactive; explicit count navigation still blocks the surface. */ -const gridSurfaceBusy = computed(() => isRefreshingData.value || props.loading === true || manualTotalRowCountLoading.value || pageJumpBusy.value); +const gridSurfaceBusy = computed(() => isRefreshingData.value || props.loading === true || manualTotalRowCountLoading.value || pageJumpBusy.value || rowPreparationProgress.value !== null); const gridPaginationBusy = computed(() => gridSurfaceBusy.value || totalRowCountBusy.value); const dataGridNativeSelectionBlockOwner = {}; watch( @@ -4367,9 +4372,11 @@ const { restoreCellValue, cancelEdit, onEditKeydown, - addRows: addEditorRows, + addRowsInBatches: addEditorRowsInBatches, + availableInsertRows, appendPastedRowsToNewRow, - appendPastedRowsAsNewRows, + appendPastedRowsToNewRowInBatches, + appendPastedRowsAsNewRowsInBatches, cloneRow: cloneEditorRow, showDeleteRowConfirm, requestDeleteRow, @@ -4946,9 +4953,37 @@ function selectedRowPlacement(position: "above" | "below"): GridNewRowPlacement return null; } -function insertRows(count: number, position: "above" | "below" | "end") { +async function prepareGridRows(prepare: (options: DataGridRowPreparationOptions) => Promise): Promise { + if (rowPreparationController || isSaving.value || !gridClipboardActive) return undefined; + const controller = new AbortController(); + rowPreparationController = controller; + const operation = dataGridResultLifecycle.beginOperation(); + const pendingVersion = pendingChangesVersion.value; + try { + return await prepare({ + signal: controller.signal, + isCurrent: () => gridClipboardActive && dataGridResultLifecycle.isCurrent(operation) && pendingChangesVersion.value === pendingVersion, + onProgress: (progress) => { + rowPreparationProgress.value = progress; + }, + }); + } finally { + rowPreparationController = null; + rowPreparationProgress.value = null; + } +} + +function cancelRowPreparation() { + rowPreparationController?.abort(); +} + +function onRowPreparationOpenChange(open: boolean) { + if (!open) cancelRowPreparation(); +} + +async function insertRows(count: number, position: "above" | "below" | "end") { const placement: GridNewRowPlacement | null = position === "end" ? null : selectedRowPlacement(position); - const firstNewRowId = addEditorRows(count, placement); + const firstNewRowId = await prepareGridRows((options) => addEditorRowsInBatches(count, placement, options)); if (firstNewRowId !== undefined) { nextTick(() => { const displayIndex = displayRowIndexById(firstNewRowId); @@ -9132,22 +9167,32 @@ function clipboardShortcut(event: KeyboardEvent, key: string): boolean { async function pasteClipboardIntoSelection() { if (!props.editable) return; const operation = dataGridResultLifecycle.beginOperation(); + const lifecycle = gridClipboardLifecycle; const text = await readTextFromClipboard(); - if (!dataGridResultLifecycle.isCurrent(operation)) return; - pasteTextIntoGrid(text); + if (!gridClipboardActive || lifecycle !== gridClipboardLifecycle || !dataGridResultLifecycle.isCurrent(operation)) return; + await pasteTextIntoGrid(text); } async function pasteClipboardAsNewRows() { if (!canInsertRows.value || isSaving.value || isConditionalUpdateActive.value) return; const operation = dataGridResultLifecycle.beginOperation(); + const lifecycle = gridClipboardLifecycle; // Capture the column mapping with the result, before the asynchronous read. const columnIndexes = [...visibleColumnIndexes.value]; try { const text = await readTextFromClipboard(); - if (!dataGridResultLifecycle.isCurrent(operation) || !canInsertRows.value || isSaving.value || isConditionalUpdateActive.value) return; - const insertPaste = parseInsertStatementPaste(text); + if (!gridClipboardActive || lifecycle !== gridClipboardLifecycle || !dataGridResultLifecycle.isCurrent(operation) || !canInsertRows.value || isSaving.value || isConditionalUpdateActive.value) return; + if (!clipboardWithinGridCapacity(text)) return; const firstNewRowId = -(newRows.value.length + 1); - const result = appendPastedRowsAsNewRows(insertPaste?.rows ?? parseDataGridClipboard(text), columnIndexes, insertPaste?.columnNames); + const limits = { maxRows: availableInsertRows.value }; + const result = await prepareGridRows(async (options) => { + const insertPaste = await parseInsertStatementPasteInBatches(text, options, limits); + if (options.signal?.aborted || options.isCurrent?.() === false) return undefined; + const rows = insertPaste?.rows ?? (await parseDataGridClipboardInBatches(text, options, limits)); + if (!rows) return undefined; + return appendPastedRowsAsNewRowsInBatches(rows, columnIndexes, insertPaste?.columnNames, options); + }); + if (!result || (!result.ok && result.reason === "cancelled")) return; if (!result.ok) { toast(batchAppendPasteError(result.reason), 5000); return; @@ -9159,7 +9204,7 @@ async function pasteClipboardAsNewRows() { }); focusInsertedTransposeRecord(firstNewRowId); } catch (error) { - if (dataGridResultLifecycle.isCurrent(operation)) toast(t("grid.copyFailed", { message: error instanceof Error ? error.message : String(error) }), 5000); + if (gridClipboardActive && lifecycle === gridClipboardLifecycle && dataGridResultLifecycle.isCurrent(operation)) reportGridPasteError(error); } } @@ -9186,14 +9231,27 @@ function batchAppendPasteError(reason: string): string { "empty-paste": "grid.batchAppendPasteEmpty", "readonly-column": "grid.batchAppendPasteReadonlyColumn", "no-matching-columns": "grid.batchAppendPasteNoMatchingColumns", + "capacity-exceeded": "grid.insertRowsCapacityExceeded", }; - return t(messages[reason] ?? "grid.batchAppendPasteInvalidTarget"); + return t(messages[reason] ?? "grid.batchAppendPasteInvalidTarget", { max: availableInsertRows.value }); } -function blankSelectionBatchAppendPasteTarget(pastedRows: readonly (readonly (string | null)[])[]): { rowId: number; columnIndexes: number[] } | null { - if (pastedRows.length <= 1) return null; +function clipboardWithinGridCapacity(text: string): boolean { + if (text.length > 16 * 1024 * 1024) { + toast(t("grid.insertRowsClipboardTooLarge"), 5000); + return false; + } + return true; +} + +function reportGridPasteError(error: unknown) { + toast(error instanceof DataGridClipboardCapacityError ? t("grid.insertRowsClipboardTooLarge") : t("grid.copyFailed", { message: error instanceof Error ? error.message : String(error) }), 5000); +} + +function blankSelectionBatchAppendPasteTarget(pastedRowCount: number): { rowId: number; columnIndexes: number[] } | null { + if (pastedRowCount <= 1) return null; const range = selectedRange.value; - if (!range || (range.startRow === range.endRow && range.startCol !== range.endCol)) return null; + if (!range) return null; const selectedItems = Array.from({ length: range.endRow - range.startRow + 1 }, (_, offset) => displayItemAt(range.startRow + offset)); if (selectedItems.some((item) => (!item?.isNew && !item?.isDraft) || item.isDeleted || item.data.some((value) => value !== null && (typeof value !== "string" || value.trim() !== "")))) return null; const item = selectedItems[0]; @@ -9202,9 +9260,17 @@ function blankSelectionBatchAppendPasteTarget(pastedRows: readonly (readonly (st return { rowId: item.id, columnIndexes: visibleColumnIndexes.value.slice(range.startCol) }; } -function appendParsedRowsToBlankTarget(targetRowId: number, rows: readonly (readonly (string | null)[])[], columnIndexes: readonly number[], columnNames?: readonly string[] | null): boolean { - const result = appendPastedRowsToNewRow(targetRowId, rows, columnIndexes, columnNames); +function appendParsedRowsToBlankTarget(targetRowId: number, rows: readonly (readonly (string | null)[])[], columnIndexes: readonly number[], columnNames?: readonly string[] | null, preparation?: DataGridRowPreparationOptions): boolean | Promise { + if (preparation) return appendPastedRowsToNewRowInBatches(targetRowId, rows, columnIndexes, columnNames, preparation).then(finishParsedRowsAppend); + if (rows.length > dataGridPreparationBatchSize(props.result.columns.length) || newRows.value.length > dataGridPreparationBatchSize(props.result.columns.length)) { + return prepareGridRows((options) => appendPastedRowsToNewRowInBatches(targetRowId, rows, columnIndexes, columnNames, options)).then((result) => (result ? finishParsedRowsAppend(result) : false)); + } + return finishParsedRowsAppend(appendPastedRowsToNewRow(targetRowId, rows, columnIndexes, columnNames)); +} + +function finishParsedRowsAppend(result: ReturnType): boolean { if (!result.ok) { + if (result.reason === "cancelled") return false; if (result.reason === "invalid-target" || result.reason === "target-not-empty") { batchAppendPasteRowId.value = null; } @@ -9216,28 +9282,44 @@ function appendParsedRowsToBlankTarget(targetRowId: number, rows: readonly (read return true; } -function pasteTextIntoGrid(text: string): boolean { +function pasteTextIntoGrid(text: string): boolean | Promise { + if (rowPreparationController || !gridClipboardActive || !clipboardWithinGridCapacity(text)) return false; + const insertionTarget = batchAppendPasteTargetRowId() !== null || blankSelectionBatchAppendPasteTarget(2) !== null; + const limits = insertionTarget ? { maxRows: dataGridPendingRowLimit(props.result.columns.length) } : {}; + if (text.length > DATA_GRID_CLIPBOARD_BATCH_CHARS) { + return prepareGridRows(async (options) => { + const insertPaste = await parseInsertStatementPasteInBatches(text, options, limits); + if (options.signal?.aborted || options.isCurrent?.() === false) return false; + const rows = insertPaste?.rows ?? (await parseDataGridClipboardInBatches(text, options, limits)); + if (!rows || options.signal?.aborted || options.isCurrent?.() === false) return false; + return applyParsedGridPaste(rows, insertPaste?.columnNames, options); + }) + .then((result) => result ?? false) + .catch((error) => { + reportGridPasteError(error); + return false; + }); + } + try { + const insertPaste = parseInsertStatementPaste(text, limits); + return applyParsedGridPaste(insertPaste?.rows ?? parseDataGridClipboard(text, limits), insertPaste?.columnNames); + } catch (error) { + reportGridPasteError(error); + return false; + } +} + +function applyParsedGridPaste(rows: readonly (readonly (string | null)[])[], columnNames?: readonly string[] | null, preparation?: DataGridRowPreparationOptions): boolean | Promise { // SQL INSERT statements are only meaningful as new rows, so they are honored // on the blank-new-row targets below; any other target pastes the parsed // values through the regular cell path instead of treating the whole // statement as literal text. - const insertPaste = parseInsertStatementPaste(text); - if (insertPaste) { - const insertTargetRowId = batchAppendPasteTargetRowId(); - if (insertTargetRowId !== null) { - return appendParsedRowsToBlankTarget(insertTargetRowId, insertPaste.rows, visibleColumnIndexes.value, insertPaste.columnNames); - } - const insertCellTarget = blankSelectionBatchAppendPasteTarget(insertPaste.rows); - if (insertCellTarget) return appendParsedRowsToBlankTarget(insertCellTarget.rowId, insertPaste.rows, insertCellTarget.columnIndexes, insertPaste.columnNames); - return pasteRowsIntoSelection(insertPaste.rows); - } - const rows = parseDataGridClipboard(text); const targetRowId = batchAppendPasteTargetRowId(); if (targetRowId !== null) { - return appendParsedRowsToBlankTarget(targetRowId, rows, visibleColumnIndexes.value); + return appendParsedRowsToBlankTarget(targetRowId, rows, visibleColumnIndexes.value, columnNames, preparation); } - const cellTarget = blankSelectionBatchAppendPasteTarget(rows); - if (cellTarget) return appendParsedRowsToBlankTarget(cellTarget.rowId, rows, cellTarget.columnIndexes); + const cellTarget = blankSelectionBatchAppendPasteTarget(rows.length); + if (cellTarget) return appendParsedRowsToBlankTarget(cellTarget.rowId, rows, cellTarget.columnIndexes, columnNames, preparation); return pasteRowsIntoSelection(rows); } @@ -9252,11 +9334,16 @@ function pasteRowsIntoSelection(rows: readonly (readonly (string | null)[])[]): const start = pasteStartCell(); if (!start) return false; let applied = false; - for (const cell of planDataGridPaste(rows, displayRowCount.value - start.rowIndex, visibleColumns.value.length - start.colIndex)) { - const item = displayItemAt(start.rowIndex + cell.rowOffset); - if (!item) continue; - const visibleCol = start.colIndex + cell.columnOffset; - applied = applyVisibleSelectedCellValue(item, visibleCol, cell.value, allowDraftSelectionValue) || applied; + beginBatch(); + try { + for (const cell of planDataGridPaste(rows, displayRowCount.value - start.rowIndex, visibleColumns.value.length - start.colIndex)) { + const item = displayItemAt(start.rowIndex + cell.rowOffset); + if (!item) continue; + const visibleCol = start.colIndex + cell.columnOffset; + applied = applyVisibleSelectedCellValue(item, visibleCol, cell.value, allowDraftSelectionValue) || applied; + } + } finally { + commitBatch(); } if (applied) toast(t("grid.pasted")); return applied; @@ -12496,13 +12583,20 @@ watch(gridSurfaceBusy, (isLoading) => { }); onActivated(() => { + gridClipboardActive = true; autoRefresh.start(); }); onDeactivated(() => { + gridClipboardActive = false; + gridClipboardLifecycle++; + rowPreparationController?.abort(); autoRefresh.stop(); }); onUnmounted(() => { + gridClipboardActive = false; + gridClipboardLifecycle++; + rowPreparationController?.abort(); syncPendingDataEditorDraft(false); cleanupFrames(); autoRefresh.stop(); @@ -14938,7 +15032,7 @@ useUpdateBlocker(() => (hasPendingChanges.value || hasPendingDataEditorDraft.val
-
+
@@ -15450,7 +15544,32 @@ useUpdateBlocker(() => (hasPendingChanges.value || hasPendingDataEditorDraft.val
- + + + + + {{ t(rowPreparationProgress?.phase === "parsing" ? "grid.insertRowsParsing" : "grid.insertRowsPreparing") }} + +
+

+ {{ rowPreparationProgress.phase === "parsing" ? `${Math.floor((rowPreparationProgress.completed / Math.max(1, rowPreparationProgress.total)) * 100)}%` : t("grid.insertRowsProgress", { current: rowPreparationProgress.completed, total: rowPreparationProgress.total }) }} +

+
+
+
+
+ + + + +
diff --git a/apps/desktop/src/components/grid/DataGridInsertRowsDialog.vue b/apps/desktop/src/components/grid/DataGridInsertRowsDialog.vue index 84d53d3afd..33cd790d87 100644 --- a/apps/desktop/src/components/grid/DataGridInsertRowsDialog.vue +++ b/apps/desktop/src/components/grid/DataGridInsertRowsDialog.vue @@ -1,17 +1,15 @@