Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions scripts/editorLineEnding.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof textBuffer>): 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, '<div class="status-bar">', '</div>\n{/if}');

assert.match(statusBar, /<div class="status-item">\{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');
});
10 changes: 9 additions & 1 deletion scripts/imageUndoKeepsFile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -288,6 +294,7 @@ function createComponent(backend: Backend, editor: unknown): Component {
tabManager: unknown,
monaco: unknown,
editor: unknown,
lineEndingLabel: unknown,
) => Component;

return factory(
Expand All @@ -296,6 +303,7 @@ function createComponent(backend: Backend, editor: unknown): Component {
tabManager,
monacoStub,
editor,
lineEndingLabel,
);
}

Expand Down
29 changes: 27 additions & 2 deletions scripts/monacoInternals.d.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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<string, number>;

/** `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' {
Expand Down
20 changes: 17 additions & 3 deletions scripts/undoHistoryPerTab.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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() {}')}
Expand Down Expand Up @@ -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 = {
Expand Down
16 changes: 13 additions & 3 deletions src/lib/components/Editor.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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;
}
Expand Down Expand Up @@ -1824,7 +1826,15 @@
<div class="status-item">
{currentLanguage}
</div>
<div class="status-item">{t('editor.status.crlf')}</div>
<!-- Not translated, like the language id and the zoom level above it:
"LF" and "CRLF" are acronyms, and the `crlf` key they replace held
the same ASCII in all five locales that bothered to define it. -->
<div class="status-item">{lineEnding}</div>
<!-- Still hardcoded, and still the only thing here that is: the document's
real encoding is detected in `fix/non-utf8-documents` (#372), which
puts it on `Tab.encoding`. Wire this to that field when it lands —
duplicating the detection to make the label true sooner would leave
two answers to one question. -->
<div class="status-item">{t('editor.status.utf8')}</div>
</div>
{/if}
Expand Down
5 changes: 0 additions & 5 deletions src/lib/utils/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -334,7 +334,6 @@ export const translations: Record<LanguageCode, Translation> = {
selected: '{{count}} selected',
selections: '{{count}} selections',
words: '{{count}} words',
crlf: 'CRLF',
utf8: 'UTF-8'
}
},
Expand Down Expand Up @@ -662,7 +661,6 @@ export const translations: Record<LanguageCode, Translation> = {
selected: '已选择 {{count}}',
selections: '{{count}} 个选择',
words: '{{count}} 字',
crlf: 'CRLF',
utf8: 'UTF-8'
}
},
Expand Down Expand Up @@ -911,7 +909,6 @@ export const translations: Record<LanguageCode, Translation> = {
selected: '{{count}} 選択',
selections: '{{count}} つの選択',
words: '{{count}} 語',
crlf: 'CRLF',
utf8: 'UTF-8'
}
},
Expand Down Expand Up @@ -1240,7 +1237,6 @@ export const translations: Record<LanguageCode, Translation> = {
selected: '已選取 {{count}} 個字元',
selections: '已選取 {{count}} 處',
words: '{{count}} 個字詞',
crlf: 'CRLF',
utf8: 'UTF-8'
}
},
Expand Down Expand Up @@ -1548,7 +1544,6 @@ export const translations: Record<LanguageCode, Translation> = {
selected: '{{count}}개 선택됨',
selections: '{{count}}개 선택 영역',
words: '{{count}}개 단어',
crlf: 'CRLF',
utf8: 'UTF-8',
lines: '{{count}}개 줄'
}
Expand Down
27 changes: 27 additions & 0 deletions src/lib/utils/tabModels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,3 +129,30 @@ export function retainTabModels(liveTabIds: Iterable<string>): 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<TextModel, 'getEOL'>): 'LF' | 'CRLF' {
return model.getEOL() === '\r\n' ? 'CRLF' : 'LF';
}