From fa3925ca524b40dfb991b3d8bdd721dab5aa3327 Mon Sep 17 00:00:00 2001 From: Melody Ruch <396921921@qq.com> Date: Sun, 20 Sep 2026 00:50:44 +0800 Subject: [PATCH] feat(editor): add whitespace display toggle for SQL editor --- .../editor/EditorSettingsDialog.vue | 16 +++++ .../src/components/editor/QueryEditor.vue | 17 +++++ apps/desktop/src/i18n/locales/az.ts | 2 + apps/desktop/src/i18n/locales/en.ts | 2 + apps/desktop/src/i18n/locales/es.ts | 2 + apps/desktop/src/i18n/locales/it.ts | 2 + apps/desktop/src/i18n/locales/ja.ts | 2 + apps/desktop/src/i18n/locales/ko.ts | 2 + apps/desktop/src/i18n/locales/pt-BR.ts | 2 + apps/desktop/src/i18n/locales/tr.ts | 2 + apps/desktop/src/i18n/locales/zh-CN.ts | 2 + apps/desktop/src/i18n/locales/zh-TW.ts | 2 + .../codemirrorShowWhitespace.spec.ts | 52 ++++++++++++++ .../lib/editor/codemirrorShowWhitespace.ts | 71 +++++++++++++++++++ .../src/lib/settings/editorSettingsDraft.ts | 1 + .../src/lib/settings/settingsSearch.ts | 1 + .../src/lib/settings/settingsTransfer.ts | 2 + apps/desktop/src/stores/settingsStore.ts | 4 ++ 18 files changed, 184 insertions(+) create mode 100644 apps/desktop/src/lib/editor/__tests__/codemirrorShowWhitespace.spec.ts create mode 100644 apps/desktop/src/lib/editor/codemirrorShowWhitespace.ts diff --git a/apps/desktop/src/components/editor/EditorSettingsDialog.vue b/apps/desktop/src/components/editor/EditorSettingsDialog.vue index 58a47d6348..1a9e2a2aae 100644 --- a/apps/desktop/src/components/editor/EditorSettingsDialog.vue +++ b/apps/desktop/src/components/editor/EditorSettingsDialog.vue @@ -584,6 +584,7 @@ const completionTriggerModeDescription = computed(() => { return t(key, { shortcut: formatShortcutDisplay(editShortcuts.value.triggerCompletion) }); }); const editWordWrap = ref(settingsStore.editorSettings.wordWrap); +const editShowWhitespace = ref(settingsStore.editorSettings.showWhitespace); const editVimModeEnabled = ref(settingsStore.editorSettings.vimModeEnabled); const editAutoCloseBrackets = ref(settingsStore.editorSettings.autoCloseBrackets); const editSqlSemanticDiagnosticsMode = ref(settingsStore.editorSettings.sqlSemanticDiagnosticsMode); @@ -913,6 +914,7 @@ function currentEditorSettingsDraft(): EditorSettingsDraft { selectFirstCompletionOnOpen: editSelectFirstCompletionOnOpen.value, completionTriggerMode: editCompletionTriggerMode.value, wordWrap: editWordWrap.value, + showWhitespace: editShowWhitespace.value, vimModeEnabled: editVimModeEnabled.value, autoCloseBrackets: editAutoCloseBrackets.value, sqlSemanticDiagnosticsMode: editSqlSemanticDiagnosticsMode.value, @@ -1551,6 +1553,7 @@ function syncEditorSettingsDraftFromStore() { editSelectFirstCompletionOnOpen.value = settingsStore.editorSettings.selectFirstCompletionOnOpen; editCompletionTriggerMode.value = settingsStore.editorSettings.completionTriggerMode; editWordWrap.value = settingsStore.editorSettings.wordWrap; + editShowWhitespace.value = settingsStore.editorSettings.showWhitespace; editVimModeEnabled.value = settingsStore.editorSettings.vimModeEnabled; editAutoCloseBrackets.value = settingsStore.editorSettings.autoCloseBrackets; editSqlSemanticDiagnosticsMode.value = settingsStore.editorSettings.sqlSemanticDiagnosticsMode; @@ -1681,6 +1684,7 @@ const editorSettingsDraftRefs: EditorSettingsDraftRefMap = { sortCompletionColumnsAlphabetically: editSortCompletionColumnsAlphabetically, selectFirstCompletionOnOpen: editSelectFirstCompletionOnOpen, wordWrap: editWordWrap, + showWhitespace: editShowWhitespace, vimModeEnabled: editVimModeEnabled, autoCloseBrackets: editAutoCloseBrackets, sqlSemanticDiagnosticsMode: editSqlSemanticDiagnosticsMode, @@ -2126,6 +2130,7 @@ function resetDefaultsForTab(tab: SettingsCategory) { editSelectFirstCompletionOnOpen.value = DEFAULT_EDITOR_SETTINGS.selectFirstCompletionOnOpen; editCompletionTriggerMode.value = DEFAULT_EDITOR_SETTINGS.completionTriggerMode; editWordWrap.value = DEFAULT_EDITOR_SETTINGS.wordWrap; + editShowWhitespace.value = DEFAULT_EDITOR_SETTINGS.showWhitespace; editVimModeEnabled.value = DEFAULT_EDITOR_SETTINGS.vimModeEnabled; editAutoCloseBrackets.value = DEFAULT_EDITOR_SETTINGS.autoCloseBrackets; editSqlSemanticDiagnosticsMode.value = DEFAULT_EDITOR_SETTINGS.sqlSemanticDiagnosticsMode; @@ -2271,6 +2276,7 @@ function resetAllDefaults() { editSortCompletionColumnsAlphabetically.value = DEFAULT_EDITOR_SETTINGS.sortCompletionColumnsAlphabetically; editSelectFirstCompletionOnOpen.value = DEFAULT_EDITOR_SETTINGS.selectFirstCompletionOnOpen; editWordWrap.value = DEFAULT_EDITOR_SETTINGS.wordWrap; + editShowWhitespace.value = DEFAULT_EDITOR_SETTINGS.showWhitespace; editVimModeEnabled.value = DEFAULT_EDITOR_SETTINGS.vimModeEnabled; editAutoCloseBrackets.value = DEFAULT_EDITOR_SETTINGS.autoCloseBrackets; editSqlSemanticDiagnosticsMode.value = DEFAULT_EDITOR_SETTINGS.sqlSemanticDiagnosticsMode; @@ -6099,6 +6105,16 @@ onUnmounted(() => { +
+
+ +

+ {{ t("settings.showWhitespaceDescription") }} +

+
+ +
+
diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index 536f75cff9..9c667c827f 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -163,6 +163,7 @@ import { createQueryEditorEscapeHandler } from "@/lib/editor/queryEditorEscape"; import { buildQueryEditorLineNumbersExtension, createQueryEditorLineNumberAlignmentExtension } from "@/lib/editor/queryEditorLineNumbers"; import { searchKeymapWithoutModD } from "@/lib/editor/codemirrorSearchKeymap"; import { defaultKeymapForGlobalShortcuts } from "@/lib/editor/codemirrorDefaultKeymap"; +import { createShowWhitespaceExtension } from "@/lib/editor/codemirrorShowWhitespace"; import { appendSqlCompletionSpace } from "@/lib/editor/sqlCompletionInsertion"; import { batchColumnSelectionColumnList, batchColumnSelectionInsertReplacement, batchColumnSelectionReplaceTo, completionReplacementTo, isBatchColumnSelectionCompletionActive, shouldResolveSqlColumnCompletion } from "@/lib/editor/batchColumnSelection"; import { compareSqlCompletions, completionLabelPresentation } from "@/lib/editor/sqlCompletionPresentation"; @@ -701,6 +702,7 @@ let hoverCloseEffect: StateEffect | null = null; let fontThemeComp: import("@codemirror/state").Compartment | null = null; let codeMirrorTheme: import("@codemirror/state").Compartment | null = null; let wordWrapComp: import("@codemirror/state").Compartment | null = null; +let showWhitespaceComp: import("@codemirror/state").Compartment | null = null; let lineNumbersComp: import("@codemirror/state").Compartment | null = null; let vimModeComp: import("@codemirror/state").Compartment | null = null; let closeBracketsComp: import("@codemirror/state").Compartment | null = null; @@ -1022,6 +1024,7 @@ const queryEditorAppearanceSettings = computed(() => { customThemes: settings.customThemes, activeCustomThemeId: settings.activeCustomThemeId, wordWrap: settings.wordWrap, + showWhitespace: settings.showWhitespace, vimModeEnabled: settings.vimModeEnabled, autoCloseBrackets: settings.autoCloseBrackets, showLineNumbers: settings.showLineNumbers, @@ -2782,6 +2785,11 @@ function wordWrapExtension() { return props.forceWordWrap || settingsStore.editorSettings.wordWrap ? editorViewModule.EditorView.lineWrapping : []; } +function showWhitespaceExtension(enabled = settingsStore.editorSettings.showWhitespace) { + if (!editorViewModule) return []; + return createShowWhitespaceExtension(editorViewModule, enabled); +} + function lineNumbersExtension(enabled = settingsStore.editorSettings.showLineNumbers) { return buildQueryEditorLineNumbersExtension(codeMirrorLineNumbers, enabled, { domEventHandlers: { @@ -6307,6 +6315,8 @@ onMounted(async () => { lineNumbers, highlightActiveLineGutter, highlightSpecialChars, + highlightWhitespace, + WidgetType, drawSelection, dropCursor, crosshairCursor, @@ -6343,6 +6353,10 @@ onMounted(async () => { EditorView, keymap, rectangularSelection, + highlightWhitespace, + WidgetType, + Decoration, + ViewPlugin, } as typeof import("@codemirror/view"); hoverCloseEffect = closeHoverTooltips; codeMirrorLineNumbers = lineNumbers; @@ -6352,6 +6366,7 @@ onMounted(async () => { fontThemeComp = new Compartment(); codeMirrorTheme = new Compartment(); wordWrapComp = new Compartment(); + showWhitespaceComp = new Compartment(); lineNumbersComp = new Compartment(); vimModeComp = new Compartment(); closeBracketsComp = new Compartment(); @@ -7083,6 +7098,7 @@ onMounted(async () => { }), ), wordWrapComp.of(props.forceWordWrap || initialSettings.wordWrap ? EditorView.lineWrapping : []), + showWhitespaceComp.of(showWhitespaceExtension(initialSettings.showWhitespace)), readOnlyComp.of([EditorState.readOnly.of(!!props.readOnly), EditorView.editable.of(!props.readOnly)]), indentComp.of(indentExtension()), // Alt+drag belongs exclusively to rectangular selection. Registering the @@ -7885,6 +7901,7 @@ async function applyEditorAppearance() { effects: [ codeMirrorTheme.reconfigure(themeExt), wordWrapComp.reconfigure(props.forceWordWrap || ss.wordWrap ? editorViewModule.EditorView.lineWrapping : []), + ...(showWhitespaceComp ? [showWhitespaceComp.reconfigure(showWhitespaceExtension(ss.showWhitespace))] : []), lineNumbersComp.reconfigure(lineNumbersExtension(ss.showLineNumbers)), vimModeComp.reconfigure(vimModeExtension(settingsStore.editorSettings.vimModeEnabled)), closeBracketsComp.reconfigure(closeBracketsExtension(settingsStore.editorSettings.autoCloseBrackets)), diff --git a/apps/desktop/src/i18n/locales/az.ts b/apps/desktop/src/i18n/locales/az.ts index a45311866e..cd3b3b8175 100644 --- a/apps/desktop/src/i18n/locales/az.ts +++ b/apps/desktop/src/i18n/locales/az.ts @@ -7415,6 +7415,8 @@ export default withEnglishFallback({ previewStatementRunButton: "Əmri işlətmə düyməsinə önbaxış", wordWrap: "Sətirləri bük", wordWrapDescription: "Uzun sətirləri redaktorun eninə uyğun bük", + showWhitespace: "Boşluq simvollarını göstər", + showWhitespaceDescription: "SQL redaktorunda boşluqları ·, tabulyasiyaları → kimi göstərir. Yalnız görünüş dəyişir, SQL mətni dəyişmir.", vimMode: "Vim rejimi", vimModeDescription: "SQL redaktorunda Vim üslublu modal redaktədən istifadə et", autoCloseBrackets: "Mötərizələri avtomatik bağla", diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 0cfaafb0e5..c046c33e38 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -7649,6 +7649,8 @@ export default { previewStatementRunButton: "Preview statement run button", wordWrap: "Word wrap", wordWrapDescription: "Wrap long lines within the editor width", + showWhitespace: "Show whitespace", + showWhitespaceDescription: "Show spaces as · and tabs as → in the SQL editor. Only the display changes, not the SQL text.", vimMode: "Vim mode", vimModeDescription: "Use Vim-style modal editing in the SQL editor", autoCloseBrackets: "Auto-close brackets", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index facf8e7c89..9ade147c93 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -7165,6 +7165,8 @@ export default withEnglishFallback({ previewStatementRunButton: "Botón de ejecución de sentencia en vista previa", wordWrap: "Ajuste de línea", wordWrapDescription: "Ajustar las líneas largas al ancho del editor", + showWhitespace: "Mostrar espacios en blanco", + showWhitespaceDescription: "Mostrar espacios como · y tabulaciones como → en el editor SQL. Solo cambia la visualización, no el texto SQL.", vimMode: "Modo Vim", vimModeDescription: "Usar edición modal estilo Vim en el editor SQL", autoCloseBrackets: "Cerrar paréntesis automáticamente", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 09741f00b5..038cf1cfd5 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -7165,6 +7165,8 @@ export default withEnglishFallback({ previewStatementRunButton: "Pulsante di esecuzione istruzione in anteprima", wordWrap: "A capo automatico", wordWrapDescription: "Manda a capo le righe lunghe entro la larghezza dell'editor", + showWhitespace: "Mostra spazi bianchi", + showWhitespaceDescription: "Mostra gli spazi come · e le tabulazioni come → nell'editor SQL. Cambia solo la visualizzazione, non il testo SQL.", vimMode: "Modalita Vim", vimModeDescription: "Usa la modifica modale in stile Vim nell'editor SQL", autoCloseBrackets: "Chiusura automatica parentesi", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index aa4576cdd7..ee2f87faef 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -7189,6 +7189,8 @@ export default withEnglishFallback({ previewStatementRunButton: "文の実行ボタンのプレビュー", wordWrap: "折り返し", wordWrapDescription: "長い行をエディタ幅内で折り返します", + showWhitespace: "空白文字を表示", + showWhitespaceDescription: "SQL エディタでスペースを ·、タブを → で表示します。表示のみ変わり、SQL テキストは変わりません。", vimMode: "Vimモード", vimModeDescription: "SQLエディタでVim形式のモーダル編集を使用します", autoCloseBrackets: "自動括弧補完", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index 10230bd1da..27d72d9805 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -6941,6 +6941,8 @@ export default withEnglishFallback({ previewStatementRunButton: "구문 실행 버튼 미리보기", wordWrap: "자동 줄바꿈", wordWrapDescription: "편집기 너비 내에서 긴 줄을 줄바꿈합니다", + showWhitespace: "공백 문자 표시", + showWhitespaceDescription: "SQL 편집기에서 공백을 ·, 탭을 →로 표시합니다. 표시만 바뀌며 SQL 텍스트는 변경되지 않습니다.", vimMode: "Vim 모드", vimModeDescription: "SQL 편집기에서 Vim 스타일의 모달 편집을 사용합니다", autoCloseBrackets: "괄호 자동 닫기", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 168704ac18..5bed9bf99a 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -7167,6 +7167,8 @@ export default withEnglishFallback({ previewStatementRunButton: "Botão de execução de instrução na prévia", wordWrap: "Quebra de linha", wordWrapDescription: "Quebrar linhas longas dentro da largura do editor", + showWhitespace: "Mostrar espaços em branco", + showWhitespaceDescription: "Mostrar espaços como · e tabulações como → no editor SQL. Altera apenas a exibição, não o texto SQL.", vimMode: "Modo Vim", vimModeDescription: "Usar edição modal no estilo Vim no editor SQL", autoCloseBrackets: "Fechar parênteses automaticamente", diff --git a/apps/desktop/src/i18n/locales/tr.ts b/apps/desktop/src/i18n/locales/tr.ts index 78796659b4..f9c42d2bd1 100644 --- a/apps/desktop/src/i18n/locales/tr.ts +++ b/apps/desktop/src/i18n/locales/tr.ts @@ -7296,6 +7296,8 @@ export default withEnglishFallback({ previewStatementRunButton: "İfade çalıştırma düğmesini önizle", wordWrap: "Sözcük kaydırma", wordWrapDescription: "Uzun satırları düzenleyici genişliğinde kaydır", + showWhitespace: "Boşluk karakterlerini göster", + showWhitespaceDescription: "SQL düzenleyicide boşlukları ·, sekmeleri → olarak gösterir. Yalnızca görünüm değişir, SQL metni değişmez.", vimMode: "Vim modu", vimModeDescription: "SQL düzenleyicisinde Vim biçemli kipli düzenleme kullan", autoCloseBrackets: "Parantezleri otomatik kapat", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index fd38ea43da..2b356f7184 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -7620,6 +7620,8 @@ export default withEnglishFallback({ previewStatementRunButton: "预览语句执行按钮", wordWrap: "自动换行", wordWrapDescription: "长内容在编辑器宽度内自动折行显示", + showWhitespace: "显示空白字符", + showWhitespaceDescription: "在 SQL 编辑器中将空格显示为 ·,制表符显示为 →。仅影响显示,不改变 SQL 文本。", vimMode: "Vim 模式", vimModeDescription: "在 SQL 编辑器中使用 Vim 风格的模态编辑", autoCloseBrackets: "自动成对补全", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 3d55c56399..ef93ba4b5a 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -6489,6 +6489,8 @@ export default withEnglishFallback({ previewStatementRunButton: "預覽語句執行按鈕", wordWrap: "自動換行", wordWrapDescription: "長內容在編輯器寬度內自動折行顯示", + showWhitespace: "顯示空白字元", + showWhitespaceDescription: "在 SQL 編輯器中將空格顯示為 ·,定位字元顯示為 →。僅影響顯示,不改變 SQL 文字。", vimMode: "Vim 模式", vimModeDescription: "在 SQL 編輯器中使用 Vim 風格的模態編輯", autoCloseBrackets: "自動成對補全", diff --git a/apps/desktop/src/lib/editor/__tests__/codemirrorShowWhitespace.spec.ts b/apps/desktop/src/lib/editor/__tests__/codemirrorShowWhitespace.spec.ts new file mode 100644 index 0000000000..60aa99897a --- /dev/null +++ b/apps/desktop/src/lib/editor/__tests__/codemirrorShowWhitespace.spec.ts @@ -0,0 +1,52 @@ +// @vitest-environment happy-dom +import { EditorState } from "@codemirror/state"; +import * as viewModule from "@codemirror/view"; +import { EditorView } from "@codemirror/view"; +import { afterEach, describe, expect, it } from "vitest"; +import { NEWLINE_MARKER, NEWLINE_MARKER_CLASS, createShowWhitespaceExtension } from "@/lib/editor/codemirrorShowWhitespace"; + +let view: EditorView | null = null; + +function mount(doc: string, enabled: boolean) { + const parent = document.createElement("div"); + document.body.appendChild(parent); + view = new EditorView({ parent, state: EditorState.create({ doc, extensions: [createShowWhitespaceExtension(viewModule, enabled)] }) }); + return view; +} + +afterEach(() => { + view?.destroy(); + view = null; + document.body.innerHTML = ""; +}); + +describe("createShowWhitespaceExtension", () => { + it("adds nothing when disabled", () => { + expect(createShowWhitespaceExtension(viewModule, false)).toEqual([]); + const v = mount("select 1\n\tfrom t", false); + expect(v.dom.querySelectorAll(`.${NEWLINE_MARKER_CLASS}`)).toHaveLength(0); + expect(v.dom.querySelector(".cm-highlightSpace, .cm-highlightTab")).toBeNull(); + }); + + it("marks a line break after every line except the last", () => { + const v = mount("select 1\n\tfrom t\nwhere x", true); + const markers = v.dom.querySelectorAll(`.${NEWLINE_MARKER_CLASS}`); + expect(markers).toHaveLength(2); + expect(markers[0]!.textContent).toBe(NEWLINE_MARKER); + }); + + it("marks spaces and tabs and never changes the document text", () => { + const doc = "select 1\n\tfrom t"; + const v = mount(doc, true); + expect(v.dom.querySelector(".cm-highlightSpace")).not.toBeNull(); + expect(v.dom.querySelector(".cm-highlightTab")).not.toBeNull(); + expect(v.state.doc.toString()).toBe(doc); + }); + + it("keeps marking empty lines and follows edits", () => { + const v = mount("a\n\nb", true); + expect(v.dom.querySelectorAll(`.${NEWLINE_MARKER_CLASS}`)).toHaveLength(2); + v.dispatch({ changes: { from: v.state.doc.length, insert: "\nc" } }); + expect(v.dom.querySelectorAll(`.${NEWLINE_MARKER_CLASS}`)).toHaveLength(3); + }); +}); diff --git a/apps/desktop/src/lib/editor/codemirrorShowWhitespace.ts b/apps/desktop/src/lib/editor/codemirrorShowWhitespace.ts new file mode 100644 index 0000000000..6e71b2f535 --- /dev/null +++ b/apps/desktop/src/lib/editor/codemirrorShowWhitespace.ts @@ -0,0 +1,71 @@ +import type { Extension } from "@codemirror/state"; + +type ViewModule = typeof import("@codemirror/view"); +type EditorViewType = import("@codemirror/view").EditorView; +type DecorationSet = import("@codemirror/view").DecorationSet; + +export const NEWLINE_MARKER = "↵"; +export const NEWLINE_MARKER_CLASS = "cm-dbx-newline-marker"; + +/** + * Display-only whitespace markers for the SQL editor: spaces as "·", tabs as "→" + * (CodeMirror's highlightWhitespace) plus a "↵" at every line end that is followed + * by a line break. The document text itself is never touched. + */ +export function createShowWhitespaceExtension(view: Pick, enabled: boolean): Extension { + if (!enabled) return []; + const { highlightWhitespace, ViewPlugin, Decoration, WidgetType, EditorView } = view; + + class NewlineMarkerWidget extends WidgetType { + eq(): boolean { + return true; + } + toDOM(): HTMLElement { + const el = document.createElement("span"); + el.className = NEWLINE_MARKER_CLASS; + el.setAttribute("aria-hidden", "true"); + el.textContent = NEWLINE_MARKER; + return el; + } + ignoreEvent(): boolean { + return true; + } + } + const marker = Decoration.widget({ widget: new NewlineMarkerWidget(), side: 1 }); + + function buildDecorations(editorView: EditorViewType): DecorationSet { + const doc = editorView.state.doc; + const ranges = []; + let lastLine = -1; + for (const { from, to } of editorView.visibleRanges) { + for (let pos = from; pos <= to; ) { + const line = doc.lineAt(pos); + if (line.number !== lastLine) { + lastLine = line.number; + if (line.number < doc.lines) ranges.push(marker.range(line.to)); + } + pos = line.to + 1; + } + } + return Decoration.set(ranges); + } + + const newlinePlugin = ViewPlugin.fromClass( + class { + decorations: DecorationSet; + constructor(editorView: EditorViewType) { + this.decorations = buildDecorations(editorView); + } + update(update: import("@codemirror/view").ViewUpdate) { + if (update.docChanged || update.viewportChanged) this.decorations = buildDecorations(update.view); + } + }, + { decorations: (plugin) => plugin.decorations }, + ); + + const markerTheme = EditorView.baseTheme({ + [`.${NEWLINE_MARKER_CLASS}`]: { opacity: "0.35", pointerEvents: "none", userSelect: "none" }, + }); + + return [highlightWhitespace(), newlinePlugin, markerTheme]; +} diff --git a/apps/desktop/src/lib/settings/editorSettingsDraft.ts b/apps/desktop/src/lib/settings/editorSettingsDraft.ts index 3d700819d8..b9378917ee 100644 --- a/apps/desktop/src/lib/settings/editorSettingsDraft.ts +++ b/apps/desktop/src/lib/settings/editorSettingsDraft.ts @@ -30,6 +30,7 @@ export const EDITOR_SETTINGS_DRAFT_KEYS = [ "sortCompletionColumnsAlphabetically", "selectFirstCompletionOnOpen", "wordWrap", + "showWhitespace", "vimModeEnabled", "autoCloseBrackets", "sqlSemanticDiagnosticsMode", diff --git a/apps/desktop/src/lib/settings/settingsSearch.ts b/apps/desktop/src/lib/settings/settingsSearch.ts index 0874ec8a02..ac0f999664 100644 --- a/apps/desktop/src/lib/settings/settingsSearch.ts +++ b/apps/desktop/src/lib/settings/settingsSearch.ts @@ -128,6 +128,7 @@ export const SETTINGS_SEARCH_DEFINITIONS: readonly SettingsSearchDefinition[] = { id: "editor-line-numbers", category: "editor", titleKey: "settings.showLineNumbers", descriptionKey: "settings.showLineNumbersDescription", targetId: "editor" }, { id: "editor-statement-frame", category: "editor", titleKey: "settings.showCurrentStatementFrame", descriptionKey: "settings.showCurrentStatementFrameDescription", targetId: "editor" }, { id: "editor-value-hints", category: "editor", titleKey: "settings.showInsertValueHints", descriptionKey: "settings.showInsertValueHintsDescription", targetId: "editor" }, + { id: "editor-show-whitespace", category: "editor", titleKey: "settings.showWhitespace", descriptionKey: "settings.showWhitespaceDescription", targetId: "editor" }, { id: "editor-word-wrap", category: "editor", titleKey: "settings.wordWrap", descriptionKey: "settings.wordWrapDescription", targetId: "editor" }, { id: "editor-vim", category: "editor", titleKey: "settings.vimMode", descriptionKey: "settings.vimModeDescription", targetId: "editor" }, { id: "editor-brackets", category: "editor", titleKey: "settings.autoCloseBrackets", descriptionKey: "settings.autoCloseBracketsDescription", targetId: "editor" }, diff --git a/apps/desktop/src/lib/settings/settingsTransfer.ts b/apps/desktop/src/lib/settings/settingsTransfer.ts index 20dbdfb729..2e4def8790 100644 --- a/apps/desktop/src/lib/settings/settingsTransfer.ts +++ b/apps/desktop/src/lib/settings/settingsTransfer.ts @@ -88,6 +88,7 @@ const SETTINGS_TRANSFER_CATEGORY_KEYS: Record(EDITOR_SETTINGS_DRAFT_KEYS.m */ const PASS_THROUGH_BOOLEAN_KEYS = [ "wordWrap", + "showWhitespace", "showExecutionTargetPicker", "autoAliasTables", "confirmDangerousSqlExecution", diff --git a/apps/desktop/src/stores/settingsStore.ts b/apps/desktop/src/stores/settingsStore.ts index 7282ebb153..52b7df6868 100644 --- a/apps/desktop/src/stores/settingsStore.ts +++ b/apps/desktop/src/stores/settingsStore.ts @@ -775,6 +775,7 @@ export interface EditorSettings { sortCompletionColumnsAlphabetically: boolean; selectFirstCompletionOnOpen: boolean; wordWrap: boolean; + showWhitespace: boolean; tableDdlWordWrap: boolean; refreshDdlOnOpen: boolean; excludeDdlStorage: boolean; @@ -1033,6 +1034,7 @@ export const DEFAULT_EDITOR_SETTINGS: EditorSettings = { sortCompletionColumnsAlphabetically: true, selectFirstCompletionOnOpen: true, wordWrap: false, + showWhitespace: false, tableDdlWordWrap: true, refreshDdlOnOpen: false, excludeDdlStorage: true, @@ -1538,6 +1540,7 @@ export function normalizeEditorSettings(settings: Partial, exist sortCompletionColumnsAlphabetically: typeof settings.sortCompletionColumnsAlphabetically === "boolean" ? settings.sortCompletionColumnsAlphabetically : DEFAULT_EDITOR_SETTINGS.sortCompletionColumnsAlphabetically, selectFirstCompletionOnOpen: typeof settings.selectFirstCompletionOnOpen === "boolean" ? settings.selectFirstCompletionOnOpen : DEFAULT_EDITOR_SETTINGS.selectFirstCompletionOnOpen, wordWrap: settings.wordWrap ?? DEFAULT_EDITOR_SETTINGS.wordWrap, + showWhitespace: typeof settings.showWhitespace === "boolean" ? settings.showWhitespace : DEFAULT_EDITOR_SETTINGS.showWhitespace, tableDdlWordWrap: typeof settings.tableDdlWordWrap === "boolean" ? settings.tableDdlWordWrap : DEFAULT_EDITOR_SETTINGS.tableDdlWordWrap, excludeDdlStorage: typeof settings.excludeDdlStorage === "boolean" ? settings.excludeDdlStorage : DEFAULT_EDITOR_SETTINGS.excludeDdlStorage, refreshDdlOnOpen: typeof settings.refreshDdlOnOpen === "boolean" ? settings.refreshDdlOnOpen : DEFAULT_EDITOR_SETTINGS.refreshDdlOnOpen, @@ -2315,6 +2318,7 @@ export const useSettingsStore = defineStore("settings", () => { if (partial.sortCompletionColumnsAlphabetically !== undefined) editorSettings.value.sortCompletionColumnsAlphabetically = partial.sortCompletionColumnsAlphabetically === true; if (partial.selectFirstCompletionOnOpen !== undefined) editorSettings.value.selectFirstCompletionOnOpen = partial.selectFirstCompletionOnOpen === true; if (partial.wordWrap !== undefined) editorSettings.value.wordWrap = partial.wordWrap; + if (partial.showWhitespace !== undefined) editorSettings.value.showWhitespace = partial.showWhitespace === true; if (partial.tableDdlWordWrap !== undefined) editorSettings.value.tableDdlWordWrap = partial.tableDdlWordWrap === true; if (partial.excludeDdlStorage !== undefined) editorSettings.value.excludeDdlStorage = partial.excludeDdlStorage === true; if (partial.refreshDdlOnOpen !== undefined) editorSettings.value.refreshDdlOnOpen = partial.refreshDdlOnOpen === true;