fix(clipboard): write CRLF line endings on Windows copies

This commit is contained in:
Thanadon Kongkanun authored and GitHub committed 2026-09-14 01:48:42 +08:00
1 parent 1b6ff2ba54
commit 78f9740366
4 files changed
+68 -6

No files matched your search

@@ -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) {
@@ -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) {
+43 -2
View File
@@ -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<typeof vi.fn>; removeChild: ReturnType<typeof vi.fn> }) {
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");
});
});
+18
View File
@@ -1,4 +1,5 @@
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import { getPlatform, type Platform } from "@/lib/backend/platform";
interface ClipboardApi {
readText?: () => Promise<string> | 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<void> {
text = clipboardLineEndings(text);
if (isTauriRuntime(env as unknown as Record<string, unknown>)) {
try {
const { writeText } = await import("@tauri-apps/plugin-clipboard-manager");