mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-21 04:58:41 +00:00
feat(grid): batch row insert progress, cancel and paste expansion
Closes #11324
This commit is contained in:
@@ -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>
|
||||
|
||||
+123
-24
@@ -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 () => {
|
||||
|
||||
+39
-3
@@ -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 = [];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "選択行の下に挿入",
|
||||
|
||||
@@ -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: "선택 행 아래에 삽입",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "Вставить ниже выбранной строки",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "在选中行下方插入",
|
||||
|
||||
@@ -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
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user