diff --git a/apps/desktop/src/composables/__tests__/useDataGridExport.spec.ts b/apps/desktop/src/composables/__tests__/useDataGridExport.spec.ts index 74c05a8620..2f85ad9dc9 100644 --- a/apps/desktop/src/composables/__tests__/useDataGridExport.spec.ts +++ b/apps/desktop/src/composables/__tests__/useDataGridExport.spec.ts @@ -730,6 +730,66 @@ describe("useDataGridExport prepared row statements", () => { expect(copyToClipboard).toHaveBeenCalledWith(text); }); + it("copies a NULL cell as an empty clipboard value", async () => { + const state = createExportState(editableTable, ["id", "name"], undefined, [1, null], undefined, undefined, [], DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS, false, undefined, false, 1, 1); + + await state.copyCell(); + + expect(copyToClipboard).toHaveBeenCalledWith(""); + }); + + it("copies all rows with empty fields for NULL cells", async () => { + const text = "id\tname\n1\t\n2\tAda"; + const state = createExportState(editableTable, ["id", "name"], undefined, undefined, undefined, [ + [1, null], + [2, "Ada"], + ]); + + await state.copyAll(); + + expect(copyToClipboard).toHaveBeenCalledWith(text); + expect(parseDataGridClipboard(text)).toEqual([ + ["id", "name"], + ["1", null], + ["2", "Ada"], + ]); + }); + + it("copies a NULL smart single-cell selection as an empty value", async () => { + const matrix: CellSelectionMatrix = { + rowIndexes: [0], + columnIndexes: [1], + columns: ["name"], + rows: [[null]], + }; + vi.mocked(extractDataGridSelection).mockResolvedValueOnce({ text: "", mimeType: "text/plain", fileExtension: "txt", rowCount: 1, columnCount: 1 }); + const state = createExportState(editableTable, ["id", "name"], matrix, [1, null]); + + await expect(state.copyWithPreference("smart")).resolves.toBe(true); + + expect(copyToClipboard).toHaveBeenCalledWith(""); + }); + + it("uses an empty default NULL text for TSV clipboard output", async () => { + const matrix: CellSelectionMatrix = { + rowIndexes: [0], + columnIndexes: [0, 1], + columns: ["id", "name"], + rows: [[1, null]], + }; + vi.mocked(extractDataGridSelection).mockResolvedValueOnce({ text: "1\t", mimeType: "text/tab-separated-values", fileExtension: "tsv", rowCount: 1, columnCount: 2 }); + const state = createExportState(editableTable, ["id", "name"], matrix, [1, null]); + + await expect(state.copyWithExtractor("tsv")).resolves.toBe(true); + + expect(extractDataGridSelection).toHaveBeenCalledWith( + expect.objectContaining({ + options: expect.objectContaining({ dsv: expect.objectContaining({ nullText: "" }) }), + }), + ); + expect(copyToClipboard).toHaveBeenCalledWith("1\t"); + }); + it("preserves JSON-column text in a single-cell smart copy", async () => { const tableMeta: DataGridTableMeta = { tableName: "events", diff --git a/apps/desktop/src/composables/useDataGridExport.ts b/apps/desktop/src/composables/useDataGridExport.ts index 6873e166c5..ac9b55598b 100644 --- a/apps/desktop/src/composables/useDataGridExport.ts +++ b/apps/desktop/src/composables/useDataGridExport.ts @@ -8,7 +8,7 @@ import { type CellSelectionMatrix, type CellSelectionRange, type SelectionData } import type { DataGridExtractRequest, DataGridExtractorOptions } from "@/lib/dataGrid/dataGridCopyExtractor"; import { useToast } from "@/composables/useToast"; import { useExportTracker } from "@/composables/useExportTracker"; -import { displayCellValue, type CellValue } from "@/lib/dataGrid/cellValue"; +import { clipboardCellValue, type CellValue } from "@/lib/dataGrid/cellValue"; import { tryStartExclusiveActivation, type ActionActivationGuard } from "@/lib/connection/actionActivation"; import { copyToClipboard } from "@/lib/common/clipboard"; import { clearDataGridClipboardCopy, rememberDataGridClipboardCopy } from "@/lib/dataGrid/dataGridClipboard"; @@ -529,7 +529,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) { const sourceIndex = visibleColumnIndexes.value[contextCell.value.col] ?? contextCell.value.col; const [resolvedItem] = await resolveVisibleRowValues([item], [sourceIndex]); const val = resolvedItem?.data[contextCell.value.col] ?? null; - await copyText(displayCellValue(val), { rows: [[val]] }); + await copyText(clipboardCellValue(val), { rows: [[val]] }); } async function copyRow() { @@ -671,10 +671,8 @@ export function useDataGridExport(options: UseDataGridExportOptions) { }); async function copyAll() { - const header = columns.value.join("\t"); const rows = (await resolveVisibleRowValues(displayItems.value.filter((item) => !item.isDraft))).map((item) => item.data); - const body = rows.map((row) => row.map((cell) => displayCellValue(cell)).join("\t")).join("\n"); - await copyText(`${header}\n${body}`, { rows, header: columns.value }); + await copyText(formatTsv(columns.value, rows), { rows, header: columns.value }); } // --- Export functions --- diff --git a/apps/desktop/src/composables/useDataGridExtractor.ts b/apps/desktop/src/composables/useDataGridExtractor.ts index 327ec1efda..0e1159515e 100644 --- a/apps/desktop/src/composables/useDataGridExtractor.ts +++ b/apps/desktop/src/composables/useDataGridExtractor.ts @@ -329,7 +329,7 @@ export function useDataGridExtractor(options: UseDataGridExtractorOptions) { const request = await resolveRequestSourceValues(initialRequest); const mongoResult = await resolveMongoExtractorResult(extractor, request); const result = mongoResult ?? (await api.extractDataGridSelection(request)); - if (!result.text) return false; + if (!result.text && !extractorAllowsEmptyOutput(extractor)) return false; // Derive the grid paste-back payload from the effective request schema so // hidden support columns, row headers, NULLs, tabs, and newlines keep the // same shape and values as the rendered raw text or TSV. @@ -365,7 +365,7 @@ export function useDataGridExtractor(options: UseDataGridExtractorOptions) { const request = await resolveRequestSourceValues(initialRequest, previewRowCount); const mongoResult = await resolveMongoExtractorResult(extractor, request, previewRowCount); const result = mongoResult ?? (await api.extractDataGridSelection(request)); - if (!result.text) throw new Error(t("grid.copyExtractorEmptySelection")); + if (!result.text && !extractorAllowsEmptyOutput(extractor)) throw new Error(t("grid.copyExtractorEmptySelection")); return { ...result, sourceRowCount, truncated: sourceRowCount > result.rowCount }; } @@ -396,6 +396,10 @@ export function useDataGridExtractor(options: UseDataGridExtractorOptions) { return { copyWithExtractor, copyWithPreference, previewWithExtractor, previewWithPreference, canCopyWithExtractor }; } +function extractorAllowsEmptyOutput(extractor: DataGridCopyExtractorId): boolean { + return DATA_GRID_COPY_EXTRACTOR_DESCRIPTORS[extractor].category === "raw" || DATA_GRID_COPY_EXTRACTOR_DESCRIPTORS[extractor].category === "delimited"; +} + function compactTableMeta(tableMeta: DataGridTableMeta | undefined, requiredColumns: string[]): DataGridTableMeta | undefined { if (!tableMeta?.columns) return tableMeta; const requiredNames = new Set(requiredColumns.map(normalizeName)); diff --git a/apps/desktop/src/lib/__tests__/dataGrid/dataGridCopyExtractor.spec.ts b/apps/desktop/src/lib/__tests__/dataGrid/dataGridCopyExtractor.spec.ts index d897eb5aa5..a64bc1c451 100644 --- a/apps/desktop/src/lib/__tests__/dataGrid/dataGridCopyExtractor.spec.ts +++ b/apps/desktop/src/lib/__tests__/dataGrid/dataGridCopyExtractor.spec.ts @@ -3,6 +3,11 @@ import { describe, expect, it } from "vitest"; import { DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS, normalizeDataGridCopyPreference, normalizeDataGridExtractorOptions, resolveDataGridCopyPreference, validateDataGridExtractorOptions } from "@/lib/dataGrid/dataGridCopyExtractor"; describe("data-grid extractor options", () => { + it("defaults DSV NULL output to an empty spreadsheet field", () => { + expect(DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS.dsv.nullText).toBe(""); + expect(normalizeDataGridExtractorOptions({}).dsv.nullText).toBe(""); + }); + it("resolves smart copy to raw for one cell and TSV otherwise", () => { expect(normalizeDataGridCopyPreference(undefined)).toBe("smart"); expect(normalizeDataGridCopyPreference("smart")).toBe("smart"); diff --git a/apps/desktop/src/lib/dataGrid/__tests__/dataGridDetail.spec.ts b/apps/desktop/src/lib/dataGrid/__tests__/dataGridDetail.spec.ts index 2453b943fe..bbd87f8b66 100644 --- a/apps/desktop/src/lib/dataGrid/__tests__/dataGridDetail.spec.ts +++ b/apps/desktop/src/lib/dataGrid/__tests__/dataGridDetail.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { buildDeleteRowConfirmDetails } from "../dataGridDetail"; +import { buildDeleteRowConfirmDetails, dataGridColumnDetailTsv, dataGridRowDetailTsv, type DataGridColumnDetail, type DataGridRowDetail } from "../dataGridDetail"; type TestRow = string[]; @@ -131,3 +131,14 @@ describe("buildDeleteRowConfirmDetails", () => { expect(result).toBe('Table: people\n{"id":"custom:1","name":"custom:Alice"}'); }); }); + +describe("data grid detail TSV", () => { + it("leaves NULL cells empty while preserving literal NULL text", () => { + const fields = [{ value: null }, { value: "NULL" }] as unknown as DataGridRowDetail["fields"]; + const rowDetail = { fields } as DataGridRowDetail; + const columnDetail = { fields } as DataGridColumnDetail; + + expect(dataGridRowDetailTsv(rowDetail)).toBe("\tNULL"); + expect(dataGridColumnDetailTsv(columnDetail)).toBe("\nNULL"); + }); +}); diff --git a/apps/desktop/src/lib/dataGrid/cellValue.ts b/apps/desktop/src/lib/dataGrid/cellValue.ts index 9a13b0f525..7f8540aaaf 100644 --- a/apps/desktop/src/lib/dataGrid/cellValue.ts +++ b/apps/desktop/src/lib/dataGrid/cellValue.ts @@ -10,6 +10,10 @@ export function displayCellValue(value: CellValue): string { return String(value); } +export function clipboardCellValue(value: CellValue): string { + return value === null ? "" : displayCellValue(value); +} + export function firstLineCellDisplayValue(value: string, flatteningMultiLine: boolean): string { const lineBreakPattern = /\r\n|\r|\n/g; if (flatteningMultiLine) { diff --git a/apps/desktop/src/lib/dataGrid/dataGridCopyExtractor.ts b/apps/desktop/src/lib/dataGrid/dataGridCopyExtractor.ts index de51950f0b..c9cf899d57 100644 --- a/apps/desktop/src/lib/dataGrid/dataGridCopyExtractor.ts +++ b/apps/desktop/src/lib/dataGrid/dataGridCopyExtractor.ts @@ -136,7 +136,7 @@ export const DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS: DataGridExtractorOptions = { dsv: { columnSeparator: ",", rowSeparator: "\n", - nullText: "NULL", + nullText: "", quote: '"', quotePolicy: "minimal", includeColumnHeader: false, diff --git a/apps/desktop/src/lib/dataGrid/dataGridDetail.ts b/apps/desktop/src/lib/dataGrid/dataGridDetail.ts index 275b56a44b..8d25d14cce 100644 --- a/apps/desktop/src/lib/dataGrid/dataGridDetail.ts +++ b/apps/desktop/src/lib/dataGrid/dataGridDetail.ts @@ -1,5 +1,5 @@ import { cellImagePreviewUrl } from "@/lib/dataGrid/cellImageUrl"; -import { displayCellValue, type CellValue } from "@/lib/dataGrid/cellValue"; +import { clipboardCellValue, displayCellValue, type CellValue } from "@/lib/dataGrid/cellValue"; import { formatJsonText } from "@/lib/dataGrid/cellDetailPresentation"; import type { DatabaseType } from "@/types/database"; @@ -215,7 +215,7 @@ export function jsonDetailDisplayValue(value: unknown): unknown { } export function dataGridRowDetailTsv(detail: DataGridRowDetail): string { - return detail.fields.map((field) => displayCellValue(field.value)).join("\t"); + return detail.fields.map((field) => clipboardCellValue(field.value)).join("\t"); } export function dataGridColumnDetailJson(detail: DataGridColumnDetail): string { @@ -230,7 +230,7 @@ export function dataGridColumnDetailJson(detail: DataGridColumnDetail): string { } export function dataGridColumnDetailTsv(detail: DataGridColumnDetail): string { - return detail.fields.map((field) => displayCellValue(field.value)).join("\n"); + return detail.fields.map((field) => clipboardCellValue(field.value)).join("\n"); } export interface BuildDeleteRowConfirmDetailsOptions { diff --git a/crates/dbx-core/src/data_grid_extractors/raw.rs b/crates/dbx-core/src/data_grid_extractors/raw.rs index 9da079c3c7..bef19daf35 100644 --- a/crates/dbx-core/src/data_grid_extractors/raw.rs +++ b/crates/dbx-core/src/data_grid_extractors/raw.rs @@ -2,7 +2,8 @@ use super::{value_text, write_bytes, DataGridExtractError, ExtractContext}; use std::io::Write; /// Writes selected cell values without DSV escaping. Tabs and newlines retain -/// the selection shape, while each value itself remains unchanged. +/// the selection shape, while NULL cells remain empty for spreadsheet-friendly +/// clipboard output. pub(super) fn write_raw(context: &ExtractContext<'_>, output: &mut dyn Write) -> Result<(), DataGridExtractError> { for (row_index, row) in context.request.rows.iter().enumerate() { if row_index > 0 { @@ -12,7 +13,9 @@ pub(super) fn write_raw(context: &ExtractContext<'_>, output: &mut dyn Write) -> if column_index > 0 { write_bytes(output, b"\t")?; } - write_bytes(output, value_text(&row[*source_index]).as_bytes())?; + if !row[*source_index].is_null() { + write_bytes(output, value_text(&row[*source_index]).as_bytes())?; + } } } Ok(()) diff --git a/crates/dbx-core/src/data_grid_extractors/tests.rs b/crates/dbx-core/src/data_grid_extractors/tests.rs index 6eaa99a67e..58ac66ebdb 100644 --- a/crates/dbx-core/src/data_grid_extractors/tests.rs +++ b/crates/dbx-core/src/data_grid_extractors/tests.rs @@ -65,6 +65,18 @@ fn extracts_raw_values_without_escaping_quotes() { assert_eq!(result.mime_type, "text/plain"); } +#[test] +fn raw_exports_null_as_an_empty_value() { + let mut request = request(DataGridExtractorId::Raw); + request.columns = vec![column("name", 0)]; + request.selected_column_indexes = vec![0]; + request.rows = vec![vec![Value::Null]]; + + let result = extract_data_grid_selection(request).expect("raw extraction"); + + assert_eq!(result.text, ""); +} + #[test] fn raw_rejects_multiple_selected_cells() { let error = diff --git a/packages/app-tests/dataGridDetail.test.ts b/packages/app-tests/dataGridDetail.test.ts index 544b6eceed..1be5c46fb9 100644 --- a/packages/app-tests/dataGridDetail.test.ts +++ b/packages/app-tests/dataGridDetail.test.ts @@ -222,7 +222,7 @@ test("dataGridRowDetailJson and dataGridRowDetailTsv format copy payloads", () = assert.equal(dataGridRowDetailJson(detail), '{\n "id": 1,\n "name": "Ada",\n "nickname": null\n}'); assert.equal(dataGridRowDetailJson(detail, { id: 1, profile: { city: "Shanghai" } }), '{\n "id": 1,\n "profile": {\n "city": "Shanghai"\n }\n}'); - assert.equal(dataGridRowDetailTsv(detail), "1\tAda\tNULL"); + assert.equal(dataGridRowDetailTsv(detail), "1\tAda\t"); }); test("dataGridRowDetailJson uses the original MongoDB document for nested values", () => { @@ -305,7 +305,7 @@ test("dataGridColumnDetailJson and dataGridColumnDetailTsv format copy payloads" assert.ok(detail); assert.equal(dataGridColumnDetailJson(detail), '[\n {\n "row": 1,\n "value": "Ada"\n },\n {\n "row": 2,\n "value": null\n }\n]'); - assert.equal(dataGridColumnDetailTsv(detail), "Ada\nNULL"); + assert.equal(dataGridColumnDetailTsv(detail), "Ada\n"); }); const detailFields: DataGridCellDetail[] = [