diff --git a/scripts/editorLineEnding.test.ts b/scripts/editorLineEnding.test.ts new file mode 100644 index 00000000..75b4bdb5 --- /dev/null +++ b/scripts/editorLineEnding.test.ts @@ -0,0 +1,95 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { PieceTreeTextBufferBuilder } from 'monaco-editor/esm/vs/editor/common/model/pieceTreeTextBuffer/pieceTreeTextBufferBuilder.js'; +import { DefaultEndOfLine } from 'monaco-editor/esm/vs/editor/common/standalone/standaloneEnums.js'; + +import { lineEndingLabel } from '../src/lib/utils/tabModels.js'; +import { readSource, sliceBetween } from './sourceTree.js'; + +// The status bar used to render `t('editor.status.crlf')` — the literal string +// "CRLF", for every document, on every platform. `samples/stress-test.md` has +// not got one CR byte in it and the app said CRLF about it. +// +// The label is now the model's own EOL, so this file drives real Monaco with +// real CRLF and LF fixtures and asks what the label would say. The fixtures are +// literals rather than files on disk for the reason `sourceTree.ts` explains at +// length: on a Windows checkout Git decides what `\n` in a repo file means, and +// a test about line endings cannot let it. + +/** + * A Monaco text buffer built exactly the way `monaco.editor.createModel` builds + * one: `createTextBufferFactory` is `new PieceTreeTextBufferBuilder()`, + * `acceptChunk`, `finish()` with `normalizeEOL` left at its default. + * + * The buffer rather than a `TextModel` because a model pulls in the editor's + * browser-side graph, and the EOL is decided down here anyway — everything + * `model.getEOL()` returns comes from this object. + */ +function textBuffer(source: string, defaultEOL: 1 | 2 = DefaultEndOfLine.LF) { + const builder = new PieceTreeTextBufferBuilder(); + builder.acceptChunk(source); + return builder.finish().create(defaultEOL).textBuffer; +} + +/** The buffer's own text, to show what normalization did to a fixture. */ +function contents(buffer: ReturnType): string { + const snapshot = buffer.createSnapshot(false); + let text = ''; + for (let chunk = snapshot.read(); chunk !== null; chunk = snapshot.read()) text += chunk; + return text; +} + +const CRLF_DOCUMENT = '# Title\r\n\r\nA paragraph.\r\n\r\n- one\r\n- two\r\n'; +const LF_DOCUMENT = '# Title\n\nA paragraph.\n\n- one\n- two\n'; + +test('the label is the document’s ending, not a constant', () => { + assert.equal(lineEndingLabel(textBuffer(CRLF_DOCUMENT)), 'CRLF'); + assert.equal(lineEndingLabel(textBuffer(LF_DOCUMENT)), 'LF'); +}); + +test('a mixed document is not mixed once it is open, and the label says which one won', () => { + // Monaco counts the endings and rewrites the WHOLE buffer to the majority + // before the model exists, so there is no third answer to give: after this + // the document really is uniform, and a save writes it out that way. The + // two assertions per fixture are one claim — the label matches the bytes. + const mostlyCrlf = textBuffer('a\r\nb\r\nc\n'); + assert.equal(contents(mostlyCrlf), 'a\r\nb\r\nc\r\n'); + assert.equal(lineEndingLabel(mostlyCrlf), 'CRLF'); + + const mostlyLf = textBuffer('a\nb\nc\r\n'); + assert.equal(contents(mostlyLf), 'a\nb\nc\n'); + assert.equal(lineEndingLabel(mostlyLf), 'LF'); + + // Half and half: Monaco requires a CR majority, so a tie goes to LF. + const tied = textBuffer('a\r\nb\n'); + assert.equal(contents(tied), 'a\nb\n'); + assert.equal(lineEndingLabel(tied), 'LF'); +}); + +test('a document with no line break at all reports the ending Monaco would insert', () => { + // An empty untitled tab, or a one-line file. There is nothing to detect, so + // the model falls back to its `defaultEOL`, which the standalone services + // resolve per platform: CRLF on Windows, LF elsewhere. That is still the + // truth — it is what Enter inserts and what the save writes. + assert.equal(lineEndingLabel(textBuffer('one line, no break', DefaultEndOfLine.CRLF)), 'CRLF'); + assert.equal(lineEndingLabel(textBuffer('', DefaultEndOfLine.LF)), 'LF'); +}); + +test('the status bar renders the measured label and no longer a translation key', () => { + const editor = readSource('src/lib/components/Editor.svelte'); + const statusBar = sliceBetween(editor, '
', '
\n{/if}'); + + assert.match(statusBar, /
\{lineEnding\}<\/div>/, 'the line-ending item shows the state'); + assert.doesNotMatch(editor, /editor\.status\.crlf/, 'the hardcoded CRLF string is gone'); + assert.match( + editor, + /lineEnding = lineEndingLabel\(model\)/, + 'and it is filled from the model, in the function that refreshes document-wide readings', + ); + + // One line-ending decision reaches this component. A second one — a `\r\n` + // test of its own over `getValue()` — is the defect this fix replaces, in a + // new place. + assert.doesNotMatch(editor, /includes\('\\r\\n'\)/, 'no hand-rolled line-ending detection in the editor'); +}); diff --git a/scripts/imageUndoKeepsFile.test.ts b/scripts/imageUndoKeepsFile.test.ts index 6cac6367..da9c136e 100644 --- a/scripts/imageUndoKeepsFile.test.ts +++ b/scripts/imageUndoKeepsFile.test.ts @@ -76,6 +76,7 @@ g.window.__TAURI_INTERNALS__ = { }; const { tabManager } = await import('../src/lib/stores/tabs.svelte.js'); +const { lineEndingLabel } = await import('../src/lib/utils/tabModels.js'); // ------------------------------------------------- the component, as written @@ -142,6 +143,10 @@ function createDocument(initial: string) { getValueInRange: (range: FakeRange) => buffer.slice(range.startColumn - 1, range.endColumn - 1), getLineContent: () => buffer, getLanguageId: () => 'markdown', + // Read by the status-bar refresh below; line endings are not this + // file's subject and are tested against real Monaco buffers in + // editorLineEnding.test.ts. + getEOL: () => '\n', }), getPosition: () => ({ lineNumber: 1, column: buffer.length + 1 }), getSelection: () => null, @@ -228,10 +233,11 @@ type Component = { * the statements that run are the component's own. */ const factorySource = ts.transpileModule( - `const __component = (invoke, settings, tabManager, monaco, editor) => { + `const __component = (invoke, settings, tabManager, monaco, editor, lineEndingLabel) => { let value = ''; let wordCount = 0; let currentLanguage = 'markdown'; + let lineEnding = 'LF'; // The status-bar refresh the content-change listener calls. Lifted from // the component rather than stubbed, so the listener under test runs @@ -288,6 +294,7 @@ function createComponent(backend: Backend, editor: unknown): Component { tabManager: unknown, monaco: unknown, editor: unknown, + lineEndingLabel: unknown, ) => Component; return factory( @@ -296,6 +303,7 @@ function createComponent(backend: Backend, editor: unknown): Component { tabManager, monacoStub, editor, + lineEndingLabel, ); } diff --git a/scripts/monacoInternals.d.ts b/scripts/monacoInternals.d.ts index 5702f724..854fb180 100644 --- a/scripts/monacoInternals.d.ts +++ b/scripts/monacoInternals.d.ts @@ -1,5 +1,4 @@ -// Types for the two Monaco internals `editorOptionWiring.test.ts` drives -// directly. +// Types for the Monaco internals this test suite drives directly. // // Monaco ships declarations for its public API surface (`monaco-editor`) only. // The option registry and the Unicode highlighter are plain ESM modules that @@ -144,6 +143,32 @@ declare module 'monaco-editor/esm/vs/editor/common/standalone/standaloneEnums.js * which every assertion in that file fails on. */ export const KeyCode: Record; + + /** `create()`'s fallback ending, used only for text with no line break at all. */ + export const DefaultEndOfLine: { readonly LF: 1; readonly CRLF: 2 }; +} + +// The text buffer itself, for `editorLineEnding.test.ts`. A `TextModel` needs a +// browser to build; the buffer under it does not, and it is where the EOL of a +// document is decided — `createTextBufferFactory` is these three calls. +declare module 'monaco-editor/esm/vs/editor/common/model/pieceTreeTextBuffer/pieceTreeTextBufferBuilder.js' { + /** Only the two members the test reads; this is not the whole buffer API. */ + export interface TextBuffer { + getEOL(): '\n' | '\r\n'; + /** Chunks of the buffer's own text, `null` at the end. */ + createSnapshot(preserveBOM: boolean): { read(): string | null }; + } + + export class PieceTreeTextBufferBuilder { + acceptChunk(chunk: string): void; + /** + * `normalizeEOL` defaults to true here and is left at its default by + * `createTextBufferFactory`, i.e. by every model Markpad creates. + */ + finish(normalizeEOL?: boolean): { + create(defaultEOL: 1 | 2): { textBuffer: TextBuffer }; + }; + } } declare module 'monaco-editor/esm/vs/base/common/keyCodes.js' { diff --git a/scripts/undoHistoryPerTab.test.ts b/scripts/undoHistoryPerTab.test.ts index 58094897..e54665c8 100644 --- a/scripts/undoHistoryPerTab.test.ts +++ b/scripts/undoHistoryPerTab.test.ts @@ -68,7 +68,9 @@ g.window.__TAURI_INTERNALS__ = { }; const { tabManager } = await import('../src/lib/stores/tabs.svelte.js'); -const { getTabModel, tabModelUri, trackedTabModelIds } = await import('../src/lib/utils/tabModels.js'); +const { getTabModel, lineEndingLabel, tabModelUri, trackedTabModelIds } = await import( + '../src/lib/utils/tabModels.js' +); const { buildTransferredTab } = await import('../src/lib/utils/tabTransfer.js'); // ------------------------------------------------- the component, as written @@ -167,6 +169,16 @@ class FakeModel { return this.language; } + /** + * Fixed, because nothing here is about line endings: the status-bar label + * that reads this is driven against real Monaco buffers in + * `editorLineEnding.test.ts`, and a fake that guessed an EOL from its + * buffer would be a second implementation of exactly what that fix removed. + */ + getEOL() { + return '\n'; + } + setLanguage(language: string) { this.language = language; } @@ -322,12 +334,13 @@ type Component = { * Types are erased, nothing else: the statements that run are the component's. */ const factorySource = ts.transpileModule( - `const __component = (tabManager, monaco, editor, getTabModel, tabModelUri) => { + `const __component = (tabManager, monaco, editor, getTabModel, tabModelUri, lineEndingLabel) => { let value = ''; let currentTabId = null; let language = 'markdown'; let currentLanguage = 'markdown'; let wordCount = 0; + let lineEnding = 'LF'; const editorReady = true; ${lifted('syncStatusFromModel', 'function syncStatusFromModel() {}')} @@ -359,9 +372,10 @@ function createComponent(editor: Editor): Component { editor: unknown, getTabModel: unknown, tabModelUri: unknown, + lineEndingLabel: unknown, ) => Component; - return factory(tabManager, monacoStub, editor, getTabModel, tabModelUri); + return factory(tabManager, monacoStub, editor, getTabModel, tabModelUri, lineEndingLabel); } type Harness = { diff --git a/src/lib/components/Editor.svelte b/src/lib/components/Editor.svelte index 999df50f..b92015dc 100644 --- a/src/lib/components/Editor.svelte +++ b/src/lib/components/Editor.svelte @@ -5,7 +5,7 @@ import { t, type LanguageCode } from '../utils/i18n.js'; import { MARKDOWN_LANGUAGE_ID, shouldLinkifyPastedUrl } from '../utils/pasteContext.js'; import { toggleLineMarker, type LineMarkerToolId } from '../utils/editorToolbar.js'; - import { getTabModel, tabModelUri } from '../utils/tabModels.js'; + import { getTabModel, lineEndingLabel, tabModelUri } from '../utils/tabModels.js'; import { installVimScrollCommands } from '../utils/vimScrollCommands.js'; import { headingLinkContext, @@ -95,6 +95,7 @@ let cursorCount = $state(0); let wordCount = $state(0); let currentLanguage = $state("markdown"); + let lineEnding = $state<"LF" | "CRLF">("LF"); let currentTabId = tabManager.activeTabId; // A Monaco action captures its label when it is registered, so actions built @@ -169,7 +170,7 @@ /** * The status-bar readings that are properties of the DOCUMENT rather than of - * an edit: the language and the word count. + * an edit: the language, the word count and the line ending. * * They used to be refreshed only by `onDidChangeModelContent`, which was * enough while a tab switch went through `setValue` — that fires a content @@ -181,6 +182,7 @@ const model = editor.getModel(); if (!model) return; currentLanguage = model.getLanguageId(); + lineEnding = lineEndingLabel(model); const text = model.getValue(); wordCount = (text.match(/\S+/g) || []).filter((w) => /\w/.test(w)).length; } @@ -1824,7 +1826,15 @@
{currentLanguage}
-
{t('editor.status.crlf')}
+ +
{lineEnding}
+
{t('editor.status.utf8')}
{/if} diff --git a/src/lib/utils/i18n.ts b/src/lib/utils/i18n.ts index 2a83e130..4cc58706 100644 --- a/src/lib/utils/i18n.ts +++ b/src/lib/utils/i18n.ts @@ -334,7 +334,6 @@ export const translations: Record = { selected: '{{count}} selected', selections: '{{count}} selections', words: '{{count}} words', - crlf: 'CRLF', utf8: 'UTF-8' } }, @@ -662,7 +661,6 @@ export const translations: Record = { selected: '已选择 {{count}}', selections: '{{count}} 个选择', words: '{{count}} 字', - crlf: 'CRLF', utf8: 'UTF-8' } }, @@ -911,7 +909,6 @@ export const translations: Record = { selected: '{{count}} 選択', selections: '{{count}} つの選択', words: '{{count}} 語', - crlf: 'CRLF', utf8: 'UTF-8' } }, @@ -1240,7 +1237,6 @@ export const translations: Record = { selected: '已選取 {{count}} 個字元', selections: '已選取 {{count}} 處', words: '{{count}} 個字詞', - crlf: 'CRLF', utf8: 'UTF-8' } }, @@ -1548,7 +1544,6 @@ export const translations: Record = { selected: '{{count}}개 선택됨', selections: '{{count}}개 선택 영역', words: '{{count}}개 단어', - crlf: 'CRLF', utf8: 'UTF-8', lines: '{{count}}개 줄' } diff --git a/src/lib/utils/tabModels.ts b/src/lib/utils/tabModels.ts index a13afbb0..6bca6fb4 100644 --- a/src/lib/utils/tabModels.ts +++ b/src/lib/utils/tabModels.ts @@ -129,3 +129,30 @@ export function retainTabModels(liveTabIds: Iterable): void { export function trackedTabModelIds(): string[] { return [...models.keys()]; } + +/** + * What the status bar calls this document's line ending. + * + * The model's own EOL, not a detector run over the text, because the model IS + * the document once it is open and Monaco has already decided this: the buffer + * builder counts the endings in the source and rewrites the WHOLE buffer to the + * majority one (ties go to LF) before the model exists — verified against the + * bundled Monaco 0.55.1 in `editorLineEnding.test.ts`. So a mixed-ending file is + * no longer mixed by the time anything can be shown about it, `getEOL()` is + * both what the buffer contains and what a save writes out, and asking a second + * question of `getValue()` could only ever produce a different answer by being + * wrong. + * + * That also settles `detectLineEnding` in `utils/frontMatter.ts`, which is + * first-CRLF-wins: it is not a rival to this and must not be lifted into one. + * It answers "which ending do I emit when rewriting this string's front matter + * block" about a string it is handed — normalized content, where any `\r\n` + * means every ending is `\r\n`, so the two agree wherever both can see the same + * document. + * + * Typed on the one method it calls rather than on `ITextModel` so the test can + * drive it with a real Monaco text buffer, which has an EOL but is not a model. + */ +export function lineEndingLabel(model: Pick): 'LF' | 'CRLF' { + return model.getEOL() === '\r\n' ? 'CRLF' : 'LF'; +}