diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index 90699cfc26..bfed63d882 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -146,7 +146,18 @@ import { } from "@/lib/dataGrid/binaryCellDownload"; import { buildBinaryHexViewRows } from "@/lib/dataGrid/binaryHexViewer"; import { canFormatCellDetailJson, cellDetailEditorText, compactJsonText, defaultCellDetailTab, formatJsonText, isGeometryColumnType, linkedCellDetailTarget, looksLikeJsonContainerText, valueEditorActions, visibleCellDetailTabs, type CellDetailTab } from "@/lib/dataGrid/cellDetailPresentation"; -import { buildDataGridCellDetail, buildDataGridColumnDetail, buildDataGridRowDetail, CELL_DETAIL_VALUE_PREVIEW_MAX_LENGTH, dataGridColumnDetailJson, dataGridColumnDetailTsv, dataGridRowDetailJson, dataGridRowDetailTsv, type DataGridCellDetail } from "@/lib/dataGrid/dataGridDetail"; +import { + buildDataGridCellDetail, + buildDataGridColumnDetail, + buildDataGridRowDetail, + buildDeleteRowConfirmDetails, + CELL_DETAIL_VALUE_PREVIEW_MAX_LENGTH, + dataGridColumnDetailJson, + dataGridColumnDetailTsv, + dataGridRowDetailJson, + dataGridRowDetailTsv, + type DataGridCellDetail, +} from "@/lib/dataGrid/dataGridDetail"; import { applyColumnFormatter, buildColumnFormatterKey, @@ -4463,6 +4474,10 @@ function formatGridItemCell(item: RowItem, columnIndex: number): string { return formatCellCached(item.data[columnIndex], columnIndex, largeValueOriginalBytes(item, columnIndex)); } +function formatGridItemCellForConfirmation(item: RowItem, columnIndex: number): string { + return formatCell(item.data[columnIndex], columnIndex, largeValueOriginalBytes(item, columnIndex), false); +} + function normalizedLargeValueIdentityPart(value: CellValue): string { if (value === null) return "null"; return `${typeof value}:${JSON.stringify(value)}`; @@ -4656,7 +4671,16 @@ const exportContextCell = computed(() => { return { ...contextCell.value, col: visibleCol }; }); -const deleteRowDetails = computed(() => (props.tableMeta?.tableName ? t("dangerDialog.deleteRowDetails", { table: props.tableMeta.tableName }) : t("dangerDialog.deleteRowDetailsNoTable"))); +const deleteRowDetails = computed(() => { + const header = props.tableMeta?.tableName ? t("dangerDialog.deleteRowDetails", { table: props.tableMeta.tableName }) : t("dangerDialog.deleteRowDetailsNoTable"); + return buildDeleteRowConfirmDetails({ + header, + rowIds: pendingDeleteRowIds.value, + columns: resultSourceColumns.value, + getRow: getRowItem, + formatCell: (item, columnIndex) => formatGridItemCellForConfirmation(item, columnIndex), + }); +}); const hasVisibleRows = computed(() => displayRowCount.value > 0); const hasActiveFilter = computed(() => (dataGridSearchMode.value === "filter" && !!deferredClientSearchText.value) || rowStatusFilter.value !== "all" || hasLocalColumnFilters.value || hasServerColumnFilters.value); @@ -5894,10 +5918,11 @@ function primitiveCellFormatKey(value: CellValue, columnIndex?: number): string return `${columnIndex ?? -1}\u0000${typeof value}\u0000${String(value)}`; } -function formatCell(value: CellValue, columnIndex?: number, originalBytes?: number): string { +function formatCell(value: CellValue, columnIndex?: number, originalBytes?: number, limitDisplay = true): string { const formatter = columnIndex === undefined ? undefined : resolvedColumnFormatters.value[columnIndex]; if (formatter?.kind === "foreign-key-display" && columnIndex !== undefined) { - return limitDataGridCellDisplay(formatForeignKeyDisplayValue(value, foreignKeyDisplayLabels.value.get(columnIndex)), resolvedDatabaseType.value === "sqlserver" ? SQLSERVER_DATA_GRID_CELL_DISPLAY_MAX_LENGTH : undefined); + const display = formatForeignKeyDisplayValue(value, foreignKeyDisplayLabels.value.get(columnIndex)); + return limitDisplay ? limitDataGridCellDisplay(display, resolvedDatabaseType.value === "sqlserver" ? SQLSERVER_DATA_GRID_CELL_DISPLAY_MAX_LENGTH : undefined) : display; } const columnInfo = columnIndex === undefined ? undefined : tableColumnForGridColumn(columnIndex); const displayColumnInfo = columnInfo ?? (columnIndex === undefined ? undefined : resultColumnInfoForGridColumn(columnIndex)); @@ -5912,7 +5937,7 @@ function formatCell(value: CellValue, columnIndex?: number, originalBytes?: numb const binaryDisplay = formatter ? null : binaryCellDisplayText(value, columnIndex === undefined ? undefined : allColumnTypes.value[columnIndex], originalBytes); if (binaryDisplay !== null) return binaryDisplay; const s = applyColumnFormatter(value, formatter); - return limitDataGridCellDisplay(s, resolvedDatabaseType.value === "sqlserver" ? SQLSERVER_DATA_GRID_CELL_DISPLAY_MAX_LENGTH : undefined); + return limitDisplay ? limitDataGridCellDisplay(s, resolvedDatabaseType.value === "sqlserver" ? SQLSERVER_DATA_GRID_CELL_DISPLAY_MAX_LENGTH : undefined) : s; } function formatCellCached(value: CellValue, columnIndex?: number, originalBytes?: number): string { @@ -12609,6 +12634,7 @@ function currentGridContextMenuItems(): ContextMenuItem[] { " :details="deleteRowDetails" :confirm-label="pendingDeleteRowIds.length > 1 ? t('grid.deleteRows', { count: pendingDeleteRowIds.length }) : t('grid.deleteRow')" + :close-on-confirm="false" @confirm="confirmDeleteRow" /> { + it("still wires deleteRowDetails into the delete-row DangerConfirmDialog", () => { + const dialogStart = dataGridSource.indexOf('v-model:open="showDeleteRowConfirm"'); + const dialogEnd = dataGridSource.indexOf('@confirm="confirmDeleteRow"', dialogStart); + + expect(dialogStart).toBeGreaterThanOrEqual(0); + expect(dialogEnd).toBeGreaterThan(dialogStart); + + const dialogSource = dataGridSource.slice(dialogStart, dialogEnd); + expect(dialogSource).toContain(':details="deleteRowDetails"'); + }); + + it("uses the unbounded confirmation formatter for delete details", () => { + const detailsStart = dataGridSource.indexOf("const deleteRowDetails = computed"); + const detailsEnd = dataGridSource.indexOf("const hasVisibleRows", detailsStart); + + expect(detailsStart).toBeGreaterThanOrEqual(0); + expect(detailsEnd).toBeGreaterThan(detailsStart); + expect(dataGridSource.slice(detailsStart, detailsEnd)).toContain("formatGridItemCellForConfirmation"); + expect(dataGridSource).toContain("formatCell(item.data[columnIndex], columnIndex, largeValueOriginalBytes(item, columnIndex), false)"); + }); + + it("keeps close-on-confirm disabled so confirmDeleteRow runs before the dialog auto-closes", () => { + const dialogStart = dataGridSource.indexOf('v-model:open="showDeleteRowConfirm"'); + const dialogEnd = dataGridSource.indexOf('@confirm="confirmDeleteRow"', dialogStart); + + expect(dialogStart).toBeGreaterThanOrEqual(0); + expect(dialogEnd).toBeGreaterThan(dialogStart); + + const dialogSource = dataGridSource.slice(dialogStart, dialogEnd); + expect(dialogSource).toContain(':close-on-confirm="false"'); + }); +}); diff --git a/apps/desktop/src/composables/__tests__/useDataGridEditor.spec.ts b/apps/desktop/src/composables/__tests__/useDataGridEditor.spec.ts index bd2e222b68..511adbb944 100644 --- a/apps/desktop/src/composables/__tests__/useDataGridEditor.spec.ts +++ b/apps/desktop/src/composables/__tests__/useDataGridEditor.spec.ts @@ -154,6 +154,56 @@ describe("useDataGridEditor row deletion confirmation", () => { expect(editor.showDeleteRowConfirm.value).toBe(false); expect(editor.newRows.value).toHaveLength(0); }); + + it("populates pendingDeleteRowIds for a single-row delete request", () => { + const editor = createEditor(undefined, true); + + editor.requestDeleteRow(-1); + + expect(editor.pendingDeleteRowIds.value).toEqual([-1]); + }); + + it("populates pendingDeleteRowIds for a multi-row delete request and clears it on confirm", () => { + const editor = createEditor(undefined, true); + editor.newRows.value = [ + [null, null, null], + [null, null, null], + ]; + + editor.requestDeleteRows([-1, -2]); + + expect(editor.pendingDeleteRowIds.value).toEqual([-1, -2]); + + editor.confirmDeleteRow(); + + expect(editor.pendingDeleteRowIds.value).toEqual([]); + expect(editor.newRows.value).toHaveLength(0); + }); + + it("clears pendingDeleteRowIds when the confirmation dialog is closed without confirming", () => { + const editor = createEditor(undefined, true); + + editor.requestDeleteRow(-1); + expect(editor.pendingDeleteRowIds.value).toEqual([-1]); + + editor.showDeleteRowConfirm.value = false; + + expect(editor.pendingDeleteRowIds.value).toEqual([]); + expect(editor.newRows.value).toHaveLength(1); // row itself was never actually deleted + }); + + it("confirmDeleteRow deletes the row and closes the dialog itself, without racing the cancel watcher", () => { + const editor = createEditor(undefined, true); + + editor.requestDeleteRow(-1); + expect(editor.newRows.value).toHaveLength(1); + + editor.confirmDeleteRow(); + + expect(editor.newRows.value).toHaveLength(0); // the row must actually be deleted + expect(editor.showDeleteRowConfirm.value).toBe(false); // and the dialog closes on its own + expect(editor.pendingDeleteRowIds.value).toEqual([]); + }); }); describe("useDataGridEditor appendPastedRowsToNewRow", () => { diff --git a/apps/desktop/src/composables/useDataGridEditor.ts b/apps/desktop/src/composables/useDataGridEditor.ts index a35c70497c..68a8df6d63 100644 --- a/apps/desktop/src/composables/useDataGridEditor.ts +++ b/apps/desktop/src/composables/useDataGridEditor.ts @@ -1243,16 +1243,10 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) { } const showDeleteRowConfirm = ref(false); - const pendingDeleteRowId = ref(null); const pendingDeleteRowIds = ref([]); function requestDeleteRow(rowId: number) { - if (!confirmDangerousRowDeletion.value) { - applyDeleteRow(rowId); - return; - } - pendingDeleteRowId.value = rowId; - showDeleteRowConfirm.value = true; + requestDeleteRows([rowId]); } function requestDeleteRows(rowIds: number[]) { @@ -1265,16 +1259,21 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) { } function confirmDeleteRow() { - if (pendingDeleteRowIds.value.length > 0) { - applyDeleteRows(pendingDeleteRowIds.value); - pendingDeleteRowIds.value = []; - return; - } - if (pendingDeleteRowId.value === null) return; - applyDeleteRow(pendingDeleteRowId.value); - pendingDeleteRowId.value = null; + const rowIds = pendingDeleteRowIds.value; + pendingDeleteRowIds.value = []; + showDeleteRowConfirm.value = false; + if (rowIds.length === 0) return; + applyDeleteRows(rowIds); } + watch( + showDeleteRowConfirm, + (isOpen) => { + if (!isOpen) pendingDeleteRowIds.value = []; + }, + { flush: "sync" }, + ); + function restoreRow(rowId: number) { const item = getRowItem(rowId); if (item?.sourceIndex !== undefined && deletedRows.value.has(item.sourceIndex)) { @@ -1798,7 +1797,6 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) { applyDeleteRows, applyDeleteRow, showDeleteRowConfirm, - pendingDeleteRowId, pendingDeleteRowIds, requestDeleteRow, requestDeleteRows, diff --git a/apps/desktop/src/composables/useSqlHighlighter.ts b/apps/desktop/src/composables/useSqlHighlighter.ts index 454a8e84f7..61a6f90adc 100644 --- a/apps/desktop/src/composables/useSqlHighlighter.ts +++ b/apps/desktop/src/composables/useSqlHighlighter.ts @@ -1,6 +1,6 @@ import { onMounted, ref } from "vue"; import { useTheme } from "@/composables/useTheme"; -import { type SqlHighlighter, createShikiSqlHighlighter } from "@/lib/sql/sqlHighlighter"; +import { type SqlHighlighter, createShikiSqlHighlighter, escapeHtml } from "@/lib/sql/sqlHighlighter"; export function useSqlHighlighter() { const { isDark } = useTheme(); @@ -13,7 +13,7 @@ export function useSqlHighlighter() { }); function highlight(sql: string): string { - return sqlHighlighter.value?.(sql) ?? sql; + return sqlHighlighter.value?.(sql) ?? escapeHtml(sql); } return { highlight, sqlHighlighter }; diff --git a/apps/desktop/src/lib/dataGrid/__tests__/dataGridDetail.spec.ts b/apps/desktop/src/lib/dataGrid/__tests__/dataGridDetail.spec.ts new file mode 100644 index 0000000000..2453b943fe --- /dev/null +++ b/apps/desktop/src/lib/dataGrid/__tests__/dataGridDetail.spec.ts @@ -0,0 +1,133 @@ +import { describe, expect, it } from "vitest"; +import { buildDeleteRowConfirmDetails } from "../dataGridDetail"; + +type TestRow = string[]; + +function testRowFormatter(row: TestRow, columnIndex: number): string { + return `FORMATTED(${row[columnIndex]})`; +} + +describe("buildDeleteRowConfirmDetails", () => { + const rows = new Map([ + [1, ["1", "Alice"]], + [2, ["2", "Bob"]], + [3, ["3", "Carol"]], + ]); + const columns = ["id", "name"]; + const getRow = (rowId: number) => rows.get(rowId); + + it("appends a single formatted row line under the header", () => { + const result = buildDeleteRowConfirmDetails({ + header: "Table: people", + rowIds: [1], + columns, + getRow, + formatCell: testRowFormatter, + }); + + expect(result).toBe('Table: people\n{"id":"FORMATTED(1)","name":"FORMATTED(Alice)"}'); + }); + + it("appends one line per row, in the given order, for multi-row deletes", () => { + const result = buildDeleteRowConfirmDetails({ + header: "Table: people", + rowIds: [2, 1, 3], + columns, + getRow, + formatCell: testRowFormatter, + }); + + expect(result).toBe(["Table: people", '{"id":"FORMATTED(2)","name":"FORMATTED(Bob)"}', '{"id":"FORMATTED(1)","name":"FORMATTED(Alice)"}', '{"id":"FORMATTED(3)","name":"FORMATTED(Carol)"}'].join("\n")); + }); + + it("skips row ids that no longer resolve instead of throwing or inserting a blank line", () => { + const result = buildDeleteRowConfirmDetails({ + header: "Table: people", + rowIds: [1, 999, 2], + columns, + getRow, + formatCell: testRowFormatter, + }); + + expect(result).toBe(["Table: people", '{"id":"FORMATTED(1)","name":"FORMATTED(Alice)"}', '{"id":"FORMATTED(2)","name":"FORMATTED(Bob)"}'].join("\n")); + }); + + it("falls back to just the header when no rows resolve", () => { + const result = buildDeleteRowConfirmDetails({ + header: "Current result row", + rowIds: [], + columns, + getRow, + formatCell: testRowFormatter, + }); + + expect(result).toBe("Current result row"); + }); + + it("does not truncate the row list itself, leaving that to DangerConfirmDialog's own bounded preview", () => { + const manyRows = new Map(); + const manyRowIds: number[] = []; + for (let id = 1; id <= 500; id += 1) { + manyRows.set(id, [String(id), `Name${id}`]); + manyRowIds.push(id); + } + const getManyRows = (rowId: number) => manyRows.get(rowId); + + const result = buildDeleteRowConfirmDetails({ + header: "Table: people", + rowIds: manyRowIds, + columns, + getRow: getManyRows, + formatCell: testRowFormatter, + }); + + const lines = result.split("\n"); + expect(lines).toHaveLength(501); // header + 500 rows + expect(lines).toContain('{"id":"FORMATTED(1)","name":"FORMATTED(Name1)"}'); + expect(lines).toContain('{"id":"FORMATTED(500)","name":"FORMATTED(Name500)"}'); + }); + + it("keeps embedded separators and line breaks inside one structured row", () => { + const result = buildDeleteRowConfirmDetails({ + header: "Table: people", + rowIds: [1], + columns: ["id", "name"], + getRow: () => ["1\nid=999", "Alice, role=admin"], + formatCell: (row, columnIndex) => row[columnIndex], + }); + + expect(result).toBe('Table: people\n{"id":"1\\nid=999","name":"Alice, role=admin"}'); + expect(result.split("\n")).toHaveLength(2); + }); + + it("keeps the complete formatted value instead of truncating confirmation data", () => { + const longValue = `${"x".repeat(300)}A`; + const result = buildDeleteRowConfirmDetails({ + header: "Table: people", + rowIds: [1], + columns: ["payload"], + getRow: () => [longValue], + formatCell: (row, columnIndex) => row[columnIndex], + }); + + expect(result).toContain(longValue); + expect(result).not.toContain(`${"x".repeat(256)}...`); + }); + + it("formats every cell through the provided formatCell callback rather than stringifying values itself", () => { + let calls = 0; + const result = buildDeleteRowConfirmDetails({ + header: "Table: people", + rowIds: [1], + columns, + getRow, + formatCell: (row, columnIndex) => { + calls += 1; + return `custom:${row[columnIndex]}`; + }, + }); + + expect(calls).toBe(columns.length); + expect(result).toBe('Table: people\n{"id":"custom:1","name":"custom:Alice"}'); + }); +}); diff --git a/apps/desktop/src/lib/dataGrid/dataGridDetail.ts b/apps/desktop/src/lib/dataGrid/dataGridDetail.ts index 595a9bbdcb..16350b18c7 100644 --- a/apps/desktop/src/lib/dataGrid/dataGridDetail.ts +++ b/apps/desktop/src/lib/dataGrid/dataGridDetail.ts @@ -228,6 +228,22 @@ export function dataGridColumnDetailTsv(detail: DataGridColumnDetail): string { return detail.fields.map((field) => displayCellValue(field.value)).join("\n"); } +export interface BuildDeleteRowConfirmDetailsOptions { + header: string; + rowIds: readonly number[]; + columns: readonly string[]; + getRow: (rowId: number) => TRow | undefined; + formatCell: (row: TRow, columnIndex: number) => string; +} + +export function buildDeleteRowConfirmDetails(options: BuildDeleteRowConfirmDetailsOptions): string { + const rowLines = options.rowIds + .map((rowId) => options.getRow(rowId)) + .filter((row): row is TRow => row !== undefined) + .map((row) => JSON.stringify(Object.fromEntries(options.columns.map((name, columnIndex) => [name, options.formatCell(row, columnIndex)])))); + return rowLines.length > 0 ? [options.header, ...rowLines].join("\n") : options.header; +} + export function filterDataGridDetailFields(fields: readonly T[], keyword: string): T[] { if (keyword.trim() === "") return [...fields]; const kw = keyword.trim().toLowerCase();