From 78f9740366105c42f0aa011801eb32883be9e111 Mon Sep 17 00:00:00 2001 From: Thanadon Kongkanun <71448810+thanadon-dev@users.noreply.github.com> Date: Mon, 14 Sep 2026 00:48:42 +0700 Subject: [PATCH] fix(clipboard): write CRLF line endings on Windows copies --- .../components/nacos/NacosAdminConsole.vue | 5 ++- .../src/composables/useDataGridExport.ts | 6 ++- .../lib/common/__tests__/clipboard.spec.ts | 45 ++++++++++++++++++- apps/desktop/src/lib/common/clipboard.ts | 18 ++++++++ 4 files changed, 68 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/components/nacos/NacosAdminConsole.vue b/apps/desktop/src/components/nacos/NacosAdminConsole.vue index 8ca619d2d1..aa0bcd6078 100644 --- a/apps/desktop/src/components/nacos/NacosAdminConsole.vue +++ b/apps/desktop/src/components/nacos/NacosAdminConsole.vue @@ -48,7 +48,7 @@ import { import { createNacosNamespaceRequestGuard, subscribeNacosNamespacesChanged, type NacosNamespacesChangedDetail } from "@/lib/nacos/nacosNamespaceCache"; import { nacosInstanceMatchesPatch, nacosInstanceRefIdentity, nacosIpAddressIsValid, nacosServiceDetailMatches } from "@/lib/nacos/nacosServiceManagement"; import { loadReadableNacosNamespaces, nacosNamespaceIdentity } from "@/lib/nacos/nacosNamespaceVisibility"; -import { copyToClipboard, readTextFromClipboard } from "@/lib/common/clipboard"; +import { clipboardLineEndings, copyToClipboard, readTextFromClipboard } from "@/lib/common/clipboard"; import { trimmedSelectionLayer } from "@/lib/editor/codemirrorTrimmedSelectionLayer"; import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/backend/safeStorage"; import { editorFontTheme, loadEditorTheme } from "@/lib/editor/editorThemes"; @@ -1188,7 +1188,8 @@ async function copyConfigIdentity() { await copyToClipboard(text); try { const copiedText = await readTextFromClipboard(); - if (copiedText !== text) { + // The clipboard holds the platform-normalized text (CRLF on Windows). + if (copiedText !== text && copiedText !== clipboardLineEndings(text)) { throw new Error(t("nacos.copyVerifyFailed")); } } catch (verifyError) { diff --git a/apps/desktop/src/composables/useDataGridExport.ts b/apps/desktop/src/composables/useDataGridExport.ts index b6eb87cc5a..b6216a779e 100644 --- a/apps/desktop/src/composables/useDataGridExport.ts +++ b/apps/desktop/src/composables/useDataGridExport.ts @@ -11,7 +11,7 @@ import { useExportTracker } from "@/composables/useExportTracker"; import { clipboardCellValue, type CellValue } from "@/lib/dataGrid/cellValue"; import { binaryCellClipboardText } from "@/lib/dataGrid/binaryCellDownload"; import { tryStartExclusiveActivation, type ActionActivationGuard } from "@/lib/connection/actionActivation"; -import { copyToClipboard } from "@/lib/common/clipboard"; +import { clipboardLineEndings, copyToClipboard } from "@/lib/common/clipboard"; import { clearDataGridClipboardCopy, rememberDataGridClipboardCopy } from "@/lib/dataGrid/dataGridClipboard"; import { buildDataGridCopyInsertStatement, type DataGridCopyInsertMode, type DataGridTableMeta } from "@/lib/dataGrid/dataGridSql"; import { formatSqlInsert, formatTsv } from "@/lib/export/exportFormats"; @@ -236,7 +236,9 @@ export function useDataGridExport(options: UseDataGridExportOptions) { clearDataGridClipboardCopy(); try { await copyToClipboard(text); - if (copiedRows) rememberDataGridClipboardCopy(text, copiedRows, copiedHeader); + // Remember the text as it now sits on the clipboard, so a paste back into + // the grid still matches and keeps its null-cell metadata. + if (copiedRows) rememberDataGridClipboardCopy(clipboardLineEndings(text), copiedRows, copiedHeader); toast(t("grid.copied")); return true; } catch (e: any) { diff --git a/apps/desktop/src/lib/common/__tests__/clipboard.spec.ts b/apps/desktop/src/lib/common/__tests__/clipboard.spec.ts index 6a103ce7c3..534d6406a6 100644 --- a/apps/desktop/src/lib/common/__tests__/clipboard.spec.ts +++ b/apps/desktop/src/lib/common/__tests__/clipboard.spec.ts @@ -1,6 +1,10 @@ -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; -import { copyToClipboard, type ClipboardEnvironment } from "@/lib/common/clipboard"; +import { clipboardLineEndings, copyToClipboard, type ClipboardEnvironment } from "@/lib/common/clipboard"; + +afterEach(() => { + vi.restoreAllMocks(); +}); function legacyClipboardEnvironment(activeDialog?: { appendChild: ReturnType; removeChild: ReturnType }) { const textarea = { @@ -45,4 +49,41 @@ describe("copyToClipboard", () => { expect(body.appendChild).toHaveBeenCalledWith(textarea); expect(body.removeChild).toHaveBeenCalledWith(textarea); }); + + it("writes CRLF line endings on Windows", async () => { + vi.spyOn(navigator, "userAgent", "get").mockReturnValue("Mozilla/5.0 (Windows NT 10.0; Win64; x64)"); + const writeText = vi.fn(); + + await copyToClipboard("SELECT 1\nFROM t\nWHERE x = 1", { navigator: { clipboard: { writeText } } }); + + expect(writeText).toHaveBeenCalledWith("SELECT 1\r\nFROM t\r\nWHERE x = 1"); + }); + + it("keeps LF line endings off Windows", async () => { + vi.spyOn(navigator, "userAgent", "get").mockReturnValue("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)"); + const writeText = vi.fn(); + + await copyToClipboard("SELECT 1\nFROM t", { navigator: { clipboard: { writeText } } }); + + expect(writeText).toHaveBeenCalledWith("SELECT 1\nFROM t"); + }); +}); + +describe("clipboardLineEndings", () => { + it("converts bare LF and CR to CRLF on Windows", () => { + expect(clipboardLineEndings("a\nb\rc", "windows")).toBe("a\r\nb\r\nc"); + }); + + it("leaves text that already uses CRLF untouched", () => { + expect(clipboardLineEndings("a\r\nb\r\n", "windows")).toBe("a\r\nb\r\n"); + }); + + it("leaves single-line text untouched on Windows", () => { + expect(clipboardLineEndings("SELECT 1", "windows")).toBe("SELECT 1"); + }); + + it("returns the text unchanged on other platforms", () => { + expect(clipboardLineEndings("a\nb", "macos")).toBe("a\nb"); + expect(clipboardLineEndings("a\nb", "linux")).toBe("a\nb"); + }); }); diff --git a/apps/desktop/src/lib/common/clipboard.ts b/apps/desktop/src/lib/common/clipboard.ts index 25e296c897..27c2a70e0e 100644 --- a/apps/desktop/src/lib/common/clipboard.ts +++ b/apps/desktop/src/lib/common/clipboard.ts @@ -1,4 +1,5 @@ import { isTauriRuntime } from "@/lib/backend/tauriRuntime"; +import { getPlatform, type Platform } from "@/lib/backend/platform"; interface ClipboardApi { readText?: () => Promise | string; @@ -42,6 +43,21 @@ export interface ClipboardEnvironment { document?: ClipboardDocument; } +/** + * Rewrites the line endings of text destined for the system clipboard. + * + * Windows native edit controls (the Win32 EDIT control, and applications built + * on it such as PowerBuilder) only treat CRLF as a line break, so text copied + * with bare LF arrives as a single line. Other platforms keep LF. + * + * Existing CRLF pairs are matched before bare CR and LF so that already + * normalized text is left byte-identical. + */ +export function clipboardLineEndings(text: string, platform: Platform = getPlatform()): string { + if (platform !== "windows") return text; + return text.replace(/\r\n|\r|\n/g, "\r\n"); +} + export interface ClipboardShortcutEvent { key: string; metaKey?: boolean; @@ -136,6 +152,8 @@ export async function readTextFromClipboard(env: ClipboardEnvironment = globalTh } export async function copyToClipboard(text: string, env: ClipboardEnvironment = globalThis as unknown as ClipboardEnvironment): Promise { + text = clipboardLineEndings(text); + if (isTauriRuntime(env as unknown as Record)) { try { const { writeText } = await import("@tauri-apps/plugin-clipboard-manager");