fix(grid): optimize data grid interactions

This commit is contained in:
zipg 2026-06-25 13:51:14 +08:00 committed by GitHub
parent 84215a7e60
commit 150daadb53
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 622 additions and 128 deletions

View File

@ -1,7 +1,7 @@
<script setup lang="ts">
import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from "vue";
import { useI18n } from "vue-i18n";
import { AlignLeft, Copy, ChevronDown } from "@lucide/vue";
import { AlignLeft, Copy, ChevronDown, Undo2, Redo2 } from "@lucide/vue";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import { useTheme } from "@/composables/useTheme";
@ -14,10 +14,14 @@ const props = defineProps<{
sql: string;
sqlFormatDialect?: SqlFormatDialect;
loading?: boolean;
canUndo?: boolean;
canRedo?: boolean;
}>();
const emit = defineEmits<{
close: [];
undo: [];
redo: [];
}>();
const { t } = useI18n();
@ -145,7 +149,7 @@ onBeforeUnmount(() => {
<span class="flex-1 min-w-0" />
<Tooltip>
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="h-6 w-6" :class="isFormatted ? 'text-amber-600 bg-amber-500/10' : 'text-amber-600/60 hover:text-amber-700 hover:bg-amber-500/10'" :disabled="formatting || !hasSql" @click="toggleFormat">
<Button variant="ghost" size="icon" class="h-6 w-6" :class="isFormatted ? 'text-amber-600 bg-amber-500/10' : 'text-amber-600/60 hover:text-amber-700 hover:bg-amber-500/10'" :disabled="formatting || !hasSql" :aria-label="t('toolbar.formatSql')" @click="toggleFormat">
<AlignLeft class="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
@ -153,7 +157,23 @@ onBeforeUnmount(() => {
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="h-6 w-6 text-muted-foreground/60 hover:text-foreground hover:bg-accent" :disabled="!hasSql" @click="handleCopy">
<Button variant="ghost" size="icon" class="h-6 w-6 text-muted-foreground/60 hover:text-foreground hover:bg-accent" :disabled="!canUndo" :aria-label="t('grid.undoChange')" @click="emit('undo')">
<Undo2 class="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ t("grid.undoChange") }}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="h-6 w-6 text-muted-foreground/60 hover:text-foreground hover:bg-accent" :disabled="!canRedo" :aria-label="t('grid.redoChange')" @click="emit('redo')">
<Redo2 class="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ t("grid.redoChange") }}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="h-6 w-6 text-muted-foreground/60 hover:text-foreground hover:bg-accent" :disabled="!hasSql" :aria-label="t('grid.copy')" @click="handleCopy">
<Copy class="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
@ -161,7 +181,7 @@ onBeforeUnmount(() => {
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="h-6 w-6 text-muted-foreground/60 hover:text-foreground hover:bg-accent" @click="emit('close')">
<Button variant="ghost" size="icon" class="h-6 w-6 text-muted-foreground/60 hover:text-foreground hover:bg-accent" :aria-label="t('toolbar.hidePreviewSql')" @click="emit('close')">
<ChevronDown class="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
@ -178,7 +198,7 @@ onBeforeUnmount(() => {
<!-- Empty -->
<div v-else-if="!hasSql" class="flex items-center justify-center h-full text-xs text-muted-foreground">
{{ t("editor.pressToExecute", { mod: "Cmd/Ctrl" }) }}
{{ t("grid.previewSqlEmpty") }}
</div>
<!-- Shiki highlighted SQL -->

View File

@ -84,7 +84,7 @@ import TemporalCellEditor from "@/components/grid/TemporalCellEditor.vue";
import EnumCellEditor from "@/components/grid/EnumCellEditor.vue";
import type { QueryResult, ColumnInfo, DatabaseType, ForeignKeyInfo, IndexInfo, TriggerInfo, TableInfoTab } from "@/types/database";
import * as api from "@/lib/api";
import { coerceDataGridCellValue, dataGridCellDisplayText, dataGridCellEditorText } from "@/lib/dataGridCellCoercion";
import { dataGridCellDisplayText, dataGridCellEditorText } from "@/lib/dataGridCellCoercion";
import { createColumnDrafts } from "@/lib/tableStructureEditorState";
import type { BuildSingleColumnAlterSqlOptions } from "@/lib/tableStructureEditorSql";
import { buildTableSelectSql, quoteTableIdentifier } from "@/lib/tableSelectSql";
@ -147,6 +147,7 @@ import { getTableMetadataCapabilities } from "@/lib/tableMetadataCapabilities";
import { forgetDataGridConditionHistory, loadDataGridConditionHistory, rememberDataGridConditionHistory } from "@/lib/dataGridConditionHistory";
import { caretPositionInsideInsertedSqlSingleQuotes, insertedSqlSingleQuoteAtCaret } from "@/lib/sqlQuoteCaret";
import { effectiveDatabaseTypeForConnection } from "@/lib/jdbcDialect";
import { isMacOS } from "@/lib/platform";
const SqlPreviewPanel = defineAsyncComponent(() => import("@/components/editor/SqlPreviewPanel.vue"));
@ -218,6 +219,9 @@ const props = defineProps<{
const dataGridTraceId = uuid().slice(0, 8);
const dataGridCreatedAt = performance.now();
const dataGridElapsed = () => `${Math.round(performance.now() - dataGridCreatedAt)}ms`;
const isMac = isMacOS();
const shortcutMod = isMac ? "Cmd" : "Ctrl";
const DATA_GRID_COMPACT_TOPBAR_WIDTH = 900;
const emit = defineEmits<{
reload: [sql?: string, searchText?: string, whereInput?: string, orderBy?: string, limit?: number, offset?: number];
@ -309,10 +313,12 @@ const columnCommentMap = computed(() => {
}
return map;
});
const dataGridTopbarWidth = ref(0);
const showColumnCommentsInHeader = computed(() => settingsStore.editorSettings.showColumnCommentsInHeader);
const showColumnTypesInHeader = computed(() => settingsStore.editorSettings.showColumnTypesInHeader);
const compactColumnHeaderActions = computed(() => settingsStore.editorSettings.compactColumnHeaderActions);
const dataGridRenderMode = computed(() => settingsStore.editorSettings.dataGridRenderMode);
const compactDataGridToolbar = computed(() => dataGridTopbarWidth.value > 0 && dataGridTopbarWidth.value < DATA_GRID_COMPACT_TOPBAR_WIDTH);
const infiniteScrollEnabled = computed(() => settingsStore.editorSettings.infiniteScroll);
const infiniteScrollMaxRows = computed(() => settingsStore.editorSettings.infiniteScrollMaxRows);
@ -1346,13 +1352,17 @@ function dismissWhereSuggestions() {
function navigateWhereSuggestion(delta: number) {
if (whereSuggestions.value.length === 0) return;
if (whereSuggestionIndex.value < 0) {
whereSuggestionIndex.value = delta > 0 ? 0 : whereSuggestions.value.length - 1;
return;
}
whereSuggestionIndex.value = Math.min(Math.max(whereSuggestionIndex.value + delta, 0), whereSuggestions.value.length - 1);
}
function showWhereHistorySuggestions() {
const history = loadDataGridConditionHistory("where", conditionHistoryScope.value, whereFilterInput.value);
whereSuggestions.value = history.map((value) => ({ value, kind: "history" }));
whereSuggestionIndex.value = whereSuggestions.value.length ? 0 : -1;
whereSuggestionIndex.value = -1;
if (whereSuggestions.value.length) updateWhereSuggestionPosition();
}
@ -1469,7 +1479,7 @@ function onWhereFilterKeydown(e: KeyboardEvent) {
}
if (e.key === "Enter") {
e.preventDefault();
if (whereSuggestions.value.length > 0) {
if (whereSuggestions.value.length > 0 && whereSuggestionIndex.value >= 0) {
acceptWhereSuggestion();
return;
}
@ -1523,13 +1533,17 @@ function dismissOrderBySuggestions() {
function navigateOrderBySuggestion(delta: number) {
if (orderBySuggestions.value.length === 0) return;
if (orderBySuggestionIndex.value < 0) {
orderBySuggestionIndex.value = delta > 0 ? 0 : orderBySuggestions.value.length - 1;
return;
}
orderBySuggestionIndex.value = Math.min(Math.max(orderBySuggestionIndex.value + delta, 0), orderBySuggestions.value.length - 1);
}
function showOrderByHistorySuggestions() {
const history = loadDataGridConditionHistory("orderBy", conditionHistoryScope.value, orderByInput.value);
orderBySuggestions.value = history.map((value) => ({ value, kind: "history" }));
orderBySuggestionIndex.value = orderBySuggestions.value.length ? 0 : -1;
orderBySuggestionIndex.value = -1;
if (orderBySuggestions.value.length) updateOrderBySuggestionPosition();
}
@ -1588,7 +1602,7 @@ function onOrderByKeydown(e: KeyboardEvent) {
}
if (e.key === "Enter") {
e.preventDefault();
if (orderBySuggestions.value.length > 0) {
if (orderBySuggestions.value.length > 0 && orderBySuggestionIndex.value >= 0) {
acceptOrderBySuggestion();
return;
}
@ -1599,6 +1613,7 @@ function onOrderByKeydown(e: KeyboardEvent) {
const isApplyingWhere = ref(false);
const rowStatusFilter = ref<RowStatusFilter>("all");
const gridRef = ref<HTMLDivElement>();
const dataGridTopbarRef = ref<HTMLDivElement>();
const headerRef = ref<HTMLDivElement>();
const gridScrollbarGutter = ref(0);
const gridHorizontalScrollbarTrackRef = ref<HTMLDivElement>();
@ -1613,6 +1628,7 @@ const gridHorizontalScrollbarDragging = ref(false);
const gridVerticalScrollbarDragging = ref(false);
let gridHorizontalScrollbarFrame = 0;
let gridHorizontalScrollbarResizeObserver: ResizeObserver | null = null;
let dataGridTopbarResizeObserver: ResizeObserver | null = null;
let gridHorizontalScrollbarDragState: {
trackRect: DOMRect;
thumbOffsetPx: number;
@ -1892,6 +1908,21 @@ function observeGridHorizontalScrollbarScroller() {
scheduleGridHorizontalScrollbarUpdate();
}
function updateDataGridTopbarWidth() {
dataGridTopbarWidth.value = dataGridTopbarRef.value?.clientWidth ?? 0;
}
function observeDataGridTopbarWidth() {
dataGridTopbarResizeObserver?.disconnect();
dataGridTopbarResizeObserver = null;
const topbar = dataGridTopbarRef.value;
updateDataGridTopbarWidth();
if (topbar && typeof ResizeObserver !== "undefined") {
dataGridTopbarResizeObserver = new ResizeObserver(updateDataGridTopbarWidth);
dataGridTopbarResizeObserver.observe(topbar);
}
}
function applyGridHorizontalScrollbarDrag(clientX: number) {
const scroller = gridScrollerElement();
const dragState = gridHorizontalScrollbarDragState;
@ -2705,12 +2736,12 @@ const {
isSaving,
saveError,
useTransaction,
enterTransaction,
exitTransaction,
startEdit,
commitEdit,
commitEditFromBlur,
applyCellValue,
restoreCellValue,
cancelEdit,
onEditKeydown,
addRow: addEditorRow,
@ -2725,6 +2756,10 @@ const {
cloneRows,
saveChanges,
discardChanges,
canUndoPendingChange,
canRedoPendingChange,
undoPendingChange,
redoPendingChange,
rowDataWithChanges,
canEditColumn,
resetGridVerticalScroll,
@ -2752,7 +2787,7 @@ async function refreshPreviewSql() {
function schedulePreviewRefresh() {
if (!showSqlPreview.value) return;
if (pendingChangeCount.value === 0) {
// All changes were discarded close the preview
// Keep the panel visible so undo/redo results are explicit in the SQL preview area.
previewSqlText.value = "";
return;
}
@ -2833,15 +2868,6 @@ function tableColumnForGridColumn(columnIndex: number): ColumnInfo | undefined {
return props.tableMeta?.columns.find((column) => column.name.toLowerCase() === columnName.toLowerCase());
}
function coerceDetailCellValue(value: string, oldValue: CellValue | undefined, columnIndex: number): CellValue {
return coerceDataGridCellValue({
value,
oldValue,
databaseType: props.databaseType,
columnInfo: tableColumnForGridColumn(columnIndex),
}) as CellValue;
}
function temporalEditorKindForColumn(columnIndex: number): TemporalCellEditorKind | undefined {
return temporalCellEditorKind(tableColumnForGridColumn(columnIndex)?.data_type, props.databaseType);
}
@ -3645,30 +3671,8 @@ function commitDetailEdit() {
const item = getRowItem(detail.rowId);
if (!item || item.isDeleted) return;
if (item.isNew && item.newIndex !== undefined) {
const oldVal = newRows.value[item.newIndex]?.[detail.colIndex];
newRows.value[item.newIndex][detail.colIndex] = coerceDetailCellValue(detailEditValue.value, oldVal, detail.colIndex);
return;
}
if (item.sourceIndex === undefined) return;
if (!canEditExistingRows.value) return;
const oldVal = props.result.rows[item.sourceIndex]?.[detail.colIndex];
const newVal = coerceDetailCellValue(detailEditValue.value, oldVal, detail.colIndex);
if (newVal !== oldVal) {
if (!dirtyRows.value.has(item.sourceIndex)) dirtyRows.value.set(item.sourceIndex, new Map());
dirtyRows.value.get(item.sourceIndex)!.set(detail.colIndex, newVal);
if (useTransaction.value && !transactionActive.value) {
enterTransaction();
}
} else {
const rowChanges = dirtyRows.value.get(item.sourceIndex);
rowChanges?.delete(detail.colIndex);
if (rowChanges?.size === 0) dirtyRows.value.delete(item.sourceIndex);
}
dirtyRows.value = new Map(dirtyRows.value);
applyCellValue(detail.rowId, detail.colIndex, detailEditValue.value);
detailCell.value = detailCell.value ? { ...detailCell.value } : null;
}
function cancelDetailEdit() {
@ -3710,16 +3714,10 @@ function restoreDetailOriginalValue() {
let restoredValue: CellValue = null;
if (item.isNew && item.newIndex !== undefined) {
newRows.value[item.newIndex][detail.colIndex] = null;
newRows.value = [...newRows.value];
} else if (item.sourceIndex !== undefined) {
if (!item.isNew && item.sourceIndex !== undefined) {
restoredValue = props.result.rows[item.sourceIndex]?.[detail.colIndex] ?? null;
const rowChanges = dirtyRows.value.get(item.sourceIndex);
rowChanges?.delete(detail.colIndex);
if (rowChanges?.size === 0) dirtyRows.value.delete(item.sourceIndex);
dirtyRows.value = new Map(dirtyRows.value);
}
restoreCellValue(detail.rowId, detail.colIndex);
detailEditValue.value = dataGridCellEditorText({
value: restoredValue,
@ -3752,22 +3750,7 @@ function setDetailNull() {
const item = getRowItem(detail.rowId);
if (!item || item.isDeleted) return;
if (item.isNew && item.newIndex !== undefined) {
newRows.value[item.newIndex][detail.colIndex] = null;
newRows.value = [...newRows.value];
resetDetailEdit();
detailCell.value = { ...detailCell.value! };
return;
}
if (item.sourceIndex === undefined) return;
if (!canEditExistingRows.value) return;
if (!dirtyRows.value.has(item.sourceIndex)) dirtyRows.value.set(item.sourceIndex, new Map());
dirtyRows.value.get(item.sourceIndex)!.set(detail.colIndex, null);
dirtyRows.value = new Map(dirtyRows.value);
if (useTransaction.value && !transactionActive.value) {
enterTransaction();
}
applyCellValue(detail.rowId, detail.colIndex, null);
resetDetailEdit();
detailCell.value = { ...detailCell.value! };
}
@ -4478,6 +4461,7 @@ function drawCanvasGrid() {
}
watch(useCanvasGridRows, () => nextTick(attachCanvasResizeObserver), { immediate: true });
watch(showDataGridTopbar, () => nextTick(observeDataGridTopbarWidth), { immediate: true });
watch(
[
displayRowRefs,
@ -4508,6 +4492,8 @@ function pauseCanvasGridWork() {
dataGridIsActive = false;
canvasResizeObserver?.disconnect();
canvasResizeObserver = null;
dataGridTopbarResizeObserver?.disconnect();
dataGridTopbarResizeObserver = null;
canvasPixelRatioMediaQueryCleanup?.();
canvasPixelRatioMediaQueryCleanup = null;
canvasPixelRatioMediaQuery = null;
@ -4525,6 +4511,7 @@ function resumeCanvasGridWork() {
dataGridIsActive = true;
nextTick(() => {
attachCanvasResizeObserver();
observeDataGridTopbarWidth();
refreshGridScrollerMetrics();
observeGridHorizontalScrollbarScroller();
});
@ -4542,6 +4529,7 @@ onDeactivated(pauseCanvasGridWork);
onUnmounted(() => {
pauseCanvasGridWork();
gridHorizontalScrollbarResizeObserver?.disconnect();
dataGridTopbarResizeObserver?.disconnect();
stopColumnHeaderDrag(false);
stopGridHorizontalScrollbarDrag();
stopGridVerticalScrollbarDrag();
@ -5160,6 +5148,18 @@ function commitGridEdit() {
nextTick(() => gridRef.value?.focus({ preventScroll: true }));
}
function undoGridChange(): boolean {
if (editingCell.value || !canUndoPendingChange.value) return false;
undoPendingChange();
return true;
}
function redoGridChange(): boolean {
if (editingCell.value || !canRedoPendingChange.value) return false;
redoPendingChange();
return true;
}
function openCellDetailSearch(): boolean {
return getDetailEditor()?.openSearch() ?? false;
}
@ -5179,6 +5179,37 @@ async function onGridKeydown(event: KeyboardEvent) {
return;
}
if (eventTargetAllowsNativeClipboard(event)) return;
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "z") {
const handled = event.shiftKey ? redoGridChange() : undoGridChange();
if (handled) {
event.preventDefault();
event.stopPropagation();
}
return;
}
if (event.ctrlKey && !event.metaKey && event.key.toLowerCase() === "y") {
if (redoGridChange()) {
event.preventDefault();
event.stopPropagation();
}
return;
}
if ((event.metaKey || event.ctrlKey) && !event.shiftKey && event.key.toLowerCase() === "n") {
if (props.editable && (props.tableMeta || props.customSaveHandler)) {
event.preventDefault();
event.stopPropagation();
addRow();
}
return;
}
if ((event.metaKey || event.ctrlKey) && !event.shiftKey && event.key.toLowerCase() === "s") {
if (saveToolbarState.value.showActions && !saveToolbarState.value.actionsDisabled) {
event.preventDefault();
event.stopPropagation();
await onToolbarCommit();
}
return;
}
if (isCopyCurrentRowShortcut(event, settingsStore.editorSettings.shortcuts) && copyCurrentRow()) {
event.preventDefault();
return;
@ -6530,6 +6561,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
<div v-if="hasData || canShowWhereSearch" class="flex-1 flex flex-col overflow-hidden" @contextmenu="onContextMenu">
<!-- Search bar -->
<div
ref="dataGridTopbarRef"
v-if="showDataGridTopbar"
class="data-grid-topbar-scroll shrink-0 overflow-x-auto border-b bg-muted/20"
@scroll="
@ -6537,7 +6569,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
updateOrderBySuggestionPosition();
"
>
<div class="data-grid-topbar flex items-stretch relative">
<div class="data-grid-topbar flex items-stretch relative" :class="{ 'data-grid-topbar--compact': compactDataGridToolbar }">
<div v-if="useTransaction && editable && (tableMeta || customSaveHandler)" class="flex items-center px-2 py-0.5 border-r shrink-0">
<Select :model-value="rowStatusFilter" @update:model-value="(value: any) => setRowStatusFilter(String(value))">
<SelectTrigger class="h-5 max-w-28 border-0 bg-transparent px-0 py-0 text-xs font-medium text-foreground/70 shadow-none focus-visible:ring-0 data-[state=open]:text-foreground [&_svg]:size-3">
@ -6705,15 +6737,16 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
</div>
</PopoverContent>
</Popover>
<span class="text-blue-600 dark:text-blue-400 text-xs font-medium select-none shrink-0">WHERE</span>
<span class="data-grid-topbar-condition-label data-grid-topbar-condition-label--where" :class="{ 'data-grid-topbar-condition-label--compact': compactDataGridToolbar }">WHERE</span>
<input
ref="whereFilterInputRef"
v-model="whereFilterInput"
autocapitalize="off"
autocorrect="off"
spellcheck="false"
class="flex-1 h-5 min-w-0 text-xs bg-transparent outline-none placeholder:text-muted-foreground/60"
placeholder=""
class="data-grid-topbar-condition-input data-grid-topbar-condition-input--where flex-1 h-5 min-w-0 text-xs bg-transparent outline-none"
:class="{ 'data-grid-topbar-condition-input--compact': compactDataGridToolbar }"
placeholder="WHERE"
@input="onWhereFilterInput"
@keydown="onWhereFilterKeydown"
@focus="showWhereHistorySuggestions"
@ -6767,15 +6800,16 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
<span class="h-5 w-px bg-border group-hover:bg-primary/60" />
</button>
<div class="flex flex-1 items-center gap-1 px-2 py-0.5 border-r min-w-0 relative">
<span class="text-orange-600 dark:text-orange-400 text-xs font-medium select-none shrink-0">ORDER BY</span>
<span class="data-grid-topbar-condition-label data-grid-topbar-condition-label--order" :class="{ 'data-grid-topbar-condition-label--compact': compactDataGridToolbar }">ORDER BY</span>
<input
ref="orderByInputRef"
v-model="orderByInput"
autocapitalize="off"
autocorrect="off"
spellcheck="false"
class="flex-1 h-5 min-w-0 text-xs bg-transparent outline-none placeholder:text-muted-foreground/60"
placeholder=""
class="data-grid-topbar-condition-input data-grid-topbar-condition-input--order flex-1 h-5 min-w-0 text-xs bg-transparent outline-none"
:class="{ 'data-grid-topbar-condition-input--compact': compactDataGridToolbar }"
placeholder="ORDER BY"
@keydown="onOrderByKeydown"
@focus="showOrderByHistorySuggestions"
@click="updateOrderBySuggestionPosition"
@ -6845,30 +6879,47 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
{{ t("grid.keylessEditWarningHint") }}
</TooltipContent>
</Tooltip>
<Button v-if="props.context !== 'results'" variant="ghost" size="sm" class="h-5 text-xs px-1.5 shrink-0" :disabled="isSaving" @click="onToolbarRefresh">
<Loader2 v-if="loading" class="w-3 h-3 mr-1 animate-spin" />
<RefreshCcw v-else class="w-3 h-3 mr-1" />
{{ t("grid.refresh") }}
</Button>
<Tooltip>
<Tooltip v-if="props.context !== 'results'">
<TooltipTrigger as-child>
<Button variant="ghost" size="sm" class="h-5 text-xs px-1.5 shrink-0" :class="dataGridRenderMode === 'canvas' ? 'text-primary bg-primary/10' : ''" @click="toggleDataGridRenderMode">
<SquareDashed class="w-3 h-3 mr-1" />
{{ dataGridRenderMode === "canvas" ? t("grid.canvasRenderMode") : t("grid.domRenderMode") }}
<Button variant="ghost" size="sm" :class="['data-grid-topbar-action-button h-5 shrink-0 text-xs px-1.5', compactDataGridToolbar ? 'data-grid-topbar-action-button--compact' : '', isSaving ? '' : '']" :disabled="isSaving" @click="onToolbarRefresh">
<Loader2 v-if="loading" class="data-grid-topbar-action-icon w-3 h-3 animate-spin" />
<RefreshCcw v-else class="data-grid-topbar-action-icon w-3 h-3" />
<span class="data-grid-topbar-action-label" :class="{ 'data-grid-topbar-action-label--compact': compactDataGridToolbar }">{{ t("grid.refresh") }}</span>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" class="max-w-sm">
{{ t("grid.renderModeHint") }}
</TooltipContent>
<TooltipContent side="bottom">{{ t("grid.refresh") }} ({{ shortcutMod }}+R)</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<Button variant="ghost" size="sm" :class="['data-grid-topbar-action-button h-5 shrink-0 text-xs px-1.5', compactDataGridToolbar ? 'data-grid-topbar-action-button--compact' : '', dataGridRenderMode === 'canvas' ? 'text-primary bg-primary/10' : '']" @click="toggleDataGridRenderMode">
<SquareDashed class="data-grid-topbar-action-icon w-3 h-3" />
<span class="data-grid-topbar-action-label" :class="{ 'data-grid-topbar-action-label--compact': compactDataGridToolbar }">{{ dataGridRenderMode === "canvas" ? t("grid.canvasRenderMode") : t("grid.domRenderMode") }}</span>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" class="max-w-sm"> {{ dataGridRenderMode === "canvas" ? t("grid.canvasRenderMode") : t("grid.domRenderMode") }} · {{ t("grid.renderModeHint") }} </TooltipContent>
</Tooltip>
<Tooltip v-if="editable && (tableMeta || customSaveHandler)">
<TooltipTrigger as-child>
<Button variant="ghost" size="sm" :class="['data-grid-topbar-action-button h-5 shrink-0 text-xs px-1.5', compactDataGridToolbar ? 'data-grid-topbar-action-button--compact' : '']" @click="addRow">
<Plus class="data-grid-topbar-action-icon w-3 h-3" />
<span class="data-grid-topbar-action-label" :class="{ 'data-grid-topbar-action-label--compact': compactDataGridToolbar }">{{ t("grid.addRow") }}</span>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">{{ t("grid.addRow") }} ({{ shortcutMod }}+N)</TooltipContent>
</Tooltip>
<Button v-if="editable && (tableMeta || customSaveHandler)" variant="ghost" size="sm" class="h-5 text-xs px-1.5 shrink-0" @click="addRow"> <Plus class="w-3 h-3 mr-1" /> {{ t("grid.addRow") }} </Button>
<template v-if="saveToolbarState.showActions">
<Tooltip v-if="pendingChangeCount > 0">
<TooltipTrigger as-child>
<Button variant="ghost" size="sm" class="h-5 text-xs px-1.5 shrink-0 text-sky-600 hover:bg-sky-500/10 hover:text-sky-700" :disabled="isPreviewLoading" @click="openSqlPreview">
<Loader2 v-if="isPreviewLoading" class="w-3 h-3 mr-1 animate-spin" />
<Eye v-else class="w-3 h-3 mr-1" />
{{ t("toolbar.previewSql") }}
<Button
variant="ghost"
size="sm"
:class="['data-grid-topbar-action-button h-5 shrink-0 text-xs px-1.5 text-sky-600 hover:bg-sky-500/10 hover:text-sky-700', compactDataGridToolbar ? 'data-grid-topbar-action-button--compact' : '']"
:disabled="isPreviewLoading"
@click="openSqlPreview"
>
<Loader2 v-if="isPreviewLoading" class="data-grid-topbar-action-icon w-3 h-3 animate-spin" />
<Eye v-else class="data-grid-topbar-action-icon w-3 h-3" />
<span class="data-grid-topbar-action-label" :class="{ 'data-grid-topbar-action-label--compact': compactDataGridToolbar }">{{ t("toolbar.previewSql") }}</span>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" class="max-w-sm">
@ -6877,23 +6928,45 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<Button variant="default" size="sm" class="h-5 text-xs px-1.5 shrink-0" :disabled="saveToolbarState.actionsDisabled" @click="onToolbarCommit">
<Loader2 v-if="isSaving" class="w-3 h-3 mr-1 animate-spin" />
<span v-else-if="pendingChangeCount > 0" class="mr-1 flex h-3.5 min-w-3.5 items-center justify-center rounded-full bg-amber-300 px-1 text-[9px] font-semibold leading-none text-amber-950 shadow-[0_0_0_1px_rgba(120,53,15,0.16)] dark:bg-amber-400 dark:text-amber-950">
<Button
variant="default"
size="sm"
:class="['data-grid-topbar-action-button data-grid-topbar-action-button--commit relative h-5 shrink-0 text-xs px-1.5', compactDataGridToolbar ? 'data-grid-topbar-action-button--compact' : '']"
:disabled="saveToolbarState.actionsDisabled"
@click="onToolbarCommit"
>
<Loader2 v-if="isSaving" class="data-grid-topbar-action-icon w-3 h-3 animate-spin" />
<Save v-else-if="compactDataGridToolbar || pendingChangeCount === 0" class="data-grid-topbar-action-icon w-3 h-3" />
<span
v-if="pendingChangeCount > 0"
:class="
compactDataGridToolbar
? 'absolute -right-1 -top-1 flex h-3.5 min-w-3.5 items-center justify-center rounded-full bg-amber-300 px-1 text-[9px] font-semibold leading-none text-amber-950 shadow-[0_0_0_1px_rgba(120,53,15,0.16)] dark:bg-amber-400 dark:text-amber-950'
: 'mr-1 flex h-3.5 min-w-3.5 items-center justify-center rounded-full bg-amber-300 px-1 text-[9px] font-semibold leading-none text-amber-950 shadow-[0_0_0_1px_rgba(120,53,15,0.16)] dark:bg-amber-400 dark:text-amber-950'
"
>
{{ pendingChangeCount }}
</span>
<Save v-else class="w-3 h-3 mr-1" />
{{ t(saveActionMode.labelKey, { count: pendingChangeCount }) }}
<span class="data-grid-topbar-action-label" :class="{ 'data-grid-topbar-action-label--compact': compactDataGridToolbar }">{{ t(saveActionMode.labelKey, { count: pendingChangeCount }) }}</span>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" class="max-w-sm">
{{ t(saveActionMode.tooltipKey, { count: pendingChangeCount }) }}
</TooltipContent>
<TooltipContent side="bottom" class="max-w-sm"> {{ t(saveActionMode.tooltipKey, { count: pendingChangeCount }) }} ({{ shortcutMod }}+S) </TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<Button
variant="outline"
size="sm"
:class="['data-grid-topbar-action-button h-5 shrink-0 text-xs px-1.5', compactDataGridToolbar ? 'data-grid-topbar-action-button--compact' : '']"
:disabled="saveToolbarState.actionsDisabled"
@click="useTransaction ? onToolbarRollback() : discardChanges()"
>
<RotateCcw class="data-grid-topbar-action-icon w-3 h-3" />
<span class="data-grid-topbar-action-label" :class="{ 'data-grid-topbar-action-label--compact': compactDataGridToolbar }">{{ t(saveActionMode.secondaryActionKey) }}</span>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">{{ t(saveActionMode.secondaryActionKey) }}</TooltipContent>
</Tooltip>
<Button variant="outline" size="sm" class="h-5 text-xs px-1.5 shrink-0" :disabled="saveToolbarState.actionsDisabled" @click="useTransaction ? onToolbarRollback() : discardChanges()">
<RotateCcw class="w-3 h-3 mr-1" />
{{ t(saveActionMode.secondaryActionKey) }}
</Button>
</template>
</div>
</div>
@ -8436,7 +8509,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
<!-- SQL Preview panel for pending data changes -->
<div v-if="showSqlPreview" class="h-52 shrink-0 border-t">
<SqlPreviewPanel :sql="previewSqlText" :loading="isPreviewLoading" @close="closeSqlPreview" />
<SqlPreviewPanel :sql="previewSqlText" :loading="isPreviewLoading" :can-undo="canUndoPendingChange" :can-redo="canRedoPendingChange" @undo="undoGridChange" @redo="redoGridChange" @close="closeSqlPreview" />
</div>
<DangerConfirmDialog
@ -8520,7 +8593,134 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
}
.data-grid-topbar {
--data-grid-topbar-transition-duration: 340ms;
--data-grid-topbar-transition-easing: cubic-bezier(0.22, 1, 0.36, 1);
min-width: 760px;
transition: min-width var(--data-grid-topbar-transition-duration) var(--data-grid-topbar-transition-easing);
}
.data-grid-topbar--compact {
min-width: 620px;
}
.data-grid-topbar-condition-label {
display: inline-flex;
flex-shrink: 0;
max-width: 5rem;
overflow: hidden;
white-space: nowrap;
font-size: 0.75rem;
font-weight: 500;
user-select: none;
opacity: 1;
transform: translateX(0);
transition:
max-width var(--data-grid-topbar-transition-duration) var(--data-grid-topbar-transition-easing),
opacity 240ms ease 60ms,
transform var(--data-grid-topbar-transition-duration) var(--data-grid-topbar-transition-easing),
color 240ms ease;
}
.data-grid-topbar-condition-label--where {
color: rgb(37 99 235);
}
.data-grid-topbar-condition-label--order {
color: rgb(234 88 12);
}
:global(.dark) [data-grid-root] .data-grid-topbar-condition-label--where {
color: rgb(96 165 250);
}
:global(.dark) [data-grid-root] .data-grid-topbar-condition-label--order {
color: rgb(251 146 60);
}
.data-grid-topbar-condition-label--compact {
max-width: 0;
opacity: 0;
transform: translateX(-4px);
}
.data-grid-topbar-condition-input::placeholder {
color: transparent;
transition: color 240ms ease;
}
.data-grid-topbar-condition-input--where.data-grid-topbar-condition-input--compact::placeholder {
color: rgb(59 130 246 / 70%);
}
.data-grid-topbar-condition-input--order.data-grid-topbar-condition-input--compact::placeholder {
color: rgb(249 115 22 / 70%);
}
:global(.dark) [data-grid-root] .data-grid-topbar-condition-input--where.data-grid-topbar-condition-input--compact::placeholder {
color: rgb(147 197 253 / 70%);
}
:global(.dark) [data-grid-root] .data-grid-topbar-condition-input--order.data-grid-topbar-condition-input--compact::placeholder {
color: rgb(253 186 116 / 70%);
}
.data-grid-topbar-action-button {
max-width: 9rem;
min-width: 1.25rem;
gap: 0;
overflow: hidden;
transition:
max-width var(--data-grid-topbar-transition-duration) var(--data-grid-topbar-transition-easing),
min-width var(--data-grid-topbar-transition-duration) var(--data-grid-topbar-transition-easing),
padding-inline var(--data-grid-topbar-transition-duration) var(--data-grid-topbar-transition-easing),
color 220ms ease,
background-color 220ms ease,
border-color 220ms ease;
}
.data-grid-topbar-action-button--compact {
max-width: 1.25rem;
min-width: 1.25rem;
padding-inline: 0;
}
.data-grid-topbar-action-button--commit.data-grid-topbar-action-button--compact {
overflow: visible;
}
.data-grid-topbar-action-icon {
flex-shrink: 0;
transition:
margin-inline-end var(--data-grid-topbar-transition-duration) var(--data-grid-topbar-transition-easing),
transform var(--data-grid-topbar-transition-duration) var(--data-grid-topbar-transition-easing);
}
.data-grid-topbar-action-button:not(.data-grid-topbar-action-button--compact) .data-grid-topbar-action-icon {
margin-inline-end: 0.25rem;
}
.data-grid-topbar-action-button--compact .data-grid-topbar-action-icon {
margin-inline-end: 0;
transform: scale(0.96);
}
.data-grid-topbar-action-label {
display: inline-block;
max-width: 8rem;
overflow: hidden;
white-space: nowrap;
opacity: 1;
transform: translateX(0);
transition:
max-width var(--data-grid-topbar-transition-duration) var(--data-grid-topbar-transition-easing),
opacity 240ms ease 60ms,
transform var(--data-grid-topbar-transition-duration) var(--data-grid-topbar-transition-easing);
}
.data-grid-topbar-action-label--compact {
max-width: 0;
opacity: 0;
transform: translateX(-4px);
}
.data-grid-topbar-scroll {

View File

@ -907,24 +907,46 @@ async function openData() {
});
const tableSchema = connectionObjectTreeNodeSchema(config, node.database, node.schema);
const tableType = node.type === "view" ? "VIEW" : node.type === "materialized_view" ? "MATERIALIZED_VIEW" : "TABLE";
const isSameDataTableTab = (tab: (typeof queryStore.tabs)[number]) => tab.mode === "data" && tab.connectionId === node.connectionId && tab.database === node.database && (tab.schema || "") === (tableSchema || "") && (tab.tableMeta?.tableName || tab.title) === node.label;
const activateExistingSameTableTab = () => {
const existing = queryStore.tabs.find(isSameDataTableTab);
if (!existing) return false;
queryStore.activeTabId = existing.id;
return true;
};
const resetReusedDataTabState = (tab: (typeof queryStore.tabs)[number]) => {
tab.title = node.label;
tab.schema = tableSchema;
tab.whereInput = undefined;
tab.orderByInput = undefined;
tab.previewSql = undefined;
tab.resultSortColumn = undefined;
tab.resultSortColumnIndex = undefined;
tab.resultSortDirection = undefined;
tab.resultSortMode = undefined;
tab.resultLocalSortOriginalRows = undefined;
tab.resultSortedSql = undefined;
tab.resultPageSql = undefined;
tab.resultPageLimit = undefined;
tab.resultPageOffset = undefined;
tab.resultTotalRowCount = undefined;
tab.resultTotalRowCountLoading = undefined;
tab.queryAnalysis = undefined;
tab.querySourceColumns = undefined;
tab.queryEditabilityReason = undefined;
};
if (activateExistingSameTableTab()) {
logPhase("existing-tab-activated", { table: node.label });
return;
}
const tabId = (() => {
if (settingsStore.editorSettings.reuseDataTab) {
const existing = queryStore.tabs.find((tab) => tab.mode === "data" && tab.connectionId === node.connectionId && tab.database === node.database);
if (existing) {
existing.title = node.label;
existing.schema = tableSchema;
// Reset per-table filter/sort state so the reused tab doesn't keep
// the previous table's WHERE/ORDER BY. DataGrid remounts (result is
// cleared below) and reinitializes its inputs from these props.
existing.whereInput = undefined;
existing.orderByInput = undefined;
existing.resultSortColumn = undefined;
existing.resultSortColumnIndex = undefined;
existing.resultSortDirection = undefined;
existing.resultSortMode = undefined;
existing.resultLocalSortOriginalRows = undefined;
existing.resultSortedSql = undefined;
queryStore.activeTabId = existing.id;
resetReusedDataTabState(existing);
return existing.id;
}
}

View File

@ -83,9 +83,12 @@ interface PendingChangesSnapshot {
rowCount: number;
}
type PendingChangesHistorySnapshot = Pick<PendingChangesSnapshot, "newRows" | "dirtyRows" | "deletedRows" | "transactionActive">;
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;
function cacheKeyBelongsToTab(cacheKey: string, tabId: string) {
return cacheKey === tabId || cacheKey.startsWith(`${tabId}-`);
@ -143,6 +146,8 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
const dirtyRows = ref<Map<number, Map<number, CellValue>>>(new Map());
const newRows = ref<CellValue[][]>([]);
const deletedRows = ref<Set<number>>(new Set());
const undoStack = ref<PendingChangesHistorySnapshot[]>([]);
const redoStack = ref<PendingChangesHistorySnapshot[]>([]);
const pendingChangesVersion = ref(0);
let restoredEditingCell = false;
let restoredTransactionActive = false;
@ -175,6 +180,8 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
const deletedRowCount = computed(() => deletedRows.value.size);
const pendingChangeCount = computed(() => dirtyRowCount.value + newRowCount.value + deletedRowCount.value);
const hasPendingChanges = computed(() => pendingChangeCount.value > 0);
const canUndoPendingChange = computed(() => undoStack.value.length > 0);
const canRedoPendingChange = computed(() => redoStack.value.length > 0);
const resolvedDatabaseType = computed(() => databaseType.value ?? effectiveDatabaseTypeForConnection(connectionStore.getConfig(connectionId.value ?? "")));
// --- Transaction state ---
@ -225,6 +232,50 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
pendingChangesVersion.value++;
}
function pendingChangesSnapshot(): PendingChangesHistorySnapshot {
return {
newRows: newRows.value.map((row) => [...row]),
dirtyRows: new Map([...dirtyRows.value].map(([rowIndex, changes]) => [rowIndex, new Map(changes)])),
deletedRows: new Set(deletedRows.value),
transactionActive: transactionActive.value,
};
}
function restorePendingChangesSnapshot(snapshot: PendingChangesHistorySnapshot) {
newRows.value = snapshot.newRows.map((row) => [...row]);
dirtyRows.value = new Map([...snapshot.dirtyRows].map(([rowIndex, changes]) => [rowIndex, new Map(changes)]));
deletedRows.value = new Set(snapshot.deletedRows);
transactionActive.value = snapshot.transactionActive === true && useTransaction.value === true;
editingCell.value = null;
touchPendingChanges();
}
function pushUndoSnapshot() {
undoStack.value = [...undoStack.value.slice(-MAX_PENDING_CHANGES_HISTORY + 1), pendingChangesSnapshot()];
redoStack.value = [];
}
function clearPendingChangeHistory() {
undoStack.value = [];
redoStack.value = [];
}
function undoPendingChange() {
const snapshot = undoStack.value[undoStack.value.length - 1];
if (!snapshot) return;
undoStack.value = undoStack.value.slice(0, -1);
redoStack.value = [...redoStack.value, pendingChangesSnapshot()];
restorePendingChangesSnapshot(snapshot);
}
function redoPendingChange() {
const snapshot = redoStack.value[redoStack.value.length - 1];
if (!snapshot) return;
redoStack.value = redoStack.value.slice(0, -1);
undoStack.value = [...undoStack.value, pendingChangesSnapshot()];
restorePendingChangesSnapshot(snapshot);
}
function exitTransaction() {
transactionActive.value = false;
}
@ -420,6 +471,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
const oldVal = newRows.value[item.newIndex]?.[col];
const newVal = coerceCellValue(editValue.value, oldVal, col);
if (newRows.value[item.newIndex]) {
if (newVal !== oldVal) pushUndoSnapshot();
newRows.value[item.newIndex][col] = newVal;
}
newRows.value = [...newRows.value];
@ -443,6 +495,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
const oldVal = result.value.rows[item.sourceIndex]?.[col];
const newVal = coerceCellValue(editValue.value, oldVal, col);
if (newVal !== oldVal) {
pushUndoSnapshot();
if (!dirtyRows.value.has(item.sourceIndex)) dirtyRows.value.set(item.sourceIndex, new Map());
dirtyRows.value.get(item.sourceIndex)!.set(col, newVal);
if (useTransaction.value && !transactionActive.value) {
@ -450,6 +503,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
}
} else {
const rowChanges = dirtyRows.value.get(item.sourceIndex);
if (rowChanges?.has(col)) pushUndoSnapshot();
rowChanges?.delete(col);
if (rowChanges?.size === 0) dirtyRows.value.delete(item.sourceIndex);
}
@ -473,8 +527,13 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
if (!item || item.isDeleted) return;
if (item.isNew && item.newIndex !== undefined) {
const oldVal = newRows.value[item.newIndex]?.[col];
newRows.value[item.newIndex][col] = value === null ? null : coerceCellValue(value, oldVal, col);
const row = newRows.value[item.newIndex];
if (!row) return;
const oldVal = row[col];
const newVal = value === null ? null : coerceCellValue(value, oldVal, col);
if (newVal === oldVal) return;
pushUndoSnapshot();
row[col] = newVal;
newRows.value = [...newRows.value];
touchPendingChanges();
return;
@ -484,15 +543,20 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
if (!canEditExistingRows.value) return;
const oldVal = result.value.rows[item.sourceIndex]?.[col];
const rowChanges = dirtyRows.value.get(item.sourceIndex);
const hasPendingCellChange = rowChanges?.has(col) ?? false;
const currentVal = hasPendingCellChange ? rowChanges!.get(col) : oldVal;
const newVal = value === null ? null : coerceCellValue(value, oldVal, col);
if (newVal === currentVal) return;
if (newVal !== oldVal) {
pushUndoSnapshot();
if (!dirtyRows.value.has(item.sourceIndex)) dirtyRows.value.set(item.sourceIndex, new Map());
dirtyRows.value.get(item.sourceIndex)!.set(col, newVal);
if (useTransaction.value && !transactionActive.value) {
enterTransaction();
}
} else {
const rowChanges = dirtyRows.value.get(item.sourceIndex);
if (hasPendingCellChange) pushUndoSnapshot();
rowChanges?.delete(col);
if (rowChanges?.size === 0) dirtyRows.value.delete(item.sourceIndex);
}
@ -500,6 +564,32 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
touchPendingChanges();
}
function restoreCellValue(rowId: number, col: number) {
if (!canEditColumn(col)) return;
const item = getRowItem(rowId);
if (!item || item.isDeleted) return;
if (item.isNew && item.newIndex !== undefined) {
const row = newRows.value[item.newIndex];
if (!row || row[col] === null) return;
pushUndoSnapshot();
row[col] = null;
newRows.value = [...newRows.value];
touchPendingChanges();
return;
}
if (item.sourceIndex === undefined) return;
if (!canEditExistingRows.value) return;
const rowChanges = dirtyRows.value.get(item.sourceIndex);
if (!rowChanges?.has(col)) return;
pushUndoSnapshot();
rowChanges.delete(col);
if (rowChanges.size === 0) dirtyRows.value.delete(item.sourceIndex);
dirtyRows.value = new Map(dirtyRows.value);
touchPendingChanges();
}
function cancelEdit() {
const restoreScroll = preserveScrollPosition();
isCancelling = true;
@ -521,6 +611,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
}
function addRow() {
pushUndoSnapshot();
rowStatusFilter.value = rowStatusFilterAfterAddingRow(rowStatusFilter.value);
newRows.value.push(result.value.columns.map(() => null));
newRows.value = [...newRows.value];
@ -557,6 +648,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
const item = getRowItem(rowId);
if (!item) return;
const clonedData = clonedRowData(item);
pushUndoSnapshot();
rowStatusFilter.value = rowStatusFilterAfterAddingRow(rowStatusFilter.value);
newRows.value.push(clonedData);
newRows.value = [...newRows.value];
@ -573,10 +665,11 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
}
function cloneRows(rowIds: number[]) {
const rowsToClone = rowIds.map((rowId) => getRowItem(rowId)).filter(Boolean) as RowItem[];
if (rowsToClone.length === 0) return;
pushUndoSnapshot();
rowStatusFilter.value = rowStatusFilterAfterAddingRow(rowStatusFilter.value);
for (const rowId of rowIds) {
const item = getRowItem(rowId);
if (!item) continue;
for (const item of rowsToClone) {
const clonedData = clonedRowData(item);
newRows.value.push(clonedData);
}
@ -591,10 +684,12 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
const item = getRowItem(rowId);
if (!item) return;
if (item.isNew && item.newIndex !== undefined) {
pushUndoSnapshot();
newRows.value.splice(item.newIndex, 1);
newRows.value = [...newRows.value];
} else if (item.sourceIndex !== undefined) {
if (!canEditExistingRows.value) return;
pushUndoSnapshot();
dirtyRows.value.delete(item.sourceIndex);
deletedRows.value.add(item.sourceIndex);
dirtyRows.value = new Map(dirtyRows.value);
@ -636,7 +731,8 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
function restoreRow(rowId: number) {
const item = getRowItem(rowId);
if (item?.sourceIndex !== undefined) {
if (item?.sourceIndex !== undefined && deletedRows.value.has(item.sourceIndex)) {
pushUndoSnapshot();
deletedRows.value.delete(item.sourceIndex);
deletedRows.value = new Set(deletedRows.value);
touchPendingChanges();
@ -644,9 +740,14 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
}
function restoreRows(rowIds: number[]) {
for (const rowId of rowIds) {
restoreRow(rowId);
const sourceIndexes = rowIds.map((rowId) => getRowItem(rowId)?.sourceIndex).filter((sourceIndex): sourceIndex is number => sourceIndex !== undefined && deletedRows.value.has(sourceIndex));
if (sourceIndexes.length === 0) return;
pushUndoSnapshot();
for (const sourceIndex of sourceIndexes) {
deletedRows.value.delete(sourceIndex);
}
deletedRows.value = new Set(deletedRows.value);
touchPendingChanges();
}
function deleteSelectedRow(contextCell: Ref<{ rowId: number; rowIndex: number; col: number } | null>) {
@ -749,6 +850,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
dirtyRows.value.clear();
newRows.value = [];
deletedRows.value.clear();
clearPendingChangeHistory();
touchPendingChanges();
exitTransaction();
isSaving.value = false;
@ -834,6 +936,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
dirtyRows.value.clear();
newRows.value = [];
deletedRows.value.clear();
clearPendingChangeHistory();
touchPendingChanges();
exitTransaction();
isSaving.value = false;
@ -846,6 +949,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
dirtyRows.value.clear();
newRows.value = [];
deletedRows.value.clear();
clearPendingChangeHistory();
touchPendingChanges();
editingCell.value = null;
exitTransaction();
@ -973,6 +1077,8 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
deletedRowCount,
pendingChangeCount,
hasPendingChanges,
canUndoPendingChange,
canRedoPendingChange,
transactionActive,
isSaving,
saveError,
@ -983,6 +1089,9 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
commitEdit,
commitEditFromBlur,
applyCellValue,
restoreCellValue,
undoPendingChange,
redoPendingChange,
cancelEdit,
onEditKeydown,
addRow,

View File

@ -592,6 +592,7 @@ export default {
exportFailed: "Export failed: {message}",
copied: "Copied",
copyFailed: "Copy failed: {message}",
previewSqlEmpty: "No pending SQL changes to preview",
domRenderMode: "DOM",
canvasRenderMode: "Canvas",
renderModeHint: "Switch between Canvas rendering and the DOM fallback grid.",
@ -802,6 +803,8 @@ export default {
dataUnavailableHintPrefix: "Press ",
dataUnavailableHintSuffix: " or click Refresh below to reload.",
refresh: "Refresh",
undoChange: "Undo change",
redoChange: "Redo change",
commit: "Commit",
rollback: "Rollback",
transactionSaveHint: "Commit {count} pending change(s) in a transaction.",

View File

@ -593,6 +593,7 @@ export default {
exportFailed: "导出失败:{message}",
copied: "已复制",
copyFailed: "复制失败:{message}",
previewSqlEmpty: "暂无待预览的 SQL 更改",
domRenderMode: "DOM",
canvasRenderMode: "Canvas",
renderModeHint: "在 Canvas 渲染和 DOM 兜底表格之间切换。",
@ -803,6 +804,8 @@ export default {
dataUnavailableHintPrefix: "按 ",
dataUnavailableHintSuffix: " 或点击下方刷新按钮重新加载。",
refresh: "刷新",
undoChange: "撤销更改",
redoChange: "重做更改",
commit: "提交",
rollback: "回滚",
transactionSaveHint: "在事务中提交 {count} 项待保存更改。",

View File

@ -102,6 +102,65 @@ function column(name: string, isPrimaryKey = false, extra: string | null = null)
};
}
function createPeopleGridEditor(result = computed(() => ({ columns: ["id", "name"], rows: [[1, "Ada"] as CellValue[]] }))) {
const rowStatusFilter = ref<"all" | "changed" | "edited" | "new" | "deleted">("all");
let editor: ReturnType<typeof useDataGridEditor>;
editor = useDataGridEditor({
result,
editable: computed(() => true),
databaseType: computed(() => "postgres"),
connectionId: computed(() => undefined),
database: computed(() => undefined),
tableMeta: computed(() => ({
tableName: "people",
columns: [column("id", true), column("name")],
primaryKeys: ["id"],
})),
onExecuteSql: computed(() => undefined),
customSaveHandler: computed(() => undefined),
sql: computed(() => "SELECT id, name FROM people"),
searchText: ref(""),
whereFilterInput: ref(""),
orderByInput: ref(""),
currentWhereInput: computed(() => undefined),
rowStatusFilter,
pageSize: ref(50),
currentPage: ref(1),
getRowItem: (rowId) => {
if (rowId === 0) {
return {
id: 0,
sourceIndex: 0,
data: editor.rowDataWithChanges(result.value.rows[0], 0),
isNew: false,
isDeleted: editor.deletedRows.value.has(0),
isDirtyCol: [false, editor.dirtyRows.value.get(0)?.has(1) ?? false],
status: editor.deletedRows.value.has(0) ? "deleted" : editor.dirtyRows.value.has(0) ? "edited" : "clean",
};
}
if (rowId < 0) {
const newIndex = -rowId - 1;
const row = editor.newRows.value[newIndex];
if (!row) return undefined;
return {
id: rowId,
newIndex,
data: row,
isNew: true,
isDeleted: false,
isDirtyCol: [false, false],
status: "new",
};
}
return undefined;
},
emit: () => {},
});
return editor;
}
test("row data helper reuses unchanged rows and clones dirty rows only", () => {
setActivePinia(createPinia());
installBrowserTestGlobals();
@ -441,6 +500,84 @@ test("saving edited rows without deletes does not reload table data", async () =
assert.deepEqual(emitted, []);
});
test("undo and redo restore pending cell edits before save", () => {
setActivePinia(createPinia());
installBrowserTestGlobals();
const result = computed(() => ({
columns: ["id", "name"],
rows: [[1, "Ada"] as CellValue[]],
}));
const editor = createPeopleGridEditor(result);
editor.applyCellValue(0, 1, "Ada Lovelace");
assert.equal(editor.canUndoPendingChange.value, true);
assert.equal(editor.canRedoPendingChange.value, false);
assert.deepEqual(editor.rowDataWithChanges(result.value.rows[0], 0), [1, "Ada Lovelace"]);
editor.undoPendingChange();
assert.equal(editor.canUndoPendingChange.value, false);
assert.equal(editor.canRedoPendingChange.value, true);
assert.equal(editor.dirtyRows.value.size, 0);
assert.deepEqual(editor.rowDataWithChanges(result.value.rows[0], 0), [1, "Ada"]);
editor.redoPendingChange();
assert.equal(editor.canUndoPendingChange.value, true);
assert.equal(editor.canRedoPendingChange.value, false);
assert.deepEqual(editor.rowDataWithChanges(result.value.rows[0], 0), [1, "Ada Lovelace"]);
});
test("restoring a pending cell edit records undo and redo history", () => {
setActivePinia(createPinia());
installBrowserTestGlobals();
const result = computed(() => ({
columns: ["id", "name"],
rows: [[1, "Ada"] as CellValue[]],
}));
const editor = createPeopleGridEditor(result);
editor.applyCellValue(0, 1, "Ada Lovelace");
editor.restoreCellValue(0, 1);
assert.equal(editor.canUndoPendingChange.value, true);
assert.equal(editor.canRedoPendingChange.value, false);
assert.equal(editor.dirtyRows.value.size, 0);
assert.deepEqual(editor.rowDataWithChanges(result.value.rows[0], 0), [1, "Ada"]);
editor.undoPendingChange();
assert.equal(editor.canUndoPendingChange.value, true);
assert.equal(editor.canRedoPendingChange.value, true);
assert.deepEqual(editor.rowDataWithChanges(result.value.rows[0], 0), [1, "Ada Lovelace"]);
editor.redoPendingChange();
assert.equal(editor.canUndoPendingChange.value, true);
assert.equal(editor.canRedoPendingChange.value, false);
assert.equal(editor.dirtyRows.value.size, 0);
assert.deepEqual(editor.rowDataWithChanges(result.value.rows[0], 0), [1, "Ada"]);
});
test("undo and redo cover row add and delete operations", () => {
setActivePinia(createPinia());
installBrowserTestGlobals();
const editor = createPeopleGridEditor();
editor.addRow();
assert.equal(editor.newRows.value.length, 1);
editor.undoPendingChange();
assert.equal(editor.newRows.value.length, 0);
editor.redoPendingChange();
assert.equal(editor.newRows.value.length, 1);
editor.applyDeleteRow(0);
assert.deepEqual([...editor.deletedRows.value], [0]);
editor.undoPendingChange();
assert.deepEqual([...editor.deletedRows.value], []);
assert.equal(editor.newRows.value.length, 1);
editor.redoPendingChange();
assert.deepEqual([...editor.deletedRows.value], [0]);
});
test("saving manually typed JSON from a MySQL grid normalizes smart quotes", async () => {
setActivePinia(createPinia());
installBrowserTestGlobals();