diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index 8908cd9167..b0a41b3a95 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -270,7 +270,7 @@ import { } from "@/lib/dataGrid/canvasDataGridRenderer"; import { resolveDataGridRowNumberLabel } from "@/lib/dataGrid/dataGridRowNumber"; import { resolveCrosshairTarget, type CrosshairTarget } from "@/lib/dataGrid/crosshairHighlight"; -import { DATA_GRID_DARK_STRIPED_ROW_BG, DATA_GRID_LIGHT_STRIPED_ROW_BG, dataGridActiveRowBackground } from "@/lib/dataGrid/dataGridPaintTheme"; +import { DATA_GRID_DARK_NEW_ROW_BG, DATA_GRID_DARK_STRIPED_ROW_BG, DATA_GRID_LIGHT_NEW_ROW_BG, DATA_GRID_LIGHT_STRIPED_ROW_BG, dataGridActiveRowBackground } from "@/lib/dataGrid/dataGridPaintTheme"; import { createRowLowerTextCache } from "@/lib/dataGrid/dataGridRowLowerText"; import { dataGridPreviewLabelKey, dataGridSaveActionMode, dataGridSaveToolbarState } from "@/lib/dataGrid/dataGridSaveUi"; import { buildDataGridSavedRowRefreshPlan, dataGridSavedRowRefreshPatches } from "@/lib/dataGrid/dataGridSavedRowRefresh"; @@ -4609,6 +4609,7 @@ const { applyCellValue, stageCellReplacements, restoreCellValue, + cancelUntouchedPendingRow, cancelEdit, onEditKeydown, addRowsInBatches: addEditorRowsInBatches, @@ -7765,22 +7766,25 @@ function rowCellsUseSelectionVisual(rowId: number): boolean { function dataGridRowStyle(item: RowItem): CSSProperties { const dark = isDark.value || (typeof document !== "undefined" && document.documentElement.classList.contains("dark")); const activeRowBg = dataGridActiveRowBackground(dark); + // 新增行/斑马纹行底色走 CSS 变量:DOM 与 Canvas(canvasDataGridRenderer 的 theme.rowNew / + // theme.rowMuted)共用同一色源,避免两侧各写一份字面色后悄悄漂移。 + const newRowBg = `var(--data-grid-row-new-bg, ${dark ? DATA_GRID_DARK_NEW_ROW_BG : DATA_GRID_LIGHT_NEW_ROW_BG})`; + const mutedRowBg = `var(--data-grid-row-muted-bg, ${dark ? DATA_GRID_DARK_STRIPED_ROW_BG : DATA_GRID_LIGHT_STRIPED_ROW_BG})`; + // 待插入行底色优先于活动行底色(isRowActive 会排除 isNew):光标总落在刚新增的那一行上 + // (新增后立即进入编辑),若让活动行底色盖掉它,「这行还没保存」恰好在这时看不见(#11634)。 + // 光标位置仍由行号列的活动色与选中单元格表达。Canvas 侧同序(resolveCanvasDataGridRowFill)。 const rowBg = item.isDeleted ? dark ? "rgb(55, 31, 32)" : "rgb(255, 244, 244)" - : isRowActive(item.displayIndex) + : isRowActive(item.displayIndex) && !item.isNew ? activeRowBg : item.isNew - ? dark - ? "rgb(51, 51, 55)" - : "rgb(243, 243, 243)" + ? newRowBg : item.isDraft - ? dark - ? "rgb(51, 51, 55)" - : "rgb(243, 243, 243)" + ? mutedRowBg : dataGridStripedRows.value && item.displayIndex % 2 === 1 - ? settingsStore.editorSettings.dataGridZebraRowBg?.trim() || `var(--data-grid-row-muted-bg, ${dark ? DATA_GRID_DARK_STRIPED_ROW_BG : DATA_GRID_LIGHT_STRIPED_ROW_BG})` + ? settingsStore.editorSettings.dataGridZebraRowBg?.trim() || mutedRowBg : "var(--data-grid-background)"; const rowNumberBg = item.isNew && item.status === "clean" @@ -10753,6 +10757,44 @@ function openCellDetailSearch(): boolean { return getDetailEditor()?.openSearch() || cellDetailPanelRef.value?.openSearch() || false; } +// #11634:光标停在当前显示的最后一行时,再按一次下键 = 「新增行」(固定追加到结果末尾, +// 不跟随工具栏的插入位置设置)。只有「本来就会被 clamp 成空操作」的那次下键会走到这里, +// 所以已有的移动语义(含 Shift 扩展、转置、多格选区)完全不受影响。 +function addRowFromArrowDown(): boolean { + // gridClipboardActive 与 insertRows → prepareGridRows 的准入条件保持一致:网格非活动(KeepAlive 隐藏) + // 时不消费按键,避免出现「吃掉了下键却没有新增」的情况。 + if (!canInsertRows.value || isSaving.value || isConditionalUpdateActive.value || rowPreparationController || !gridClipboardActive) return false; + if (isTransposeMode.value || editingCell.value) return false; + if (hasRowSelection.value || hasColumnSelection.value || selectedCellCount.value !== 1) return false; + const position = currentSelectedCellPosition(); + if (!position || position.rowIndex !== displayRowCount.value - 1) return false; + // 快速录入的草稿行本身就在末尾,它已经是「待录入行」;再加一行会凭空多出第二个空行。 + // 注意:草稿行不进 selectedCellCount(useDataGridSelection 的计数语义会跳过 draft 行), + // 所以上面「单格选中」的条件通常已经拦住了它;这里保留一次显式判定作为第二道保险, + // 避免将来放宽选择语义后下键突然在草稿行上追加 pending 行。 + if (displayItemAt(position.rowIndex)?.isDraft) return false; + void insertRows(1, "end"); + return true; +} + +// #11634:网格焦点下的 Esc(单元格编辑器内的 Esc 由 cancelEdit 处理,两段式)。 +// 顺序对齐 DBeaver 的 ESC = Reset cell changes:先撤掉「刚新增且从未写入」的整行, +// 再把单格的待保存改动回退为原值。多格 / 整行 / 整列选区不参与:一次 Esc 不应抹掉一整片改动。 +function cancelPendingRowOrRestoreCell(): boolean { + if (isTransposeMode.value || editingCell.value || !props.editable) return false; + // 保存进行中不得改动待提交状态:这一轮保存的快照已经捕获了当前值,此刻回退只会让用户以为 + // 改动被撤销,数据库仍会写入(同层的 stageCellReplacements / rowPreparationIsCurrent 都做了 + // 同样的 !isSaving 门槛)。 + if (isSaving.value) return false; + if (hasRowSelection.value || hasColumnSelection.value || selectedCellCount.value !== 1) return false; + const position = currentSelectedCellPosition(); + if (!position) return false; + const item = displayItemAt(position.rowIndex); + if (!item || item.isDeleted) return false; + if (cancelUntouchedPendingRow(item.id)) return true; + return restoreCellValue(item.id, actualColumnIndex(position.colIndex)); +} + async function onGridKeydown(event: KeyboardEvent) { if (event.defaultPrevented) return; @@ -10851,6 +10893,18 @@ async function onGridKeydown(event: KeyboardEvent) { return; } } + // 两者都只在该按键本来是空操作/无绑定时才消费(下键在最后一行被 clamp;Esc 在网格上原本无绑定), + // 因此不会与上面的可配置快捷键、转置分支以及下面的常规移动产生竞争。 + if (event.key === "ArrowDown" && !event.shiftKey && !event.metaKey && !event.ctrlKey && !event.altKey && addRowFromArrowDown()) { + event.preventDefault(); + event.stopPropagation(); + return; + } + if (event.key === "Escape" && cancelPendingRowOrRestoreCell()) { + event.preventDefault(); + event.stopPropagation(); + return; + } if (event.key === "ArrowUp" && moveSelectedCell(-1, 0, event.shiftKey)) { event.preventDefault(); return; @@ -16391,7 +16445,7 @@ useUpdateBlocker(() => (hasPendingChanges.value || hasPendingDataEditorDraft.val [data-grid-root] { --data-grid-background: var(--background-solid, var(--background)); --data-grid-row-muted-bg: rgb(240, 240, 240); - --data-grid-row-new-bg: rgb(243, 243, 243); + --data-grid-row-new-bg: rgb(231, 248, 242); --data-grid-row-deleted-bg: rgb(255, 244, 244); --data-grid-cell-active-bg: rgb(219, 234, 254); --data-grid-cell-crosshair-row-bg: rgb(174, 195, 224); @@ -16425,7 +16479,7 @@ useUpdateBlocker(() => (hasPendingChanges.value || hasPendingDataEditorDraft.val [data-grid-root].data-grid--dark, :global(.dark) [data-grid-root] { --data-grid-row-muted-bg: rgb(40, 40, 43); - --data-grid-row-new-bg: rgb(51, 51, 55); + --data-grid-row-new-bg: rgb(19, 37, 33); --data-grid-row-deleted-bg: rgb(55, 31, 32); --data-grid-cell-active-bg: rgb(27, 46, 70); --data-grid-cell-crosshair-row-bg: rgb(75, 84, 98); @@ -16460,7 +16514,7 @@ useUpdateBlocker(() => (hasPendingChanges.value || hasPendingDataEditorDraft.val @supports (background: color-mix(in oklab, white 50%, transparent)) { [data-grid-root] { --data-grid-row-muted-bg: color-mix(in oklab, var(--muted) 99%, var(--foreground)); - --data-grid-row-new-bg: color-mix(in oklab, var(--primary) 5%, transparent); + --data-grid-row-new-bg: color-mix(in oklab, rgb(16 185 129) 10%, var(--background)); --data-grid-row-deleted-bg: color-mix(in oklab, var(--destructive) 5%, transparent); --data-grid-cell-dirty-bg: color-mix(in oklab, rgb(240 177 0) 10%, transparent); --data-grid-cell-crosshair-row-bg: color-mix(in srgb, var(--primary) 34%, var(--background)); diff --git a/apps/desktop/src/components/grid/__tests__/DataGridArrowDownInsertRow.spec.ts b/apps/desktop/src/components/grid/__tests__/DataGridArrowDownInsertRow.spec.ts new file mode 100644 index 0000000000..6176201811 --- /dev/null +++ b/apps/desktop/src/components/grid/__tests__/DataGridArrowDownInsertRow.spec.ts @@ -0,0 +1,448 @@ +// @vitest-environment happy-dom + +import { createApp, defineComponent, h, markRaw, nextTick, ref, type App, type PropType } from "vue"; +import { createPinia, setActivePinia } from "pinia"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import i18n from "@/i18n"; +import type { ColumnInfo, QueryResult } from "@/types/database"; +import { TooltipProvider } from "@/components/ui/tooltip"; + +// DataGrid.vue imports RecycleScroller directly (no global registration), +// so the module itself must be stubbed for deterministic row rendering. +vi.mock("vue-virtual-scroller", async () => { + const { defineComponent, h } = await import("vue"); + return { + RecycleScroller: defineComponent({ + props: { + items: { + type: Array as PropType, + default: () => [], + }, + }, + setup(props, { attrs, slots }) { + return () => + h( + "div", + attrs, + props.items.map((item) => slots.default?.({ item })), + ); + }, + }), + }; +}); + +vi.mock("@/composables/useDataGridColumnResize", async (importOriginal) => { + const actual = await importOriginal(); + const { ref } = await import("vue"); + return { + ...actual, + useDataGridColumnResize: () => ({ + initColumnWidths: vi.fn(), + onResizeStart: vi.fn(), + autoFitColumn: vi.fn(), + renderedColumnWidths: ref([120, 120]), + totalWidth: ref(240), + columnVars: ref({ "--total-w": "240px" }), + getIsResizing: () => false, + }), + }; +}); + +import DataGrid from "../DataGrid.vue"; +import { useSettingsStore } from "@/stores/settingsStore"; + +const mountedApps: Array<{ app: App; host: HTMLElement }> = []; + +interface MountOptions { + renderMode?: "dom" | "canvas"; + editable?: boolean; + allowInsertRows?: boolean; + quickEntry?: boolean; + rowCount?: number; +} + +function mountGrid(options: MountOptions = {}) { + const pinia = createPinia(); + setActivePinia(pinia); + const settingsStore = useSettingsStore(); + settingsStore.updateEditorSettings({ + dataGridRenderMode: "canvas", + dataGridMultiRowTranspose: false, + dataGridHideNullColumns: false, + dataGridStripedRows: true, + dataGridQuickEntry: options.quickEntry ?? false, + }); + + const rows = Array.from({ length: options.rowCount ?? 2 }, (_, index) => [index + 1, `name-${index}`]); + const result = markRaw({ + columns: ["id", "name"], + rows, + affected_rows: 0, + execution_time_ms: 0, + }); + const columns: ColumnInfo[] = [ + { name: "id", data_type: "int", is_nullable: false, column_default: null, is_primary_key: true, extra: null }, + { name: "name", data_type: "varchar", is_nullable: true, column_default: null, is_primary_key: false, extra: null }, + ]; + + const host = document.createElement("div"); + document.body.append(host); + const grid = ref<{ + selectSingleCell: (row: number, col: number) => void; + editSelectedCell: () => boolean; + isSaving: boolean; + }>(); + const Root = defineComponent({ + setup() { + return () => + h( + TooltipProvider, + { delayDuration: 0 }, + { + default: () => + h(DataGrid, { + ref: grid, + result, + databaseType: "mysql", + context: "table-data", + editable: options.editable ?? true, + allowInsertRows: options.allowInsertRows, + connectionId: "connection-1", + database: "app", + tableMeta: { tableName: "users", columns, primaryKeys: ["id"] }, + }), + }, + ); + }, + }); + const app = createApp(Root); + app.use(pinia); + app.use(i18n); + app.mount(host); + if ((options.renderMode ?? "dom") === "dom") settingsStore.updateEditorSettings({ dataGridRenderMode: "dom" }); + const mounted = { app, host, grid }; + mountedApps.push({ app, host }); + return mounted; +} + +async function settle() { + await nextTick(); + await Promise.resolve(); + await nextTick(); +} + +function gridRoot(host: HTMLElement): HTMLElement { + const root = host.querySelector("[data-grid-root]"); + if (!root) throw new Error("Data grid root not found"); + return root; +} + +function dispatchKey(target: HTMLElement, key: string, modifiers: { ctrlKey?: boolean; metaKey?: boolean; altKey?: boolean; shiftKey?: boolean } = {}): KeyboardEvent { + const event = new KeyboardEvent("keydown", { bubbles: true, cancelable: true, key, ...modifiers }); + target.dispatchEvent(event); + return event; +} + +function displayRows(host: HTMLElement): HTMLElement[] { + return [...host.querySelectorAll(".data-grid-row")]; +} + +function cellsOf(row: HTMLElement): HTMLElement[] { + return [...row.querySelectorAll(".data-grid-cell")]; +} + +async function selectCellAt(host: HTMLElement, rowIndex: number, visibleColIndex: number) { + const cell = cellsOf(displayRows(host)[rowIndex]!)[visibleColIndex]; + if (!cell) throw new Error(`Cell not found: row ${rowIndex} col ${visibleColIndex}`); + cell.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true, button: 0 })); + window.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, cancelable: true, button: 0 })); + await settle(); +} + +async function addRowWithArrowDown(host: HTMLElement, rowCount: number) { + await selectCellAt(host, rowCount - 1, 0); + dispatchKey(gridRoot(host), "ArrowDown"); + await settle(); +} + +/** 新增行会立即打开编辑器;用户先收尾(Esc 只取消输入)才能继续操作网格。 */ +async function closeOpenEditor(host: HTMLElement) { + const editor = host.querySelector(".cell-edit-input"); + if (!editor) return; + editor.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, cancelable: true, key: "Escape" })); + await settle(); +} + +async function typeIntoOpenEditor(host: HTMLElement, value: string) { + const input = host.querySelector(".cell-edit-input"); + if (!input) throw new Error("Cell editor is not open"); + input.value = value; + input.dispatchEvent(new Event("input", { bubbles: true })); + await settle(); + input.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, cancelable: true, key: "Enter" })); + await settle(); +} + +function selectedRowIndex(host: HTMLElement): number | undefined { + const row = displayRows(host).find((candidate) => candidate.querySelector(".cell-selected, .cell-selected-dirty, .cell-selected--single") !== null); + return row ? Number(row.dataset.rowIndex) : undefined; +} + +afterEach(() => { + for (const { app, host } of mountedApps.splice(0)) { + app.unmount(); + host.remove(); + } +}); + +describe("DataGrid arrow-down insert row", () => { + it("adds a pending row when ArrowDown is pressed on the last displayed row", async () => { + const { host } = mountGrid({ rowCount: 2 }); + await settle(); + expect(displayRows(host)).toHaveLength(2); + + await addRowWithArrowDown(host, 2); + + const rows = displayRows(host); + expect(rows).toHaveLength(3); + const newRow = rows[2]!; + // 追加在显示末尾,且被标注为待插入行 + expect(newRow.classList.contains("data-grid-row--new")).toBe(true); + // 整行底色来自新增行色源(DOM 与 Canvas 共用同一 CSS 变量) + expect(newRow.getAttribute("style")).toContain("--data-grid-row-new-bg"); + // 未填写的新行沿用 "*" 占位 + expect(newRow.querySelector(".data-grid-row-number")?.textContent?.trim()).toBe("*"); + // 与「新增行」按钮一致:新增后立即进入首列编辑 + expect(host.querySelector(".cell-edit-input")).not.toBeNull(); + }); + + it("keeps the normal down-move away from the last row", async () => { + const { host } = mountGrid({ rowCount: 3 }); + await settle(); + await selectCellAt(host, 0, 0); + expect(selectedRowIndex(host)).toBe(0); + + const event = dispatchKey(gridRoot(host), "ArrowDown"); + await settle(); + + expect(event.defaultPrevented).toBe(true); + expect(selectedRowIndex(host)).toBe(1); + expect(displayRows(host)).toHaveLength(3); + }); + + it("does not add a row when Shift extends the selection on the last row", async () => { + const { host } = mountGrid({ rowCount: 2 }); + await settle(); + await selectCellAt(host, 1, 0); + + dispatchKey(gridRoot(host), "ArrowDown", { shiftKey: true }); + await settle(); + + expect(displayRows(host)).toHaveLength(2); + expect(host.querySelector(".cell-edit-input")).toBeNull(); + }); + + it("does not add a row when the grid is read-only", async () => { + const { host } = mountGrid({ rowCount: 2, editable: false }); + await settle(); + await selectCellAt(host, 1, 0); + + dispatchKey(gridRoot(host), "ArrowDown"); + await settle(); + + expect(displayRows(host)).toHaveLength(2); + expect(host.querySelector(".cell-edit-input")).toBeNull(); + }); + + it("does not add a row when the save target rejects inserts", async () => { + const { host } = mountGrid({ rowCount: 2, allowInsertRows: false }); + await settle(); + await selectCellAt(host, 1, 0); + + dispatchKey(gridRoot(host), "ArrowDown"); + await settle(); + + expect(displayRows(host)).toHaveLength(2); + }); + + it("does not add a second row when the down arrow is pressed again right after adding", async () => { + const { host } = mountGrid({ rowCount: 2 }); + await settle(); + await addRowWithArrowDown(host, 2); + expect(displayRows(host)).toHaveLength(3); + + // 新增后编辑器已开在新行上:此时的下键属于编辑器,不得再追加一行 + const event = dispatchKey(gridRoot(host), "ArrowDown"); + await settle(); + + expect(displayRows(host)).toHaveLength(3); + expect(event.defaultPrevented).toBe(false); + }); + + // 行为级回归守卫(不是分支证明):快速录入开启时末尾是草稿行,此时下键不得凭空多出第二个空行。 + // 草稿行本身不进 selectedCellCount,所以真正拦住它的是「单格选中」条件;这条用例锁定的是最终行为。 + it("does not add a row while quick entry leaves a draft row at the end", async () => { + const { host } = mountGrid({ rowCount: 2, quickEntry: true }); + await settle(); + const rowsBefore = displayRows(host); + // 2 条已落库行 + 末尾那行快速录入草稿行 + expect(rowsBefore.length).toBe(3); + await selectCellAt(host, rowsBefore.length - 1, 0); + + dispatchKey(gridRoot(host), "ArrowDown"); + await settle(); + + expect(displayRows(host)).toHaveLength(rowsBefore.length); + }); + + it("adds a row in canvas render mode too", async () => { + const { host, grid } = mountGrid({ rowCount: 2, renderMode: "canvas" }); + await settle(); + const root = gridRoot(host); + // Canvas 模式没有行元素,用「新增行立即进入编辑」这条与按钮一致的信号判定 + expect(displayRows(host)).toHaveLength(0); + grid.value!.selectSingleCell(1, 0); + await settle(); + + const event = dispatchKey(root, "ArrowDown"); + await settle(); + + expect(event.defaultPrevented).toBe(true); + expect(host.querySelector(".canvas-grid-overlay .cell-edit-input")).not.toBeNull(); + }); +}); + +describe("DataGrid escape pending row and cell revert", () => { + it("removes the untouched new row under the cursor and keeps it undoable", async () => { + const { host } = mountGrid({ rowCount: 2 }); + await settle(); + await addRowWithArrowDown(host, 2); + await closeOpenEditor(host); + expect(displayRows(host)).toHaveLength(3); + + await selectCellAt(host, 2, 0); + const escape = dispatchKey(gridRoot(host), "Escape"); + await settle(); + + expect(escape.defaultPrevented).toBe(true); + expect(displayRows(host)).toHaveLength(2); + + dispatchKey(gridRoot(host), "z", { ctrlKey: true }); + await settle(); + expect(displayRows(host)).toHaveLength(3); + }); + + it("clears the cell instead of removing a new row that already has a value", async () => { + const { host } = mountGrid({ rowCount: 2 }); + await settle(); + await addRowWithArrowDown(host, 2); + // 新增后编辑器开在新行上:填一个值再提交,该行就不再是「未触碰」 + await typeIntoOpenEditor(host, "typed"); + await selectCellAt(host, 2, 0); + + const escape = dispatchKey(gridRoot(host), "Escape"); + await settle(); + + expect(escape.defaultPrevented).toBe(true); + expect(displayRows(host)).toHaveLength(3); + expect(cellsOf(displayRows(host)[2]!)[0]!.classList.contains("cell-dirty")).toBe(false); + expect(displayRows(host)[2]!.textContent).not.toContain("typed"); + }); + + it("keeps the pending row when Escape only cancels the inline editor", async () => { + const { host } = mountGrid({ rowCount: 2 }); + await settle(); + await addRowWithArrowDown(host, 2); + const editor = host.querySelector(".cell-edit-input"); + expect(editor).not.toBeNull(); + + editor!.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, cancelable: true, key: "Escape" })); + await settle(); + + // 两段式:编辑器内的 Esc 只取消输入,新增行留在网格里(网格焦点下的第二次 Esc 才撤掉它) + expect(host.querySelector(".cell-edit-input")).toBeNull(); + expect(displayRows(host)).toHaveLength(3); + + await selectCellAt(host, 2, 0); + dispatchKey(gridRoot(host), "Escape"); + await settle(); + expect(displayRows(host)).toHaveLength(2); + }); + + it("restores a dirty existing cell to its original value", async () => { + const { host } = mountGrid({ rowCount: 2 }); + await settle(); + await selectCellAt(host, 0, 1); + dispatchKey(gridRoot(host), "Enter"); + await settle(); + await typeIntoOpenEditor(host, "renamed"); + await selectCellAt(host, 0, 1); + expect(cellsOf(displayRows(host)[0]!)[1]!.classList.contains("cell-dirty")).toBe(true); + + const escape = dispatchKey(gridRoot(host), "Escape"); + await settle(); + + expect(escape.defaultPrevented).toBe(true); + expect(cellsOf(displayRows(host)[0]!)[1]!.classList.contains("cell-dirty")).toBe(false); + expect(displayRows(host)[0]!.textContent).toContain("name-0"); + expect(displayRows(host)[0]!.textContent).not.toContain("renamed"); + }); + + it("does not revert while a save is in flight, and reverts once it finishes", async () => { + const { host, grid } = mountGrid({ rowCount: 2 }); + await settle(); + await selectCellAt(host, 0, 1); + dispatchKey(gridRoot(host), "Enter"); + await settle(); + await typeIntoOpenEditor(host, "renamed"); + await selectCellAt(host, 0, 1); + const editedCell = () => cellsOf(displayRows(host)[0]!)[1]!; + expect(editedCell().classList.contains("cell-dirty")).toBe(true); + + // 保存进行中:这一轮保存已经捕获了当前值,此刻回退会与数据库写入结果脱节 + grid.value!.isSaving = true; + const whileSaving = dispatchKey(gridRoot(host), "Escape"); + await settle(); + expect(whileSaving.defaultPrevented).toBe(false); + expect(editedCell().classList.contains("cell-dirty")).toBe(true); + expect(displayRows(host)[0]!.textContent).toContain("renamed"); + + // 正向对照:保存结束后同一个键位恢复回退能力(证明拦住它的是 isSaving 门槛本身) + grid.value!.isSaving = false; + const afterSaving = dispatchKey(gridRoot(host), "Escape"); + await settle(); + expect(afterSaving.defaultPrevented).toBe(true); + expect(editedCell().classList.contains("cell-dirty")).toBe(false); + expect(displayRows(host)[0]!.textContent).toContain("name-0"); + }); + + it("does not consume Escape when there is nothing to revert", async () => { + const { host } = mountGrid({ rowCount: 2 }); + await settle(); + await selectCellAt(host, 0, 1); + + const escape = dispatchKey(gridRoot(host), "Escape"); + await settle(); + + expect(escape.defaultPrevented).toBe(false); + expect(displayRows(host)).toHaveLength(2); + expect(displayRows(host)[0]!.textContent).toContain("name-0"); + }); + + it("does not revert a multi-cell selection", async () => { + const { host } = mountGrid({ rowCount: 2 }); + await settle(); + await selectCellAt(host, 0, 0); + await selectCellAt(host, 0, 1); + // 拖选出跨格范围:第一次选择作为锚点,第二次带 shiftKey 扩展 + const rangeCell = cellsOf(displayRows(host)[1]!)[1]!; + rangeCell.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true, button: 0, shiftKey: true })); + window.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, cancelable: true, button: 0 })); + await settle(); + + const escape = dispatchKey(gridRoot(host), "Escape"); + await settle(); + + expect(escape.defaultPrevented).toBe(false); + }); +}); diff --git a/apps/desktop/src/composables/__tests__/useDataGridEditor.spec.ts b/apps/desktop/src/composables/__tests__/useDataGridEditor.spec.ts index 7853be957d..29f6c75126 100644 --- a/apps/desktop/src/composables/__tests__/useDataGridEditor.spec.ts +++ b/apps/desktop/src/composables/__tests__/useDataGridEditor.spec.ts @@ -2384,3 +2384,52 @@ describe("Mongo collection-grid clipboard round-trip", () => { } }); }); + +describe("useDataGridEditor pending-row cancel and cell revert", () => { + it("reports whether restoreCellValue actually reverted something", () => { + const editor = createEditor(undefined, true, undefined, undefined, [["Ada", "original", "Lovelace"]]); + + expect(editor.restoreCellValue(0, 1)).toBe(false); + editor.applyCellValue(0, 1, "edited"); + expect(editor.restoreCellValue(0, 1)).toBe(true); + expect(editor.restoreCellValue(0, 1)).toBe(false); + + editor.newRows.value = []; + editor.addRows(1); + // 未触碰的新行整行都是 NULL,没有可回退的内容 + expect(editor.restoreCellValue(-1, 0)).toBe(false); + editor.applyCellValue(-1, 0, "typed"); + expect(editor.restoreCellValue(-1, 0)).toBe(true); + }); + + it("cancels only untouched pending rows", () => { + const editor = createEditor(undefined, true, undefined, undefined, [["Ada", "original", "Lovelace"]]); + editor.newRows.value = []; + editor.addRows(2); + + // 已落库的行不是「新增行」,取消新增不适用于它 + expect(editor.cancelUntouchedPendingRow(0)).toBe(false); + expect(editor.cancelUntouchedPendingRow(-1)).toBe(true); + expect(editor.newRows.value).toHaveLength(1); + + // 填写过再清空的行不再是「未触碰」,必须留在网格与保存预览里 + editor.applyCellValue(-1, 0, "kept"); + editor.restoreCellValue(-1, 0); + expect(editor.cancelUntouchedPendingRow(-1)).toBe(false); + expect(editor.newRows.value).toHaveLength(1); + }); + + it("keeps the cancelled pending row undoable", () => { + const editor = createEditor(undefined, true, undefined, undefined, [["Ada", "original", "Lovelace"]]); + editor.newRows.value = []; + editor.addRows(2); + + expect(editor.cancelUntouchedPendingRow(-1)).toBe(true); + expect(editor.newRows.value).toHaveLength(1); + expect(editor.canUndoPendingChange.value).toBe(true); + + editor.undoPendingChange(); + expect(editor.newRows.value).toHaveLength(2); + expect(editor.newRowMeta.value[0]?.untouched).toBe(true); + }); +}); diff --git a/apps/desktop/src/composables/useDataGridEditor.ts b/apps/desktop/src/composables/useDataGridEditor.ts index 34ad6d6a1a..f21c6e0812 100644 --- a/apps/desktop/src/composables/useDataGridEditor.ts +++ b/apps/desktop/src/composables/useDataGridEditor.ts @@ -1184,45 +1184,60 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) { } } - function restoreCellValue(rowId: number, col: number) { - if (!canEditColumn(col)) return; + // 返回「是否真的回退了内容」。网格焦点下的 Esc(#11634)只有在确有改动被回退时才消费按键。 + function restoreCellValue(rowId: number, col: number): boolean { + if (!canEditColumn(col)) return false; const item = getRowItem(rowId); - if (!item || item.isDeleted) return; + if (!item || item.isDeleted) return false; if (item.isDraft) { ensureQuickEntryDraftRow(); - if (quickEntryDraftRow.value[col] === null) return; + if (quickEntryDraftRow.value[col] === null) return false; pushUndoSnapshot(); const nextDraftRow = [...quickEntryDraftRow.value]; nextDraftRow[col] = null; quickEntryDraftRow.value = draftRowHasValue(nextDraftRow) ? nextDraftRow : emptyDraftRow(); touchPendingChanges(); - return; + return true; } if (item.isNew && item.newIndex !== undefined) { - if (isSavingNewRow(item)) return; + if (isSavingNewRow(item)) return false; const row = newRows.value[item.newIndex]; - if (!row || row[col] === null) return; + if (!row || row[col] === null) return false; pushUndoSnapshot(); row[col] = null; updateNewRowEditedColumns(item.newIndex, col, null); newRows.value = [...newRows.value]; newRowMeta.value = [...newRowMeta.value]; touchPendingChanges(); - return; + return true; } - if (item.sourceIndex === undefined) return; - if (!canEditExistingRows.value) return; + if (item.sourceIndex === undefined) return false; + if (!canEditExistingRows.value) return false; const rowChanges = dirtyRows.value.get(item.sourceIndex); - if (!rowChanges?.has(col)) return; + if (!rowChanges?.has(col)) return false; pushUndoSnapshot(); rowChanges.delete(col); if (rowChanges.size === 0) dirtyRows.value.delete(item.sourceIndex); dirtyRows.value = new Map(dirtyRows.value); touchPendingChanges(); onCellValueChanged?.(rowId, col); + return true; + } + + // #11634:Esc 取消「刚新增且从未写入」的待插入行。这类行不进 INSERT 批次 + // (snapshotPendingSaveChanges 会跳过 untouched 行),因此撤掉它等于整体取消这次新增。 + // 「填写过再清空」的行在首次提交时就被 updateNewRowEditedColumns 摘掉了 untouched 标记, + // 不在取消之列——它们必须保留在保存预览里(useDataGridEditor.spec.ts 锁定了该语义)。 + function cancelUntouchedPendingRow(rowId: number): boolean { + const item = getRowItem(rowId); + if (!item?.isNew || item.newIndex === undefined) return false; + if (isSavingNewRow(item) || !isUntouchedNewRow(item.data, item.newIndex)) return false; + // 走既有的未触碰行删除路径:免二次确认,且写入撤销栈 + requestDeleteRows([rowId]); + return true; } function cancelEdit() { @@ -1241,6 +1256,9 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) { } else if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); + // 只取消本次输入,绝不在这里顺手删掉刚新增的空行:新增行在网格里是「批量粘贴的目标」 + // (batchAppendPaste 要求选中的是空白新增行),把「离开编辑器」和「取消新增」合并 + // 会让习惯了 Esc 收尾的用户莫名丢掉那行。取消新增由网格焦点下的第二次 Esc 承担(#11634)。 cancelEdit(); } } @@ -2563,6 +2581,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) { commitEditFromBlur, applyCellValue, restoreCellValue, + cancelUntouchedPendingRow, cancelEdit, onEditKeydown, addRow, diff --git a/apps/desktop/src/lib/dataGrid/canvasDataGridRenderer.ts b/apps/desktop/src/lib/dataGrid/canvasDataGridRenderer.ts index bd5edb0640..3c0e4290fa 100644 --- a/apps/desktop/src/lib/dataGrid/canvasDataGridRenderer.ts +++ b/apps/desktop/src/lib/dataGrid/canvasDataGridRenderer.ts @@ -170,14 +170,18 @@ export function resolveCanvasDataGridRowBase( const stripedRows = options.stripedRows ?? options.zebraStriping ?? true; const stripedRowBg = options.zebraRowBg?.trim() || theme.rowMuted; if (item.isDeleted) return theme.rowDeleted; - if (item.isNew && !isActive) return theme.rowNew; + // 待插入行的整行底色优先于活动行底色(#11634):光标总在新行上(新增后立即进入编辑), + // 若让活动行底色盖掉它,「这行还没保存」就恰好在这时看不见了。 + if (item.isNew) return theme.rowNew; if (item.isDraft && !isActive) return theme.rowMuted; if (stripedRows && item.displayIndex % 2 === 1 && !isActive) return stripedRowBg; return theme.background; } -export function resolveCanvasDataGridRowFill(theme: Pick, rowBase: string, options: { isActive: boolean; isDeleted: boolean; isSelected: boolean }): string { +export function resolveCanvasDataGridRowFill(theme: Pick, rowBase: string, options: { isActive: boolean; isDeleted: boolean; isSelected: boolean; isNew?: boolean }): string { if (options.isSelected) return theme.cellSelected; + // 同上:待插入行保留自己的底色,活动行高亮不再覆盖它 + if (options.isNew) return rowBase; if (options.isActive && !options.isDeleted) return theme.cellActive; return rowBase; } @@ -479,6 +483,7 @@ export function drawCanvasDataGrid(options: DrawCanvasDataGridOptions): boolean isActive: rowIsActive, isDeleted: item.isDeleted, isSelected: rowSelectionVisual, + isNew: item.isNew, }); const rowBorderY = crispCanvasLine(y + CANVAS_DATA_GRID_ROW_HEIGHT - 1, scaleY); ctx.globalAlpha = item.isDeleted ? 0.7 : 1; diff --git a/apps/desktop/src/lib/dataGrid/dataGridPaintTheme.ts b/apps/desktop/src/lib/dataGrid/dataGridPaintTheme.ts index d2d93a33f1..a97d3dcc93 100644 --- a/apps/desktop/src/lib/dataGrid/dataGridPaintTheme.ts +++ b/apps/desktop/src/lib/dataGrid/dataGridPaintTheme.ts @@ -61,6 +61,12 @@ export const DATA_GRID_DARK_CROSSHAIR_ROW_BG = "rgb(75, 84, 98)"; export const DATA_GRID_DARK_CROSSHAIR_COL_BG = "rgb(98, 111, 130)"; export const DATA_GRID_LIGHT_STRIPED_ROW_BG = "rgb(240, 240, 240)"; export const DATA_GRID_DARK_STRIPED_ROW_BG = "rgb(40, 40, 43)"; +// 新增行(未落库的待插入行)整行底色,必须与普通行、斑马纹行「一眼可辨」(#11634): +// 用与行号列 --data-grid-row-number-new-bg 同族的 emerald,但强度更低(10% vs 15%), +// 使整行有绿调的同时,行号标记(填写后才出现)仍然比行底色更醒目。取值与网格组件的 +// --data-grid-row-new-bg(含 color-mix 变体)保持一致。 +export const DATA_GRID_LIGHT_NEW_ROW_BG = "rgb(231, 248, 242)"; +export const DATA_GRID_DARK_NEW_ROW_BG = "rgb(19, 37, 33)"; export const DATA_GRID_DARK_ROW_NUMBER_BG = "rgb(35, 37, 42)"; const DATA_GRID_DARK_ROW_NUMBER_NEW_BG = "rgb(33, 45, 40)"; const DATA_GRID_DARK_ROW_NUMBER_EDITED_BG = "rgb(48, 41, 28)"; @@ -273,7 +279,7 @@ export function resolveDataGridPaintTheme(options: { getVar: (name: string) => s const accent = cssVarColor(getVar, "--accent", isDark ? "rgb(46, 47, 51)" : "rgb(245, 245, 245)"); const activeSurface = dataGridActiveRowBackground(isDark); const rowMuted = isDark ? DATA_GRID_DARK_STRIPED_ROW_BG : DATA_GRID_LIGHT_STRIPED_ROW_BG; - const rowNew = isDark ? "rgb(51, 51, 55)" : "rgb(243, 243, 243)"; + const rowNew = isDark ? DATA_GRID_DARK_NEW_ROW_BG : DATA_GRID_LIGHT_NEW_ROW_BG; const rowDeleted = isDark ? "rgb(55, 31, 32)" : "rgb(255, 244, 244)"; const cellActive = activeSurface; // 浅/深都读 CSS 变量(--data-grid-cell-crosshair-*-bg 从 --primary 派生),保证 DOM 与 diff --git a/docs/content/docs/data-grid.cn.mdx b/docs/content/docs/data-grid.cn.mdx index 33c0f03bcf..e49836a193 100644 --- a/docs/content/docs/data-grid.cn.mdx +++ b/docs/content/docs/data-grid.cn.mdx @@ -65,6 +65,12 @@ DBX 使用虚拟滚动,只渲染当前可视区域。常用浏览操作包括 日期时间、枚举、JSON 和其他类型会在可识别时使用专用编辑控件。批量编辑前应确认所选区域和目标字段类型一致。 +## 键盘操作 + +- **下键到末尾再加一行**:光标停在当前显示的最后一行时,再按一次 `↓` 会新增一行并进入首列编辑;新行**固定追加到结果末尾**(与工具栏「新增行」按钮不同——按钮跟随「插入位置」设置) +- **`Esc` 取消新增行**:在从未填写过的新增行上按 `Esc`,该行会被撤掉(`Ctrl+Z` 可恢复);填写过再清空的行会保留 +- **`Esc` 回退单元格**:编辑器打开时 `Esc` 只取消本次输入;焦点回到表格后再按 `Esc`,当前单元格的待保存改动回退成原值(对齐 DBeaver 的 Reset cell changes)。多格选区不适用 + ## SQL 预览与保存 行级关系型数据修改先保存在本地待提交状态。点击保存时,DBX 会展示生成的 `INSERT`、`UPDATE` 和 `DELETE` SQL。 diff --git a/docs/content/docs/data-grid.mdx b/docs/content/docs/data-grid.mdx index 526e04d95e..98a3ea1d1d 100644 --- a/docs/content/docs/data-grid.mdx +++ b/docs/content/docs/data-grid.mdx @@ -65,6 +65,12 @@ Read-only cells can still open details, copy, and export. Check the data header DBX uses specialized controls for recognizable date/time, enum, JSON, and related types. Before bulk editing, verify that the selected region matches the target column types. +## Keyboard + +- **Add a row with the down arrow**: with the cursor on the last displayed row, pressing `↓` again adds a row and opens it for editing in its first column. The new row is always appended to the end of the result - unlike the Add Row button, which follows the toolbar insert position. +- **Cancel a new row with `Esc`**: pressing `Esc` on a new row that has never been filled in removes that row (`Ctrl+Z` restores it). A row that was filled in and then cleared is kept. +- **Revert a cell with `Esc`**: while a cell editor is open, `Esc` only cancels the current input; with the grid focused, `Esc` reverts the selected cell's pending change to its original value, matching DBeaver's *Reset cell changes*. Multi-cell selections are left alone. + ## SQL Preview and Save Row-level relational mutations remain local until saved. DBX then shows the generated `INSERT`, `UPDATE`, and `DELETE` statements. diff --git a/docs/content/docs/keyboard-shortcuts.cn.mdx b/docs/content/docs/keyboard-shortcuts.cn.mdx index 7efd3120c3..5459c272fc 100644 --- a/docs/content/docs/keyboard-shortcuts.cn.mdx +++ b/docs/content/docs/keyboard-shortcuts.cn.mdx @@ -46,6 +46,8 @@ DBX 的快捷键来自同一份可配置 action registry。**设置 → 快捷 「聚焦 WHERE 条件输入框」默认不绑定快捷键。可在 **设置 → 快捷键 → 数据表格** 中自行设置,按下已配置的快捷键后将光标移至当前表数据标签页的 WHERE 输入框,保留现有条件且不执行查询。WHERE 输入框不可用时不生效。 +网格还有两个固定手势(不可自定义):光标停在当前显示的最后一行时按 `↓` 会在结果末尾追加一行(与「新增行」按钮不同,按钮跟随「插入位置」设置);编辑器内的 `Esc` 只取消本次输入,焦点回到网格后按 `Esc` 会撤掉未填写过的新增行,否则把当前单元格回退为原值。详见[数据表格](/cn/docs/data-grid)。 + ### 全局和导航 | 默认键 | 操作 | diff --git a/docs/content/docs/keyboard-shortcuts.mdx b/docs/content/docs/keyboard-shortcuts.mdx index b6a58ed041..c4f0301a27 100644 --- a/docs/content/docs/keyboard-shortcuts.mdx +++ b/docs/content/docs/keyboard-shortcuts.mdx @@ -46,6 +46,8 @@ Standard copy and paste still follow platform conventions. Copy Current Row, Del **Focus WHERE condition** has no default shortcut. Assign your preferred binding in **Settings → Shortcuts → Data grid** to focus the WHERE input in the active table data tab. It keeps the current condition and does not execute a query. It does nothing when the WHERE input is unavailable. +The grid also has two fixed gestures that are not rebindable: pressing `↓` while the cursor is on the last displayed row appends a row at the end of the result (unlike Add Row, which follows the toolbar insert position), and `Esc` inside a cell editor only cancels the current input — with the grid focused, `Esc` removes a new row that was never filled in, otherwise it reverts the selected cell to its original value. See [Data grid](/en/docs/data-grid). + ### Global and Navigation | Default | Action | diff --git a/packages/app-tests/canvasDataGridRenderer.test.ts b/packages/app-tests/canvasDataGridRenderer.test.ts index 2b3c588187..1fb8d3602e 100644 --- a/packages/app-tests/canvasDataGridRenderer.test.ts +++ b/packages/app-tests/canvasDataGridRenderer.test.ts @@ -3,6 +3,19 @@ import { test } from "vitest"; import { canvasDataGridActionReservedWidth, drawBooleanCheckbox, fitCanvasText, resolveCanvasCellTextLayout, resolveCanvasDataGridRowBase, resolveCanvasDataGridRowFill } from "../../apps/desktop/src/lib/dataGrid/canvasDataGridRenderer.ts"; import { DATA_GRID_DARK_STRIPED_ROW_BG, DATA_GRID_LIGHT_STRIPED_ROW_BG, resolveDataGridPaintTheme } from "../../apps/desktop/src/lib/dataGrid/dataGridPaintTheme.ts"; +function rgbChannels(color: string): number[] { + const match = color.match(/rgba?\(([^)]+)\)/); + assert.ok(match, `not an rgb colour: ${color}`); + return match[1]!.split(",").slice(0, 3).map((part) => Number.parseFloat(part.trim())); +} + +/** 最大单通道差值:用于证明「一眼可辨」,而不是仅仅不相等。 */ +function channelDistance(first: string, second: string): number { + const left = rgbChannels(first); + const right = rgbChannels(second); + return Math.max(...left.map((channel, index) => Math.abs(channel - right[index]!))); +} + function measureContext(charWidth = 1): CanvasRenderingContext2D { return { font: "13px sans-serif", @@ -59,6 +72,31 @@ test("data grid paint themes use the increased striped row contrast", () => { assert.equal(resolveDataGridPaintTheme({ getVar, isDark: true }).rowMuted, DATA_GRID_DARK_STRIPED_ROW_BG); }); +test("pending new rows are visually distinct from striped rows and the base surface in both themes", () => { + const getVar = () => ""; + + for (const isDark of [false, true]) { + const theme = resolveDataGridPaintTheme({ getVar, isDark }); + // 19/20 世纪那种「243 vs 240」的差距在屏幕上不可辨:要求至少一个通道差 ≥ 8 + assert.ok(channelDistance(theme.rowNew, theme.rowMuted) >= 8, `new vs striped (dark=${isDark}): ${theme.rowNew} / ${theme.rowMuted}`); + assert.ok(channelDistance(theme.rowNew, theme.background) >= 8, `new vs background (dark=${isDark}): ${theme.rowNew} / ${theme.background}`); + assert.ok(channelDistance(theme.rowMuted, theme.background) >= 8, `striped vs background (dark=${isDark}): ${theme.rowMuted} / ${theme.background}`); + } +}); + +test("pending new rows keep their own colour when they hold the active cursor", () => { + const theme = { background: "bg-base", rowMuted: "row-muted", rowNew: "row-new", rowDeleted: "row-deleted", cellActive: "active-blue", cellSelected: "selected-blue" }; + const newRow = { displayIndex: 0, isDeleted: false, isNew: true, isDraft: false }; + const baseRow = { displayIndex: 0, isDeleted: false, isNew: false, isDraft: false }; + + // 光标总在新行上(新增后立即进入编辑),活动行底色不得盖掉「这行还没保存」 + assert.equal(resolveCanvasDataGridRowBase(theme, newRow, { isActive: true }), "row-new"); + assert.equal(resolveCanvasDataGridRowFill(theme, "row-new", { isActive: true, isDeleted: false, isSelected: false, isNew: true }), "row-new"); + // 对照:普通行的活动底色与选中底色保持不变 + assert.equal(resolveCanvasDataGridRowFill(theme, "bg-base", { isActive: true, isDeleted: false, isSelected: false }), "active-blue"); + assert.equal(resolveCanvasDataGridRowFill(theme, "row-new", { isActive: true, isDeleted: false, isSelected: true, isNew: true }), "selected-blue"); +}); + test("data grid paint theme uses the resolved striped row token", () => { const getVar = (name: string) => (name === "--data-grid-row-muted-bg" ? "rgb(235, 239, 244)" : ""); diff --git a/packages/app-tests/dataGridBackgroundConsistency.test.ts b/packages/app-tests/dataGridBackgroundConsistency.test.ts index 3e4725bd39..a71065dac2 100644 --- a/packages/app-tests/dataGridBackgroundConsistency.test.ts +++ b/packages/app-tests/dataGridBackgroundConsistency.test.ts @@ -1,7 +1,11 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import { test } from "vitest"; -import { resolveDataGridPaintTheme } from "../../apps/desktop/src/lib/dataGrid/dataGridPaintTheme.ts"; +import { DATA_GRID_DARK_NEW_ROW_BG, DATA_GRID_LIGHT_NEW_ROW_BG, resolveDataGridPaintTheme } from "../../apps/desktop/src/lib/dataGrid/dataGridPaintTheme.ts"; + +function escapeColor(color: string): string { + return color.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} test("DataGrid DOM surfaces use one opaque theme background", () => { const source = readFileSync("apps/desktop/src/components/grid/DataGrid.vue", "utf8"); @@ -22,6 +26,17 @@ test("DataGrid DOM surfaces use one opaque theme background", () => { assert.doesNotMatch(source, /(?:canvas-grid-scroller|data-grid-scroller)[^}]*background-color:\s*var\(--background\)/s); }); +test("DataGrid DOM paints pending new rows with the same colour source as the canvas", () => { + const source = readFileSync("apps/desktop/src/components/grid/DataGrid.vue", "utf8"); + + // DOM 行底色与 Canvas 的 theme.rowNew 必须来自同一个常量/变量,避免只改一侧而悄悄漂移 + for (const color of [DATA_GRID_LIGHT_NEW_ROW_BG, DATA_GRID_DARK_NEW_ROW_BG]) { + assert.match(source, new RegExp(`--data-grid-row-new-bg:\\s*${escapeColor(color)};`)); + } + // 现代浏览器走 color-mix 变体:与行号列的新行标记同族(emerald)但更淡 + assert.match(source, /--data-grid-row-new-bg:\s*color-mix\(in oklab,\s*rgb\(16 185 129\) 10%,\s*var\(--background\)\);/); +}); + test("DataGrid canvas background stays opaque when the wallpaper background has alpha", () => { const getThemeBackground = (varBackground: string, solidBackground: string, dataGridBackground: string, isDark: boolean) => { return resolveDataGridPaintTheme({