diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index cc10350b32..2e9d586acd 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -104,6 +104,7 @@ import { buildSqlShortcutExecutionSql, enabledSqlShortcutActions, resolveSqlShor import { resolveSqlShortcutTableToken } from "@/lib/sql/sqlShortcutTableTarget"; import { normalizeShortcutSettings, shortcutToCodeMirrorKey } from "@/lib/editor/shortcutRegistry"; import { trimmedSelectionLayer } from "@/lib/editor/codemirrorTrimmedSelectionLayer"; +import { editorClipboardLineEndingsExtension } from "@/lib/editor/editorClipboardLineEndings"; import { selectionMatchOccurrences } from "@/lib/editor/codemirrorSelectionMatches"; @@ -1841,6 +1842,7 @@ const codeMirrorLifecycle = useQueryEditorCodeMirror({ }), sqlBlockFoldService, drawSelection(), + editorClipboardLineEndingsExtension(EditorView), trimmedSelectionLayer(), selectionMatchOccurrences(), dropCursor(), diff --git a/apps/desktop/src/components/objects/DdlViewDialog.vue b/apps/desktop/src/components/objects/DdlViewDialog.vue index 69425e2f0e..56eb5868fb 100644 --- a/apps/desktop/src/components/objects/DdlViewDialog.vue +++ b/apps/desktop/src/components/objects/DdlViewDialog.vue @@ -8,6 +8,7 @@ import { useToast } from "@/composables/useToast"; import { useTheme } from "@/composables/useTheme"; import { useSettingsStore } from "@/stores/settingsStore"; import { loadEditorTheme, editorFontTheme } from "@/lib/editor/editorThemes"; +import { editorClipboardLineEndingsExtension } from "@/lib/editor/editorClipboardLineEndings"; import { createDbxCodeMirrorSqlDialect } from "@/lib/editor/codemirrorSqlDialect"; import { copyToClipboard } from "@/lib/common/clipboard"; import { formatSqlForDisplay, type SqlFormatDialect } from "@/lib/sql/sqlFormatter"; @@ -208,6 +209,7 @@ async function initDdlEditor(content: string) { }, }), basicSetup, + editorClipboardLineEndingsExtension(EditorView), EditorState.allowMultipleSelections.of(true), langSql.sql({ dialect }), themeExt, diff --git a/apps/desktop/src/composables/useCellDetailEditor.ts b/apps/desktop/src/composables/useCellDetailEditor.ts index fe27aef55b..44d9100b3c 100644 --- a/apps/desktop/src/composables/useCellDetailEditor.ts +++ b/apps/desktop/src/composables/useCellDetailEditor.ts @@ -7,6 +7,7 @@ import { defaultKeymap, history, historyKeymap } from "@codemirror/commands"; import { bracketMatching, foldGutter, foldKeymap } from "@codemirror/language"; import { trimmedSelectionLayer } from "@/lib/editor/codemirrorTrimmedSelectionLayer"; import { EDITOR_FONT_FAMILY_CSS_VAR, EDITOR_FONT_SIZE_CSS_VAR, cellDetailActiveLineColor, loadEditorTheme, editorFontTheme } from "@/lib/editor/editorThemes"; +import { editorClipboardLineEndingsExtension } from "@/lib/editor/editorClipboardLineEndings"; import { shortcutToCodeMirrorKey } from "@/lib/editor/shortcutRegistry"; import { useSettingsStore } from "@/stores/settingsStore"; import { CELL_DETAIL_JSON_FORMAT_MAX_LENGTH, isJsonColumnType } from "@/lib/dataGrid/cellDetailPresentation"; @@ -217,6 +218,7 @@ export function useCellDetailEditor(options: UseCellDetailEditorOptions): UseCel highlightSpecialChars(), history(), drawSelection(), + editorClipboardLineEndingsExtension(EditorView), trimmedSelectionLayer(), dropCursor(), highlightActiveLine(), diff --git a/apps/desktop/src/lib/editor/__tests__/editorClipboardLineEndings.spec.ts b/apps/desktop/src/lib/editor/__tests__/editorClipboardLineEndings.spec.ts new file mode 100644 index 0000000000..b703a5b8d9 --- /dev/null +++ b/apps/desktop/src/lib/editor/__tests__/editorClipboardLineEndings.spec.ts @@ -0,0 +1,49 @@ +import { strict as assert } from "node:assert"; +import { afterEach, test, vi } from "vitest"; +import { EditorState } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import { editorClipboardLineEndingsExtension } from "@/lib/editor/editorClipboardLineEndings"; + +const SQL = "SELECT 1\nFROM t\nWHERE a = 1;"; + +/** Run the document through the filters CodeMirror applies to copied text. */ +function copiedText(state: EditorState, text: string): string { + return state.facet(EditorView.clipboardOutputFilter).reduce((value, filter) => filter(value, state), text); +} + +function stateWithExtension(): EditorState { + return EditorState.create({ doc: SQL, extensions: [editorClipboardLineEndingsExtension(EditorView)] }); +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +function stubPlatform(userAgent: string) { + vi.stubGlobal("navigator", { userAgent }); +} + +test("copied editor text uses CRLF on Windows", () => { + stubPlatform("Mozilla/5.0 (Windows NT 10.0; Win64; x64)"); + assert.equal(copiedText(stateWithExtension(), SQL), "SELECT 1\r\nFROM t\r\nWHERE a = 1;"); +}); + +test("the document itself keeps LF", () => { + stubPlatform("Mozilla/5.0 (Windows NT 10.0; Win64; x64)"); + assert.equal(stateWithExtension().doc.toString(), SQL); +}); + +test("copied editor text keeps LF on macOS", () => { + stubPlatform("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)"); + assert.equal(copiedText(stateWithExtension(), SQL), SQL); +}); + +test("single-line text is unchanged on Windows", () => { + stubPlatform("Mozilla/5.0 (Windows NT 10.0; Win64; x64)"); + assert.equal(copiedText(stateWithExtension(), "SELECT 1;"), "SELECT 1;"); +}); + +test("a view without the facet contributes no extension", () => { + const state = EditorState.create({ doc: SQL, extensions: [editorClipboardLineEndingsExtension({})] }); + assert.deepEqual(state.facet(EditorView.clipboardOutputFilter), []); +}); diff --git a/apps/desktop/src/lib/editor/editorClipboardLineEndings.ts b/apps/desktop/src/lib/editor/editorClipboardLineEndings.ts new file mode 100644 index 0000000000..ad8ea97442 --- /dev/null +++ b/apps/desktop/src/lib/editor/editorClipboardLineEndings.ts @@ -0,0 +1,26 @@ +import type { Extension } from "@codemirror/state"; +import { clipboardLineEndings } from "@/lib/common/clipboard"; + +type EditorViewWithClipboardFilter = { + clipboardOutputFilter?: { of(filter: (text: string) => string): Extension }; +}; + +/** + * Give text copied out of a CodeMirror editor the same line endings as every other DBX + * copy path. + * + * `copyToClipboard` rewrites line endings to CRLF on Windows, but CodeMirror serves + * Ctrl+C/Ctrl+X itself and writes `text/plain` straight from the document, which always + * uses `\n`. Windows native edit controls (the Win32 EDIT control, and the WinForms apps + * built on it) treat only CRLF as a line break, so a multi-line statement copied from an + * editor arrives there as a single line. + * + * `EditorView.clipboardOutputFilter` is CodeMirror's own hook for copy/cut/drag text, so + * the document keeps using `\n` and only what leaves the editor is rewritten. + */ +export function editorClipboardLineEndingsExtension(editorView: EditorViewWithClipboardFilter | null | undefined): Extension { + const facet = editorView?.clipboardOutputFilter; + // The facet arrived in @codemirror/view 6.32; without it the copy path keeps LF. + if (!facet) return []; + return facet.of((text) => clipboardLineEndings(text)); +}