feat(grid): batch row insert progress, cancel and paste expansion

Closes #11324
This commit is contained in:
ccq
2026-10-09 14:57:35 +08:00
committed by GitHub
parent 428e6bd69c
commit ee8cba7daa
27 changed files with 1035 additions and 170 deletions
+160 -41
View File
@@ -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<DataGridRowPreparationProgress | null>(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<T>(prepare: (options: DataGridRowPreparationOptions) => Promise<T>): Promise<T | undefined> {
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<boolean> {
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<typeof appendPastedRowsToNewRow>): 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<boolean> {
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<boolean> {
// 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
<div v-if="hasGridVerticalOverflow" ref="gridVerticalScrollbarTrackRef" class="data-grid-vertical-scrollbar" @pointerdown="startGridVerticalScrollbarDrag">
<div ref="gridVerticalScrollbarThumbRef" class="data-grid-vertical-scrollbar__thumb" />
</div>
<div v-if="gridSurfaceBusy" class="absolute inset-0 z-20 flex items-center justify-center" :class="pageJumpProgress ? 'bg-background/35 backdrop-blur-[1px]' : 'bg-background/50'">
<div v-if="gridSurfaceBusy && !rowPreparationProgress" class="absolute inset-0 z-20 flex items-center justify-center" :class="pageJumpProgress ? 'bg-background/35 backdrop-blur-[1px]' : 'bg-background/50'">
<DataGridBusyOverlay :elapsed-ms="loadingElapsed" :page-jump-progress="pageJumpProgress" :show-cancel="showCancel" :cancelling="cancelling" :cancel-disabled="cancelDisabled" @cancel="emit('cancel')" />
</div>
</div>
@@ -15450,7 +15544,32 @@ useUpdateBlocker(() => (hasPendingChanges.value || hasPendingDataEditorDraft.val
</div>
</template>
</DataGridBulkEditDialog>
<DataGridInsertRowsDialog v-if="insertRowsDialogMounted" v-model:open="insertRowsDialogOpen" :can-place-at-selection="canPlaceInsertAtSelection" :initial-position="insertPosition" @insert="insertRows" />
<DataGridInsertRowsDialog v-if="insertRowsDialogMounted" v-model:open="insertRowsDialogOpen" :can-place-at-selection="canPlaceInsertAtSelection" :initial-position="insertPosition" :max-rows="availableInsertRows" @insert="insertRows" />
<Dialog :open="rowPreparationProgress !== null" @update:open="onRowPreparationOpenChange">
<DialogContent data-grid-row-preparation class="sm:max-w-[420px]" :show-close-button="false" @interact-outside.prevent>
<DialogHeader>
<DialogTitle>{{ t(rowPreparationProgress?.phase === "parsing" ? "grid.insertRowsParsing" : "grid.insertRowsPreparing") }}</DialogTitle>
</DialogHeader>
<div v-if="rowPreparationProgress" class="space-y-2">
<p class="text-sm tabular-nums" aria-live="polite">
{{ rowPreparationProgress.phase === "parsing" ? `${Math.floor((rowPreparationProgress.completed / Math.max(1, rowPreparationProgress.total)) * 100)}%` : t("grid.insertRowsProgress", { current: rowPreparationProgress.completed, total: rowPreparationProgress.total }) }}
</p>
<div
class="h-1.5 overflow-hidden rounded-full bg-muted"
role="progressbar"
:aria-label="t(rowPreparationProgress.phase === 'parsing' ? 'grid.insertRowsParsing' : 'grid.insertRowsPreparing')"
:aria-valuemin="0"
:aria-valuemax="rowPreparationProgress.total"
:aria-valuenow="rowPreparationProgress.completed"
>
<div class="h-full bg-primary" :style="{ width: `${(rowPreparationProgress.completed / rowPreparationProgress.total) * 100}%` }" />
</div>
</div>
<DialogFooter>
<Button variant="outline" @click="cancelRowPreparation"><X class="h-4 w-4" />{{ t("dangerDialog.cancel") }}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<DataGridExtractorDialog v-model:open="extractorConfigOpen" :preference="selectedCopyPreference" :options="settingsStore.editorSettings.dataGridExtractorOptions" :items="copyPreferenceMenuItems" :preview="previewWithPreference" @save="saveExtractorConfiguration" />
<DataGridCopyColumnNamesDialog v-if="copyColumnNamesDialogMounted" v-model:open="copyColumnNamesDialogOpen" :column-names="copyColumnNamesDialogColumns" :database-type="resolvedDatabaseType" :column-comments="columnCommentMap" @copy="copyText" />
@@ -1,17 +1,15 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { DATA_GRID_MAX_BATCH_INSERT_ROWS } from "@/composables/useDataGridEditor";
import { type GridInsertRowPosition } from "@/lib/dataGrid/gridNewRowPlacement";
import { dataGridPendingRowLimit } from "@/lib/dataGrid/dataGridRowPreparation";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
const MAX_INSERT_ROWS = DATA_GRID_MAX_BATCH_INSERT_ROWS;
const { t } = useI18n();
const open = defineModel<boolean>("open", { default: false });
const props = defineProps<{ canPlaceAtSelection?: boolean; initialPosition?: GridInsertRowPosition }>();
const props = withDefaults(defineProps<{ canPlaceAtSelection?: boolean; initialPosition?: GridInsertRowPosition; maxRows?: number }>(), { maxRows: dataGridPendingRowLimit(1) });
const emit = defineEmits<{ insert: [count: number, position: GridInsertRowPosition] }>();
const canUsePosition = computed(() => props.canPlaceAtSelection !== false);
@@ -40,19 +38,17 @@ function parseIntegerOrNull(raw: string | number): number | null {
const trimmed = String(raw).trim();
if (!/^\d+$/.test(trimmed)) return null;
const value = Number(trimmed);
return Number.isInteger(value) && value >= 1 ? value : null;
return Number.isSafeInteger(value) && value >= 1 ? value : null;
}
const parsedCount = computed<number | null>(() => {
const parsed = parseIntegerOrNull(rowCount.value);
if (parsed === null) return null;
return Math.min(parsed, MAX_INSERT_ROWS);
});
const requestedCount = computed(() => parseIntegerOrNull(rowCount.value));
const capacityExceeded = computed(() => requestedCount.value !== null && requestedCount.value > props.maxRows);
const parsedCount = computed(() => (capacityExceeded.value ? null : requestedCount.value));
const inputInvalid = computed(() => {
const raw = String(rowCount.value).trim();
if (raw === "") return false;
return parseIntegerOrNull(raw) === null;
return parsedCount.value === null;
});
function confirmInsert() {
@@ -73,9 +69,10 @@ function confirmInsert() {
<div class="space-y-3">
<div class="space-y-2">
<label for="insert-rows-count" class="text-sm font-medium">{{ t("grid.insertRowCountLabel") }}</label>
<Input id="insert-rows-count" v-model="rowCount" type="number" min="1" :max="MAX_INSERT_ROWS" :aria-invalid="inputInvalid" class="w-40" @keydown.enter.prevent="confirmInsert" />
<p v-if="inputInvalid" class="text-sm text-destructive">{{ t("grid.insertRowCountInvalid") }}</p>
<p class="text-xs text-muted-foreground">{{ t("grid.insertRowsMaxHint", { max: MAX_INSERT_ROWS }) }}</p>
<Input id="insert-rows-count" v-model="rowCount" type="number" min="1" step="1" :max="maxRows" :aria-invalid="inputInvalid" class="w-40" @keydown.enter.prevent="confirmInsert" />
<p v-if="capacityExceeded" class="text-sm text-destructive">{{ t("grid.insertRowsCapacityExceeded", { max: maxRows }) }}</p>
<p v-else-if="inputInvalid" class="text-sm text-destructive">{{ t("grid.insertRowCountInvalid") }}</p>
<p class="text-xs text-muted-foreground">{{ t("grid.insertRowsMaxHint", { max: maxRows }) }}</p>
</div>
<div class="space-y-1.5">
<span class="text-sm font-medium">{{ t("grid.insertRowPositionLabel") }}</span>
@@ -1,6 +1,6 @@
// @vitest-environment happy-dom
import { createApp, defineComponent, h, markRaw, nextTick, shallowRef, type App, type PropType } from "vue";
import { createApp, defineComponent, h, KeepAlive, markRaw, nextTick, shallowRef, type App, type PropType } from "vue";
import { createPinia, setActivePinia } from "pinia";
import { afterEach, describe, expect, it, vi } from "vitest";
import i18n from "@/i18n";
@@ -9,6 +9,7 @@ import type { CustomSaveHandler } from "@/composables/useDataGridEditor";
import { buildMongoUpdateDocument } from "@/lib/mongo/mongoDocumentValues";
import { TooltipProvider } from "@/components/ui/tooltip";
import { readTextFromClipboard } from "@/lib/common/clipboard";
import { useToast } from "@/composables/useToast";
vi.mock("@/lib/common/clipboard", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/common/clipboard")>()),
@@ -104,6 +105,7 @@ function mountGrid(options: MountGridOptions = {}) {
const settingsStore = useSettingsStore();
settingsStore.updateEditorSettings({ dataGridRenderMode: "canvas", dataGridHideNullColumns: options.hideNullColumns ?? false, dataGridQuickEntry: options.quickEntry ?? false });
const resultRef = shallowRef(markRaw(options.result ?? defaultResult()));
const visibleRef = shallowRef(true);
const host = document.createElement("div");
document.body.append(host);
@@ -115,26 +117,31 @@ function mountGrid(options: MountGridOptions = {}) {
{ delayDuration: 0 },
{
default: () =>
h(DataGrid, {
result: resultRef.value,
databaseType: options.databaseType ?? "dameng",
context: "table-data",
editable: options.editable ?? true,
allowInsertRows: options.allowInsertRows,
customSaveHandler: options.customSaveHandler,
readonlyColumnIndexes: options.readonlyColumnIndexes,
tableMeta: {
tableName: "paste_target",
columns: resultRef.value.columns.map((name, index) => ({
name,
data_type: index === 0 ? "int" : "varchar",
is_nullable: index !== 0,
column_default: null,
is_primary_key: index === 0,
extra: null,
})),
primaryKeys: ["c0"],
},
h(KeepAlive, null, {
default: () =>
visibleRef.value
? h(DataGrid, {
result: resultRef.value,
databaseType: options.databaseType ?? "dameng",
context: "table-data",
editable: options.editable ?? true,
allowInsertRows: options.allowInsertRows,
customSaveHandler: options.customSaveHandler,
readonlyColumnIndexes: options.readonlyColumnIndexes,
tableMeta: {
tableName: "paste_target",
columns: resultRef.value.columns.map((name, index) => ({
name,
data_type: index === 0 ? "int" : "varchar",
is_nullable: index !== 0,
column_default: null,
is_primary_key: index === 0,
extra: null,
})),
primaryKeys: ["c0"],
},
})
: null,
}),
},
);
@@ -148,7 +155,7 @@ function mountGrid(options: MountGridOptions = {}) {
settingsStore.updateEditorSettings({ dataGridRenderMode: "dom" });
const mounted = { app, host };
mountedApps.push(mounted);
return { ...mounted, resultRef };
return { ...mounted, resultRef, visibleRef };
}
async function settle() {
@@ -237,6 +244,51 @@ async function pasteAsNewRows(host: HTMLElement) {
}
describe("DataGrid paste as new rows", () => {
it("drops a clipboard read that finishes after deactivation and reactivation", async () => {
const { host, visibleRef } = mountGrid();
await settle();
let finishRead!: (text: string) => void;
vi.mocked(readTextFromClipboard).mockReturnValue(
new Promise((resolve) => {
finishRead = resolve;
}),
);
await pasteAsNewRows(host);
visibleRef.value = false;
await settle();
visibleRef.value = true;
await settle();
finishRead("2\tignored\tAda\tLovelace");
await settle();
expect(pendingRows(host)).toHaveLength(0);
});
it("bounds a single SQL line with excessive logical rows and leaves no drafts", async () => {
const columns = Array.from({ length: 1000 }, (_, i) => `c${i}`);
const { host } = mountGrid({ result: { ...defaultResult(), columns, rows: [[1, ...Array(999).fill(null)]] }, hideNullColumns: true });
await settle();
vi.mocked(readTextFromClipboard).mockResolvedValue(`INSERT INTO t VALUES ${"(1),".repeat(1000)}(1)`);
await pasteAsNewRows(host);
await vi.waitFor(() => expect(document.querySelector("[data-grid-row-preparation]")).toBeNull(), { timeout: 5000, interval: 10 });
expect(pendingRows(host)).toHaveLength(0);
await vi.waitFor(() => expect(useToast().message.value).toBe(i18n.global.t("grid.insertRowsClipboardTooLarge")), { interval: 1 });
});
it("allows a narrow overwrite paste in a wide table beyond the pending insertion row limit", async () => {
const columns = Array.from({ length: 2000 }, (_, i) => `c${i}`);
const rows = Array.from({ length: 600 }, (_, i) => [i, ...Array(1999).fill(null)]);
const save = vi.fn<CustomSaveHandler["save"]>().mockResolvedValue();
const { host } = mountGrid({ result: { ...defaultResult(), columns, rows }, hideNullColumns: true, customSaveHandler: { canInsert: true, save } });
await settle();
await selectColumnHeader(host, 0);
await paste(host, Array.from({ length: 600 }, (_, i) => String(i + 1000)).join("\n"));
host.querySelector<HTMLElement>("[data-toolbar-action='save']")!.click();
await settle();
expect(save).toHaveBeenCalledOnce();
expect(save.mock.calls[0]![0].newRows).toHaveLength(0);
expect(save.mock.calls[0]![0].dirtyRows.size).toBe(600);
});
it("inserts clipboard rows from the toolbar without preallocated blank rows or a selection", async () => {
const { host } = mountGrid({ hideNullColumns: true });
await settle();
@@ -311,6 +363,52 @@ describe("DataGrid paste as new rows", () => {
});
describe("DataGrid multi-row paste from a blank cell", () => {
it("shows cancellable progress for a large paste and leaves the blank row unchanged", async () => {
const { host } = mountGrid();
await settle();
await addBlankRow(host);
await selectCell(visibleCells(pendingRows(host)[0]!)[0]!);
await paste(host, Array.from({ length: 5000 }, (_, index) => `${index + 2}\tExcel`).join("\n"));
const progress = document.querySelector<HTMLElement>("[data-grid-row-preparation] [role='progressbar']");
expect(progress).not.toBeNull();
await vi.waitFor(() => expect(Number(progress!.getAttribute("aria-valuenow"))).toBeGreaterThan(0), { interval: 1 });
document.querySelector<HTMLButtonElement>("[data-grid-row-preparation] button")!.click();
await vi.waitFor(() => expect(document.querySelector("[data-grid-row-preparation]")).toBeNull(), { interval: 1 });
expect(pendingRows(host)).toHaveLength(1);
expect(visibleCellTexts(pendingRows(host)[0]!)).toEqual(["NULL", "NULL", "NULL", "NULL"]);
});
it("drops a large paste when its result is replaced during preparation", async () => {
const { host, resultRef } = mountGrid();
await settle();
await addBlankRow(host);
await selectCell(visibleCells(pendingRows(host)[0]!)[0]!);
await paste(host, Array.from({ length: 5000 }, (_, index) => `${index + 2}\tExcel`).join("\n"));
expect(document.querySelector("[data-grid-row-preparation]")).not.toBeNull();
resultRef.value = markRaw({ ...defaultResult(), rows: [[99, null, "replacement", "result"]] });
await vi.waitFor(() => expect(document.querySelector("[data-grid-row-preparation]")).toBeNull(), { interval: 1 });
expect(pendingRows(host)).toHaveLength(0);
expect(visibleCellTexts(displayRows(host)[0]!)[0]).toBe("99");
});
it("expands one blank new row to hold all 50 Excel rows and saves every row", async () => {
const save = vi.fn<CustomSaveHandler["save"]>().mockResolvedValue();
const { host } = mountGrid({ customSaveHandler: { canInsert: true, save } });
await settle();
await addBlankRow(host);
await selectCell(visibleCells(pendingRows(host)[0]!)[0]!);
await paste(host, Array.from({ length: 50 }, (_, index) => `${index + 2}\tExcel ${index + 1}`).join("\r\n"));
expect(pendingRows(host)).toHaveLength(50);
expect(visibleCellTexts(pendingRows(host).at(-1)!).slice(0, 2)).toEqual(["51", "Excel 50"]);
host.querySelector<HTMLElement>("[data-toolbar-action='save']")!.click();
await settle();
expect(save.mock.calls[0]![0].newRows).toHaveLength(50);
expect(save.mock.calls[0]![0].newRows.at(-1)).toEqual([51, "Excel 50", null, null]);
expect(save.mock.calls[0]![0].dirtyRows.size).toBe(0);
});
it("expands beyond pre-added blank rows when pasted rows exceed them", async () => {
const { host } = mountGrid();
await settle();
@@ -495,7 +593,7 @@ describe("DataGrid multi-row paste from a blank cell", () => {
expect(visibleCellTexts(rows[0]!)[2]).toBe("first");
});
it("does not append from a multi-cell range", async () => {
it("expands a multi-cell range on a blank new row without losing clipboard rows", async () => {
const { host } = mountGrid();
await settle();
await addBlankRow(host);
@@ -507,8 +605,9 @@ describe("DataGrid multi-row paste from a blank cell", () => {
await paste(host, "a\tb\nc\td");
const rows = pendingRows(host);
expect(rows).toHaveLength(1);
expect(rows).toHaveLength(2);
expect(visibleCellTexts(rows[0]!).slice(1, 3)).toEqual(["a", "b"]);
expect(visibleCellTexts(rows[1]!).slice(1, 3)).toEqual(["c", "d"]);
});
it("does not append from a column selection", async () => {
@@ -67,7 +67,7 @@ describe("DataGridInsertRowsDialog", () => {
expect(onInsert).toHaveBeenCalledWith(1, "end");
});
it("accepts a numeric value emitted by the number input", async () => {
it.each([3, 1500])("inserts the requested %i rows without truncating the count", async (count) => {
const onInsert = vi.fn();
const host = document.createElement("div");
document.body.append(host);
@@ -81,12 +81,48 @@ describe("DataGridInsertRowsDialog", () => {
mountedApps.push({ app, host });
const input = host.querySelector("#insert-rows-count") as HTMLInputElement;
input.value = "3";
input.value = String(count);
input.dispatchEvent(new Event("input", { bubbles: true }));
await nextTick();
[...host.querySelectorAll("button")].find((button) => button.textContent === "Insert")!.click();
await nextTick();
expect(onInsert).toHaveBeenCalledWith(3, "below");
expect(onInsert).toHaveBeenCalledWith(count, "below");
});
it.each(["0", "-1", "1.5", "9007199254740992"])("rejects an invalid row count: %s", async (count) => {
const onInsert = vi.fn();
const host = document.createElement("div");
document.body.append(host);
const app = createApp(DataGridInsertRowsDialog, { open: true, onInsert });
app.use(i18n);
app.mount(host);
mountedApps.push({ app, host });
const input = host.querySelector("#insert-rows-count") as HTMLInputElement;
input.value = count;
input.dispatchEvent(new Event("input", { bubbles: true }));
await nextTick();
const insert = [...host.querySelectorAll("button")].find((button) => button.textContent === "Insert")!;
expect(insert.disabled).toBe(true);
input.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Enter" }));
await nextTick();
expect(onInsert).not.toHaveBeenCalled();
});
it("rejects a request exceeding the remaining grid capacity instead of truncating it", async () => {
const onInsert = vi.fn();
const host = document.createElement("div");
document.body.append(host);
const app = createApp(DataGridInsertRowsDialog, { open: true, maxRows: 1200, onInsert });
app.use(i18n);
app.mount(host);
mountedApps.push({ app, host });
const input = host.querySelector("#insert-rows-count") as HTMLInputElement;
input.value = "1500";
input.dispatchEvent(new Event("input", { bubbles: true }));
await nextTick();
expect([...host.querySelectorAll("button")].find((button) => button.textContent === "Insert")!.disabled).toBe(true);
expect(onInsert).not.toHaveBeenCalled();
});
});
@@ -5,6 +5,7 @@ import { clearDataGridPendingSnapshot, DATA_GRID_QUICK_ENTRY_DRAFT_ROW_ID, useDa
import { clearDataGridClipboardCopy, parseDataGridClipboard, rememberDataGridClipboardCopy } from "@/lib/dataGrid/dataGridClipboard";
import { buildMongoUpdateDocument, MONGO_DOCUMENT_GRID_NULL, mongoDocumentGridInputValue, mongoDocumentGridValue } from "@/lib/mongo/mongoDocumentValues";
import type { CellValue } from "@/lib/dataGrid/cellValue";
import { buildOrderedGridRows } from "@/lib/dataGrid/gridNewRowPlacement";
const mocks = vi.hoisted(() => ({
getConfig: vi.fn(),
@@ -688,7 +689,195 @@ describe("useDataGridEditor cell mutation notifications", () => {
});
});
describe("useDataGridEditor addRows", () => {
it("prepares a large batch with progress before committing one undoable change", async () => {
const editor = createEditor();
editor.newRows.value = [];
const progress: number[] = [];
const firstId = await editor.addRowsInBatches(5000, null, {
onProgress: ({ completed }) => {
progress.push(completed);
expect(editor.newRows.value).toEqual([]);
expect(editor.canUndoPendingChange.value).toBe(false);
},
});
expect(firstId).toBe(-1);
expect(progress[0]).toBe(0);
expect(progress.at(-1)).toBe(5000);
expect(progress.length).toBeGreaterThan(2);
expect(editor.newRows.value).toHaveLength(5000);
editor.undoPendingChange();
expect(editor.newRows.value).toEqual([]);
});
it("cancels a prepared batch without pending rows, undo history, or a transaction", async () => {
const editor = createEditor();
const controller = new AbortController();
const before = editor.newRows.value;
const result = await editor.addRowsInBatches(5000, null, {
signal: controller.signal,
onProgress: ({ completed }) => {
if (completed >= 1000) controller.abort();
},
});
expect(result).toBeUndefined();
expect(editor.newRows.value).toBe(before);
expect(editor.canUndoPendingChange.value).toBe(false);
expect(editor.transactionActive.value).toBe(false);
});
it("drops prepared rows if pending state changes while yielding", async () => {
const editor = createEditor();
let changed = false;
const result = await editor.addRowsInBatches(5000, null, {
onProgress: ({ completed }) => {
if (completed >= 1000 && !changed) {
editor.discardChanges();
changed = true;
}
},
});
expect(result).toBeUndefined();
expect(editor.newRows.value).toEqual([]);
expect(editor.canUndoPendingChange.value).toBe(false);
});
it("rejects a huge allocation before emitting progress or modifying pending rows", async () => {
const editor = createEditor();
const onProgress = vi.fn();
expect(await editor.addRowsInBatches(1_000_000_000, null, { onProgress })).toBeUndefined();
expect(editor.newRows.value).toEqual([[null, null, null]]);
expect(editor.canUndoPendingChange.value).toBe(false);
expect(onProgress).not.toHaveBeenCalled();
expect(editor.saveError.value).not.toBe("");
});
it("uses the cumulative cell capacity for wide tables", () => {
const { editor, result } = createEditorWithResult();
editor.newRows.value = [];
result.value = { columns: Array.from({ length: 2000 }, (_, index) => `column${index}`), rows: [] };
expect(editor.availableInsertRows.value).toBe(500);
editor.addRows(400);
expect(editor.availableInsertRows.value).toBe(100);
const before = editor.newRows.value;
editor.addRows(101);
expect(editor.newRows.value).toBe(before);
expect(editor.saveError.value).not.toBe("");
});
it("adds more than 1000 independent blank rows as one undoable change", async () => {
const editor = createEditor();
editor.newRows.value = [];
expect(editor.addRows(1500)).toBe(-1);
expect(editor.newRows.value).toHaveLength(1500);
expect(editor.newRowMeta.value).toHaveLength(1500);
expect(editor.newRows.value.at(-1)).toEqual([null, null, null]);
editor.newRows.value[0]![0] = "first";
expect(editor.newRows.value[1]).toEqual([null, null, null]);
await nextTick();
editor.undoPendingChange();
expect(editor.newRows.value).toEqual([]);
editor.redoPendingChange();
expect(editor.newRows.value).toHaveLength(1500);
expect(editor.newRows.value[0]).toEqual(["first", null, null]);
});
it.each([0, -1, 1.5, Number.NaN, Number.POSITIVE_INFINITY, Number.MAX_SAFE_INTEGER + 1])("rejects an invalid count without changing pending rows: %s", (count) => {
const editor = createEditor();
expect(editor.addRows(count)).toBeUndefined();
expect(editor.newRows.value).toEqual([[null, null, null]]);
expect(editor.canUndoPendingChange.value).toBe(false);
});
});
describe("useDataGridEditor appendPastedRowsToNewRow", () => {
it("can cancel before scanning a large reusable buffer", async () => {
const editor = createEditor();
editor.newRows.value = [];
editor.addRows(5000);
const read = vi.fn(() => null);
Object.defineProperty(editor.newRows.value[4900]!, 0, { get: read });
const controller = new AbortController();
const before = editor.newRows.value;
const result = await editor.appendPastedRowsToNewRowInBatches(
-1,
Array.from({ length: 5000 }, () => ["Ada"]),
[0],
undefined,
{
signal: controller.signal,
onProgress: () => controller.abort(),
},
);
expect(result).toEqual({ ok: false, reason: "cancelled" });
expect(read).not.toHaveBeenCalled();
expect(editor.newRows.value).toBe(before);
});
it("preserves unrelated pending row objects when replacing a blank target", () => {
const editor = createEditor();
editor.newRows.value = [
[null, null, null],
["keep", null, null],
];
const retained = editor.newRows.value[1];
expect(editor.appendPastedRowsToNewRow(-1, [["Ada"], ["Grace"]], [0])).toEqual({ ok: true, rowCount: 2 });
expect(editor.newRows.value[2]).toBe(retained);
editor.undoPendingChange();
expect(editor.newRows.value).toEqual([
[null, null, null],
["keep", null, null],
]);
});
it("keeps recent undo and redo available while bounding history for a million pending cells", () => {
const { editor, result } = createEditorWithResult();
result.value = { columns: Array.from({ length: 50 }, (_, i) => `c${i}`), rows: [] };
editor.newRows.value = [];
editor.addRows(20_000);
for (let i = 0; i < 6; i++) editor.applyCellValue(-1, 0, `edit-${i}`);
editor.undoPendingChange();
expect(editor.newRows.value[0]![0]).toBe("edit-4");
editor.undoPendingChange();
expect(editor.newRows.value[0]![0]).toBe("edit-3");
expect(editor.canUndoPendingChange.value).toBe(false);
editor.redoPendingChange();
expect(editor.newRows.value[0]![0]).toBe("edit-4");
});
it("cancels a large paste without consuming blank rows or adding undo history", async () => {
const editor = createEditor();
const controller = new AbortController();
const before = editor.newRows.value;
const result = await editor.appendPastedRowsToNewRowInBatches(
-1,
Array.from({ length: 5000 }, () => ["Ada"]),
[0],
undefined,
{
signal: controller.signal,
onProgress: ({ completed }) => {
if (completed >= 1000) controller.abort();
},
},
);
expect(result).toEqual({ ok: false, reason: "cancelled" });
expect(editor.newRows.value).toBe(before);
expect(editor.newRows.value).toEqual([[null, null, null]]);
expect(editor.canUndoPendingChange.value).toBe(false);
});
it("rejects a clipboard batch exceeding pending cell capacity atomically", () => {
const { editor, result } = createEditorWithResult();
result.value = { columns: Array.from({ length: 2000 }, (_, index) => `column${index}`), rows: [] };
editor.addRows(499);
const before = editor.newRows.value;
expect(editor.appendPastedRowsAsNewRows([["Ada"], ["Grace"]], [0])).toEqual({ ok: false, reason: "capacity-exceeded" });
expect(editor.newRows.value).toBe(before);
});
beforeEach(() => {
mocks.getConfig.mockReturnValue({ id: "connection-1", db_type: "postgres" });
});
@@ -893,6 +1082,63 @@ describe("useDataGridEditor appendPastedRowsToNewRow", () => {
]);
});
it.each(["above", "below"] as const)("keeps expanded paste rows %s the original anchor", async (position) => {
const editor = createEditor(undefined, true, undefined, undefined, [["source", null, null]]);
editor.newRows.value = [];
editor.addRows(1, { anchorId: 0, position });
await nextTick();
expect(editor.appendPastedRowsToNewRow(-1, [["Ada"], ["Grace"]], [0])).toEqual({ ok: true, rowCount: 2 });
const ordered = () => buildOrderedGridRows([0], editor.newRowMeta.value, editor.newRows.value.length);
const expected =
position === "above"
? [
{ kind: "new", newIndex: 0 },
{ kind: "new", newIndex: 1 },
{ kind: "source", sourceIndex: 0 },
]
: [
{ kind: "source", sourceIndex: 0 },
{ kind: "new", newIndex: 0 },
{ kind: "new", newIndex: 1 },
];
expect(ordered()).toEqual(expected);
editor.undoPendingChange();
expect(editor.newRows.value).toEqual([[null, null, null]]);
editor.redoPendingChange();
expect(ordered()).toEqual(expected);
expect(editor.newRows.value).toEqual([
["Ada", null, null],
["Grace", null, null],
]);
});
it("preserves blank rows inserted at a different anchor while expanding a paste", async () => {
const editor = createEditor(undefined, true, undefined, undefined, [
["first", null, null],
["second", null, null],
]);
editor.newRows.value = [];
editor.addRows(1, { anchorId: 0, position: "below" });
editor.addRows(1, { anchorId: 1, position: "below" });
await nextTick();
editor.appendPastedRowsToNewRow(-1, [["Ada"], ["Grace"]], [0]);
expect(editor.newRows.value).toEqual([
["Ada", null, null],
["Grace", null, null],
[null, null, null],
]);
expect(buildOrderedGridRows([0, 1], editor.newRowMeta.value, editor.newRows.value.length)).toEqual([
{ kind: "source", sourceIndex: 0 },
{ kind: "new", newIndex: 0 },
{ kind: "new", newIndex: 1 },
{ kind: "source", sourceIndex: 1 },
{ kind: "new", newIndex: 2 },
]);
});
it("turns rows pasted into the terminal new-row draft into pending rows", () => {
const editor = createEditor();
editor.newRows.value = [];
+157 -47
View File
@@ -9,6 +9,7 @@ import { normalizeDataGridSaveError } from "@/lib/dataGrid/dataGridSql";
import { isOpaqueAggregateStateColumnType } from "@/lib/dataGrid/binaryCellDownload";
import { rowStatusFilterAfterAddingRow, type RowStatusFilter } from "@/lib/dataGrid/gridRowStatus";
import type { GridNewRowMeta, GridNewRowPlacement } from "@/lib/dataGrid/gridNewRowPlacement";
import { dataGridPendingRowLimit, dataGridPreparationBatchSize, finishDataGridRowPreparation, runDataGridRowPreparation, type DataGridRowPreparationOptions, type DataGridRowPreparationProgress } from "@/lib/dataGrid/dataGridRowPreparation";
import { supportsDataGridTransaction } from "@/lib/table/tableEditing";
import { useConnectionStore } from "@/stores/connectionStore";
import { useHistoryStore } from "@/stores/historyStore";
@@ -37,7 +38,6 @@ interface RowItem {
}
export const DATA_GRID_QUICK_ENTRY_DRAFT_ROW_ID = Number.MIN_SAFE_INTEGER;
export const DATA_GRID_MAX_BATCH_INSERT_ROWS = 1000;
type RowKind = "none" | "existing" | "new" | "draft";
type ConditionalUpdateOutcome = "not-started" | "running" | "completed" | "failed" | "unknown";
@@ -51,7 +51,7 @@ interface ConditionalUpdateExecution {
outcome: ConditionalUpdateOutcome;
}
export type DataGridAppendPastedRowsResult = { ok: true; rowCount: number } | { ok: false; reason: "not-editable" | "invalid-target" | "target-not-empty" | "empty-paste" | "readonly-column" | "no-matching-columns" };
export type DataGridAppendPastedRowsResult = { ok: true; rowCount: number } | { ok: false; reason: "not-editable" | "invalid-target" | "target-not-empty" | "empty-paste" | "readonly-column" | "no-matching-columns" | "capacity-exceeded" | "cancelled" };
type CommitEditResult =
| {
@@ -199,12 +199,13 @@ interface QueuedAutoSaveChange {
value: CellValue;
}
type PendingChangesHistorySnapshot = Pick<PendingChangesSnapshot, "newRows" | "newRowMeta" | "quickEntryDraftRow" | "dirtyRows" | "deletedRows" | "transactionActive" | "manualSaveRequired">;
type PendingChangesHistorySnapshot = Pick<PendingChangesSnapshot, "newRows" | "newRowMeta" | "quickEntryDraftRow" | "dirtyRows" | "deletedRows" | "transactionActive" | "manualSaveRequired"> & { cellCount?: number };
const pendingChangesCache = new Map<string, PendingChangesSnapshot>();
const closingPendingSnapshotTabs = new Set<string>();
const BEFORE_TAB_SWITCH_EVENT = "dbx:before-tab-switch";
const MAX_PENDING_CHANGES_HISTORY = 100;
const MAX_PENDING_HISTORY_CELLS = 2_000_000;
function dataGridRowsIdentityChanged(previousRows: CellValue[][] | undefined, nextRows: CellValue[][], appendedFromRowCount?: number): boolean {
if (!previousRows) return true;
@@ -286,6 +287,21 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
const scrollerRef = ref<GridScrollerRef | null>(null);
const dirtyRows = shallowRef<Map<number, Map<number, CellValue>>>(new Map());
const newRows = ref<CellValue[][]>([]);
const availableInsertRows = computed(() => Math.max(0, dataGridPendingRowLimit(result.value.columns.length) - newRows.value.length));
function canAllocateNewRows(count: number): boolean {
if (count <= availableInsertRows.value) return true;
saveError.value = i18n.global.t("grid.insertRowsCapacityExceeded", { max: availableInsertRows.value });
return false;
}
function rowPreparationIsCurrent(): () => boolean {
const version = pendingChangesVersion.value;
const rows = newRows.value;
const sourceRows = result.value.rows;
const columns = result.value.columns;
return () => !!editable.value && !isSaving.value && !isConditionalUpdateActive.value && pendingChangesVersion.value === version && newRows.value === rows && result.value.rows === sourceRows && result.value.columns === columns;
}
// Parallel to newRows: one stable token + display placement per pending row.
// Kept in lockstep with every structural mutation of newRows.
const newRowMeta = ref<GridNewRowMeta[]>([]);
@@ -294,7 +310,10 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
return { token: nextNewRowToken++, placement, sourceIndex, editedColumns: editedColumns?.length ? [...editedColumns] : undefined };
}
function cloneNewRowMeta(meta: readonly GridNewRowMeta[]): GridNewRowMeta[] {
return meta.map((item) => ({ token: item.token, placement: item.placement ? { ...item.placement } : null, sourceIndex: item.sourceIndex, editedColumns: item.editedColumns ? [...item.editedColumns] : undefined }));
return toRaw(meta).map((entry) => {
const item = toRaw(entry);
return { token: item.token, placement: item.placement ? { ...toRaw(item.placement) } : null, sourceIndex: item.sourceIndex, editedColumns: item.editedColumns ? [...toRaw(item.editedColumns)] : undefined };
});
}
function updateClonedRowEditedColumns(newIndex: number, col: number, value: CellValue) {
@@ -467,13 +486,35 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
}
function pendingChangesSnapshot(): PendingChangesHistorySnapshot {
return finishDataGridRowPreparation(preparePendingChangesSnapshot());
}
function* preparePendingChangesSnapshot(): Generator<DataGridRowPreparationProgress, PendingChangesHistorySnapshot> {
const sourceRows = toRaw(newRows.value);
const sourceMeta = toRaw(newRowMeta.value);
const rows: CellValue[][] = [];
const meta: GridNewRowMeta[] = [];
const batchSize = dataGridPreparationBatchSize(result.value.columns.length);
if (sourceRows.length > batchSize) yield { completed: 0, total: sourceRows.length };
let cellCount = 0;
for (let index = 0; index < sourceRows.length; index++) {
const row = toRaw(sourceRows[index]!);
rows.push([...row]);
cellCount += row.length;
if (sourceMeta[index]) meta.push(...cloneNewRowMeta([sourceMeta[index]!]));
if (sourceRows.length > batchSize && ((index + 1) % batchSize === 0 || index + 1 === sourceRows.length)) yield { completed: index + 1, total: sourceRows.length };
}
const changes = new Map([...toRaw(dirtyRows.value)].map(([rowIndex, values]) => [rowIndex, new Map(toRaw(values))]));
for (const values of changes.values()) cellCount += values.size;
cellCount += deletedRows.value.size + quickEntryDraftRow.value.length;
return {
cellCount,
manualSaveRequired: manualSaveRequired.value,
newRows: newRows.value.map((row) => [...row]),
newRowMeta: cloneNewRowMeta(newRowMeta.value),
quickEntryDraftRow: quickEntryDraftRow.value.length > 0 ? [...quickEntryDraftRow.value] : undefined,
dirtyRows: new Map([...dirtyRows.value].map(([rowIndex, changes]) => [rowIndex, new Map(changes)])),
deletedRows: new Set(deletedRows.value),
newRows: rows,
newRowMeta: meta,
quickEntryDraftRow: quickEntryDraftRow.value.length > 0 ? [...toRaw(quickEntryDraftRow.value)] : undefined,
dirtyRows: changes,
deletedRows: new Set(toRaw(deletedRows.value)),
transactionActive: transactionActive.value,
};
}
@@ -504,8 +545,21 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
touchPendingChanges();
}
function pushUndoSnapshot() {
undoStack.value = [...undoStack.value.slice(-MAX_PENDING_CHANGES_HISTORY + 1), pendingChangesSnapshot()];
function boundedPendingHistory(history: readonly PendingChangesHistorySnapshot[], snapshot: PendingChangesHistorySnapshot): PendingChangesHistorySnapshot[] {
const retained = [snapshot];
let cells = snapshot.cellCount ?? 0;
for (let index = history.length - 1; index >= 0 && retained.length < MAX_PENDING_CHANGES_HISTORY; index--) {
const previous = history[index]!;
const previousCells = previous.cellCount ?? 0;
if (cells + previousCells > MAX_PENDING_HISTORY_CELLS) break;
cells += previousCells;
retained.push(previous);
}
return retained.reverse();
}
function pushUndoSnapshot(snapshot = pendingChangesSnapshot()) {
undoStack.value = boundedPendingHistory(undoStack.value, snapshot);
redoStack.value = [];
}
@@ -518,7 +572,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
const snapshot = undoStack.value[undoStack.value.length - 1];
if (!snapshot) return;
undoStack.value = undoStack.value.slice(0, -1);
redoStack.value = [...redoStack.value, pendingChangesSnapshot()];
redoStack.value = boundedPendingHistory(redoStack.value, pendingChangesSnapshot());
restorePendingChangesSnapshot(snapshot);
}
@@ -526,7 +580,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
const snapshot = redoStack.value[redoStack.value.length - 1];
if (!snapshot) return;
redoStack.value = redoStack.value.slice(0, -1);
undoStack.value = [...undoStack.value, pendingChangesSnapshot()];
undoStack.value = boundedPendingHistory(undoStack.value, pendingChangesSnapshot());
restorePendingChangesSnapshot(snapshot);
}
@@ -828,6 +882,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
quickEntryDraftRow.value = emptyDraftRow();
return;
}
if (!canAllocateNewRows(1)) return;
rowStatusFilter.value = rowStatusFilterAfterAddingRow(rowStatusFilter.value);
newRows.value = [...newRows.value, [...quickEntryDraftRow.value]];
newRowMeta.value = [...newRowMeta.value, allocateNewRowMeta(null)];
@@ -897,6 +952,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
if (options.promoteDraft === false) {
return { changed: false, rowKind: "draft" };
}
if (!canAllocateNewRows(1)) return { changed: false, rowKind: "draft" };
rowStatusFilter.value = rowStatusFilterAfterAddingRow(rowStatusFilter.value);
newRows.value = [...newRows.value, nextDraftRow];
newRowMeta.value = [...newRowMeta.value, allocateNewRowMeta(null)];
@@ -1182,6 +1238,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
function addRow() {
if (isConditionalUpdateActive.value) return;
if (!canAllocateNewRows(1)) return;
pushUndoSnapshot();
rowStatusFilter.value = rowStatusFilterAfterAddingRow(rowStatusFilter.value);
newRows.value.push(result.value.columns.map(() => null));
@@ -1210,19 +1267,37 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
// positions). Returns the first inserted row's id, or undefined when the
// count was rejected.
function addRows(count: number, placement: GridNewRowPlacement | null = null): number | undefined {
return finishDataGridRowPreparation(prepareAddedRows(count, placement));
}
function addRowsInBatches(count: number, placement: GridNewRowPlacement | null = null, preparation: DataGridRowPreparationOptions = {}): Promise<number | undefined> {
const isCurrent = rowPreparationIsCurrent();
return runDataGridRowPreparation(prepareAddedRows(count, placement), { ...preparation, isCurrent: () => isCurrent() && preparation.isCurrent?.() !== false }, undefined);
}
function* prepareAddedRows(count: number, placement: GridNewRowPlacement | null): Generator<DataGridRowPreparationProgress, number | undefined> {
if (isConditionalUpdateActive.value) return undefined;
if (!Number.isInteger(count) || count <= 0) return undefined;
const clampedCount = Math.min(count, DATA_GRID_MAX_BATCH_INSERT_ROWS);
if (!Number.isSafeInteger(count) || count <= 0) return undefined;
if (!canAllocateNewRows(count)) return undefined;
const firstNewIndex = newRows.value.length;
pushUndoSnapshot();
rowStatusFilter.value = rowStatusFilterAfterAddingRow(rowStatusFilter.value);
const isCurrent = rowPreparationIsCurrent();
const batchSize = dataGridPreparationBatchSize(result.value.columns.length);
const preparedRows: CellValue[][] = [];
const preparedMeta: GridNewRowMeta[] = [];
const blankRow = result.value.columns.map(() => null);
for (let i = 0; i < clampedCount; i++) {
newRows.value.push([...blankRow]);
newRowMeta.value.push(allocateNewRowMeta(placement));
if (count > batchSize) yield { completed: 0, total: count };
for (let i = 0; i < count; i++) {
preparedRows.push([...blankRow]);
preparedMeta.push(allocateNewRowMeta(placement));
if (count > batchSize && ((i + 1) % batchSize === 0 || i + 1 === count)) yield { completed: i + 1, total: count };
}
newRows.value = [...newRows.value];
newRowMeta.value = [...newRowMeta.value];
if (!isCurrent()) return undefined;
const snapshot = yield* preparePendingChangesSnapshot();
if (!isCurrent()) return undefined;
pushUndoSnapshot(snapshot);
rowStatusFilter.value = rowStatusFilterAfterAddingRow(rowStatusFilter.value);
newRows.value = [...newRows.value, ...preparedRows];
newRowMeta.value = [...newRowMeta.value, ...preparedMeta];
touchPendingChanges();
if (useTransaction.value && !transactionActive.value) {
enterTransaction();
@@ -1241,17 +1316,36 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
}
function appendPastedRowsAsNewRows(pastedRows: readonly (readonly (string | null)[])[], columnIndexes: readonly number[], columnNames?: readonly string[] | null): DataGridAppendPastedRowsResult {
return appendPastedRows(null, pastedRows, columnIndexes, columnNames);
return finishDataGridRowPreparation(preparePastedRows(null, pastedRows, columnIndexes, columnNames));
}
function appendPastedRowsToNewRow(targetRowId: number, pastedRows: readonly (readonly (string | null)[])[], columnIndexes: readonly number[], columnNames?: readonly string[] | null): DataGridAppendPastedRowsResult {
return appendPastedRows(targetRowId, pastedRows, columnIndexes, columnNames);
return finishDataGridRowPreparation(preparePastedRows(targetRowId, pastedRows, columnIndexes, columnNames));
}
function appendPastedRowsAsNewRowsInBatches(pastedRows: readonly (readonly (string | null)[])[], columnIndexes: readonly number[], columnNames?: readonly string[] | null, preparation: DataGridRowPreparationOptions = {}): Promise<DataGridAppendPastedRowsResult> {
return appendPastedRowsInBatches(null, pastedRows, columnIndexes, columnNames, preparation);
}
function appendPastedRowsToNewRowInBatches(targetRowId: number, pastedRows: readonly (readonly (string | null)[])[], columnIndexes: readonly number[], columnNames?: readonly string[] | null, preparation: DataGridRowPreparationOptions = {}): Promise<DataGridAppendPastedRowsResult> {
return appendPastedRowsInBatches(targetRowId, pastedRows, columnIndexes, columnNames, preparation);
}
function appendPastedRowsInBatches(targetRowId: number | null, pastedRows: readonly (readonly (string | null)[])[], columnIndexes: readonly number[], columnNames: readonly string[] | null | undefined, preparation: DataGridRowPreparationOptions): Promise<DataGridAppendPastedRowsResult> {
const isCurrent = rowPreparationIsCurrent();
return runDataGridRowPreparation(preparePastedRows(targetRowId, pastedRows, columnIndexes, columnNames), { ...preparation, isCurrent: () => isCurrent() && preparation.isCurrent?.() !== false }, { ok: false, reason: "cancelled" });
}
// A null target appends populated rows directly, without a preparatory blank
// row or a second undo entry. Cell/row paste still validates its blank target.
function appendPastedRows(targetRowId: number | null, pastedRows: readonly (readonly (string | null)[])[], columnIndexes: readonly number[], columnNames?: readonly string[] | null): DataGridAppendPastedRowsResult {
function* preparePastedRows(targetRowId: number | null, pastedRows: readonly (readonly (string | null)[])[], columnIndexes: readonly number[], columnNames?: readonly string[] | null): Generator<DataGridRowPreparationProgress, DataGridAppendPastedRowsResult> {
if (!editable.value) return { ok: false, reason: "not-editable" };
const batchSize = dataGridPreparationBatchSize(result.value.columns.length);
if (pastedRows.length > dataGridPendingRowLimit(result.value.columns.length)) {
canAllocateNewRows(pastedRows.length);
return { ok: false, reason: "capacity-exceeded" };
}
if (pastedRows.length > batchSize || newRows.value.length > batchSize) yield { completed: 0, total: pastedRows.length };
if (pastedRows.every((row) => row.every((value) => value === ""))) {
return { ok: false, reason: "empty-paste" };
}
@@ -1267,7 +1361,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
const targetRow = targetIsDraft ? quickEntryDraftRow.value : targetNewIndex === undefined ? undefined : newRows.value[targetNewIndex];
if (targetRowId !== null && (!targetRow || !isBlankNewRow(targetRow))) return { ok: false, reason: "target-not-empty" };
const pastedColumnCount = Math.max(...pastedRows.map((row) => row.length));
const pastedColumnCount = pastedRows.reduce((max, row) => Math.max(max, row.length), 0);
if (pastedColumnCount <= 0) return { ok: false, reason: "empty-paste" };
// With explicit column names (SQL INSERT), align pasted values by column
@@ -1292,18 +1386,29 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
if (valueTargets.length === 0) return { ok: false, reason: "no-matching-columns" };
if (valueTargets.some(({ columnIndex }) => !canEditColumn(columnIndex))) return { ok: false, reason: "readonly-column" };
const nextRows = newRows.value.map((row) => [...row]);
const nextMeta = cloneNewRowMeta(newRowMeta.value);
const isCurrent = rowPreparationIsCurrent();
const sourceRows = toRaw(newRows.value);
const sourceMeta = toRaw(newRowMeta.value);
const targetPlacement = targetNewIndex === undefined ? null : (sourceMeta[targetNewIndex]?.placement ?? null);
let reusableNewRowCount = 0;
if (targetNewIndex !== undefined) {
for (let rowIndex = targetNewIndex!; rowIndex < nextRows.length && reusableNewRowCount < pastedRows.length; rowIndex++) {
if (!isBlankNewRow(nextRows[rowIndex]!)) break;
for (let rowIndex = targetNewIndex!; rowIndex < sourceRows.length && reusableNewRowCount < pastedRows.length; rowIndex++) {
if (!isBlankNewRow(sourceRows[rowIndex]!)) break;
const placement = sourceMeta[rowIndex]?.placement ?? null;
if (placement?.anchorId !== targetPlacement?.anchorId || placement?.position !== targetPlacement?.position) break;
reusableNewRowCount++;
if (reusableNewRowCount % batchSize === 0) yield { completed: 0, total: pastedRows.length };
}
}
if (!canAllocateNewRows(pastedRows.length - reusableNewRowCount)) return { ok: false, reason: "capacity-exceeded" };
let nextRows: CellValue[][];
let nextMeta: GridNewRowMeta[];
const shouldClearColumn = clonedColumnClearPredicate();
const mappedRows = pastedRows.map((pastedRow, rowIndex) => {
const nextRow = rowIndex < reusableNewRowCount ? nextRows[targetNewIndex! + rowIndex]! : emptyDraftRow();
const mappedRows: CellValue[][] = [];
const mappedMeta: GridNewRowMeta[] = [];
for (let rowIndex = 0; rowIndex < pastedRows.length; rowIndex++) {
const pastedRow = pastedRows[rowIndex]!;
const nextRow = rowIndex < reusableNewRowCount ? [...toRaw(sourceRows[targetNewIndex! + rowIndex]!)] : emptyDraftRow();
for (const { columnIndex, valueIndex } of valueTargets) {
if (valueIndex >= pastedRow.length) continue;
// Pasting a copied row into a new row must not reuse its generated key.
@@ -1314,25 +1419,24 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
const value = pastedRow[valueIndex];
nextRow[columnIndex] = value === null ? null : coerceCellValue(value, nextRow[columnIndex], columnIndex);
}
return nextRow;
});
mappedRows.push(nextRow);
mappedMeta.push(rowIndex < reusableNewRowCount && sourceMeta[targetNewIndex! + rowIndex] ? cloneNewRowMeta([sourceMeta[targetNewIndex! + rowIndex]!])[0]! : allocateNewRowMeta(targetPlacement));
if (pastedRows.length > batchSize && ((rowIndex + 1) % batchSize === 0 || rowIndex + 1 === pastedRows.length)) yield { completed: rowIndex + 1, total: pastedRows.length };
}
pushUndoSnapshot();
if (!isCurrent()) return { ok: false, reason: "cancelled" };
const snapshot = yield* preparePendingChangesSnapshot();
if (!isCurrent()) return { ok: false, reason: "cancelled" };
pushUndoSnapshot(snapshot);
if (targetIsDraft || targetRowId === null) {
nextRows.push(...mappedRows);
for (let i = 0; i < mappedRows.length; i++) nextMeta.push(allocateNewRowMeta(null));
nextRows = sourceRows.concat(mappedRows);
nextMeta = sourceMeta.concat(mappedMeta);
if (targetIsDraft) quickEntryDraftRow.value = emptyDraftRow();
} else {
// Reused blank rows keep their original placement; rows added beyond the
// reusable count append at the end (preserving existing paste behavior).
const newMetas = mappedRows.map((_, rowOffset) => {
if (rowOffset < reusableNewRowCount) {
return nextMeta[targetNewIndex! + rowOffset] ?? allocateNewRowMeta(null);
}
return allocateNewRowMeta(null);
});
nextMeta.splice(targetNewIndex!, reusableNewRowCount, ...newMetas);
nextRows.splice(targetNewIndex!, reusableNewRowCount, ...mappedRows);
// Keep the expanded batch at its insertion anchor, without consuming
// blank rows that were inserted elsewhere in the grid.
nextMeta = [...sourceMeta.slice(0, targetNewIndex), ...mappedMeta, ...sourceMeta.slice(targetNewIndex! + reusableNewRowCount)];
nextRows = [...sourceRows.slice(0, targetNewIndex), ...mappedRows, ...sourceRows.slice(targetNewIndex! + reusableNewRowCount)];
}
newRows.value = nextRows;
newRowMeta.value = nextMeta;
@@ -1379,6 +1483,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
function cloneRow(rowId: number, resolvedValues?: ReadonlyMap<number, CellValue>) {
const item = getRowItem(rowId);
if (!item) return;
if (!canAllocateNewRows(1)) return;
const clonedData = clonedRowData(item, resolvedValues);
pushUndoSnapshot();
rowStatusFilter.value = rowStatusFilterAfterAddingRow(rowStatusFilter.value);
@@ -1403,6 +1508,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
if (isConditionalUpdateActive.value) return;
const rowsToClone = rowIds.map((rowId) => getRowItem(rowId)).filter(Boolean) as RowItem[];
if (rowsToClone.length === 0) return;
if (!canAllocateNewRows(rowsToClone.length)) return;
pushUndoSnapshot();
rowStatusFilter.value = rowStatusFilterAfterAddingRow(rowStatusFilter.value);
for (const item of rowsToClone) {
@@ -2361,8 +2467,12 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
onEditKeydown,
addRow,
addRows,
addRowsInBatches,
availableInsertRows,
appendPastedRowsToNewRow,
appendPastedRowsAsNewRows,
appendPastedRowsToNewRowInBatches,
appendPastedRowsAsNewRowsInBatches,
cloneRow,
cloneRows,
applyDeleteRows,
+6 -1
View File
@@ -2201,7 +2201,12 @@ export default withEnglishFallback({
insertRowsDescription: "Bu sayda yadda saxlanmamış qaralama sətir yaradın. Yadda saxlayarkən boş sətirlər nəzərə alınmır; yadda saxlandıqdan sonra göstərilmə sırası cari sorğuya uyğun olur.",
insertRowCountLabel: "Sətir sayı",
insertRowCountInvalid: "0-dan böyük tam ədəd daxil edin.",
insertRowsMaxHint: "Ən çox {max} sətir.",
insertRowsMaxHint: "Sütunlara və saxlanılmamış sətirlərə əsasən daha {max} sətir əlavə edilə bilər.",
insertRowsCapacityExceeded: "Yalnız daha {max} sətir əlavə edilə bilər. Gözləyən sətirləri saxlayın və ya ləğv edin.",
insertRowsPreparing: "Sətirlər hazırlanır",
insertRowsParsing: "Mübadilə buferi təhlil edilir",
insertRowsProgress: "{current} / {total} sətir",
insertRowsClipboardTooLarge: "Mübadilə buferinin məlumatları çox böyükdür. Daha kiçik qruplarla yerləşdirin və saxlayın.",
insertRowsConfirm: "Əlavə et",
insertAboveSelected: "Seçilmiş sətrin üstünə əlavə et",
insertBelowSelected: "Seçilmiş sətrin altına əlavə et",
+6 -1
View File
@@ -2648,7 +2648,12 @@ export default {
insertRowsDescription: "Create this many unsaved draft rows. Empty rows are ignored when saving; after saving, the display order follows the current query.",
insertRowCountLabel: "Row count",
insertRowCountInvalid: "Enter a whole number greater than 0.",
insertRowsMaxHint: "Up to {max} rows.",
insertRowsMaxHint: "Room for {max} more rows, based on columns and unsaved rows.",
insertRowsCapacityExceeded: "Only {max} more rows fit in this grid. Save or discard pending rows before adding more.",
insertRowsPreparing: "Preparing rows",
insertRowsParsing: "Parsing clipboard",
insertRowsProgress: "{current} / {total} rows",
insertRowsClipboardTooLarge: "Clipboard data is too large for this grid. Paste and save in smaller batches.",
insertRowsConfirm: "Insert",
insertAboveSelected: "Insert Above Selected Row",
insertBelowSelected: "Insert Below Selected Row",
+6 -1
View File
@@ -2473,7 +2473,12 @@ export default withEnglishFallback({
insertRowsDescription: "Crea este número de filas de borrador sin guardar. Las filas vacías se ignoran al guardar; después de guardar, el orden de visualización sigue la consulta actual.",
insertRowCountLabel: "Número de filas",
insertRowCountInvalid: "Introduce un número entero mayor que 0.",
insertRowsMaxHint: "Hasta {max} filas.",
insertRowsMaxHint: "Espacio para {max} filas más, según las columnas y las filas sin guardar.",
insertRowsCapacityExceeded: "Solo caben {max} filas más. Guarda o descarta las filas pendientes.",
insertRowsPreparing: "Preparando filas",
insertRowsParsing: "Analizando el portapapeles",
insertRowsProgress: "{current} / {total} filas",
insertRowsClipboardTooLarge: "Los datos del portapapeles son demasiado grandes. Pega y guarda en lotes más pequeños.",
insertRowsConfirm: "Insertar",
insertAboveSelected: "Insertar encima de la fila seleccionada",
insertBelowSelected: "Insertar debajo de la fila seleccionada",
+6 -1
View File
@@ -2482,7 +2482,12 @@ export default withEnglishFallback({
insertRowsDescription: "Buat baris draf yang belum disimpan sebanyak ini. Baris kosong diabaikan saat menyimpan; setelah disimpan, urutan tampilan mengikuti kueri saat ini.",
insertRowCountLabel: "Jumlah baris",
insertRowCountInvalid: "Masukkan bilangan bulat lebih besar dari 0.",
insertRowsMaxHint: "Hingga {max} baris.",
insertRowsMaxHint: "Masih dapat menambah {max} baris, berdasarkan kolom dan baris yang belum disimpan.",
insertRowsCapacityExceeded: "Hanya {max} baris lagi yang dapat ditambahkan. Simpan atau buang baris tertunda.",
insertRowsPreparing: "Menyiapkan baris",
insertRowsParsing: "Mengurai papan klip",
insertRowsProgress: "{current} / {total} baris",
insertRowsClipboardTooLarge: "Data clipboard terlalu besar. Tempel dan simpan dalam kelompok yang lebih kecil.",
insertRowsConfirm: "Sisipkan",
insertAboveSelected: "Sisipkan di Atas Baris Terpilih",
insertBelowSelected: "Sisipkan di Bawah Baris Terpilih",
+6 -1
View File
@@ -2366,7 +2366,12 @@ export default withEnglishFallback({
insertRowsDescription: "Crea questo numero di righe di bozza non salvate. Le righe vuote vengono ignorate al salvataggio; dopo il salvataggio, l'ordine di visualizzazione segue la query corrente.",
insertRowCountLabel: "Numero di righe",
insertRowCountInvalid: "Inserisci un numero intero maggiore di 0.",
insertRowsMaxHint: "Fino a {max} righe.",
insertRowsMaxHint: "Spazio per altre {max} righe, in base alle colonne e alle righe non salvate.",
insertRowsCapacityExceeded: "È possibile aggiungere solo altre {max} righe. Salva o scarta le righe in sospeso.",
insertRowsPreparing: "Preparazione delle righe",
insertRowsParsing: "Analisi degli appunti",
insertRowsProgress: "{current} / {total} righe",
insertRowsClipboardTooLarge: "I dati degli appunti sono troppo grandi. Incolla e salva in gruppi più piccoli.",
insertRowsConfirm: "Inserisci",
insertAboveSelected: "Inserisci sopra la riga selezionata",
insertBelowSelected: "Inserisci sotto la riga selezionata",
+6 -1
View File
@@ -2373,7 +2373,12 @@ export default withEnglishFallback({
insertRowsDescription: "未保存の下書き行を指定数作成します。保存時、空の行は無視されます。保存後の表示順は現在のクエリに従います。",
insertRowCountLabel: "行数",
insertRowCountInvalid: "0 より大きい整数を入力してください。",
insertRowsMaxHint: "最大 {max} 行まで。",
insertRowsMaxHint: "列数と未保存の行に基づき、あと {max} 行追加できます。",
insertRowsCapacityExceeded: "追加できるのはあと {max} 行です。未保存の行を保存または破棄してください。",
insertRowsPreparing: "行を準備中",
insertRowsParsing: "クリップボードを解析中",
insertRowsProgress: "{current} / {total} 行",
insertRowsClipboardTooLarge: "クリップボードのデータが大きすぎます。分割して貼り付け、保存してください。",
insertRowsConfirm: "挿入",
insertAboveSelected: "選択行の上に挿入",
insertBelowSelected: "選択行の下に挿入",
+6 -1
View File
@@ -2420,7 +2420,12 @@ export default withEnglishFallback({
insertRowsDescription: "저장되지 않은 초안 행을 지정한 개수만큼 만듭니다. 저장 시 비어 있는 행은 무시되며, 저장 후 표시 순서는 현재 쿼리를 따릅니다.",
insertRowCountLabel: "행 수",
insertRowCountInvalid: "0보다 큰 정수를 입력하세요.",
insertRowsMaxHint: "최대 {max}행까지.",
insertRowsMaxHint: "열 수와 저장하지 않은 행을 기준으로 {max}행을 더 추가할 수 있습니다.",
insertRowsCapacityExceeded: "{max}행만 더 추가할 수 있습니다. 대기 중인 행을 저장하거나 버리세요.",
insertRowsPreparing: "행 준비 중",
insertRowsParsing: "클립보드 분석 중",
insertRowsProgress: "{current} / {total}행",
insertRowsClipboardTooLarge: "클립보드 데이터가 너무 큽니다. 나누어 붙여넣고 저장하세요.",
insertRowsConfirm: "삽입",
insertAboveSelected: "선택 행 위에 삽입",
insertBelowSelected: "선택 행 아래에 삽입",
+6 -1
View File
@@ -2368,7 +2368,12 @@ export default withEnglishFallback({
insertRowsDescription: "Cria este número de linhas de rascunho não salvas. Linhas vazias são ignoradas ao salvar; após salvar, a ordem de exibição segue a consulta atual.",
insertRowCountLabel: "Número de linhas",
insertRowCountInvalid: "Digite um número inteiro maior que 0.",
insertRowsMaxHint: "Até {max} linhas.",
insertRowsMaxHint: "Espaço para mais {max} linhas, conforme as colunas e linhas não salvas.",
insertRowsCapacityExceeded: "Só cabem mais {max} linhas. Salve ou descarte as linhas pendentes.",
insertRowsPreparing: "Preparando linhas",
insertRowsParsing: "Analisando a área de transferência",
insertRowsProgress: "{current} / {total} linhas",
insertRowsClipboardTooLarge: "Os dados da área de transferência são grandes demais. Cole e salve em lotes menores.",
insertRowsConfirm: "Inserir",
insertAboveSelected: "Inserir acima da linha selecionada",
insertBelowSelected: "Inserir abaixo da linha selecionada",
+6 -1
View File
@@ -2542,7 +2542,12 @@ export default withEnglishFallback({
insertRowsDescription: "Создаёт указанное число несохранённых черновых строк. Пустые строки игнорируются при сохранении; после сохранения порядок отображения соответствует текущему запросу.",
insertRowCountLabel: "Количество строк",
insertRowCountInvalid: "Введите целое число больше 0.",
insertRowsMaxHint: "Не более {max} строк.",
insertRowsMaxHint: "Можно добавить ещё {max} строк с учётом столбцов и несохранённых строк.",
insertRowsCapacityExceeded: "Можно добавить только {max} строк. Сохраните или отмените ожидающие строки.",
insertRowsPreparing: "Подготовка строк",
insertRowsParsing: "Разбор буфера обмена",
insertRowsProgress: "{current} / {total} строк",
insertRowsClipboardTooLarge: "Данные буфера обмена слишком велики. Вставляйте и сохраняйте меньшими партиями.",
insertRowsConfirm: "Вставить",
insertAboveSelected: "Вставить выше выбранной строки",
insertBelowSelected: "Вставить ниже выбранной строки",
+6 -1
View File
@@ -2194,7 +2194,12 @@ export default withEnglishFallback({
insertRowsDescription: "Bu kadar kaydedilmemiş taslak satır oluşturun. Boş satırlar kaydedilirken yok sayılır; kaydettikten sonra görüntülenme sırası geçerli sorguyu izler.",
insertRowCountLabel: "Satır sayısı",
insertRowCountInvalid: "0'dan büyük bir tam sayı girin.",
insertRowsMaxHint: "En fazla {max} satır.",
insertRowsMaxHint: "Sütunlara ve kaydedilmemiş satırlara göre {max} satır daha eklenebilir.",
insertRowsCapacityExceeded: "Yalnızca {max} satır daha eklenebilir. Bekleyen satırları kaydedin veya silin.",
insertRowsPreparing: "Satırlar hazırlanıyor",
insertRowsParsing: "Pano ayrıştırılıyor",
insertRowsProgress: "{current} / {total} satır",
insertRowsClipboardTooLarge: "Pano verileri çok büyük. Daha küçük gruplar halinde yapıştırıp kaydedin.",
insertRowsConfirm: "Ekle",
insertAboveSelected: "Seçili Satırın Üstüne Ekle",
insertBelowSelected: "Seçili Satırın Altına Ekle",
+6 -1
View File
@@ -2583,7 +2583,12 @@ export default withEnglishFallback({
insertRowsDescription: "创建指定数量的未保存草稿行。保存时未填写的空行会被忽略;保存后的显示位置由当前查询排序决定。",
insertRowCountLabel: "行数",
insertRowCountInvalid: "请输入大于 0 的整数。",
insertRowsMaxHint: "最多 {max} 行。",
insertRowsMaxHint: "按列数及未保存数据计算,当前可再新增 {max} 行。",
insertRowsCapacityExceeded: "当前最多可再新增 {max} 行,请先保存或放弃待提交数据。",
insertRowsPreparing: "正在准备数据行",
insertRowsParsing: "正在解析剪贴板",
insertRowsProgress: "已准备 {current} / {total} 行",
insertRowsClipboardTooLarge: "剪贴板数据超出当前网格容量,请分批粘贴并保存。",
insertRowsConfirm: "插入",
insertAboveSelected: "在选中行上方插入",
insertBelowSelected: "在选中行下方插入",
+6 -1
View File
@@ -2369,7 +2369,12 @@ export default withEnglishFallback({
insertRowsDescription: "建立指定數量的未儲存草稿列。儲存時未填寫的空白列會被忽略;儲存後的顯示位置由目前查詢排序決定。",
insertRowCountLabel: "列數",
insertRowCountInvalid: "請輸入大於 0 的整數。",
insertRowsMaxHint: "最多 {max} 列。",
insertRowsMaxHint: "依欄位數與未儲存資料計算,目前可再新增 {max} 列。",
insertRowsCapacityExceeded: "目前最多可再新增 {max} 列,請先儲存或捨棄待提交資料。",
insertRowsPreparing: "正在準備資料列",
insertRowsParsing: "正在解析剪貼簿",
insertRowsProgress: "已準備 {current} / {total} 列",
insertRowsClipboardTooLarge: "剪貼簿資料超出目前網格容量,請分批貼上並儲存。",
insertRowsConfirm: "插入",
insertAboveSelected: "在所選列上方插入",
insertBelowSelected: "在所選列下方插入",
@@ -1,7 +1,8 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { copyToClipboard } from "@/lib/common/clipboard";
import { claimDataGridPaste, claimDataGridSelectAll, clearDataGridClipboardCopy, parseDataGridClipboard, planDataGridPaste, rememberDataGridClipboardCopy } from "@/lib/dataGrid/dataGridClipboard";
import { claimDataGridPaste, claimDataGridSelectAll, clearDataGridClipboardCopy, parseDataGridClipboard, parseDataGridClipboardInBatches, planDataGridPaste, rememberDataGridClipboardCopy } from "@/lib/dataGrid/dataGridClipboard";
import { DataGridClipboardCapacityError } from "@/lib/dataGrid/dataGridRowPreparation";
afterEach(() => clearDataGridClipboardCopy());
@@ -103,6 +104,32 @@ describe("planDataGridPaste", () => {
});
describe("parseDataGridClipboard", () => {
it("bounds logical rows and cells before allocating an oversized clipboard", () => {
expect(() => parseDataGridClipboard("a\nb\nc", { maxRows: 2 })).toThrow(DataGridClipboardCapacityError);
expect(() => parseDataGridClipboard("a\tb\tc", { maxCells: 2 })).toThrow(DataGridClipboardCapacityError);
});
it("counts internal multiline values as one logical row", () => {
const text = "line\n".repeat(1000);
rememberDataGridClipboardCopy(text, [[text]]);
expect(parseDataGridClipboard(text, { maxRows: 1, maxCells: 1 })).toEqual([[text]]);
});
it.each(["a\r\nb\r\n", "a\rb\r", "a\nb\n"])("preserves line endings through batched parsing: %j", async (text) => {
expect(await parseDataGridClipboardInBatches(text)).toEqual([["a"], ["b"]]);
});
it("cancels during parsing before returning a clipboard matrix", async () => {
const controller = new AbortController();
const result = await parseDataGridClipboardInBatches("a\n".repeat(50_000), {
signal: controller.signal,
onProgress: ({ completed }) => {
if (completed > 0) controller.abort();
},
});
expect(result).toBeNull();
});
it("restores null values copied from the DBX grid", () => {
rememberDataGridClipboardCopy("NULL\tNULL", [[null, "NULL"]]);
@@ -1,8 +1,44 @@
import { describe, expect, it } from "vitest";
import { parseInsertStatementPaste } from "@/lib/dataGrid/dataGridInsertPaste";
import { parseInsertStatementPaste, parseInsertStatementPasteInBatches } from "@/lib/dataGrid/dataGridInsertPaste";
import { DataGridClipboardCapacityError } from "@/lib/dataGrid/dataGridRowPreparation";
describe("parseInsertStatementPaste", () => {
it("rejects excessive logical tuples in a single SQL line without a spread stack overflow", () => {
const text = `INSERT INTO t VALUES ${Array.from({ length: 200_000 }, () => "(1)").join(",")}`;
expect(() => parseInsertStatementPaste(text)).toThrow(DataGridClipboardCapacityError);
});
it("bounds cells while parsing a wide tuple and rows across statements", () => {
expect(() => parseInsertStatementPaste("INSERT INTO t VALUES (1,2,3)", { maxCells: 2 })).toThrow(DataGridClipboardCapacityError);
expect(() => parseInsertStatementPaste("INSERT INTO t VALUES (1); INSERT INTO t VALUES (2)", { maxRows: 1 })).toThrow(DataGridClipboardCapacityError);
});
it("can cancel before allocating rows during a long SQL literal scan", async () => {
const controller = new AbortController();
const progress: number[] = [];
const text = `INSERT INTO t VALUES ('${"a".repeat(1_000_000)}')`;
const parsed = await parseInsertStatementPasteInBatches(text, {
signal: controller.signal,
onProgress: ({ completed, phase }) => {
expect(phase).toBe("parsing");
progress.push(completed);
if (completed > 0) controller.abort();
},
});
expect(parsed).toBeNull();
expect(progress.at(-1)).toBeLessThan(text.length);
});
it("parses a large SQL batch completely through the asynchronous path", async () => {
const text = `INSERT INTO t (a) VALUES ${Array.from({ length: 5000 }, (_, i) => `(${i})`).join(",")}`;
const progress: number[] = [];
const parsed = await parseInsertStatementPasteInBatches(text, { onProgress: ({ completed }) => progress.push(completed) });
expect(parsed?.rows).toHaveLength(5000);
expect(parsed?.rows.at(-1)).toEqual(["4999"]);
expect(progress.length).toBeGreaterThan(2);
});
it("returns null for non-INSERT text", () => {
expect(parseInsertStatementPaste("name\tage\nalice\t30")).toBeNull();
expect(parseInsertStatementPaste("SELECT * FROM users")).toBeNull();
@@ -1,6 +1,6 @@
import { eventTargetUsesNativeClipboard, getClipboardWriteRevision } from "@/lib/common/clipboard";
import { displayCellValue, type CellValue } from "@/lib/dataGrid/cellValue";
import { parseClipboardTable } from "@/lib/dataGrid/gridSelection";
import { DATA_GRID_CLIPBOARD_BATCH_CHARS, DataGridClipboardCapacityError, finishDataGridRowPreparation, runDataGridRowPreparation, type DataGridClipboardLimits, type DataGridRowPreparationOptions, type DataGridRowPreparationProgress } from "@/lib/dataGrid/dataGridRowPreparation";
export type DataGridPasteIntent = "native" | "block" | "paste";
export type DataGridSelectAllIntent = "native" | "block" | "select";
@@ -54,11 +54,60 @@ export function rememberDataGridClipboardCopy(text: string, rows: readonly (read
internalClipboardCopy = { text, rows: [...headerRows, ...copiedRows], writeRevision: getClipboardWriteRevision() };
}
export function parseDataGridClipboard(text: string): Array<Array<string | null>> {
export function parseDataGridClipboard(text: string, limits: DataGridClipboardLimits = {}): Array<Array<string | null>> {
return finishDataGridRowPreparation(prepareDataGridClipboard(text, limits));
}
export function parseDataGridClipboardInBatches(text: string, options: DataGridRowPreparationOptions = {}, limits: DataGridClipboardLimits = {}): Promise<Array<Array<string | null>> | null> {
return runDataGridRowPreparation(prepareDataGridClipboard(text, limits), options, null);
}
function* prepareDataGridClipboard(text: string, limits: DataGridClipboardLimits): Generator<DataGridRowPreparationProgress, Array<Array<string | null>>> {
const maxRows = limits.maxRows ?? 100_000;
const maxCells = limits.maxCells ?? 1_000_000;
let cells = 0;
const rows: Array<Array<string | null>> = [];
if (internalClipboardCopy?.text === text && internalClipboardCopy.writeRevision === getClipboardWriteRevision()) {
return internalClipboardCopy.rows.map((row) => [...row]);
const source = internalClipboardCopy.rows;
if (source.length > maxRows) throw new DataGridClipboardCapacityError();
if (source.length > 1000) yield { completed: 0, total: source.length, phase: "parsing" };
for (const row of source) {
cells += row.length;
if (cells > maxCells) throw new DataGridClipboardCapacityError();
rows.push([...row]);
if (rows.length % 1000 === 0) yield { completed: rows.length, total: source.length, phase: "parsing" };
}
return rows;
}
return parseClipboardTable(text);
let row: Array<string | null> = [];
let start = 0;
let nextYield = DATA_GRID_CLIPBOARD_BATCH_CHARS;
if (text.length > DATA_GRID_CLIPBOARD_BATCH_CHARS) yield { completed: 0, total: text.length, phase: "parsing" };
const appendCell = (end: number) => {
if (rows.length >= maxRows || ++cells > maxCells) throw new DataGridClipboardCapacityError();
row.push(text.slice(start, end));
};
for (let index = 0; index < text.length; index++) {
if (index >= nextYield) {
yield { completed: index, total: text.length, phase: "parsing" };
nextYield = index + DATA_GRID_CLIPBOARD_BATCH_CHARS;
}
const char = text[index];
if (char !== "\t" && char !== "\r" && char !== "\n") continue;
appendCell(index);
if (char !== "\t") {
rows.push(row);
row = [];
if (char === "\r" && text[index + 1] === "\n") index++;
if (rows.length % 1000 === 0) yield { completed: index + 1, total: text.length, phase: "parsing" };
}
start = index + 1;
}
if (start < text.length || row.length || !rows.length) {
appendCell(text.length);
rows.push(row);
}
return rows;
}
export function planDataGridPaste(rows: readonly (readonly (string | null)[])[], maxRows: number, maxColumns: number): DataGridPasteCell[] {
@@ -5,16 +5,13 @@
// functions, hex literals) is kept verbatim so the regular cell coercion
// handles it.
import { DATA_GRID_CLIPBOARD_BATCH_CHARS, DataGridClipboardCapacityError, finishDataGridRowPreparation, runDataGridRowPreparation, type DataGridClipboardLimits, type DataGridRowPreparationOptions, type DataGridRowPreparationProgress } from "./dataGridRowPreparation";
export interface ParsedInsertStatementPaste {
rows: Array<Array<string | null>>;
columnNames: string[] | null;
}
interface InsertStatementPart {
columnNames: string[] | null;
tuples: Array<Array<string | null>>;
}
type CharClass = "code" | "single-quote" | "double-quote" | "backtick" | "bracket-ident";
interface ScanState {
@@ -26,11 +23,17 @@ function appendRaw(state: ScanState, char: string): void {
state.text += char;
}
function splitTopLevelStatements(text: string): string[] {
function* splitTopLevelStatements(text: string): Generator<DataGridRowPreparationProgress, string[]> {
const statements: string[] = [];
const state: ScanState = { class: "code", text: "" };
let depth = 0;
let nextYield = DATA_GRID_CLIPBOARD_BATCH_CHARS;
if (text.length > DATA_GRID_CLIPBOARD_BATCH_CHARS) yield { completed: 0, total: text.length * 2, phase: "parsing" };
for (let index = 0; index < text.length; index++) {
if (index >= nextYield) {
yield { completed: index, total: text.length * 2, phase: "parsing" };
nextYield = index + DATA_GRID_CLIPBOARD_BATCH_CHARS;
}
const char = text[index]!;
if (state.class === "code") {
if (char === "'" || char === '"' || char === "`") {
@@ -102,6 +105,10 @@ function normalizeIdentifier(raw: string): string {
interface Lexer {
source: string;
pos: number;
nextYield: number;
progressOffset: number;
progressTotal: number;
maxItems: number;
}
function skipWhitespace(lexer: Lexer): void {
@@ -118,7 +125,7 @@ function matchKeyword(lexer: Lexer, keyword: string): boolean {
return true;
}
function readBracketedGroup(lexer: Lexer): string[] | null {
function* readBracketedGroup(lexer: Lexer): Generator<DataGridRowPreparationProgress, string[] | null> {
skipWhitespace(lexer);
if (lexer.source[lexer.pos] !== "(") return null;
lexer.pos++;
@@ -127,6 +134,10 @@ function readBracketedGroup(lexer: Lexer): string[] | null {
let depth = 0;
let classState: CharClass = "code";
while (lexer.pos < lexer.source.length) {
if (lexer.pos >= lexer.nextYield) {
yield { completed: lexer.progressOffset + lexer.pos, total: lexer.progressTotal, phase: "parsing" };
lexer.nextYield = lexer.pos + DATA_GRID_CLIPBOARD_BATCH_CHARS;
}
const char = lexer.source[lexer.pos++]!;
if (classState === "code") {
if (char === "'" || char === '"' || char === "`") {
@@ -137,11 +148,13 @@ function readBracketedGroup(lexer: Lexer): string[] | null {
depth++;
} else if (char === ")") {
if (depth === 0) {
if (items.length >= lexer.maxItems) throw new DataGridClipboardCapacityError();
items.push(current);
return items;
}
depth--;
} else if (char === "," && depth === 0) {
if (items.length >= lexer.maxItems) throw new DataGridClipboardCapacityError();
items.push(current);
current = "";
continue;
@@ -223,8 +236,8 @@ function convertValue(raw: string | null): string | null {
return trimmed;
}
function parseSingleInsertStatement(statement: string): InsertStatementPart | null {
const lexer: Lexer = { source: statement, pos: 0 };
function* parseSingleInsertStatement(statement: string, rows: ParsedInsertStatementPaste["rows"], limits: Required<DataGridClipboardLimits>, progressOffset: number, progressTotal: number, budget: { cells: number }): Generator<DataGridRowPreparationProgress, { columnNames: string[] | null } | null> {
const lexer: Lexer = { source: statement, pos: 0, nextYield: DATA_GRID_CLIPBOARD_BATCH_CHARS, progressOffset, progressTotal, maxItems: limits.maxCells };
if (!matchKeyword(lexer, "INSERT")) return null;
if (!matchKeyword(lexer, "INTO")) return null;
// Skip the table reference (possibly db.schema."table" or `db`.`table`).
@@ -232,22 +245,26 @@ function parseSingleInsertStatement(statement: string): InsertStatementPart | nu
// Optional column-name list: the next bracketed group before VALUES/VALUE.
const save = lexer.pos;
let columnNames: string[] | null = null;
const group = readBracketedGroup(lexer);
const group = yield* readBracketedGroup(lexer);
if (group) {
columnNames = group.map(normalizeIdentifier);
} else {
lexer.pos = save;
}
if (!matchKeyword(lexer, "VALUES") && !matchKeyword(lexer, "VALUE")) return null;
const tuples: Array<Array<string | null>> = [];
const firstRow = rows.length;
for (;;) {
skipWhitespace(lexer);
if (lexer.source[lexer.pos] !== "(") break;
const rawValues = readBracketedGroup(lexer);
if (rows.length >= limits.maxRows) throw new DataGridClipboardCapacityError();
lexer.maxItems = limits.maxCells - budget.cells;
const rawValues = yield* readBracketedGroup(lexer);
if (rawValues === null) return null;
// Values inside a tuple: split on top-level commas again (readBracketedGroup
// already did that) and convert each token.
tuples.push(rawValues.map(convertValue));
budget.cells += rawValues.length;
rows.push(rawValues.map(convertValue));
if (rows.length % 1000 === 0) yield { completed: progressOffset + lexer.pos, total: progressTotal, phase: "parsing" };
skipWhitespace(lexer);
if (lexer.source[lexer.pos] === ",") {
lexer.pos++;
@@ -255,20 +272,31 @@ function parseSingleInsertStatement(statement: string): InsertStatementPart | nu
}
break;
}
if (tuples.length === 0) return null;
return { columnNames, tuples };
if (rows.length === firstRow) return null;
return { columnNames };
}
export function parseInsertStatementPaste(text: string): ParsedInsertStatementPaste | null {
const statements = splitTopLevelStatements(text);
export function parseInsertStatementPaste(text: string, limits: DataGridClipboardLimits = {}): ParsedInsertStatementPaste | null {
return finishDataGridRowPreparation(prepareInsertStatementPaste(text, limits));
}
export function parseInsertStatementPasteInBatches(text: string, options: DataGridRowPreparationOptions = {}, limits: DataGridClipboardLimits = {}): Promise<ParsedInsertStatementPaste | null> {
return runDataGridRowPreparation(prepareInsertStatementPaste(text, limits), options, null);
}
function* prepareInsertStatementPaste(text: string, limits: DataGridClipboardLimits): Generator<DataGridRowPreparationProgress, ParsedInsertStatementPaste | null> {
const statements = yield* splitTopLevelStatements(text);
// The first statement decides the paste's semantics; leading comments were
// already stripped by the statement splitter.
if (statements.length === 0 || !/^\s*insert\s+into\s/i.test(statements[0]!)) return null;
const rows: Array<Array<string | null>> = [];
let columnNames: string[] | null = null;
let sawColumnNames = false;
let progressOffset = text.length;
const budget = { cells: 0 };
const resolvedLimits = { maxRows: limits.maxRows ?? 100_000, maxCells: limits.maxCells ?? 1_000_000 };
for (const statement of statements) {
const parsed = parseSingleInsertStatement(statement);
const parsed = yield* parseSingleInsertStatement(statement, rows, resolvedLimits, progressOffset, text.length * 2, budget);
if (!parsed) return null;
if (parsed.columnNames) {
if (!sawColumnNames) {
@@ -278,7 +306,7 @@ export function parseInsertStatementPaste(text: string): ParsedInsertStatementPa
columnNames = null;
}
}
rows.push(...parsed.tuples);
progressOffset += statement.length;
}
if (rows.length === 0) return null;
return { rows, columnNames };
@@ -0,0 +1,58 @@
export interface DataGridRowPreparationProgress {
completed: number;
total: number;
phase?: "parsing";
}
export interface DataGridClipboardLimits {
maxRows?: number;
maxCells?: number;
}
export class DataGridClipboardCapacityError extends Error {
constructor() {
super("Clipboard exceeds grid preparation capacity");
this.name = "DataGridClipboardCapacityError";
}
}
export const DATA_GRID_CLIPBOARD_BATCH_CHARS = 32_768;
export interface DataGridRowPreparationOptions {
signal?: AbortSignal;
isCurrent?: () => boolean;
onProgress?: (progress: DataGridRowPreparationProgress) => void;
}
// Bound the entire pending buffer, including repeated insertions and wide tables.
export function dataGridPendingRowLimit(columnCount: number): number {
return Math.min(100_000, Math.floor(1_000_000 / Math.max(1, columnCount)));
}
export function dataGridPreparationBatchSize(columnCount: number): number {
return Math.max(1, Math.min(1000, Math.floor(20_000 / Math.max(1, columnCount))));
}
export function finishDataGridRowPreparation<T>(operation: Generator<DataGridRowPreparationProgress, T>): T {
let step = operation.next();
while (!step.done) step = operation.next();
return step.value;
}
export async function runDataGridRowPreparation<T>(operation: Generator<DataGridRowPreparationProgress, T>, options: DataGridRowPreparationOptions, cancelled: T): Promise<T> {
const isCancelled = () => options.signal?.aborted || options.isCurrent?.() === false;
try {
if (isCancelled()) return cancelled;
let step = operation.next();
while (!step.done) {
options.onProgress?.(step.value);
if (isCancelled()) return cancelled;
await new Promise<void>((resolve) => setTimeout(resolve, 0));
if (isCancelled()) return cancelled;
step = operation.next();
}
return step.value;
} finally {
operation.return(cancelled);
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

+4 -4
View File
@@ -2,7 +2,7 @@ import { strict as assert } from "node:assert";
import { test } from "vitest";
import { computed, nextTick, ref } from "vue";
import { createPinia, setActivePinia } from "pinia";
import { DATA_GRID_MAX_BATCH_INSERT_ROWS, DATA_GRID_QUICK_ENTRY_DRAFT_ROW_ID, useDataGridEditor } from "../../apps/desktop/src/composables/useDataGridEditor.ts";
import { DATA_GRID_QUICK_ENTRY_DRAFT_ROW_ID, useDataGridEditor } from "../../apps/desktop/src/composables/useDataGridEditor.ts";
import type { CellValue } from "../../apps/desktop/src/lib/dataGrid/cellValue.ts";
import type { DataGridSaveStatementOptions } from "../../apps/desktop/src/lib/dataGrid/dataGridSql.ts";
import { matchesRowStatusFilter, type RowStatusFilter } from "../../apps/desktop/src/lib/dataGrid/gridRowStatus.ts";
@@ -1155,13 +1155,13 @@ test("addRows ignores invalid counts", () => {
assert.equal(editor.canUndoPendingChange.value, false);
});
test("addRows clamps counts above the batch limit", () => {
test("addRows preserves counts above the old batch limit", () => {
setActivePinia(createPinia());
installBrowserTestGlobals();
const editor = createPeopleGridEditor();
editor.addRows(DATA_GRID_MAX_BATCH_INSERT_ROWS + 100);
assert.equal(editor.newRows.value.length, DATA_GRID_MAX_BATCH_INSERT_ROWS);
editor.addRows(1100);
assert.equal(editor.newRows.value.length, 1100);
});
test("addRows records the display placement alongside the pending rows", () => {