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/keymap.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.test.ts index 586f2bc2ff2..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 @@ -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,204 @@ 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() + }) + + 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)', () => { + 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 +533,52 @@ 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() + }) + + 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 bdbac57bfc6..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 @@ -20,6 +20,50 @@ 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 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). */ + 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, + blockEmpty: $from.parent.content.size === 0, + hasSiblingBlocks: item.childCount > 1, + 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 +180,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 +232,28 @@ 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, 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 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 || !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)) { return removeEmptyWrappedBlock(editor, $from) } @@ -207,11 +278,17 @@ 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, 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) }, '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..721b91dc305 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-fidelity.test.ts @@ -0,0 +1,64 @@ +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 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') + }) + + 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..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 @@ -106,16 +106,78 @@ 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 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 + * 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 + // 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) + } + 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') } 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..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 @@ -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,66 @@ 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'], + ['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'], + // 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')) + }) + }) + + // 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)) @@ -195,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 5417c0ee047..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 @@ -47,6 +47,20 @@ 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 *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 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/ + /** * 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,20 +134,57 @@ 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) - try { - const content: JSONContent[] = [] - for (const block of splitMarkdownBlocks(body)) { - // `MarkdownManager.parse` always returns a doc node with a `content` array; spread its blocks. - content.push(...(manager.parse(block).content ?? [])) + // 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') + 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(body) } + 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 2b9514b4f6a..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 @@ -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' @@ -13,6 +14,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 { 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 +43,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 } from './title-heading' import '@sim/emcn/components/code/code.css' import './rich-markdown-editor.css' @@ -55,6 +58,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 +89,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 +114,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 +181,7 @@ export const RichMarkdownEditor = memo(function RichMarkdownEditor({ onChange={setDraftContent} onSaveShortcut={saveImmediately} onCollabReadyChange={setCollabReady} + onDeriveTitleFromHeading={onDeriveTitleFromHeading} /> ) }) @@ -194,6 +208,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 +239,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 +305,17 @@ export function LoadedRichMarkdownEditor({ onChangeRef.current = onChange const onSaveShortcutRef = useRef(onSaveShortcut) onSaveShortcutRef.current = onSaveShortcut + + /** + * 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 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). @@ -542,14 +570,45 @@ 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 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 ( + !editor.isEditable || + !isUntitledName(fileNameRef.current) || + firstHeadingTitle(editor.state.doc) === null + ) + return + deriveTitleTimerRef.current = setTimeout(() => { + const liveEditor = editorInstanceRef.current + if (!liveEditor || !liveEditor.isEditable || !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) + }, + [] + ) + /** * 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..f6c46bd027f --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/title-heading.test.ts @@ -0,0 +1,63 @@ +/** + * @vitest-environment jsdom + */ +import { Editor } from '@tiptap/core' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createMarkdownEditorExtensions } from './editor-extensions' +import { firstHeadingTitle } 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() + }) +}) 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..3877519ca97 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/title-heading.ts @@ -0,0 +1,9 @@ +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 +} 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('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..4800fb13e9a --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/untitled-title.ts @@ -0,0 +1,57 @@ +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, 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' + +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) +} + +/** + * 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`. + 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 +}