From 1585977ed39aa90b477734e39b6d226b29f95b7c Mon Sep 17 00:00:00 2001 From: Marcus Chandra Date: Sun, 26 Jul 2026 21:34:43 -0700 Subject: [PATCH 01/11] improvement(files): smarter bullet delete/indent, fix empty-nested-bullet heading corruption Backspace at the start of a list item now outdents a nested item or clears a top-level item to a paragraph in place instead of deleting the row and jumping the caret to the previous block; Enter on an empty nested item outdents. Empty non-trailing top-level items still collapse cleanly since they cannot round-trip as a lifted paragraph. Also strips nested empty list-item marker lines on serialize: a nested empty bullet re-parsed as a Setext heading underline, silently turning its parent line into an H2 and dropping the bullet. Top-level empty items are preserved. --- .../rich-markdown-editor/keymap.test.ts | 178 ++++++++++++++++-- .../rich-markdown-editor/keymap.ts | 103 ++++++++-- .../markdown-fidelity.test.ts | 54 ++++++ .../rich-markdown-editor/markdown-fidelity.ts | 72 ++++++- 4 files changed, 359 insertions(+), 48 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-fidelity.test.ts diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.test.ts index 586f2bc2ff2..7e8c25444d6 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.test.ts @@ -11,6 +11,7 @@ import { GapCursor } from '@tiptap/pm/gapcursor' import { AllSelection, NodeSelection } from '@tiptap/pm/state' import { beforeEach, describe, expect, it, vi } from 'vitest' import { createMarkdownEditorExtensions } from './editor-extensions' +import { postProcessSerializedMarkdown } from './markdown-fidelity' import { MENTION_PLUGIN_KEY } from './mention' import { SLASH_COMMAND_PLUGIN_KEY } from './slash-command/slash-command' @@ -192,13 +193,11 @@ describe('empty wrapped-block Backspace', () => { it.each([ ['bullet middle', '- one\n- two\n- three', 'two', '- one\n- three'], ['bullet first', '- one\n- two\n- three', 'one', '- two\n- three'], - ['bullet last', '- one\n- two', 'two', '- one'], ['ordered middle', '1. one\n2. two\n3. three', 'two', '1. one\n2. three'], ['task middle', '- [ ] one\n- [ ] two\n- [ ] three', 'two', '- [ ] one\n- [ ] three'], ['blockquote middle', '> one\n>\n> two\n>\n> three', 'two', '> one\n>\n> three'], - ['nested item', '- one\n - two\n- three', 'two', '- one\n- three'], ])( - 'removes the emptied %s cleanly — one container, no stray paragraph, round-trips', + 'removes an emptied non-trailing %s cleanly — one container, no stray paragraph, round-trips', (_label, markdown, word, expected) => { const editor = editorWith('') editor.commands.setContent(markdown, { contentType: 'markdown' }) @@ -213,10 +212,10 @@ describe('empty wrapped-block Backspace', () => { } ) - it('leaves a gap cursor — never a NodeSelection — when the removed bullet was followed by an image at doc start', () => { - // Regression: `Selection.near` after the delete silently NodeSelected the following image, so a - // second Backspace while "clearing the bullet" deleted the image (and typing would have replaced - // it). The selection left behind must never make the next keystroke destructive. + it('clears a lone empty bullet before an image to a paragraph and never destroys the image', () => { + // Regression: an earlier delete-and-jump path silently NodeSelected the following image, so a + // second Backspace while "clearing the bullet" deleted it. The lone empty bullet now lifts into an + // empty paragraph in place, the image is untouched, and no repeated Backspace destroys it. const editor = editorWith({ type: 'doc', content: [ @@ -227,11 +226,11 @@ describe('empty wrapped-block Backspace', () => { editor.commands.setTextSelection(3) pressBackspace(editor) - expect(blockShape(editor)).toEqual(['image', 'paragraph']) + expect(blockShape(editor)).toEqual(['paragraph', 'image', 'paragraph']) expect(editor.state.selection).not.toBeInstanceOf(NodeSelection) pressBackspace(editor) - expect(blockShape(editor)).toEqual(['image', 'paragraph']) + expect(blockShape(editor)).toContain('image') editor.destroy() }) @@ -252,11 +251,10 @@ describe('empty wrapped-block Backspace', () => { editor.destroy() }) - it('never NodeSelects a leaf BEFORE the removed bullet either (findFrom textOnly skips atoms)', () => { - // `Selection.findFrom($gap, -1, true)` cannot return a NodeSelection: with textOnly, - // prosemirror-state's findSelectionIn skips atoms entirely (`!text && isSelectable`). With an - // image directly before the emptied bullet and no textblock behind it, the backward search - // returns null and the gap-cursor branch takes over — the image is never silently selected. + it('clears a lone empty bullet after an image to a paragraph without selecting the image', () => { + // With an image directly before the emptied bullet, clearing the bullet must not silently select + // (and so endanger) the image. The bullet lifts into an empty paragraph in place; the image is + // untouched and no repeated Backspace destroys it. const editor = editorWith({ type: 'doc', content: [ @@ -268,11 +266,11 @@ describe('empty wrapped-block Backspace', () => { expect(editor.state.selection.$from.parent.type.name).toBe('paragraph') pressBackspace(editor) - expect(blockShape(editor)).toEqual(['image', 'paragraph']) + expect(blockShape(editor)).toEqual(['image', 'paragraph', 'paragraph']) expect(editor.state.selection).not.toBeInstanceOf(NodeSelection) pressBackspace(editor) - expect(blockShape(editor)).toEqual(['image', 'paragraph']) + expect(blockShape(editor)).toContain('image') editor.destroy() }) @@ -291,7 +289,7 @@ describe('empty wrapped-block Backspace', () => { editor.destroy() }) - it('still prefers the previous textblock caret when one exists (image after the bullet untouched)', () => { + it('clears a lone empty bullet between a paragraph and an image to a paragraph, leaving the image', () => { const editor = editorWith({ type: 'doc', content: [ @@ -303,10 +301,138 @@ describe('empty wrapped-block Backspace', () => { editor.commands.setTextSelection(10) pressBackspace(editor) - expect(blockShape(editor)).toEqual(['paragraph', 'image', 'paragraph']) + expect(blockShape(editor)).toEqual(['paragraph', 'paragraph', 'image', 'paragraph']) expect(editor.state.selection.empty).toBe(true) expect(editor.state.selection).not.toBeInstanceOf(NodeSelection) - expect(editor.state.selection.$from.parent.textContent).toBe('hello') + // The cleared bullet is now an empty paragraph, and 'hello' is untouched above it. + expect(editor.state.doc.firstChild?.textContent).toBe('hello') + editor.destroy() + }) +}) + +describe('list Backspace (clear / outdent)', () => { + beforeEach(() => { + Element.prototype.scrollIntoView = vi.fn() + }) + + /** Puts the caret at the very start of the item text `word`. */ + function caretAtStartOf(editor: Editor, word: string): void { + editor.state.doc.descendants((node, pos) => { + if (node.isText && node.text === word) editor.commands.setTextSelection(pos) + }) + } + + it('lifts a top-level bullet WITH TEXT into a paragraph, keeping the text (round-trips)', () => { + const editor = editorWith('') + editor.commands.setContent('- one\n- two', { contentType: 'markdown' }) + editor.commands.focus() + caretAtStartOf(editor, 'two') + pressBackspace(editor) + + // A bulletList (just 'one') followed by the lifted 'two' paragraph (+ TipTap's trailing filler). + expect(blockShape(editor).slice(0, 2)).toEqual(['bulletList', 'paragraph']) + const { md, reparsed } = markdownRoundTrip(editor) + expect(md.trim()).toBe('- one\n\ntwo') + expect(reparsed).toBe(md) + editor.destroy() + }) + + it('clears an empty TRAILING bullet to a paragraph in place — no delete, caret stays on the line', () => { + const editor = editorWith('') + editor.commands.setContent('- one\n- two', { contentType: 'markdown' }) + editor.commands.focus() + emptyItem(editor, 'two') + pressBackspace(editor) + + // The bullet becomes a paragraph; the list keeps only 'one'. The caret sits in the new empty + // paragraph rather than jumping back into the 'one' bullet. + const list = editor.getJSON().content?.find((node) => node.type === 'bulletList') + expect(list?.content).toHaveLength(1) + expect(editor.state.selection.empty).toBe(true) + expect(editor.state.selection.$from.parent.type.name).toBe('paragraph') + expect(editor.state.selection.$from.parent.textContent).toBe('') + expect(editor.getMarkdown().trim()).toBe('- one') + editor.destroy() + }) + + it('clears a lone empty bullet to an empty paragraph (whole doc)', () => { + const editor = editorWith('') + editor.commands.setContent('- one', { contentType: 'markdown' }) + editor.commands.focus() + emptyItem(editor, 'one') + pressBackspace(editor) + + expect(editor.getJSON().content?.some((n) => n.type === 'bulletList')).toBe(false) + expect(editor.state.selection.$from.parent.type.name).toBe('paragraph') + editor.destroy() + }) + + it('outdents a nested bullet WITH TEXT one level instead of merging it (round-trips)', () => { + const editor = editorWith('') + editor.commands.setContent('- one\n - two', { contentType: 'markdown' }) + editor.commands.focus() + caretAtStartOf(editor, 'two') + pressBackspace(editor) + + const { md, reparsed } = markdownRoundTrip(editor) + expect(md.trim()).toBe('- one\n- two') + expect(reparsed).toBe(md) + editor.destroy() + }) + + it('outdents an empty nested bullet one level (round-trips)', () => { + const editor = editorWith('') + editor.commands.setContent('- one\n - two\n- three', { contentType: 'markdown' }) + editor.commands.focus() + emptyItem(editor, 'two') + pressBackspace(editor) + + const { md, reparsed } = markdownRoundTrip(editor) + expect(md.trim()).toBe('- one\n- \n- three') + expect(reparsed).toBe(md) + editor.destroy() + }) + + it('clears a checklist item the same way (task item → paragraph)', () => { + const editor = editorWith('') + editor.commands.setContent('- [ ] one\n- [ ] two', { contentType: 'markdown' }) + editor.commands.focus() + caretAtStartOf(editor, 'two') + pressBackspace(editor) + + expect(blockShape(editor).slice(0, 2)).toEqual(['taskList', 'paragraph']) + expect(editor.getMarkdown().trim()).toBe('- [ ] one\n\ntwo') + editor.destroy() + }) +}) + +describe('empty nested bullet does not corrupt its parent (Enter → Tab)', () => { + beforeEach(() => { + Element.prototype.scrollIntoView = vi.fn() + }) + + it('serializes a stranded empty sub-bullet away instead of turning the parent into a heading', () => { + // Repro: type a bullet, Enter for a new bullet, Tab to indent it into an empty sub-bullet, then + // leave it. The serialized `- one\n - ` would re-parse as `- ## one` (Setext underline). The + // serialize step must strip the empty sub-bullet so the parent stays a bullet and round-trips. + const editor = editorWith('') + editor.commands.setContent('- one', { contentType: 'markdown' }) + editor.commands.focus() + let end = -1 + editor.state.doc.descendants((node, pos) => { + if (node.isText && node.text === 'one') end = pos + 3 + }) + editor.commands.setTextSelection(end) + pressKey(editor, 'Enter') + pressKey(editor, 'Tab') + + const saved = postProcessSerializedMarkdown(editor.getMarkdown()) + expect(saved).toBe('- one\n') + + // Reloading the saved markdown keeps a bullet — never a heading. + editor.commands.setContent(saved, { contentType: 'markdown' }) + expect(blockShape(editor)).not.toContain('heading') + expect(blockShape(editor)).toContain('bulletList') editor.destroy() }) }) @@ -341,6 +467,20 @@ describe('empty list-item Enter', () => { expect(editor.getJSON().content?.some((node) => node.type === 'paragraph')).toBe(true) editor.destroy() }) + + it('outdents an empty NESTED item one level instead of removing it', () => { + const editor = editorWith('') + editor.commands.setContent('- one\n - two', { contentType: 'markdown' }) + editor.commands.focus() + emptyItem(editor, 'two') + pressKey(editor, 'Enter') + + // The emptied nested item outdents to a second top-level bullet rather than being deleted. + const list = editor.getJSON().content?.find((node) => node.type === 'bulletList') + expect(list?.content).toHaveLength(2) + expect(list?.content?.every((item) => item.type === 'listItem')).toBe(true) + editor.destroy() + }) }) describe('verbatim block boundary (isolating)', () => { diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.ts index bdbac57bfc6..ed3829ede28 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.ts @@ -20,6 +20,44 @@ const WRAPPER_TYPES = new Set(['listItem', 'taskItem', 'blockquote']) /** Item node types a list is built from, used to detect an empty item's position within its list. */ const LIST_ITEM_TYPES = new Set(['listItem', 'taskItem']) +/** The enclosing list/task item at a caret position, with the facts the boundary keys branch on. */ +interface ListItemContext { + /** `'listItem'` or `'taskItem'` — the type name `liftListItem` must be called with. */ + itemType: string + /** The item's list is itself inside another list item, i.e. the item is indented. */ + isNested: boolean + /** The item carries no text. */ + isEmpty: boolean + /** The item is the last child of its immediate list. */ + isTrailing: boolean + /** The caret sits in the item's first child block (the row a boundary key should act on). */ + isFirstBlock: boolean +} + +/** + * Resolves the nearest enclosing list/task item at `$from` and the facts the Backspace/Enter handlers + * branch on (nesting, emptiness, trailing position, whether the caret is in the item's first block), or + * null when the caret is not inside a list item. Walking up from `$from` finds the item regardless of + * how deeply the caret's block is nested inside it. + */ +function getListItemContext($from: ResolvedPos): ListItemContext | null { + for (let depth = $from.depth; depth >= 1; depth--) { + const item = $from.node(depth) + if (!LIST_ITEM_TYPES.has(item.type.name)) continue + const listDepth = depth - 1 + const isNested = listDepth >= 1 && LIST_ITEM_TYPES.has($from.node(listDepth - 1).type.name) + const list = $from.node(listDepth) + return { + itemType: item.type.name, + isNested, + isEmpty: item.textContent.length === 0, + isTrailing: $from.index(listDepth) === list.childCount - 1, + isFirstBlock: $from.index(depth) === 0, + } + } + return null +} + const RICH_LEAF_SELECTION_FOCUS_KEY = new PluginKey('richLeafSelectionFocus') /** True when the resolved position sits anywhere inside a {@link WRAPPER_TYPES} ancestor. */ @@ -136,21 +174,26 @@ function selectAdjacentSelectedLeaf(editor: Editor, direction: 'up' | 'down'): b * Editor-specific keyboard behavior layered on top of StarterKit's defaults: * * - **Backspace** at the start of a heading reverts it to a paragraph (ProseMirror's default joins or - * no-ops, stranding the heading style; a second Backspace then merges as usual). At the start of an - * *empty block inside a list item, task item, or blockquote* it removes that whole emptied wrapper via - * {@link removeEmptyWrappedBlock} instead of ProseMirror's default lift — lifting an empty item out of - * the middle of a list/quote splits the container in two and strands an empty paragraph (a visible gap - * that also re-parses to a different markdown document), while the default `joinBackward` alternately - * no-ops on nested items (leaving them stuck) or merges an empty continuation paragraph into the - * previous item. At the start of a block whose previous sibling is a divider or image, where - * ProseMirror's `joinBackward` can't cross the leaf and no-ops: an *empty* block is deleted (clearing - * the blank line between/below dividers without touching the divider itself), while a *non-empty* - * block selects the leaf — so a first Backspace highlights what a second deletes, the same - * highlight-before-delete affordance as clicking it and parity with the arrow-key leaf selection. - * - **Enter** on an *empty, non-trailing list/task item* removes the empty item ({@link - * removeEmptyWrappedBlock}) rather than letting the default split the list into two around a stranded - * empty paragraph (which does not round-trip). A *trailing* empty item still falls through to the - * default, which exits the list — the standard "press Enter on a blank bullet to leave the list". + * no-ops, stranding the heading style; a second Backspace then merges as usual). At the start of a + * *list or task item* it outdents or clears in place via {@link getListItemContext}: a nested item outdents one + * level, a top-level item with text lifts out of the list into a paragraph (keeping the text), and a + * top-level *empty trailing* (or sole) item lifts into an empty paragraph in place — so the blank + * bullet made by pressing Enter can be cleared back to normal text on the same line instead of being + * deleted with the caret jumping to the previous block. The one case lift can't take is a top-level + * *empty, non-trailing* item: lifting it strands an empty paragraph between the two list halves, which + * re-parses to a different markdown document (an empty line between list items is a loose list, not a + * break); that item is removed via {@link removeEmptyWrappedBlock} instead, keeping the list whole. An + * empty block inside a *blockquote* is likewise removed via {@link removeEmptyWrappedBlock}. At the + * start of a block whose previous sibling is a divider or image, where ProseMirror's `joinBackward` + * can't cross the leaf and no-ops: an *empty* block is deleted (clearing the blank line between/below + * dividers without touching the divider itself), while a *non-empty* block selects the leaf — so a + * first Backspace highlights what a second deletes, the same highlight-before-delete affordance as + * clicking it and parity with the arrow-key leaf selection. + * - **Enter** on an empty *nested* list/task item outdents it one level, on an empty + * *non-trailing top-level* item removes it ({@link removeEmptyWrappedBlock}) rather than splitting the + * list around a stranded empty paragraph (which does not round-trip), and on an empty *trailing* item + * falls through to the default, which exits the list — the standard "press Enter on a blank bullet to + * leave the list". * - **Mod-A** inside a code block selects only that block's contents; pressing it again (when the * block is already fully selected) falls through to the default whole-document select-all, the * same scoped behavior as a code editor. @@ -183,6 +226,24 @@ export const RichMarkdownKeymap = Extension.create({ if ($from.parent.type.name === 'heading') { return editor.commands.setParagraph() } + const listCtx = getListItemContext($from) + if (listCtx?.isFirstBlock) { + const { itemType, isNested, isEmpty, isTrailing } = listCtx + // Backspace at the start of a bullet outdents or clears it in place rather than + // deleting the row and jumping the caret to the previous block. + // - Nested item → outdent one level (empty or not). + // - Top-level item with text → lift out of the list into a paragraph, keeping the text. + // - Top-level empty item that is trailing (or the sole item) → lift into an empty paragraph in + // place, so a fresh bullet made with Enter can be cleared back to normal text on the same line. + // A top-level *empty, non-trailing* item is the one case lift can't take: it strands an empty + // paragraph between the two list halves, which re-parses to a different markdown document (an + // empty line between list items is a loose list, not a break). That case removes the row via + // {@link removeEmptyWrappedBlock} instead, which keeps the list whole and round-trips. + if (isNested || !isEmpty || isTrailing) { + return editor.commands.liftListItem(itemType) + } + return removeEmptyWrappedBlock(editor, $from) + } if ($from.parent.content.size === 0 && isInsideWrapper($from)) { return removeEmptyWrappedBlock(editor, $from) } @@ -207,11 +268,13 @@ export const RichMarkdownKeymap = Extension.create({ if (!selection.empty || selection.$from.parentOffset !== 0) return false const { $from } = selection if ($from.parent.content.size !== 0) return false - const itemDepth = $from.depth - 1 - if (itemDepth < 1 || !LIST_ITEM_TYPES.has($from.node(itemDepth).type.name)) return false - const listDepth = itemDepth - 1 - const isTrailingItem = $from.index(listDepth) === $from.node(listDepth).childCount - 1 - if (isTrailingItem) return false + const listCtx = getListItemContext($from) + if (!listCtx?.isFirstBlock) return false + // Enter on an empty item: a nested item outdents one level, a trailing top-level item + // falls through to the default (exits the list), and a non-trailing top-level item is removed + // rather than splitting the list around a stranded empty paragraph (which does not round-trip). + if (listCtx.isNested) return editor.commands.liftListItem(listCtx.itemType) + if (listCtx.isTrailing) return false return removeEmptyWrappedBlock(editor, $from) }, 'Mod-a': ({ editor }) => { diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-fidelity.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-fidelity.test.ts new file mode 100644 index 00000000000..2ce63fb7fef --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-fidelity.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest' +import { postProcessSerializedMarkdown } from './markdown-fidelity' + +describe('postProcessSerializedMarkdown — empty list-item stripping', () => { + it('drops a nested empty bullet that would re-parse as a Setext heading', () => { + // `- one\n - ` re-parses as `- ## one` (the ` - ` acts as a Setext underline). Stripping the + // empty bullet on serialize keeps the parent a bullet and makes the round-trip stable. + expect(postProcessSerializedMarkdown('- one\n - \n\n')).toBe('- one\n') + }) + + it('drops a nested empty ordered item', () => { + expect(postProcessSerializedMarkdown('1. one\n 2. \n')).toBe('1. one\n') + }) + + it('preserves a top-level empty bullet (placeholder / imported blank — round-trips faithfully)', () => { + // A top-level empty item is not Setext-hazardous and round-trips as an empty item, so it must be + // kept: it may be a placeholder row the user is about to fill, or an intentionally-blank imported item. + expect(postProcessSerializedMarkdown('- one\n- \n')).toBe('- one\n- \n') + expect(postProcessSerializedMarkdown('- one\n- \n- three\n')).toBe('- one\n- \n- three\n') + expect(postProcessSerializedMarkdown('1. one\n2. \n')).toBe('1. one\n2. \n') + }) + + it('keeps bullets that have content', () => { + expect(postProcessSerializedMarkdown('- a\n- b\n')).toBe('- a\n- b\n') + expect(postProcessSerializedMarkdown('- a\n - b\n')).toBe('- a\n - b\n') + }) + + it('keeps a nested empty parent whose next line is an indented child (no orphaning)', () => { + expect(postProcessSerializedMarkdown('- top\n - \n - child\n')).toBe( + '- top\n - \n - child\n' + ) + }) + + it('keeps a thematic break and empty checklist items (not Setext-hazardous)', () => { + expect(postProcessSerializedMarkdown('text\n\n---\n\nmore\n')).toBe('text\n\n---\n\nmore\n') + expect(postProcessSerializedMarkdown('- [ ] a\n- [ ] \n')).toBe('- [ ] a\n- [ ] \n') + }) + + it('leaves marker-only lines inside a fenced code block untouched', () => { + const code = '```\n- \n-\n1. \n```\n' + expect(postProcessSerializedMarkdown(code)).toBe(code) + }) + + it('leaves marker-only lines inside a tilde (~~~) fence untouched', () => { + const code = '~~~\n- \n1. \n~~~\n' + expect(postProcessSerializedMarkdown(code)).toBe(code) + }) + + it('does not strip inside an unterminated fence (fence stays open to EOF)', () => { + // A fence with no closing delimiter must keep every interior line, including marker-only ones. + const code = '```\n- \n-\n' + expect(postProcessSerializedMarkdown(code)).toBe(code) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-fidelity.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-fidelity.ts index f416482d491..5eec0de4dfe 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-fidelity.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-fidelity.ts @@ -106,16 +106,70 @@ export function normalizeLinkHref(href: string): string { return `https://${trimmed}` } +/** A line that is a bullet/ordered list marker with no content (`-`, ` - `, `1. `). Task items (`- [ ]`) don't match. */ +const EMPTY_LIST_ITEM_LINE = /^([ \t]*)(?:[-*+]|\d+[.)])[ \t]*$/ +/** A fenced code-block delimiter (``` or ~~~), used to leave code interiors untouched. */ +const FENCE_DELIMITER = /^[ \t]*(`{3,}|~{3,})/ +/** Leading indentation of a line, used to detect whether an empty list item has indented children. */ +const LEADING_INDENT = /^[ \t]*/ + +/** + * Removes *nested* (indented) empty list-item marker lines from serialized markdown. A nested empty + * bullet (` - `) sitting directly under a parent item's text re-parses as a Setext heading underline — + * silently turning the parent line into an `## heading` and dropping the empty bullet on the next load + * (a data-corrupting round-trip). Only indented empty items are stripped: a *top-level* empty bullet + * (`- ` / `1. `) round-trips faithfully (it stays an empty item, never a heading), so it is preserved — + * a placeholder row or an intentionally-blank imported item is not silently deleted. Lines inside fenced + * code blocks are left untouched, and an empty item whose next non-blank line is more deeply indented is + * kept so its children are never orphaned. + * + * Operates only on the editor's own serialized output, which uses fenced (never 4-space-indented) code + * blocks and `\n` newlines — so tracking fences is sufficient and a bare `-` inside an indented code + * block or a `-\r` line is not a case that can occur here. + */ +function stripEmptyListItemLines(markdown: string): string { + const lines = markdown.split('\n') + const kept: string[] = [] + let fence: string | null = null + for (let i = 0; i < lines.length; i++) { + const line = lines[i] + const delimiter = line.match(FENCE_DELIMITER)?.[1] + if (fence) { + kept.push(line) + if (delimiter && delimiter[0] === fence[0] && delimiter.length >= fence.length) fence = null + continue + } + if (delimiter) { + fence = delimiter + kept.push(line) + continue + } + const empty = line.match(EMPTY_LIST_ITEM_LINE) + if (empty) { + const indent = empty[1].length + let next = i + 1 + while (next < lines.length && lines[next].trim() === '') next++ + const hasChildren = + next < lines.length && (lines[next].match(LEADING_INDENT)?.[0].length ?? 0) > indent + // Strip only nested (indented) empty items — the Setext-underline hazard. A top-level empty item + // round-trips faithfully and is preserved. Never orphan an item that has more-indented children. + if (indent > 0 && !hasChildren) continue + } + kept.push(line) + } + return kept.join('\n') +} + /** - * Cleans up serializer output: restores callout markers the serializer backslash-escapes - * (`> \[!NOTE\]` → `> [!NOTE]`) and collapses trailing blank lines to a single newline. The - * table serializer's spurious surrounding blank lines are trimmed at the source (PipeSafeTable), - * so no global leading-newline strip is needed here — avoiding clobbering content that legitimately - * begins with whitespace. + * Cleans up serializer output: drops empty list-item marker lines that would otherwise corrupt on + * round-trip ({@link stripEmptyListItemLines}), restores callout markers the serializer + * backslash-escapes (`> \[!NOTE\]` → `> [!NOTE]`), and collapses trailing blank lines to a single + * newline. The table serializer's spurious surrounding blank lines are trimmed at the source + * (PipeSafeTable), so no global leading-newline strip is needed here — avoiding clobbering content + * that legitimately begins with whitespace. */ export function postProcessSerializedMarkdown(markdown: string): string { - return collapseAutolinkedUrls(markdown.replace(ESCAPED_CALLOUT_REGEX, '$1[!$2]')).replace( - /\n+$/, - '\n' - ) + return collapseAutolinkedUrls( + stripEmptyListItemLines(markdown).replace(ESCAPED_CALLOUT_REGEX, '$1[!$2]') + ).replace(/\n+$/, '\n') } From b5b30f636c21bd828ab4882a9b96d11a9c4595ff Mon Sep 17 00:00:00 2001 From: Marcus Chandra Date: Sun, 26 Jul 2026 21:34:44 -0700 Subject: [PATCH 02/11] feat(files): sync an untitled file's name with its leading heading While a file is still named untitled(.md), typing a leading heading auto-renames the file after it (debounced), and renaming the file first seeds a leading H1 from the new name. One-shot: coupling stops once the file has a real name, and the heading seed always prepends so existing content is never clobbered. --- .../components/file-viewer/file-viewer.tsx | 7 ++ .../rich-markdown-editor.tsx | 74 +++++++++++ .../title-heading.test.ts | 117 ++++++++++++++++++ .../rich-markdown-editor/title-heading.ts | 15 +++ .../workspace/[workspaceId]/files/files.tsx | 42 ++++++- .../files/untitled-title.test.ts | 88 +++++++++++++ .../[workspaceId]/files/untitled-title.ts | 63 ++++++++++ 7 files changed, 400 insertions(+), 6 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/title-heading.test.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/title-heading.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/files/untitled-title.test.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/files/untitled-title.ts diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx index b9bc4b4a302..bba37374240 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx @@ -116,6 +116,11 @@ interface FileViewerProps { * never target one editor. See {@link RichMarkdownEditorProps.collaborative}. */ collaborative?: boolean + /** + * Called (debounced) with the markdown document's leading-heading text while the file is still + * untitled, so the caller can name the file after it. Only wired for the editable markdown editor. + */ + onDeriveTitleFromHeading?: (headingText: string) => void } export function FileViewer(props: FileViewerProps) { @@ -148,6 +153,7 @@ function FileViewerContent({ disableStreamingAutoScroll = false, previewContextKey, collaborative, + onDeriveTitleFromHeading, }: FileViewerProps) { const category = resolveFileCategory(file.type, file.name) @@ -193,6 +199,7 @@ function FileViewerContent({ disableStreamingAutoScroll={disableStreamingAutoScroll} previewContextKey={previewContextKey} collaborative={collaborative} + onDeriveTitleFromHeading={onDeriveTitleFromHeading} /> ) } diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx index 2b9514b4f6a..10c859b2ab9 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx @@ -13,6 +13,10 @@ import { useRouter } from 'next/navigation' import { useSession } from '@/lib/auth/auth-client' import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' import { extractEmbeddedFileRef } from '@/lib/uploads/utils/embedded-image-ref' +import { + headingTextFromName, + isUntitledName, +} from '@/app/workspace/[workspaceId]/files/untitled-title' import { useUploadWorkspaceFile } from '@/hooks/queries/workspace-files' import type { SaveStatus } from '@/hooks/use-autosave' import { useFileContentSource } from '@/hooks/use-file-content-source' @@ -41,6 +45,7 @@ import { LinkHoverCard } from './menus/link-hover-card' import { TableBubbleMenu } from './menus/table-menu' import { normalizeMarkdownContent } from './normalize-content' import { isRoundTripSafe } from './round-trip-safety' +import { firstHeadingTitle, titleHeadingNode } from './title-heading' import '@sim/emcn/components/code/code.css' import './rich-markdown-editor.css' @@ -55,6 +60,9 @@ const EXTENSIONS = createMarkdownEditorExtensions({ const STREAM_REPARSE_THROTTLE_THRESHOLD = 40_000 const STREAM_REPARSE_THROTTLE_MS = 120 +/** Debounce before naming a still-untitled file after its leading heading, so it fires once typing settles. */ +const DERIVE_TITLE_DEBOUNCE_MS = 600 + interface RichMarkdownEditorProps { file: WorkspaceFileRecord workspaceId: string @@ -83,6 +91,12 @@ interface RichMarkdownEditorProps { * construction (they cannot both drive one editor and corrupt the shared doc). */ collaborative?: boolean + /** + * Called (debounced) with the document's leading-heading text while the file is still untitled, so the + * caller can name the file after it. Omitted on read-only/non-editable surfaces. See + * {@link isUntitledName}. + */ + onDeriveTitleFromHeading?: (headingText: string) => void } /** Inline WYSIWYG markdown editor: agent output streams in read-only, then the same instance becomes editable on settle. */ @@ -102,6 +116,7 @@ export const RichMarkdownEditor = memo(function RichMarkdownEditor({ previewContextKey, disableTagging, collaborative = false, + onDeriveTitleFromHeading, }: RichMarkdownEditorProps) { const { data: session, isPending: isSessionPending } = useSession() const userId = session?.user?.id ?? '' @@ -168,6 +183,7 @@ export const RichMarkdownEditor = memo(function RichMarkdownEditor({ onChange={setDraftContent} onSaveShortcut={saveImmediately} onCollabReadyChange={setCollabReady} + onDeriveTitleFromHeading={onDeriveTitleFromHeading} /> ) }) @@ -194,6 +210,8 @@ interface LoadedRichMarkdownEditorProps { onSaveShortcut: () => Promise /** Reports whether the collaborative document is synced+seeded (autosave gate). */ onCollabReadyChange: (ready: boolean) => void + /** See {@link RichMarkdownEditorProps.onDeriveTitleFromHeading}. */ + onDeriveTitleFromHeading?: (headingText: string) => void } interface SettledContent { @@ -223,6 +241,7 @@ export function LoadedRichMarkdownEditor({ onChange, onSaveShortcut, onCollabReadyChange, + onDeriveTitleFromHeading, }: LoadedRichMarkdownEditorProps) { /** Whether this editor mounted mid-stream — if so it starts empty and syncs streamed chunks until settle. */ const streamingAtMountRef = useRef(isStreaming) @@ -288,6 +307,19 @@ export function LoadedRichMarkdownEditor({ onChangeRef.current = onChange const onSaveShortcutRef = useRef(onSaveShortcut) onSaveShortcutRef.current = onSaveShortcut + + /** + * Untitled ⇄ heading coupling, active only while the file is unnamed. `onDeriveTitleFromHeading` is + * called (debounced) so the caller can name the file after its leading heading; `fileNameRef` lets the + * onUpdate handler read the current name without re-subscribing, and `prevFileNameRef` lets the + * name→heading seed detect the untitled→named transition. See {@link isUntitledName}. + */ + const onDeriveTitleFromHeadingRef = useRef(onDeriveTitleFromHeading) + onDeriveTitleFromHeadingRef.current = onDeriveTitleFromHeading + const fileNameRef = useRef(file.name) + fileNameRef.current = file.name + const prevFileNameRef = useRef(file.name) + const deriveTitleTimerRef = useRef | null>(null) /** * Read in the RAF tick so an already-scheduled tick still sees the latest edit kind (it can change * between sessions within one turn, e.g. an append followed by a rewrite). @@ -546,10 +578,52 @@ export function LoadedRichMarkdownEditor({ const md = postProcessSerializedMarkdown(editor.getMarkdown()) lastSyncedBodyRef.current = md onChangeRef.current(applyFrontmatter(settledRef.current?.frontmatter ?? '', md)) + // While the file is still untitled, name it after its leading heading once typing settles. Always + // clear any pending timer first, so deleting/rewriting the heading before it fires cancels the + // stale rename; the timer re-derives the title from the live doc rather than a value captured at + // schedule time, so it can never name the file after a heading the user has since changed or removed. + if (deriveTitleTimerRef.current) clearTimeout(deriveTitleTimerRef.current) + if (!isUntitledName(fileNameRef.current) || firstHeadingTitle(editor.state.doc) === null) + return + deriveTitleTimerRef.current = setTimeout(() => { + const liveEditor = editorInstanceRef.current + if (!liveEditor || !isUntitledName(fileNameRef.current)) return + const title = firstHeadingTitle(liveEditor.state.doc) + if (title) onDeriveTitleFromHeadingRef.current?.(title) + }, DERIVE_TITLE_DEBOUNCE_MS) }, }) editorInstanceRef.current = editor + useEffect( + () => () => { + if (deriveTitleTimerRef.current) clearTimeout(deriveTitleTimerRef.current) + }, + [] + ) + + /** + * Name→heading seed: when a still-untitled file is given a real name, seed the document's leading + * heading from that name — but only into a document with no heading of its own yet, so existing + * content is never clobbered. One-shot by construction: it fires only on the untitled→named transition. + */ + useEffect(() => { + // Wait for a render with a live, editable editor before consuming the transition — advancing + // prevFileNameRef while editor is still null would record the untitled→named change as "seen" and + // silently skip the seed on the later render when the editor is ready. + if (!editor || !isEditable) return + const prevName = prevFileNameRef.current + prevFileNameRef.current = file.name + if (!isUntitledName(prevName) || isUntitledName(file.name)) return + if (firstHeadingTitle(editor.state.doc) !== null) return + const title = headingTextFromName(file.name) + if (!title) return + // Always prepend, never replace: an empty doc becomes `# Title` + a body line, while any existing + // content — including structure-only scaffold (an empty list/blockquote/code block that carries no + // text) — is preserved above rather than clobbered. + editor.commands.insertContentAt(0, titleHeadingNode(title)) + }, [file.name, editor, isEditable]) + /** * The loaded markdown to seed the shared doc from, held by pointer so the parse * runs once at seed time rather than every render. diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/title-heading.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/title-heading.test.ts new file mode 100644 index 00000000000..1379ca3de59 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/title-heading.test.ts @@ -0,0 +1,117 @@ +/** + * @vitest-environment jsdom + */ +import { Editor } from '@tiptap/core' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createMarkdownEditorExtensions } from './editor-extensions' +import { firstHeadingTitle, titleHeadingNode } from './title-heading' + +function editorWith(markdown: string): Editor { + const editor = new Editor({ extensions: createMarkdownEditorExtensions({ placeholder: '' }) }) + if (markdown) editor.commands.setContent(markdown, { contentType: 'markdown' }) + return editor +} + +describe('firstHeadingTitle', () => { + beforeEach(() => { + vi.stubGlobal( + 'ResizeObserver', + class { + observe() {} + unobserve() {} + disconnect() {} + } + ) + Element.prototype.scrollIntoView = vi.fn() + document.elementFromPoint = vi.fn(() => null) + }) + + it('returns the leading H1 text', () => { + const editor = editorWith('# Q3 Planning\n\nbody') + expect(firstHeadingTitle(editor.state.doc)).toBe('Q3 Planning') + editor.destroy() + }) + + it('returns the text of any leading heading level', () => { + const editor = editorWith('## Sub title') + expect(firstHeadingTitle(editor.state.doc)).toBe('Sub title') + editor.destroy() + }) + + it('returns null when the first block is a paragraph, not a heading', () => { + const editor = editorWith('just text\n\n# later heading') + expect(firstHeadingTitle(editor.state.doc)).toBeNull() + editor.destroy() + }) + + it('returns null for an empty leading heading', () => { + const editor = editorWith('') + editor.commands.setContent({ type: 'doc', content: [{ type: 'heading', attrs: { level: 1 } }] }) + expect(firstHeadingTitle(editor.state.doc)).toBeNull() + editor.destroy() + }) + + it('returns null for a whitespace-only leading heading (trim boundary)', () => { + const editor = editorWith('') + editor.commands.setContent({ + type: 'doc', + content: [{ type: 'heading', attrs: { level: 1 }, content: [{ type: 'text', text: ' ' }] }], + }) + expect(firstHeadingTitle(editor.state.doc)).toBeNull() + editor.destroy() + }) +}) + +describe('title-heading seed (Trigger B action — always prepend, never replace)', () => { + beforeEach(() => { + vi.stubGlobal( + 'ResizeObserver', + class { + observe() {} + unobserve() {} + disconnect() {} + } + ) + Element.prototype.scrollIntoView = vi.fn() + document.elementFromPoint = vi.fn(() => null) + }) + + it('seeds an empty doc with a leading H1 (+ a body line)', () => { + const editor = editorWith('') + editor.commands.insertContentAt(0, titleHeadingNode('Q3 Planning')) + + expect(editor.state.doc.firstChild?.type.name).toBe('heading') + expect(firstHeadingTitle(editor.state.doc)).toBe('Q3 Planning') + expect(editor.getMarkdown().trim()).toBe('# Q3 Planning') + editor.destroy() + }) + + it('prepends an H1 to existing body without a heading, keeping the body', () => { + const editor = editorWith('some body text') + editor.commands.insertContentAt(0, titleHeadingNode('My Notes')) + + expect(editor.state.doc.firstChild?.type.name).toBe('heading') + expect(firstHeadingTitle(editor.state.doc)).toBe('My Notes') + expect(editor.getMarkdown()).toContain('some body text') + editor.destroy() + }) + + it('preserves structure-only scaffold (empty bullet list) instead of clobbering it', () => { + // Regression: an empty bullet list carries no text, so a replace-based seed would drop it. The + // prepend keeps it below the seeded H1. + const editor = editorWith('') + editor.commands.setContent({ + type: 'doc', + content: [ + { type: 'bulletList', content: [{ type: 'listItem', content: [{ type: 'paragraph' }] }] }, + ], + }) + editor.commands.insertContentAt(0, titleHeadingNode('Kept')) + + const shape: string[] = [] + editor.state.doc.forEach((n) => shape.push(n.type.name)) + expect(shape).toEqual(['heading', 'bulletList', 'paragraph']) + expect(firstHeadingTitle(editor.state.doc)).toBe('Kept') + editor.destroy() + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/title-heading.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/title-heading.ts new file mode 100644 index 00000000000..80f35f1b12e --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/title-heading.ts @@ -0,0 +1,15 @@ +import type { JSONContent } from '@tiptap/core' +import type { Node as ProseMirrorNode } from '@tiptap/pm/model' + +/** The text of the document's leading heading (any level), or null when the first block isn't a heading. */ +export function firstHeadingTitle(doc: ProseMirrorNode): string | null { + const first = doc.firstChild + if (!first || first.type.name !== 'heading') return null + const text = first.textContent.trim() + return text.length > 0 ? text : null +} + +/** A level-1 heading node carrying `title`, used to seed a document's title from the file name. */ +export function titleHeadingNode(title: string): JSONContent { + return { type: 'heading', attrs: { level: 1 }, content: [{ type: 'text', text: title }] } +} diff --git a/apps/sim/app/workspace/[workspaceId]/files/files.tsx b/apps/sim/app/workspace/[workspaceId]/files/files.tsx index 8094080b93d..807c8cc3ad9 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/files.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/files.tsx @@ -85,6 +85,12 @@ import { filesSortParams, filesUrlKeys, } from '@/app/workspace/[workspaceId]/files/search-params' +import { + DEFAULT_UNTITLED_NAME, + deriveMarkdownFileName, + isUntitledName, + uniqueMarkdownName, +} from '@/app/workspace/[workspaceId]/files/untitled-title' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' import { useWorkspaceMembersQuery, type WorkspaceMember } from '@/hooks/queries/workspace' @@ -356,6 +362,34 @@ export function Files() { const selectedFileRef = useRef(selectedFile) selectedFileRef.current = selectedFile + /** + * While a file is still untitled, name it after the leading heading the user types in its editor. The + * editor reports the heading text (debounced); here we re-check the file is still untitled, derive a + * unique `.md` name among its folder siblings, and rename. A no-op once the file has a real name. + */ + const handleDeriveTitleFromHeading = useCallback( + (headingText: string) => { + const currentFile = selectedFileRef.current + if (!currentFile || !isUntitledName(currentFile.name)) return + const derived = deriveMarkdownFileName(headingText) + if (!derived) return + const siblingNames = new Set( + filesRef.current + .filter( + (f) => + (f.folderId ?? null) === (currentFile.folderId ?? null) && f.id !== currentFile.id + ) + .map((f) => f.name) + ) + const name = uniqueMarkdownName(derived, siblingNames) + if (name === currentFile.name) return + renameFile + .mutateAsync({ workspaceId, fileId: currentFile.id, name }) + .catch((err) => logger.error('Failed to auto-name file from heading:', err)) + }, + [workspaceId] + ) + const shareFile = shareFileId ? (files.find((f) => f.id === shareFileId) ?? null) : null const shareModal = shareFile ? ( (f.folderId ?? null) === currentFolderId).map((f) => f.name) ) - let name = 'untitled.md' - let counter = 1 - while (existingNames.has(name)) { - name = `untitled (${counter}).md` - counter++ - } + const name = uniqueMarkdownName(DEFAULT_UNTITLED_NAME, existingNames) const mimeType = getMimeTypeFromExtension('md') const blob = new Blob([''], { type: mimeType }) @@ -1931,6 +1960,7 @@ export function Files() { saveRef={saveRef} discardRef={discardRef} collaborative + onDeriveTitleFromHeading={handleDeriveTitleFromHeading} /> { + // Guards against DEFAULT_UNTITLED_NAME / uniqueMarkdownName drifting from the isUntitledName regex: + // the default name and its deduped siblings must always read back as "untitled". + it('recognizes the default name and its deduped siblings as untitled', () => { + expect(isUntitledName(DEFAULT_UNTITLED_NAME)).toBe(true) + const second = uniqueMarkdownName(DEFAULT_UNTITLED_NAME, new Set([DEFAULT_UNTITLED_NAME])) + expect(second).toBe('untitled (1).md') + expect(isUntitledName(second)).toBe(true) + }) +}) + +describe('isUntitledName', () => { + it.each([ + ['untitled.md', true], + ['untitled (1).md', true], + ['untitled (23).md', true], + ['Untitled.md', false], + ['untitled.txt', false], + ['untitled', false], + ['my notes.md', false], + ['untitled draft.md', false], + ['untitled ().md', false], + ])('%s → %s', (name, expected) => { + expect(isUntitledName(name)).toBe(expected) + }) +}) + +describe('headingTextFromName', () => { + it('strips a trailing .md extension', () => { + expect(headingTextFromName('Q3 Planning.md')).toBe('Q3 Planning') + expect(headingTextFromName('notes.MD')).toBe('notes') + }) + it('leaves a name without extension untouched', () => { + expect(headingTextFromName('Q3 Planning')).toBe('Q3 Planning') + }) +}) + +describe('deriveMarkdownFileName', () => { + it('turns heading text into a .md file name', () => { + expect(deriveMarkdownFileName('Q3 Planning')).toBe('Q3 Planning.md') + }) + it('strips filesystem-illegal characters and collapses whitespace', () => { + expect(deriveMarkdownFileName('Roadmap: Q3 / Q4 *draft*')).toBe('Roadmap Q3 Q4 draft.md') + }) + it('keeps hyphens and dots inside the title', () => { + expect(deriveMarkdownFileName('v1.2 - release-notes')).toBe('v1.2 - release-notes.md') + }) + it('returns null when nothing usable remains', () => { + expect(deriveMarkdownFileName(' ')).toBeNull() + expect(deriveMarkdownFileName('///')).toBeNull() + }) + + it('does not double the extension when the heading already ends in .md', () => { + expect(deriveMarkdownFileName('README.md')).toBe('README.md') + expect(deriveMarkdownFileName('notes.MD')).toBe('notes.MD') + }) + it('hard-caps the length (no ellipsis) before the extension', () => { + const result = deriveMarkdownFileName('a'.repeat(200)) + expect(result).toBe(`${'a'.repeat(100)}.md`) + }) + + it('re-trims when the hard cap lands on a space (no "foo .md")', () => { + // 99 non-space chars + space at index 99 → truncate(100) leaves a trailing space to re-trim away. + const result = deriveMarkdownFileName(`${'a'.repeat(99)} bcd`) + expect(result).toBe(`${'a'.repeat(99)}.md`) + }) +}) + +describe('uniqueMarkdownName', () => { + it('returns the name unchanged when free', () => { + expect(uniqueMarkdownName('notes.md', new Set())).toBe('notes.md') + }) + it('appends an incrementing suffix before the extension when taken', () => { + expect(uniqueMarkdownName('notes.md', new Set(['notes.md']))).toBe('notes (1).md') + expect(uniqueMarkdownName('notes.md', new Set(['notes.md', 'notes (1).md']))).toBe( + 'notes (2).md' + ) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/untitled-title.ts b/apps/sim/app/workspace/[workspaceId]/files/untitled-title.ts new file mode 100644 index 00000000000..9ad05aa8f44 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/untitled-title.ts @@ -0,0 +1,63 @@ +import { truncate } from '@sim/utils/string' + +/** + * The name a freshly-created markdown file is given in `handleCreateFile`: `untitled.md`, or + * `untitled (n).md` when that is taken. A file keeps this "unnamed" status until it is renamed — + * while unnamed, the file name and the document's leading heading are coupled (typing a heading + * names the file; naming the file seeds a heading). See {@link isUntitledName}. + */ +export const DEFAULT_UNTITLED_NAME = 'untitled.md' + +const UNTITLED_NAME_RE = /^untitled(?: \(\d+\))?\.md$/ + +/** Longest title kept when deriving a file name from a heading, before the `.md` extension. */ +const MAX_DERIVED_TITLE_LENGTH = 100 + +/** + * Filename characters disallowed across the common platforms (`\ / : * ? " < > |`) plus C0 control + * characters, replaced with a space when deriving a file name from heading text. + */ +const ILLEGAL_FILENAME_CHARS = /[\\/:*?"<>|\x00-\x1f]/g + +/** True when `name` is still the auto-assigned untitled markdown name (`untitled.md`, `untitled (2).md`). */ +export function isUntitledName(name: string): boolean { + return UNTITLED_NAME_RE.test(name) +} + +/** The title text for a file: its name without the trailing `.md` extension. */ +export function headingTextFromName(name: string): string { + return name.replace(/\.md$/i, '') +} + +/** + * Derives a markdown file name from heading text — illegal filename characters dropped, whitespace + * collapsed, trimmed, hard-capped at {@link MAX_DERIVED_TITLE_LENGTH}, and suffixed with `.md`. + * Returns null when nothing usable remains (e.g. a heading of only slashes), so the caller keeps the + * current name. + */ +export function deriveMarkdownFileName(headingText: string): string | null { + const base = headingText.replace(ILLEGAL_FILENAME_CHARS, ' ').replace(/\s+/g, ' ').trim() + if (!base) return null + // Re-trim after the hard cap: truncation can land mid-word and leave a trailing space (`"foo .md"`). + const capped = truncate(base, MAX_DERIVED_TITLE_LENGTH, '').trim() + if (!capped) return null + // A heading that already ends in `.md` (e.g. `# README.md`) must not become `README.md.md` — + // that would also break symmetry with headingTextFromName, which strips only one extension. + return /\.md$/i.test(capped) ? capped : `${capped}.md` +} + +/** + * Makes `name` unique among `existingNames` by appending ` (n)` before the `.md` extension — the same + * scheme `handleCreateFile` uses for the default untitled name. + */ +export function uniqueMarkdownName(name: string, existingNames: ReadonlySet): string { + if (!existingNames.has(name)) return name + const withoutExt = name.replace(/\.md$/i, '') + let counter = 1 + let candidate = `${withoutExt} (${counter}).md` + while (existingNames.has(candidate)) { + counter++ + candidate = `${withoutExt} (${counter}).md` + } + return candidate +} From 2d39f67e0b2bb9d6126a87595e0ea2a1eb232f05 Mon Sep 17 00:00:00 2001 From: Marcus Chandra Date: Sun, 26 Jul 2026 21:49:21 -0700 Subject: [PATCH 03/11] fix(files): count inline atoms in list-item emptiness, keep multi-block items on Backspace Addresses review findings on the list Backspace logic: - Emptiness now uses the caret block's content.size (counts inline images/mentions), not textContent, so a bullet holding only a non-text atom is no longer treated as empty and deleted. - An empty first block whose item has sibling blocks removes only that block instead of lifting the whole item out of the list. --- .../rich-markdown-editor/keymap.test.ts | 66 +++++++++++++++++++ .../rich-markdown-editor/keymap.ts | 36 ++++++---- 2 files changed, 89 insertions(+), 13 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.test.ts index 7e8c25444d6..f5fd2639af0 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.test.ts @@ -404,6 +404,72 @@ describe('list Backspace (clear / outdent)', () => { expect(editor.getMarkdown().trim()).toBe('- [ ] one\n\ntwo') editor.destroy() }) + + it('does not delete a non-trailing item whose block holds only a non-text atom', () => { + // Emptiness is the caret block's content.size, not its text: a bullet holding only an inline atom + // (image/mention — here a hardBreak stand-in) is NOT block-empty, so Backspace clears it to a + // paragraph (content preserved) instead of removeEmptyWrappedBlock deleting the whole row. + const editor = editorWith('') + editor.commands.setContent({ + type: 'doc', + content: [ + { + type: 'bulletList', + content: [ + { + type: 'listItem', + content: [{ type: 'paragraph', content: [{ type: 'hardBreak' }] }], + }, + { + type: 'listItem', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'two' }] }], + }, + ], + }, + ], + }) + const atomPos = firstPosOf(editor, 'hardBreak') + editor.commands.setTextSelection(atomPos) + pressBackspace(editor) + + // The atom survives (not deleted) and 'two' is untouched. + expect(firstPosOf(editor, 'hardBreak')).toBeGreaterThanOrEqual(0) + expect(editor.state.doc.textContent).toContain('two') + editor.destroy() + }) + + it('removes only the empty first block of a multi-block item, not the whole item', () => { + // An empty first block whose item has sibling blocks must not lift the whole item out of the list; + // only that empty block is removed, the rest of the item (and the list) stays intact. + const editor = editorWith('') + editor.commands.setContent({ + type: 'doc', + content: [ + { + type: 'bulletList', + content: [ + { + type: 'listItem', + content: [ + { type: 'paragraph' }, + { type: 'paragraph', content: [{ type: 'text', text: 'more' }] }, + ], + }, + ], + }, + ], + }) + // Caret at the start of the empty first paragraph (position 3: doc>bulletList>listItem>paragraph). + editor.commands.setTextSelection(3) + pressBackspace(editor) + + // Still a list (item was NOT lifted out to a top-level paragraph), and 'more' survives. + expect(blockShape(editor)[0]).toBe('bulletList') + expect(editor.state.doc.textContent).toBe('more') + const list = editor.getJSON().content?.find((n) => n.type === 'bulletList') + expect(list?.content).toHaveLength(1) + editor.destroy() + }) }) describe('empty nested bullet does not corrupt its parent (Enter → Tab)', () => { diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.ts index ed3829ede28..00b8b622e73 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.ts @@ -26,8 +26,13 @@ interface ListItemContext { itemType: string /** The item's list is itself inside another list item, i.e. the item is indented. */ isNested: boolean - /** The item carries no text. */ - isEmpty: boolean + /** + * The caret's own block (the row the boundary key acts on) has no content. Uses `content.size`, so an + * inline image or mention atom counts as content — a bullet holding only an image is NOT block-empty. + */ + blockEmpty: boolean + /** The item has more than one child block (continuation paragraph, nested list, block image, …). */ + hasSiblingBlocks: boolean /** The item is the last child of its immediate list. */ isTrailing: boolean /** The caret sits in the item's first child block (the row a boundary key should act on). */ @@ -50,7 +55,8 @@ function getListItemContext($from: ResolvedPos): ListItemContext | null { return { itemType: item.type.name, isNested, - isEmpty: item.textContent.length === 0, + blockEmpty: $from.parent.content.size === 0, + hasSiblingBlocks: item.childCount > 1, isTrailing: $from.index(listDepth) === list.childCount - 1, isFirstBlock: $from.index(depth) === 0, } @@ -228,20 +234,24 @@ export const RichMarkdownKeymap = Extension.create({ } const listCtx = getListItemContext($from) if (listCtx?.isFirstBlock) { - const { itemType, isNested, isEmpty, isTrailing } = listCtx + const { itemType, isNested, blockEmpty, hasSiblingBlocks, isTrailing } = listCtx // Backspace at the start of a bullet outdents or clears it in place rather than // deleting the row and jumping the caret to the previous block. // - Nested item → outdent one level (empty or not). - // - Top-level item with text → lift out of the list into a paragraph, keeping the text. - // - Top-level empty item that is trailing (or the sole item) → lift into an empty paragraph in - // place, so a fresh bullet made with Enter can be cleared back to normal text on the same line. - // A top-level *empty, non-trailing* item is the one case lift can't take: it strands an empty - // paragraph between the two list halves, which re-parses to a different markdown document (an - // empty line between list items is a loose list, not a break). That case removes the row via + // - Top-level item whose first line has content (text OR an inline image/mention) → lift out of + // the list into a paragraph, keeping that content. + // - Top-level item whose empty first block has *sibling* blocks (a continuation paragraph, a + // block image, a nested list) → remove only that empty first block via {@link + // removeEmptyWrappedBlock}, leaving the rest of the item intact (never lift the whole item). + // - Top-level empty single-block item that is trailing (or the sole item) → lift into an empty + // paragraph in place, so a fresh bullet made with Enter can be cleared to normal text in place. + // A top-level *empty, non-trailing* single-block item is the one case lift can't take: it strands + // an empty paragraph between the two list halves, which re-parses to a different markdown document + // (an empty line between list items is a loose list, not a break). That case removes the row via // {@link removeEmptyWrappedBlock} instead, which keeps the list whole and round-trips. - if (isNested || !isEmpty || isTrailing) { - return editor.commands.liftListItem(itemType) - } + if (isNested || !blockEmpty) return editor.commands.liftListItem(itemType) + if (hasSiblingBlocks) return removeEmptyWrappedBlock(editor, $from) + if (isTrailing) return editor.commands.liftListItem(itemType) return removeEmptyWrappedBlock(editor, $from) } if ($from.parent.content.size === 0 && isInsideWrapper($from)) { From 9cf904582545df94414156f88676793756818123 Mon Sep 17 00:00:00 2001 From: Marcus Chandra Date: Sun, 26 Jul 2026 21:58:51 -0700 Subject: [PATCH 04/11] fix(files): preserve the untitled to named heading seed across a rename during editor load The parent captures the file name at mount (before content/session finish loading) and passes it as the transition baseline, so a rename that lands in the loading window is still seen as an untitled to named transition and the leading heading seed is not skipped. --- .../rich-markdown-editor.tsx | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx index 10c859b2ab9..d235beb4108 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx @@ -122,6 +122,14 @@ export const RichMarkdownEditor = memo(function RichMarkdownEditor({ const userId = session?.user?.id ?? '' const userName = session?.user?.name?.trim() || 'Collaborator' + /** + * The file name captured when this editor first mounts — before content/session finish loading, so it + * is still `untitled` if the file was untitled when opened. The child uses it as the untitled→named + * transition baseline; capturing it here (not at the child's later mount) means a rename that lands + * during the loading window is still seen as a transition and the heading seed is not lost. + */ + const initialFileNameRef = useRef(file.name) + /** * Autosave gate for the collaborative path: the child reports `false` while its * shared document is still syncing/seeding and `true` once it is safe to persist @@ -184,6 +192,7 @@ export const RichMarkdownEditor = memo(function RichMarkdownEditor({ onSaveShortcut={saveImmediately} onCollabReadyChange={setCollabReady} onDeriveTitleFromHeading={onDeriveTitleFromHeading} + initialFileName={initialFileNameRef.current} /> ) }) @@ -212,6 +221,12 @@ interface LoadedRichMarkdownEditorProps { onCollabReadyChange: (ready: boolean) => void /** See {@link RichMarkdownEditorProps.onDeriveTitleFromHeading}. */ onDeriveTitleFromHeading?: (headingText: string) => void + /** + * The file name at the moment this editor was opened (captured by the parent before content/session + * load). Used as the untitled→named transition baseline so a rename during the loading window is not + * missed. See {@link RichMarkdownEditorProps.onDeriveTitleFromHeading}. + */ + initialFileName: string } interface SettledContent { @@ -242,6 +257,7 @@ export function LoadedRichMarkdownEditor({ onSaveShortcut, onCollabReadyChange, onDeriveTitleFromHeading, + initialFileName, }: LoadedRichMarkdownEditorProps) { /** Whether this editor mounted mid-stream — if so it starts empty and syncs streamed chunks until settle. */ const streamingAtMountRef = useRef(isStreaming) @@ -318,7 +334,7 @@ export function LoadedRichMarkdownEditor({ onDeriveTitleFromHeadingRef.current = onDeriveTitleFromHeading const fileNameRef = useRef(file.name) fileNameRef.current = file.name - const prevFileNameRef = useRef(file.name) + const prevFileNameRef = useRef(initialFileName) const deriveTitleTimerRef = useRef | null>(null) /** * Read in the RAF tick so an already-scheduled tick still sees the latest edit kind (it can change From f9b2f9bee17f38bfdfb0f3d3d63f5e9d58e3b655 Mon Sep 17 00:00:00 2001 From: Marcus Chandra Date: Sun, 26 Jul 2026 22:14:53 -0700 Subject: [PATCH 05/11] fix(files): drop the name-to-heading seed, keep title sync one-way MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes the effect that inserted a leading H1 when an untitled file was renamed. On the collaborative Files page every open client observed the untitled-to-named transition and inserted into the shared doc, producing duplicate headings; it could also re-insert a heading a user had just deleted while a rename was in flight. Seeding document content from an async rename transition is the wrong model on a shared editor. The primary direction — typing a leading heading renames a still-untitled file — is unaffected (it never mutates the doc). --- .../rich-markdown-editor.tsx | 53 ++---------------- .../title-heading.test.ts | 56 +------------------ .../rich-markdown-editor/title-heading.ts | 6 -- .../files/untitled-title.test.ts | 11 ---- .../[workspaceId]/files/untitled-title.ts | 8 +-- 5 files changed, 7 insertions(+), 127 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx index d235beb4108..1f3fc00846f 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx @@ -13,10 +13,7 @@ import { useRouter } from 'next/navigation' import { useSession } from '@/lib/auth/auth-client' import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' import { extractEmbeddedFileRef } from '@/lib/uploads/utils/embedded-image-ref' -import { - headingTextFromName, - isUntitledName, -} from '@/app/workspace/[workspaceId]/files/untitled-title' +import { isUntitledName } from '@/app/workspace/[workspaceId]/files/untitled-title' import { useUploadWorkspaceFile } from '@/hooks/queries/workspace-files' import type { SaveStatus } from '@/hooks/use-autosave' import { useFileContentSource } from '@/hooks/use-file-content-source' @@ -45,7 +42,7 @@ import { LinkHoverCard } from './menus/link-hover-card' import { TableBubbleMenu } from './menus/table-menu' import { normalizeMarkdownContent } from './normalize-content' import { isRoundTripSafe } from './round-trip-safety' -import { firstHeadingTitle, titleHeadingNode } from './title-heading' +import { firstHeadingTitle } from './title-heading' import '@sim/emcn/components/code/code.css' import './rich-markdown-editor.css' @@ -122,14 +119,6 @@ export const RichMarkdownEditor = memo(function RichMarkdownEditor({ const userId = session?.user?.id ?? '' const userName = session?.user?.name?.trim() || 'Collaborator' - /** - * The file name captured when this editor first mounts — before content/session finish loading, so it - * is still `untitled` if the file was untitled when opened. The child uses it as the untitled→named - * transition baseline; capturing it here (not at the child's later mount) means a rename that lands - * during the loading window is still seen as a transition and the heading seed is not lost. - */ - const initialFileNameRef = useRef(file.name) - /** * Autosave gate for the collaborative path: the child reports `false` while its * shared document is still syncing/seeding and `true` once it is safe to persist @@ -192,7 +181,6 @@ export const RichMarkdownEditor = memo(function RichMarkdownEditor({ onSaveShortcut={saveImmediately} onCollabReadyChange={setCollabReady} onDeriveTitleFromHeading={onDeriveTitleFromHeading} - initialFileName={initialFileNameRef.current} /> ) }) @@ -221,12 +209,6 @@ interface LoadedRichMarkdownEditorProps { onCollabReadyChange: (ready: boolean) => void /** See {@link RichMarkdownEditorProps.onDeriveTitleFromHeading}. */ onDeriveTitleFromHeading?: (headingText: string) => void - /** - * The file name at the moment this editor was opened (captured by the parent before content/session - * load). Used as the untitled→named transition baseline so a rename during the loading window is not - * missed. See {@link RichMarkdownEditorProps.onDeriveTitleFromHeading}. - */ - initialFileName: string } interface SettledContent { @@ -257,7 +239,6 @@ export function LoadedRichMarkdownEditor({ onSaveShortcut, onCollabReadyChange, onDeriveTitleFromHeading, - initialFileName, }: LoadedRichMarkdownEditorProps) { /** Whether this editor mounted mid-stream — if so it starts empty and syncs streamed chunks until settle. */ const streamingAtMountRef = useRef(isStreaming) @@ -325,16 +306,14 @@ export function LoadedRichMarkdownEditor({ onSaveShortcutRef.current = onSaveShortcut /** - * Untitled ⇄ heading coupling, active only while the file is unnamed. `onDeriveTitleFromHeading` is - * called (debounced) so the caller can name the file after its leading heading; `fileNameRef` lets the - * onUpdate handler read the current name without re-subscribing, and `prevFileNameRef` lets the - * name→heading seed detect the untitled→named transition. See {@link isUntitledName}. + * While the file is still unnamed, name it after its leading heading: `onDeriveTitleFromHeading` is + * called (debounced) so the caller can rename the file, and `fileNameRef` lets the onUpdate handler + * read the current name without re-subscribing. See {@link isUntitledName}. */ const onDeriveTitleFromHeadingRef = useRef(onDeriveTitleFromHeading) onDeriveTitleFromHeadingRef.current = onDeriveTitleFromHeading const fileNameRef = useRef(file.name) fileNameRef.current = file.name - const prevFileNameRef = useRef(initialFileName) const deriveTitleTimerRef = useRef | null>(null) /** * Read in the RAF tick so an already-scheduled tick still sees the latest edit kind (it can change @@ -618,28 +597,6 @@ export function LoadedRichMarkdownEditor({ [] ) - /** - * Name→heading seed: when a still-untitled file is given a real name, seed the document's leading - * heading from that name — but only into a document with no heading of its own yet, so existing - * content is never clobbered. One-shot by construction: it fires only on the untitled→named transition. - */ - useEffect(() => { - // Wait for a render with a live, editable editor before consuming the transition — advancing - // prevFileNameRef while editor is still null would record the untitled→named change as "seen" and - // silently skip the seed on the later render when the editor is ready. - if (!editor || !isEditable) return - const prevName = prevFileNameRef.current - prevFileNameRef.current = file.name - if (!isUntitledName(prevName) || isUntitledName(file.name)) return - if (firstHeadingTitle(editor.state.doc) !== null) return - const title = headingTextFromName(file.name) - if (!title) return - // Always prepend, never replace: an empty doc becomes `# Title` + a body line, while any existing - // content — including structure-only scaffold (an empty list/blockquote/code block that carries no - // text) — is preserved above rather than clobbered. - editor.commands.insertContentAt(0, titleHeadingNode(title)) - }, [file.name, editor, isEditable]) - /** * The loaded markdown to seed the shared doc from, held by pointer so the parse * runs once at seed time rather than every render. diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/title-heading.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/title-heading.test.ts index 1379ca3de59..f6c46bd027f 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/title-heading.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/title-heading.test.ts @@ -4,7 +4,7 @@ import { Editor } from '@tiptap/core' import { beforeEach, describe, expect, it, vi } from 'vitest' import { createMarkdownEditorExtensions } from './editor-extensions' -import { firstHeadingTitle, titleHeadingNode } from './title-heading' +import { firstHeadingTitle } from './title-heading' function editorWith(markdown: string): Editor { const editor = new Editor({ extensions: createMarkdownEditorExtensions({ placeholder: '' }) }) @@ -61,57 +61,3 @@ describe('firstHeadingTitle', () => { editor.destroy() }) }) - -describe('title-heading seed (Trigger B action — always prepend, never replace)', () => { - beforeEach(() => { - vi.stubGlobal( - 'ResizeObserver', - class { - observe() {} - unobserve() {} - disconnect() {} - } - ) - Element.prototype.scrollIntoView = vi.fn() - document.elementFromPoint = vi.fn(() => null) - }) - - it('seeds an empty doc with a leading H1 (+ a body line)', () => { - const editor = editorWith('') - editor.commands.insertContentAt(0, titleHeadingNode('Q3 Planning')) - - expect(editor.state.doc.firstChild?.type.name).toBe('heading') - expect(firstHeadingTitle(editor.state.doc)).toBe('Q3 Planning') - expect(editor.getMarkdown().trim()).toBe('# Q3 Planning') - editor.destroy() - }) - - it('prepends an H1 to existing body without a heading, keeping the body', () => { - const editor = editorWith('some body text') - editor.commands.insertContentAt(0, titleHeadingNode('My Notes')) - - expect(editor.state.doc.firstChild?.type.name).toBe('heading') - expect(firstHeadingTitle(editor.state.doc)).toBe('My Notes') - expect(editor.getMarkdown()).toContain('some body text') - editor.destroy() - }) - - it('preserves structure-only scaffold (empty bullet list) instead of clobbering it', () => { - // Regression: an empty bullet list carries no text, so a replace-based seed would drop it. The - // prepend keeps it below the seeded H1. - const editor = editorWith('') - editor.commands.setContent({ - type: 'doc', - content: [ - { type: 'bulletList', content: [{ type: 'listItem', content: [{ type: 'paragraph' }] }] }, - ], - }) - editor.commands.insertContentAt(0, titleHeadingNode('Kept')) - - const shape: string[] = [] - editor.state.doc.forEach((n) => shape.push(n.type.name)) - expect(shape).toEqual(['heading', 'bulletList', 'paragraph']) - expect(firstHeadingTitle(editor.state.doc)).toBe('Kept') - editor.destroy() - }) -}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/title-heading.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/title-heading.ts index 80f35f1b12e..3877519ca97 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/title-heading.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/title-heading.ts @@ -1,4 +1,3 @@ -import type { JSONContent } from '@tiptap/core' import type { Node as ProseMirrorNode } from '@tiptap/pm/model' /** The text of the document's leading heading (any level), or null when the first block isn't a heading. */ @@ -8,8 +7,3 @@ export function firstHeadingTitle(doc: ProseMirrorNode): string | null { const text = first.textContent.trim() return text.length > 0 ? text : null } - -/** A level-1 heading node carrying `title`, used to seed a document's title from the file name. */ -export function titleHeadingNode(title: string): JSONContent { - return { type: 'heading', attrs: { level: 1 }, content: [{ type: 'text', text: title }] } -} diff --git a/apps/sim/app/workspace/[workspaceId]/files/untitled-title.test.ts b/apps/sim/app/workspace/[workspaceId]/files/untitled-title.test.ts index 4a915018009..e9ee0ba2e43 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/untitled-title.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/untitled-title.test.ts @@ -2,7 +2,6 @@ import { describe, expect, it } from 'vitest' import { DEFAULT_UNTITLED_NAME, deriveMarkdownFileName, - headingTextFromName, isUntitledName, uniqueMarkdownName, } from './untitled-title' @@ -34,16 +33,6 @@ describe('isUntitledName', () => { }) }) -describe('headingTextFromName', () => { - it('strips a trailing .md extension', () => { - expect(headingTextFromName('Q3 Planning.md')).toBe('Q3 Planning') - expect(headingTextFromName('notes.MD')).toBe('notes') - }) - it('leaves a name without extension untouched', () => { - expect(headingTextFromName('Q3 Planning')).toBe('Q3 Planning') - }) -}) - describe('deriveMarkdownFileName', () => { it('turns heading text into a .md file name', () => { expect(deriveMarkdownFileName('Q3 Planning')).toBe('Q3 Planning.md') diff --git a/apps/sim/app/workspace/[workspaceId]/files/untitled-title.ts b/apps/sim/app/workspace/[workspaceId]/files/untitled-title.ts index 9ad05aa8f44..33a198e9d69 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/untitled-title.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/untitled-title.ts @@ -24,11 +24,6 @@ export function isUntitledName(name: string): boolean { return UNTITLED_NAME_RE.test(name) } -/** The title text for a file: its name without the trailing `.md` extension. */ -export function headingTextFromName(name: string): string { - return name.replace(/\.md$/i, '') -} - /** * Derives a markdown file name from heading text — illegal filename characters dropped, whitespace * collapsed, trimmed, hard-capped at {@link MAX_DERIVED_TITLE_LENGTH}, and suffixed with `.md`. @@ -41,8 +36,7 @@ export function deriveMarkdownFileName(headingText: string): string | null { // Re-trim after the hard cap: truncation can land mid-word and leave a trailing space (`"foo .md"`). const capped = truncate(base, MAX_DERIVED_TITLE_LENGTH, '').trim() if (!capped) return null - // A heading that already ends in `.md` (e.g. `# README.md`) must not become `README.md.md` — - // that would also break symmetry with headingTextFromName, which strips only one extension. + // A heading that already ends in `.md` (e.g. `# README.md`) must not become `README.md.md`. return /\.md$/i.test(capped) ? capped : `${capped}.md` } From ceda453ba556baa442efacfd58d472d871409892 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 27 Jul 2026 12:17:13 -0700 Subject: [PATCH 06/11] fix(files): keep empty lines between paragraphs on reload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chunked markdown parser (parseMarkdownToDoc) parses each block stripped of the blank lines between them, so it dropped the empty paragraphs @tiptap/markdown builds from runs of blank lines — a saved visual blank line silently vanished on the next load (the settle/reopen re-seed goes through the chunker). The whole-document parser preserves them, but whether a gap yields an empty paragraph is a global, block-type- dependent decision (kept between two paragraphs, dropped after a heading), so it can't be reconstructed block-locally. Route documents with empty-paragraph blank-line spacing to the whole-document parser for exact fidelity — the same tradeoff NON_CHUNKABLE makes; ordinary single-blank-line separation still takes the fast chunked path. Adds a suite asserting chunked output matches the whole-document parser for leading/trailing/between gaps and around lists/headings. --- .../markdown-parse.test.ts | 40 +++++++++++++++++++ .../rich-markdown-editor/markdown-parse.ts | 17 +++++++- 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-parse.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-parse.test.ts index 48cb5718ab8..1fbdddd6efe 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-parse.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-parse.test.ts @@ -59,6 +59,12 @@ const CASES: Array<[string, string]> = [ '1. First\n - sub bullet\n - another\n 1. deep ordered\n 2. item\n2. Second', ], ['heading-separated sections', '# A\n\nalpha\n\n## B\n\nbeta\n\n## C\n\ngamma'], + // Blank-line spacing: `@tiptap/markdown` reconstructs empty paragraphs from runs of blank lines, so + // the chunker must reinsert them or a saved blank line vanishes on reload. See the dedicated + // "empty paragraphs" suite below for the exact whole-document-parser parity. + ['one empty paragraph between paragraphs', 'first\n\n\n\nsecond'], + ['two empty paragraphs between paragraphs', 'first\n\n\n\n\n\nsecond'], + ['empty paragraphs between headings and text', '# A\n\n\n\nalpha\n\n\n\n## B'], ] describe('parseMarkdownToDoc (chunked)', () => { @@ -87,6 +93,40 @@ describe('parseMarkdownToDoc (chunked)', () => { expect(splitMarkdownBlocks('\n\n \n')).toEqual([]) }) + // The chunker used to drop empty paragraphs (visual blank lines between blocks) that the whole-document + // parser preserves, so a saved blank line silently vanished on the next load. These assert the chunked + // parse reconstructs the SAME empty-paragraph structure the whole-document parser does — at document + // edges and between blocks, for one or many blank lines, and around lists. + describe('empty paragraphs (blank-line spacing) match the whole-document parser', () => { + /** Block-type shape of a doc, `∅` for an empty paragraph, normalized through the editor. */ + function shapeOf(md: string, parse: 'chunked' | 'whole'): string { + editor = new Editor({ extensions: createMarkdownContentExtensions() }) + if (parse === 'whole') editor.commands.setContent(md, { contentType: 'markdown' }) + else editor.commands.setContent(parseMarkdownToDoc(md), { contentType: 'json' }) + const shape = (editor.getJSON().content ?? []) + .map((n) => (n.type === 'paragraph' && !n.content?.length ? '∅' : n.type)) + .join(',') + editor.destroy() + editor = null + return shape + } + + it.each([ + ['one empty between paragraphs', 'a\n\n\n\nb'], + ['two empties between paragraphs', 'a\n\n\n\n\n\nb'], + ['three empties between paragraphs', 'a\n\n\n\n\n\n\n\nb'], + ['even blank-line gap (rounds down)', 'a\n\n\n\n\nb'], + ['leading empties', '\n\n\n\na'], + ['trailing empties', 'a\n\n\n\n'], + ['leading + between + trailing', '\n\n\na\n\n\n\nb\n\n\n'], + ['empties between a heading and text', '# H\n\n\n\ntext'], + ['empties after a tight list', '- a\n- b\n\n\n\ntext'], + ['empties before a tight list', 'text\n\n\n\n- a\n- b'], + ])('chunked matches whole-doc: %s', (_label, md) => { + expect(shapeOf(md, 'chunked')).toBe(shapeOf(md, 'whole')) + }) + }) + it('parses reference-style links whole (non-chunkable) without dropping the definition', () => { const body = 'See [the docs][ref] for details.\n\n[ref]: https://example.com/docs' expect(serializeMarkdownBody(body)).toBe(oneShot(body)) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-parse.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-parse.ts index 5417c0ee047..d3b659a3db9 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-parse.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-parse.ts @@ -47,6 +47,14 @@ const FENCE_CLOSE = /^ {0,3}(`{3,}|~{3,})[ \t]*$/ const LIST_MARKER = /^[ ]{0,3}(?:[-*+]|\d+[.)])\s/ const BLOCKQUOTE = /^[ ]{0,3}>/ +/** + * Blank-line spacing that `@tiptap/markdown` reconstructs as empty paragraphs — a run of two or more + * blank lines somewhere, or blank line(s) at the document's leading/trailing edge. `[^\S\n]` matches + * only horizontal whitespace (and `\r`), so a "blank" line may carry spaces/tabs and CRLF is handled. + * The three alternatives are: 2+ interior blank lines, leading blank line(s), trailing blank line(s). + */ +const EMPTY_PARAGRAPH_SPACING = /\n[^\S\n]*\n[^\S\n]*\n|^[^\S\n]*\n[^\S\n]*\n|\n[^\S\n]*\n[^\S\n]*$/ + /** * Split a markdown body into top-level blocks that can each be parsed independently and reassembled * without changing meaning. Blank lines separate candidate groups (fenced code blocks stay atomic), @@ -120,10 +128,17 @@ export function splitMarkdownBlocks(body: string): string[] { * vs ~1270ms at 61KB — and byte-identical, because each block is parsed with the same tokenizers. * Documents whose constructs span blocks ({@link NON_CHUNKABLE}) parse whole, and any failure falls * back to a single whole-document parse, so correctness never depends on the splitter. + * + * Blank-line spacing ({@link EMPTY_PARAGRAPH_SPACING}) also parses whole: the chunker parses each block + * stripped of the blank lines between them, so it drops the empty paragraphs `@tiptap/markdown` builds + * from runs of blank lines — a saved visual blank line would silently vanish on reload. Whether a gap + * yields an empty paragraph is a global, block-type-dependent decision (kept between two paragraphs, + * dropped after a heading), so it can't be reconstructed block-locally; these documents parse whole for + * exact fidelity. Ordinary single-blank-line separation still takes the fast chunked path. */ export function parseMarkdownToDoc(body: string): JSONContent { const manager = markdownManager() - if (NON_CHUNKABLE.test(body)) return manager.parse(body) + if (NON_CHUNKABLE.test(body) || EMPTY_PARAGRAPH_SPACING.test(body)) return manager.parse(body) try { const content: JSONContent[] = [] for (const block of splitMarkdownBlocks(body)) { From b54d0ed21324a3eedc2bd9fffa753d1af52bf653 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 27 Jul 2026 13:14:31 -0700 Subject: [PATCH 07/11] fix(files): only auto-name an untitled file when the user can edit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The debounced untitled→filename hook ran on every onUpdate — including the mount-time seed and for view-only viewers — without checking edit permission, so a read-only user could schedule a rename they have no permission to make (a spurious, server-rejected write). Gate the derive-title on editor.isEditable (canEdit + settled + collab-ready, the same signal the autosave path uses), at both schedule and fire time. --- .../rich-markdown-editor.tsx | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx index 1f3fc00846f..5e01283f0f9 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx @@ -573,16 +573,23 @@ export function LoadedRichMarkdownEditor({ const md = postProcessSerializedMarkdown(editor.getMarkdown()) lastSyncedBodyRef.current = md onChangeRef.current(applyFrontmatter(settledRef.current?.frontmatter ?? '', md)) - // While the file is still untitled, name it after its leading heading once typing settles. Always - // clear any pending timer first, so deleting/rewriting the heading before it fires cancels the - // stale rename; the timer re-derives the title from the live doc rather than a value captured at + // While the file is still untitled, name it after its leading heading once typing settles — but + // only when the user can actually edit. A view-only viewer (or the not-yet-editable seed at mount, + // which also fires onUpdate) must never schedule a rename it has no permission to make; + // `editor.isEditable` is the same gate the autosave path uses (canEdit + settled + collab-ready). + // Always clear any pending timer first, so deleting/rewriting the heading before it fires cancels + // the stale rename; the timer re-derives the title from the live doc rather than a value captured at // schedule time, so it can never name the file after a heading the user has since changed or removed. if (deriveTitleTimerRef.current) clearTimeout(deriveTitleTimerRef.current) - if (!isUntitledName(fileNameRef.current) || firstHeadingTitle(editor.state.doc) === null) + if ( + !editor.isEditable || + !isUntitledName(fileNameRef.current) || + firstHeadingTitle(editor.state.doc) === null + ) return deriveTitleTimerRef.current = setTimeout(() => { const liveEditor = editorInstanceRef.current - if (!liveEditor || !isUntitledName(fileNameRef.current)) return + if (!liveEditor || !liveEditor.isEditable || !isUntitledName(fileNameRef.current)) return const title = firstHeadingTitle(liveEditor.state.doc) if (title) onDeriveTitleFromHeadingRef.current?.(title) }, DERIVE_TITLE_DEBOUNCE_MS) From 494a03e6c99d8494e49200223141f8814ff0dbec Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 27 Jul 2026 13:25:58 -0700 Subject: [PATCH 08/11] fix(files): normalize line endings before the empty-paragraph guard; Enter/Backspace symmetry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - markdown-parse: EMPTY_PARAGRAPH_SPACING/NON_CHUNKABLE tested the raw body, but a classic \r-only file (blank lines are \r) would miss the \n-anchored guard and still be chunked, dropping empties. Normalize line endings once up front so the routing guards, the chunker, and the parser all see the same \n. +CRLF/CR test cases. - keymap: Enter on an empty first block of a multi-block item now removes only that block (removeEmptyWrappedBlock) instead of exiting the list, mirroring the Backspace hasSiblingBlocks case — the trailing check no longer swallows multi-block items. +test. --- .../rich-markdown-editor/keymap.test.ts | 32 +++++++++++++++++++ .../rich-markdown-editor/keymap.ts | 10 ++++-- .../markdown-parse.test.ts | 4 +++ .../rich-markdown-editor/markdown-parse.ts | 11 +++++-- 4 files changed, 51 insertions(+), 6 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.test.ts index f5fd2639af0..9c2ba8f3b70 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.test.ts @@ -547,6 +547,38 @@ describe('empty list-item Enter', () => { expect(list?.content?.every((item) => item.type === 'listItem')).toBe(true) editor.destroy() }) + + it('removes only the empty first block of a multi-block item, matching Backspace (keeps the list)', () => { + // Symmetry with the Backspace multi-block case: an empty first block whose item has sibling blocks + // is removed in place — the continuation and the list stay intact — rather than exiting the list. + const editor = editorWith('') + editor.commands.setContent({ + type: 'doc', + content: [ + { + type: 'bulletList', + content: [ + { + type: 'listItem', + content: [ + { type: 'paragraph' }, + { type: 'paragraph', content: [{ type: 'text', text: 'more' }] }, + ], + }, + ], + }, + ], + }) + // Caret at the start of the empty first paragraph (doc>bulletList>listItem>paragraph). + editor.commands.setTextSelection(3) + pressKey(editor, 'Enter') + + expect(blockShape(editor)[0]).toBe('bulletList') + expect(editor.state.doc.textContent).toBe('more') + const list = editor.getJSON().content?.find((n) => n.type === 'bulletList') + expect(list?.content).toHaveLength(1) + editor.destroy() + }) }) describe('verbatim block boundary (isolating)', () => { diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.ts index 00b8b622e73..524e35eee21 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.ts @@ -280,10 +280,14 @@ export const RichMarkdownKeymap = Extension.create({ if ($from.parent.content.size !== 0) return false const listCtx = getListItemContext($from) if (!listCtx?.isFirstBlock) return false - // Enter on an empty item: a nested item outdents one level, a trailing top-level item - // falls through to the default (exits the list), and a non-trailing top-level item is removed - // rather than splitting the list around a stranded empty paragraph (which does not round-trip). + // Enter on an empty item, mirroring the Backspace cases above: a nested item outdents one level; + // an empty first block that has *sibling* blocks (continuation paragraph, block image, nested + // list) removes only that empty block in place, keeping the rest of the item — never exiting the + // list or splitting it; a trailing single-block item falls through to the default (exits the + // list); and a non-trailing single-block item is removed rather than splitting the list around a + // stranded empty paragraph (which does not round-trip). if (listCtx.isNested) return editor.commands.liftListItem(listCtx.itemType) + if (listCtx.hasSiblingBlocks) return removeEmptyWrappedBlock(editor, $from) if (listCtx.isTrailing) return false return removeEmptyWrappedBlock(editor, $from) }, diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-parse.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-parse.test.ts index 1fbdddd6efe..a07ce3ed1a2 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-parse.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-parse.test.ts @@ -122,6 +122,10 @@ describe('parseMarkdownToDoc (chunked)', () => { ['empties between a heading and text', '# H\n\n\n\ntext'], ['empties after a tight list', '- a\n- b\n\n\n\ntext'], ['empties before a tight list', 'text\n\n\n\n- a\n- b'], + // Line-ending variants: the whole-vs-chunked routing must normalize first, or a `\r`-only body + // skips the empty-paragraph guard and is chunked (dropping the empties this fix restores). + ['CRLF between empties', 'a\r\n\r\n\r\n\r\nb'], + ['CR-only (classic Mac) between empties', 'a\r\r\r\rb'], ])('chunked matches whole-doc: %s', (_label, md) => { expect(shapeOf(md, 'chunked')).toBe(shapeOf(md, 'whole')) }) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-parse.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-parse.ts index d3b659a3db9..25dfb59ab2b 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-parse.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-parse.ts @@ -138,16 +138,21 @@ export function splitMarkdownBlocks(body: string): string[] { */ export function parseMarkdownToDoc(body: string): JSONContent { const manager = markdownManager() - if (NON_CHUNKABLE.test(body) || EMPTY_PARAGRAPH_SPACING.test(body)) return manager.parse(body) + // Normalize line endings up front so the routing guards see the same `\n` the chunker and parser + // do — the guards' `\n`-anchored tests would otherwise miss a classic `\r`-only body (its blank + // lines are `\r`), routing it to the chunker that then drops its empty paragraphs. + const normalized = body.replace(/\r\n?/g, '\n') + if (NON_CHUNKABLE.test(normalized) || EMPTY_PARAGRAPH_SPACING.test(normalized)) + return manager.parse(normalized) try { const content: JSONContent[] = [] - for (const block of splitMarkdownBlocks(body)) { + for (const block of splitMarkdownBlocks(normalized)) { // `MarkdownManager.parse` always returns a doc node with a `content` array; spread its blocks. content.push(...(manager.parse(block).content ?? [])) } return { type: 'doc', content } } catch { - return manager.parse(body) + return manager.parse(normalized) } } From 16da2892e6f56ec770e0b1da8ce06ab0d8bbf70a Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 27 Jul 2026 13:57:09 -0700 Subject: [PATCH 09/11] fix(files): editor audit follow-ups (trailing-blank read-only, collab rename, over-strip) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A 4-agent independent audit (UX vs inkeep + SOTA, cleanliness, adversarial correctness) surfaced these: - HIGH regression: files ending in a blank line opened READ-ONLY. The empty-paragraph routing preserved a TRAILING empty paragraph, but postProcess collapses trailing newlines → serialize/parse non-idempotent → isRoundTripSafe flipped the file read-only. A trailing empty paragraph can't be serialized stably, so parseMarkdownToDoc now strips trailing empty paragraphs and the guard no longer routes on trailing blanks. Interior/leading empties are unaffected. +regression tests. - Medium: the debounced untitled→filename rename fired on remote Yjs edits too, so every peer renamed and could rename from a not-yet-synced heading. Gate on isChangeOrigin (local edits only; false for non-collab surfaces). - Medium: stripEmptyListItemLines over-stripped a nested empty item that follows a same-indent sibling (a real placeholder the parser keeps). Narrowed to the actual Setext hazard — an empty item DIRECTLY under a shallower parent line — matching the function's own docstring intent. Probe-verified. +test. - Low: corrected untitled-title.ts docstring that described a reverse name→heading coupling removed during review. --- .../markdown-fidelity.test.ts | 10 ++++ .../rich-markdown-editor/markdown-fidelity.ts | 30 ++++++---- .../markdown-parse.test.ts | 33 +++++++++- .../rich-markdown-editor/markdown-parse.ts | 60 ++++++++++++++----- .../rich-markdown-editor.tsx | 19 +++--- .../[workspaceId]/files/untitled-title.ts | 4 +- 6 files changed, 118 insertions(+), 38 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-fidelity.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-fidelity.test.ts index 2ce63fb7fef..721b91dc305 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-fidelity.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-fidelity.test.ts @@ -31,6 +31,16 @@ describe('postProcessSerializedMarkdown — empty list-item stripping', () => { ) }) + it('keeps a nested empty item that follows a same-indent sibling (real placeholder, no Setext hazard)', () => { + // ` - ` after ` - two` (same indent) is a real empty item the parser keeps — it does NOT underline + // a shallower parent's text, so it must not be stripped. (Only ` - ` directly under `- one` does.) + expect(postProcessSerializedMarkdown('- one\n - two\n - \n - three\n')).toBe( + '- one\n - two\n - \n - three\n' + ) + // The hazard case — empty item directly under the shallower parent — is still stripped. + expect(postProcessSerializedMarkdown('- one\n - \n - three\n')).toBe('- one\n - three\n') + }) + it('keeps a thematic break and empty checklist items (not Setext-hazardous)', () => { expect(postProcessSerializedMarkdown('text\n\n---\n\nmore\n')).toBe('text\n\n---\n\nmore\n') expect(postProcessSerializedMarkdown('- [ ] a\n- [ ] \n')).toBe('- [ ] a\n- [ ] \n') diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-fidelity.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-fidelity.ts index 5eec0de4dfe..94511d5ebad 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-fidelity.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-fidelity.ts @@ -114,14 +114,16 @@ const FENCE_DELIMITER = /^[ \t]*(`{3,}|~{3,})/ const LEADING_INDENT = /^[ \t]*/ /** - * Removes *nested* (indented) empty list-item marker lines from serialized markdown. A nested empty - * bullet (` - `) sitting directly under a parent item's text re-parses as a Setext heading underline — - * silently turning the parent line into an `## heading` and dropping the empty bullet on the next load - * (a data-corrupting round-trip). Only indented empty items are stripped: a *top-level* empty bullet - * (`- ` / `1. `) round-trips faithfully (it stays an empty item, never a heading), so it is preserved — - * a placeholder row or an intentionally-blank imported item is not silently deleted. Lines inside fenced - * code blocks are left untouched, and an empty item whose next non-blank line is more deeply indented is - * kept so its children are never orphaned. + * Removes only the *nested* empty list-item marker lines that re-parse as a Setext heading underline: + * a nested empty bullet (` - `) sitting DIRECTLY under a shallower parent line silently turns that + * parent's text into an `## heading` and drops the bullet on the next load (a data-corrupting + * round-trip). The strip is therefore scoped by three conditions, all required: + * - *indented* (`indent > 0`): a top-level empty bullet (`- ` / `1. `) round-trips faithfully as an + * empty item, never a heading, so a placeholder/blank imported row is preserved. + * - the immediately-preceding line is *shallower* (the parent whose text the underline would consume): + * an empty item after a *same-indent sibling* (` - two` then ` - `) does NOT corrupt — the parser + * keeps it as a real empty item — so it is preserved. A blank line above also breaks the hazard. + * - no more-indented children on the next non-blank line, so its children are never orphaned. * * Operates only on the editor's own serialized output, which uses fenced (never 4-space-indented) code * blocks and `\n` newlines — so tracking fences is sufficient and a bare `-` inside an indented code @@ -151,9 +153,15 @@ function stripEmptyListItemLines(markdown: string): string { while (next < lines.length && lines[next].trim() === '') next++ const hasChildren = next < lines.length && (lines[next].match(LEADING_INDENT)?.[0].length ?? 0) > indent - // Strip only nested (indented) empty items — the Setext-underline hazard. A top-level empty item - // round-trips faithfully and is preserved. Never orphan an item that has more-indented children. - if (indent > 0 && !hasChildren) continue + // The Setext-underline hazard exists only when the empty item follows a SHALLOWER parent line + // (whose text the underline would consume). An empty item after a same/deeper-indent sibling + // (` - two` then ` - `) is a real empty item the parser keeps — a nested placeholder between + // siblings must not be lost. Uses the preceding non-blank line's indent; a lone empty item with + // nothing above it (`prevIndent = -1`) has no parent text to corrupt but stays stripped as before. + let prevIdx = i - 1 + while (prevIdx >= 0 && lines[prevIdx].trim() === '') prevIdx-- + const prevIndent = prevIdx >= 0 ? (lines[prevIdx].match(LEADING_INDENT)?.[0].length ?? 0) : -1 + if (indent > 0 && !hasChildren && prevIndent < indent) continue } kept.push(line) } diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-parse.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-parse.test.ts index a07ce3ed1a2..d59ef6afe6e 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-parse.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-parse.test.ts @@ -117,8 +117,7 @@ describe('parseMarkdownToDoc (chunked)', () => { ['three empties between paragraphs', 'a\n\n\n\n\n\n\n\nb'], ['even blank-line gap (rounds down)', 'a\n\n\n\n\nb'], ['leading empties', '\n\n\n\na'], - ['trailing empties', 'a\n\n\n\n'], - ['leading + between + trailing', '\n\n\na\n\n\n\nb\n\n\n'], + ['leading + between', '\n\n\na\n\n\n\nb'], ['empties between a heading and text', '# H\n\n\n\ntext'], ['empties after a tight list', '- a\n- b\n\n\n\ntext'], ['empties before a tight list', 'text\n\n\n\n- a\n- b'], @@ -131,6 +130,29 @@ describe('parseMarkdownToDoc (chunked)', () => { }) }) + // Regression: a file ending in a blank line (a trailing empty paragraph) must stay EDITABLE. Such an + // empty paragraph can't be serialized stably (postProcess collapses trailing newlines), so the parser + // strips it — keeping the doc round-trip-safe/idempotent instead of flipping the file read-only. + describe('trailing blank lines stay editable (regression)', () => { + it.each([ + ['plain paragraph', 'abc\n\n'], + ['heading + text', '# Title\n\nSome text\n\n'], + ['three trailing newlines', 'hello\n\n\n'], + ['two paragraphs', 'para one\n\npara two\n\n'], + ['interior empties + trailing', 'a\n\n\n\nb\n\n'], + ])('a file ending in a blank line is round-trip-safe: %s', (_label, md) => { + expect(isRoundTripSafe(md)).toBe(true) + }) + + it('strips the trailing empty paragraph but keeps interior ones', () => { + const trailing = parseMarkdownToDoc('abc\n\n').content ?? [] + expect(trailing.at(-1)?.type).toBe('paragraph') + expect(trailing.at(-1)?.content?.length ?? 0).toBeGreaterThan(0) + const interior = parseMarkdownToDoc('a\n\n\n\nb').content ?? [] + expect(interior.some((n) => n.type === 'paragraph' && !n.content?.length)).toBe(true) + }) + }) + it('parses reference-style links whole (non-chunkable) without dropping the definition', () => { const body = 'See [the docs][ref] for details.\n\n[ref]: https://example.com/docs' expect(serializeMarkdownBody(body)).toBe(oneShot(body)) @@ -239,13 +261,18 @@ function buildFuzzDoc(seed: number): string { describe('chunked parse — property test over randomized documents', () => { it('chunked === one-shot for every document, and idempotent for every editable one', () => { const failures: Array<{ seed: number; kind: string }> = [] + // Compare modulo trailing whitespace: `parseMarkdownToDoc` strips trailing empty paragraphs (they + // can't be serialized stably — postProcess collapses trailing newlines — so keeping them would flip + // the file read-only), whereas the raw one-shot parse keeps them. That trailing-only divergence is + // intended and invisible after save; interior/leading fidelity is still compared exactly. + const trimEnd = (md: string) => md.replace(/\n+$/, '') for (let seed = 1; seed <= 400; seed++) { const body = buildFuzzDoc(seed) const chunked = serializeMarkdownBody(body) // Fidelity is the load-bearing invariant — chunked must never diverge from the whole-document // parse, for ANY input; idempotency only needs to hold where the doc is editable (raw HTML is // non-idempotent in the underlying editor regardless of chunking, which is why it opens read-only). - if (chunked !== oneShot(body)) failures.push({ seed, kind: 'fidelity' }) + if (trimEnd(chunked) !== trimEnd(oneShot(body))) failures.push({ seed, kind: 'fidelity' }) else if (isRoundTripSafe(body) && serializeMarkdownBody(chunked) !== chunked) { failures.push({ seed, kind: 'idempotency' }) } diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-parse.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-parse.ts index 25dfb59ab2b..80125ca6cc6 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-parse.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-parse.ts @@ -48,12 +48,17 @@ const LIST_MARKER = /^[ ]{0,3}(?:[-*+]|\d+[.)])\s/ const BLOCKQUOTE = /^[ ]{0,3}>/ /** - * Blank-line spacing that `@tiptap/markdown` reconstructs as empty paragraphs — a run of two or more - * blank lines somewhere, or blank line(s) at the document's leading/trailing edge. `[^\S\n]` matches - * only horizontal whitespace (and `\r`), so a "blank" line may carry spaces/tabs and CRLF is handled. - * The three alternatives are: 2+ interior blank lines, leading blank line(s), trailing blank line(s). + * Blank-line spacing that `@tiptap/markdown` reconstructs as *interior* or *leading* empty paragraphs — + * a run of two or more blank lines somewhere, or blank line(s) at the document's leading edge. `[^\S\n]` + * matches only horizontal whitespace (and `\r`), so a "blank" line may carry spaces/tabs and CRLF is + * handled. TRAILING blank lines are deliberately NOT matched here: a trailing empty paragraph can never + * be serialized stably — {@link postProcessSerializedMarkdown} collapses trailing newlines — so + * {@link parseMarkdownToDoc} strips trailing empty paragraphs entirely (see {@link stripTrailingEmptyParagraphs}), + * and routing a file that merely ends in a blank line to the whole-document parser would only reconstruct + * one to be dropped again, making serialize→parse non-idempotent (the round-trip-safety probe would then + * flip the file read-only). */ -const EMPTY_PARAGRAPH_SPACING = /\n[^\S\n]*\n[^\S\n]*\n|^[^\S\n]*\n[^\S\n]*\n|\n[^\S\n]*\n[^\S\n]*$/ +const EMPTY_PARAGRAPH_SPACING = /\n[^\S\n]*\n[^\S\n]*\n|^[^\S\n]*\n[^\S\n]*\n/ /** * Split a markdown body into top-level blocks that can each be parsed independently and reassembled @@ -142,18 +147,43 @@ export function parseMarkdownToDoc(body: string): JSONContent { // do — the guards' `\n`-anchored tests would otherwise miss a classic `\r`-only body (its blank // lines are `\r`), routing it to the chunker that then drops its empty paragraphs. const normalized = body.replace(/\r\n?/g, '\n') - if (NON_CHUNKABLE.test(normalized) || EMPTY_PARAGRAPH_SPACING.test(normalized)) - return manager.parse(normalized) - try { - const content: JSONContent[] = [] - for (const block of splitMarkdownBlocks(normalized)) { - // `MarkdownManager.parse` always returns a doc node with a `content` array; spread its blocks. - content.push(...(manager.parse(block).content ?? [])) + let doc: JSONContent + if (NON_CHUNKABLE.test(normalized) || EMPTY_PARAGRAPH_SPACING.test(normalized)) { + doc = manager.parse(normalized) + } else { + try { + const content: JSONContent[] = [] + for (const block of splitMarkdownBlocks(normalized)) { + // `MarkdownManager.parse` always returns a doc node with a `content` array; spread its blocks. + content.push(...(manager.parse(block).content ?? [])) + } + doc = { type: 'doc', content } + } catch { + doc = manager.parse(normalized) } - return { type: 'doc', content } - } catch { - return manager.parse(normalized) } + return stripTrailingEmptyParagraphs(doc) +} + +/** An empty paragraph node — the shape a blank line reconstructs to (no content, or `content: []`). */ +function isEmptyParagraph(node: JSONContent): boolean { + return node.type === 'paragraph' && !node.content?.length +} + +/** + * Drop trailing empty paragraphs from a parsed doc. {@link postProcessSerializedMarkdown} collapses + * trailing blank lines to a single newline, so a trailing empty paragraph can never round-trip — the + * whole-document parser reconstructs one from a file ending in a blank line, but keeping it makes + * serialize→parse non-idempotent, which flips the file read-only via the round-trip-safety probe. + * Leading/interior empty paragraphs are untouched (postProcess never strips those). TipTap re-adds its + * own trailing filler paragraph on `setContent`, so the editor still has a place to type. + */ +function stripTrailingEmptyParagraphs(doc: JSONContent): JSONContent { + const content = doc.content + if (!content || content.length === 0) return doc + let end = content.length + while (end > 0 && isEmptyParagraph(content[end - 1])) end-- + return end === content.length ? doc : { ...doc, content: content.slice(0, end) } } /** diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx index 5e01283f0f9..a57a2c473f2 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx @@ -4,6 +4,7 @@ import { memo, useEffect, useRef, useState } from 'react' import { cn, toast } from '@sim/emcn' import type { JoinFileDocError } from '@sim/realtime-protocol/file-doc' import type { Extensions, JSONContent } from '@tiptap/core' +import { isChangeOrigin } from '@tiptap/extension-collaboration' import { Fragment, Slice } from '@tiptap/pm/model' import { NodeSelection } from '@tiptap/pm/state' import { dropPoint } from '@tiptap/pm/transform' @@ -569,19 +570,23 @@ export function LoadedRichMarkdownEditor({ return false }, }, - onUpdate: ({ editor }) => { + onUpdate: ({ editor, transaction }) => { const md = postProcessSerializedMarkdown(editor.getMarkdown()) lastSyncedBodyRef.current = md onChangeRef.current(applyFrontmatter(settledRef.current?.frontmatter ?? '', md)) // While the file is still untitled, name it after its leading heading once typing settles — but - // only when the user can actually edit. A view-only viewer (or the not-yet-editable seed at mount, - // which also fires onUpdate) must never schedule a rename it has no permission to make; - // `editor.isEditable` is the same gate the autosave path uses (canEdit + settled + collab-ready). - // Always clear any pending timer first, so deleting/rewriting the heading before it fires cancels - // the stale rename; the timer re-derives the title from the live doc rather than a value captured at - // schedule time, so it can never name the file after a heading the user has since changed or removed. + // only for the LOCAL user's own edits, and only when they can actually edit. `isChangeOrigin` is + // true when this update is a remote Yjs change (a peer typing) — every connected client would + // otherwise schedule the same rename, and could rename from a peer's not-yet-synced heading; it is + // false for local edits and for non-collaborative surfaces. `editor.isEditable` is the same gate + // the autosave path uses (canEdit + settled + collab-ready), so a view-only viewer or the + // not-yet-editable mount seed never schedules a rename. Always clear any pending timer first, so + // deleting/rewriting the heading before it fires cancels the stale rename; the timer re-derives the + // title from the live doc rather than a value captured at schedule time, so it can never name the + // file after a heading the user has since changed or removed. if (deriveTitleTimerRef.current) clearTimeout(deriveTitleTimerRef.current) if ( + isChangeOrigin(transaction) || !editor.isEditable || !isUntitledName(fileNameRef.current) || firstHeadingTitle(editor.state.doc) === null diff --git a/apps/sim/app/workspace/[workspaceId]/files/untitled-title.ts b/apps/sim/app/workspace/[workspaceId]/files/untitled-title.ts index 33a198e9d69..4800fb13e9a 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/untitled-title.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/untitled-title.ts @@ -3,8 +3,8 @@ import { truncate } from '@sim/utils/string' /** * The name a freshly-created markdown file is given in `handleCreateFile`: `untitled.md`, or * `untitled (n).md` when that is taken. A file keeps this "unnamed" status until it is renamed — - * while unnamed, the file name and the document's leading heading are coupled (typing a heading - * names the file; naming the file seeds a heading). See {@link isUntitledName}. + * while unnamed, typing a leading heading names the file (one direction only; the reverse + * name→heading seed was removed as unsafe on the shared editor). See {@link isUntitledName}. */ export const DEFAULT_UNTITLED_NAME = 'untitled.md' From 29176a98afebb7808984186153cecb500a6238f3 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 27 Jul 2026 14:05:45 -0700 Subject: [PATCH 10/11] fix(files): a remote edit must not cancel the local rename debounce The isChangeOrigin gate cleared the debounce timer BEFORE bailing on a remote update, so a peer's edit arriving within the 600ms window cancelled the local user's pending rename. Bail on isChangeOrigin first, before touching the timer; only local edits clear/reschedule it. --- .../rich-markdown-editor.tsx | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx index a57a2c473f2..c7bd689c7d7 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx @@ -575,18 +575,18 @@ export function LoadedRichMarkdownEditor({ lastSyncedBodyRef.current = md onChangeRef.current(applyFrontmatter(settledRef.current?.frontmatter ?? '', md)) // While the file is still untitled, name it after its leading heading once typing settles — but - // only for the LOCAL user's own edits, and only when they can actually edit. `isChangeOrigin` is - // true when this update is a remote Yjs change (a peer typing) — every connected client would - // otherwise schedule the same rename, and could rename from a peer's not-yet-synced heading; it is - // false for local edits and for non-collaborative surfaces. `editor.isEditable` is the same gate - // the autosave path uses (canEdit + settled + collab-ready), so a view-only viewer or the - // not-yet-editable mount seed never schedules a rename. Always clear any pending timer first, so - // deleting/rewriting the heading before it fires cancels the stale rename; the timer re-derives the - // title from the live doc rather than a value captured at schedule time, so it can never name the - // file after a heading the user has since changed or removed. + // only for the LOCAL user's own edits. `isChangeOrigin` is true for a remote Yjs change (a peer + // typing); bail BEFORE touching the timer so a remote edit never cancels or reschedules the local + // user's pending rename (and every client doesn't schedule the same rename from a peer's + // not-yet-synced heading). It is false for local edits and non-collaborative surfaces. + if (isChangeOrigin(transaction)) return + // Local edit: restart the debounce. Clearing first cancels a stale rename if the heading was + // removed/changed before it fired; the timer re-derives the title from the live doc rather than a + // value captured now, so it can never name the file after a heading the user has since changed. + // `editor.isEditable` is the autosave gate (canEdit + settled + collab-ready), so a view-only + // viewer or the not-yet-editable mount seed never schedules a rename. if (deriveTitleTimerRef.current) clearTimeout(deriveTitleTimerRef.current) if ( - isChangeOrigin(transaction) || !editor.isEditable || !isUntitledName(fileNameRef.current) || firstHeadingTitle(editor.state.doc) === null From eae98dfa29849823745417ec573b2df9651f5ecd Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 27 Jul 2026 14:28:37 -0700 Subject: [PATCH 11/11] docs(files): correct EMPTY_PARAGRAPH_SPACING rationale after trailing-strip The stacked trailing-empty-paragraph strip made the older comment overstate a correctness necessity it no longer owns, mislabel trailing runs of 2+ blanks, and advertise dead CRLF handling. Reword to match what the code actually does. --- .../rich-markdown-editor/markdown-parse.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-parse.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-parse.ts index 80125ca6cc6..c38fac63008 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-parse.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-parse.ts @@ -50,13 +50,14 @@ const BLOCKQUOTE = /^[ ]{0,3}>/ /** * Blank-line spacing that `@tiptap/markdown` reconstructs as *interior* or *leading* empty paragraphs — * a run of two or more blank lines somewhere, or blank line(s) at the document's leading edge. `[^\S\n]` - * matches only horizontal whitespace (and `\r`), so a "blank" line may carry spaces/tabs and CRLF is - * handled. TRAILING blank lines are deliberately NOT matched here: a trailing empty paragraph can never - * be serialized stably — {@link postProcessSerializedMarkdown} collapses trailing newlines — so - * {@link parseMarkdownToDoc} strips trailing empty paragraphs entirely (see {@link stripTrailingEmptyParagraphs}), - * and routing a file that merely ends in a blank line to the whole-document parser would only reconstruct - * one to be dropped again, making serialize→parse non-idempotent (the round-trip-safety probe would then - * flip the file read-only). + * matches horizontal whitespace, so a "blank" line may carry spaces/tabs. This is only ever tested + * against the `\r`-normalized body ({@link parseMarkdownToDoc}), so no CRLF handling is needed here. + * + * A *single* trailing blank line is deliberately not matched — purely to avoid routing an otherwise-plain + * file to the slower whole-document parser. Correctness does not depend on it: {@link parseMarkdownToDoc} + * strips trailing empty paragraphs on *both* parse paths ({@link stripTrailingEmptyParagraphs}), so + * serialize→parse stays idempotent regardless of which parser ran. (A trailing run of two or more blanks + * still matches the interior alternative — harmless, since the strip cleans it either way.) */ const EMPTY_PARAGRAPH_SPACING = /\n[^\S\n]*\n[^\S\n]*\n|^[^\S\n]*\n[^\S\n]*\n/