diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index b0454d7a5..b2b14a859 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -157,7 +157,7 @@ import { allNullColumnIndexes } from "@/lib/dataGrid/dataGridColumnVisibility"; import { buildDataGridColumnLookupItems, filterDataGridColumnLookupItems } from "@/lib/dataGrid/dataGridColumnLookup"; import { uniqueDataGridColumnOrderKeys } from "@/lib/dataGrid/dataGridColumnOrder"; import { dataGridColumnLayoutScopeKey, TABLE_DATA_GRID_COLUMN_ORDER_CHANGED_EVENT, tableDataGridColumnOrderScopeKey } from "@/lib/dataGrid/dataGridColumnLayoutStorage"; -import { parseClipboardTable, summarizeSelection } from "@/lib/dataGrid/gridSelection"; +import { summarizeSelection } from "@/lib/dataGrid/gridSelection"; import { createDataGridCellContextMenuItems, createDataGridColumnContextMenuItems, @@ -173,7 +173,7 @@ import { import { useToast } from "@/composables/useToast"; import { useDataGridExport } from "@/composables/useDataGridExport"; import { eventTargetAllowsNativeClipboard, isPlainClipboardShortcut, readTextFromClipboard } from "@/lib/common/clipboard"; -import { claimDataGridPaste, planDataGridPaste } from "@/lib/dataGrid/dataGridClipboard"; +import { claimDataGridPaste, clearDataGridClipboardCopy, parseDataGridClipboard, planDataGridPaste } from "@/lib/dataGrid/dataGridClipboard"; import { DATA_GRID_ROW_NUM_WIDTH, useDataGridColumnResize } from "@/composables/useDataGridColumnResize"; import { createDataGridColumnStructureSignature } from "@/lib/dataGrid/dataGridColumnWidthState"; import { useDataGridColumnLayout, useDataGridColumnLayoutState } from "@/composables/useDataGridColumnLayout"; @@ -4912,6 +4912,10 @@ function resumeCanvasGridWork() { }); } +function clearInternalClipboardCopy() { + clearDataGridClipboardCopy(); +} + onMounted(resumeCanvasGridWork); onActivated(resumeCanvasGridWork); onMounted(() => { @@ -4920,6 +4924,8 @@ onMounted(() => { window.visualViewport?.addEventListener("resize", scheduleCanvasPixelRatioRefresh); window.addEventListener("dbx:ui-scale-applied", scheduleCanvasPixelRatioRefresh); window.addEventListener(TABLE_DATA_GRID_COLUMN_ORDER_CHANGED_EVENT, onTableDataGridColumnOrderChanged); + window.addEventListener("blur", clearInternalClipboardCopy); + document.addEventListener("visibilitychange", clearInternalClipboardCopy); }); onDeactivated(pauseCanvasGridWork); onUnmounted(() => { @@ -4936,6 +4942,8 @@ onUnmounted(() => { window.visualViewport?.removeEventListener("resize", scheduleCanvasPixelRatioRefresh); window.removeEventListener("dbx:ui-scale-applied", scheduleCanvasPixelRatioRefresh); window.removeEventListener(TABLE_DATA_GRID_COLUMN_ORDER_CHANGED_EVENT, onTableDataGridColumnOrderChanged); + window.removeEventListener("blur", clearInternalClipboardCopy); + document.removeEventListener("visibilitychange", clearInternalClipboardCopy); }); function setRowStatusFilter(value: string) { @@ -5295,7 +5303,7 @@ async function pasteClipboardIntoSelection() { } function pasteTextIntoSelection(text: string): boolean { - const rows = parseClipboardTable(text); + const rows = parseDataGridClipboard(text); const allowDraftSelectionValue = selectedRangeTargetsOnlyDraftRow(); if (rows.length === 1 && rows[0]?.length === 1 && fillSelectionWithValue(rows[0][0])) { diff --git a/apps/desktop/src/composables/useDataGridExport.ts b/apps/desktop/src/composables/useDataGridExport.ts index 7e23be1c2..1bab9ecb4 100644 --- a/apps/desktop/src/composables/useDataGridExport.ts +++ b/apps/desktop/src/composables/useDataGridExport.ts @@ -7,6 +7,7 @@ import { useToast } from "@/composables/useToast"; import { displayCellValue, 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"; import { buildDataGridCopyInsertStatement, buildDataGridCopyUpdateStatements, type DataGridCopyInsertMode, type DataGridTableMeta } from "@/lib/dataGrid/dataGridSql"; import { formatSqlInsert, formatTsv } from "@/lib/export/exportFormats"; import { uuid } from "@/lib/common/utils"; @@ -210,9 +211,12 @@ export function useDataGridExport(options: UseDataGridExportOptions) { } = options; const selectedCellMatrix = selectedCellMatrixOption ?? computed(() => null); - async function copyText(text: string) { + async function copyText(text: string, gridCopy?: { rows: readonly (readonly unknown[])[]; includeHeader?: boolean }) { + const copiedRows = gridCopy?.rows.map((row) => [...row]); + clearDataGridClipboardCopy(); try { await copyToClipboard(text); + if (copiedRows) rememberDataGridClipboardCopy(text, copiedRows, gridCopy?.includeHeader); toast(t("grid.copied")); } catch (e: any) { toast(t("grid.copyFailed", { message: e?.message || String(e) }), 5000); @@ -684,12 +688,14 @@ export function useDataGridExport(options: UseDataGridExportOptions) { // --- Selection copy functions --- async function copySelectionTsv() { if (!hasCellSelection.value) return; - await copyText(formatSelectionAsTsv(selectedCells.value)); + const selection = selectedCells.value; + await copyText(formatSelectionAsTsv(selection), { rows: selection.rows }); } async function copySelectionTsvWithHeaders() { if (!hasCellSelection.value) return; - await copyText(formatSelectionAsTsv(selectedCells.value, true)); + const selection = selectedCells.value; + await copyText(formatSelectionAsTsv(selection, true), { rows: selection.rows, includeHeader: true }); } async function copySelectionCsv() { @@ -711,14 +717,14 @@ export function useDataGridExport(options: UseDataGridExportOptions) { if (!hasRowSelection.value || selectedRowIds.value.size === 0) return; const rows = displayItems.value.filter((item) => selectedRowIds.value.has(item.id) && !item.isDraft).map((item) => item.data); if (rows.length === 0) return; - await copyText(formatSelectionAsTsv({ columns: columns.value, rows })); + await copyText(formatSelectionAsTsv({ columns: columns.value, rows }), { rows }); } async function copySelectedRowsTsvWithHeaders() { if (!hasRowSelection.value || selectedRowIds.value.size === 0) return; const rows = displayItems.value.filter((item) => selectedRowIds.value.has(item.id) && !item.isDraft).map((item) => item.data); if (rows.length === 0) return; - await copyText(formatSelectionAsTsv({ columns: columns.value, rows }, true)); + await copyText(formatSelectionAsTsv({ columns: columns.value, rows }, true), { rows, includeHeader: true }); } async function copyColumnNames() { @@ -753,7 +759,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) { const item = getRowItem(contextCell.value.rowId); if (!item || item.isDraft) return; const val = item?.data[contextCell.value.col] ?? null; - await copyText(displayCellValue(val)); + await copyText(displayCellValue(val), { rows: [[val]] }); } async function copyRow() { @@ -823,11 +829,9 @@ export function useDataGridExport(options: UseDataGridExportOptions) { async function copyAll() { const header = columns.value.join("\t"); - const body = displayItems.value - .filter((item) => !item.isDraft) - .map((item) => item.data.map((c) => displayCellValue(c)).join("\t")) - .join("\n"); - await copyText(`${header}\n${body}`); + const rows = 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, includeHeader: true }); } // --- Export functions --- diff --git a/apps/desktop/src/lib/__tests__/dataGrid/dataGridClipboard.spec.ts b/apps/desktop/src/lib/__tests__/dataGrid/dataGridClipboard.spec.ts index a0f4fe01d..ef6f8f8ea 100644 --- a/apps/desktop/src/lib/__tests__/dataGrid/dataGridClipboard.spec.ts +++ b/apps/desktop/src/lib/__tests__/dataGrid/dataGridClipboard.spec.ts @@ -1,6 +1,9 @@ -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; -import { claimDataGridPaste, planDataGridPaste } from "@/lib/dataGrid/dataGridClipboard"; +import { copyToClipboard } from "@/lib/common/clipboard"; +import { claimDataGridPaste, clearDataGridClipboardCopy, parseDataGridClipboard, planDataGridPaste, rememberDataGridClipboardCopy } from "@/lib/dataGrid/dataGridClipboard"; + +afterEach(() => clearDataGridClipboardCopy()); function target(nativeClipboard: boolean): EventTarget { return { @@ -72,3 +75,62 @@ describe("planDataGridPaste", () => { expect(planDataGridPaste([["a"]], 1, 0)).toEqual([]); }); }); + +describe("parseDataGridClipboard", () => { + it("restores null values copied from the DBX grid", () => { + rememberDataGridClipboardCopy("NULL\tNULL", [[null, "NULL"]]); + + expect(parseDataGridClipboard("NULL\tNULL")).toEqual([[null, "NULL"]]); + }); + + it("keeps null text from external clipboard content as strings", () => { + rememberDataGridClipboardCopy("NULL", [[null]]); + clearDataGridClipboardCopy(); + + expect(parseDataGridClipboard("NULL")).toEqual([["NULL"]]); + expect(parseDataGridClipboard("null")).toEqual([["null"]]); + }); + + it("does not reuse null metadata after a literal NULL is copied", () => { + rememberDataGridClipboardCopy("NULL", [[null]]); + rememberDataGridClipboardCopy("NULL", [["NULL"]]); + + expect(parseDataGridClipboard("NULL")).toEqual([["NULL"]]); + }); + + it("invalidates null metadata after another successful in-app copy", async () => { + rememberDataGridClipboardCopy("NULL", [[null]]); + await copyToClipboard("NULL", { navigator: { clipboard: { writeText: vi.fn() } } }); + + expect(parseDataGridClipboard("NULL")).toEqual([["NULL"]]); + }); + + it("keeps null metadata when a later in-app copy fails", async () => { + rememberDataGridClipboardCopy("NULL", [[null]]); + await expect(copyToClipboard("NULL", { navigator: { clipboard: { writeText: vi.fn().mockRejectedValue(new Error("denied")) } } })).rejects.toThrow("Clipboard API is not available"); + + expect(parseDataGridClipboard("NULL")).toEqual([[null]]); + }); + + it("preserves null positions beside cells containing tabs and newlines", () => { + const text = "left\tinside\tNULL\nline 1\nline 2\ttail"; + rememberDataGridClipboardCopy(text, [ + ["left\tinside", null], + ["line 1\nline 2", "tail"], + ]); + + expect(parseDataGridClipboard(text)).toEqual([ + ["left\tinside", null], + ["line 1\nline 2", "tail"], + ]); + }); + + it("restores null positions after copied headers", () => { + rememberDataGridClipboardCopy("name\tnote\nAda\tNULL", [["Ada", null]], true); + + expect(parseDataGridClipboard("name\tnote\nAda\tNULL")).toEqual([ + ["name", "note"], + ["Ada", null], + ]); + }); +}); diff --git a/apps/desktop/src/lib/common/clipboard.ts b/apps/desktop/src/lib/common/clipboard.ts index 13ca322fa..c0cc7f045 100644 --- a/apps/desktop/src/lib/common/clipboard.ts +++ b/apps/desktop/src/lib/common/clipboard.ts @@ -58,6 +58,15 @@ interface NativeClipboardSelectionEnvironment { const EDITABLE_CLIPBOARD_TARGET_SELECTOR = "input, textarea, [contenteditable='true'], [role='textbox']"; const NATIVE_CLIPBOARD_REGION_SELECTOR = "[data-native-clipboard]"; +let clipboardWriteRevision = 0; + +export function getClipboardWriteRevision(): number { + return clipboardWriteRevision; +} + +function recordClipboardWrite(): void { + clipboardWriteRevision += 1; +} function closestElement(target: unknown, selector: string): unknown { return (target as { closest?: (selector: string) => unknown } | null)?.closest?.(selector) ?? null; @@ -117,6 +126,7 @@ export async function copyToClipboard(text: string, env: ClipboardEnvironment = try { const { writeText } = await import("@tauri-apps/plugin-clipboard-manager"); await writeText(text); + recordClipboardWrite(); return; } catch { // Preserve Web Clipboard and legacy copy compatibility when native writes fail. @@ -126,6 +136,7 @@ export async function copyToClipboard(text: string, env: ClipboardEnvironment = try { if (env.navigator?.clipboard?.writeText) { await env.navigator.clipboard.writeText(text); + recordClipboardWrite(); return; } } catch { @@ -153,6 +164,7 @@ export async function copyToClipboard(text: string, env: ClipboardEnvironment = if (!document.execCommand("copy")) { throw new Error("Clipboard copy failed"); } + recordClipboardWrite(); } finally { document.body.removeChild(textarea); } diff --git a/apps/desktop/src/lib/dataGrid/dataGridClipboard.ts b/apps/desktop/src/lib/dataGrid/dataGridClipboard.ts index 15d2867e7..2b7688b2a 100644 --- a/apps/desktop/src/lib/dataGrid/dataGridClipboard.ts +++ b/apps/desktop/src/lib/dataGrid/dataGridClipboard.ts @@ -1,13 +1,23 @@ -import { eventTargetUsesNativeClipboard } from "@/lib/common/clipboard"; +import { eventTargetUsesNativeClipboard, getClipboardWriteRevision } from "@/lib/common/clipboard"; +import { displayCellValue, type CellValue } from "@/lib/dataGrid/cellValue"; +import { parseClipboardTable } from "@/lib/dataGrid/gridSelection"; export type DataGridPasteIntent = "native" | "block" | "paste"; export interface DataGridPasteCell { rowOffset: number; columnOffset: number; - value: string; + value: string | null; } +interface InternalDataGridClipboardCopy { + text: string; + rows: Array>; + writeRevision: number; +} + +let internalClipboardCopy: InternalDataGridClipboardCopy | null = null; + interface DataGridPasteEvent { target?: EventTarget | null; preventDefault(): void; @@ -21,7 +31,30 @@ export function claimDataGridPaste(event: DataGridPasteEvent, editable: boolean, return editable && hasSelection ? "paste" : "block"; } -export function planDataGridPaste(rows: readonly (readonly string[])[], maxRows: number, maxColumns: number): DataGridPasteCell[] { +export function clearDataGridClipboardCopy(): void { + internalClipboardCopy = null; +} + +export function rememberDataGridClipboardCopy(text: string, rows: readonly (readonly unknown[])[], includeHeader = false): void { + if (!rows.some((row) => row.some((value) => value === null))) { + internalClipboardCopy = null; + return; + } + + // Preserve the logical grid matrix because plain TSV cannot escape embedded tabs or newlines. + const headerRows = includeHeader ? parseClipboardTable(text).slice(0, 1) : []; + const copiedRows = rows.map((row) => row.map((value) => (value === null ? null : displayCellValue(value as CellValue)))); + internalClipboardCopy = { text, rows: [...headerRows, ...copiedRows], writeRevision: getClipboardWriteRevision() }; +} + +export function parseDataGridClipboard(text: string): Array> { + if (internalClipboardCopy?.text === text && internalClipboardCopy.writeRevision === getClipboardWriteRevision()) { + return internalClipboardCopy.rows.map((row) => [...row]); + } + return parseClipboardTable(text); +} + +export function planDataGridPaste(rows: readonly (readonly (string | null)[])[], maxRows: number, maxColumns: number): DataGridPasteCell[] { if (maxRows <= 0 || maxColumns <= 0) return []; const cells: DataGridPasteCell[] = []; rows.slice(0, maxRows).forEach((row, rowOffset) => {