Skip to content

Commit fc6b143

Browse files
fix: Tiptap round-trip safety + autosave scoping for #437/#440/#449 (31/31, 8/8 real files) (#445)
* test: add failing regression test for #437/#440 markdown editor round-trip Captures 14 failure modes in the Tiptap+tiptap-markdown round-trip used by the file-preview pane (mountMarkdownEditor in editor.ts). The autosave loop in controller.ts reads getTiptapMarkdown() and diffs it against state.fullDocumentContent, then emits edit_block calls for every hunk. Any drift introduced by parse-and-reserialize gets silently written to disk. Failure cases (all 14/14 fail on current main): - GFM pipe table -> 'AB12' - '~' -> '\~' (prosemirror-markdown strikethrough escaping) - Adjacent block elements gain blank-line separators - Wikilink + heading combo drifts on spacing - Trailing newline stripped - Combined #437 fixture - YAML frontmatter --- parsed as Setext heading (#437 LevionLaurion) - '[x]' -> '\[x\]' (#440) - Underscore-rich identifiers drift on trailing newline (#440) - '~/path' -> '\~/path' (#440) - Loose lists drift on trailing newline (#440) - CRLF -> LF + soft-break collapse (related to #97) - Realistic README-style file: tables collapse + soft breaks merge, >70%-changed threshold in computeEditBlocks would emit a single edit_block replacing the entire file with the degraded version - Realistic doc with table embedded in prose: prose around table also lost via soft-break merge Adds jsdom@^24 as a devDependency to mount Tiptap headlessly. * fix(markdown editor): round-trip safety + edit-diff invariants Tiptap's parse-and-reserialise round-trip silently mutates user files (see #437, #440). Pure round-trip safety (untouched file -> identical output) is too strict; what matters in production is that an edit produces only the user's actual change, not collateral normalisation. Two test suites: - test-markdown-editor-roundtrip.js (existing, 14 cases): strict round-trip — files survive open->getMarkdown() byte-for-byte. - test-markdown-editor-edit-diff.js (new, 7 cases): realistic — apply a small edit, run computeEditBlocks against the original, assert the diff is bounded (no whole-file rewrite, <=3 hunks, <=20% lines differing, expected text actually present). Implementation: - Centralise Tiptap config in buildTiptapExtensions() so test and production exercise the same code path. Disable strike to stop \~ escaping. Add Table / TableRow / TableHeader / TableCell extensions so GFM pipe tables don't collapse to concatenated cell text. - preprocessForEditor / applyPostProcess wrap the editor with a RoundTripContext that captures: original EOL (LF vs CRLF), YAML frontmatter prefix, gap between frontmatter and body, trailing newline. All re-applied on the way out. - Post-process repairs: unescapeSafeChars (\[, \], \~ in prose), restoreTableSeparatorStyle (|---| vs | --- |), restoreSoftBreaks (Tiptap's space-joined paragraph lines back to original line breaks), collapseBlockSeparators (Tiptap's spurious blank lines between adjacent block elements). Order matters: softBreaks must run before blockSeparators because blockSeparators matches surrounding lines against pairs from the original. - Export computeEditBlocks from controller.ts so the edit-diff test exercises the real autosave decision logic, including the >70% whole-file rewrite trigger. All 21 tests pass. Includes the in-the-wild #437 README fixture, the mixed table+prose+frontmatter case, CRLF preservation, wikilink round-trip, and the realistic 'edit a paragraph in a doc with frontmatter + wikilinks + tasks + table' edit-diff case. * fix(file-preview): prevent unnecessary markdown autosaves * fix(markdown editor): 4 in-the-wild round-trip failures Captured from /Users/eduardsruzga/work/best-value-ai/README.md, which hit 5 distinct corruption hunks on no-edit open in the previous state. Now: byte-exact round-trip, 0 hunks. Tests added (test-markdown-editor-roundtrip.js): testBareUrlNotAutoLinked — 'https://x' stays bare, no <…> testEmojiPrefixedSoftBreaksRestored — 3 paragraph lines stay 3 lines testLinkInTableCellSurvivesRoundTrip — [`x`](url) keeps its href testStarBulletMarkerPreserved — '*' bullets stay '*', not '-' Fixes: - linkify: false. The auto-bracketing ('https://x' -> '<https://x>') was a serializer-side artifact of the parser's URL-detection rule. Disable the rule; pasted URLs in the editor still become clickable via Tiptap's built-in Link extension. - restoreSoftBreaks now tries both ' ' (the common joiner) and '' (when the boundary is between punctuation like ')' and a non-letter like an emoji — the case that broke the 3-line emoji-prefixed sequence in best-value-ai's README). - restoreBulletMarkers maps output bullet lines onto source bullet lines positionally, restoring the marker style ('*' / '-' / '+'). New bullets the user adds keep the editor's default '-'. - Code-text links ([`x`](url)) are stripped of their URL by tiptap-markdown's parser when the link text is purely inline code. Replace with ASCII placeholders during preprocess; restore in postprocess. Pattern matches the existing wikilink workaround. - unescapeSafeChars is now line-aligned: it only removes \[, \], \~ escapes when the same source line, stripped of those escapes, also matches the output line stripped. Means we never touch escapes the user authored — we only undo escapes Tiptap added. Also wired the codeLinks placeholder mapping into the RoundTripContext so production autosave gets the same restore behaviour as the test harness. Result: 18/18 strict + 7/7 edit-diff = 25/25 passing. The best-value-ai/README.md (12977 bytes, 214 lines) round-trips byte-for- byte with 0 autosave hunks. * fix(markdown editor): preserve relative-path links Tiptap's Link extension validates URLs and silently drops links whose URL doesn't match its scheme/relative-prefix allow-list. Bare relative paths with subdirectories (`scripts/foo.mjs`, `references/output.md`) fall through this validation — the parse drops the link, leaving just the link text. Most common corruption mode in real skill files (SKILL.md routinely links to `scripts/` and `references/`). Generalised the existing code-text-link placeholder workaround into INLINE_LINK_RE + isFragileLink(). The same regex and placeholder system now handles both: 1. Code-text links: `[\`x\`](url)` 2. Bare-relative-subpath links: `[X](dir/file)` URLs we leave alone (Tiptap accepts them): - Schemes (http://, mailto:, tel:, ftp:, etc.) - Anchors (#section) - Single-segment paths (file.md) - Explicitly-relative paths (./, ../, /) Tests: - testRelativePathLinksSurvive captures the failure with skill-files fixtures (init-skill.mjs, validate-skill.mjs, references/output.md, section anchors). - All 19 strict + 7 edit-diff tests still pass. Skill-files batch test: 4 of 8 SKILL.md files now round-trip byte-exact (was 1). Remaining failures are different bugs: HTML entity escaping, trailing-whitespace strip, bold-around-inline-code mis-shifting. * fix(file-preview): scope markdown autosave edits * fix(markdown editor): 6 more in-the-wild round-trip failures Captured by running the SKILL.md files in ~/.desktop-commander/skills/ through the round-trip pipeline. 4 of 8 files broke before this commit; all 8 now round-trip byte-exact. Tests added (test-markdown-editor-roundtrip.js): testLessThanInProseNotEscaped '< $0.01' -> '&lt; $0.01' testTrailingHardBreakWhitespacePreserved 'foo \n' (CommonMark hard break) lost the trailing spaces or rewritten as 'foo\\\n' testBoldAroundInlineCodePreserved '**`x`**' -> '`x`', '**`x` + `y`**' -> '`x` **+** `y`', '**Key in `x`:**' -> '**Key in** `x`**:**' testEscapedPipeInTableCellPreserved '\\|' inside a cell becomes bare '|', splits the cell testListItemWithContinuationLine '- item\n cont\n' joins to '- item cont\n' Fixes: - unescapeHtmlEntitiesInProse: undo Tiptap's '&lt;'/'&gt;'/'&amp;' HTML entity escaping in prose. Line-aligned like unescapeSafeChars; only removes entities when the corresponding source line, stripped of those entities, matches the output line. User-authored entities are preserved. - restoreTrailingHardBreaks: detect source lines ending in two trailing spaces and re-add them. Handles both Tiptap serializer shapes: '\\\n' line continuation (paragraph) and silently-stripped (list item). - BOLD_AROUND_CODE_RE + boldCodeRuns: placeholder '**…`code`…**' spans during preprocess, restore after serialize. Bypasses ProseMirror's flat-mark schema limitation. - pipeEscapeCount + PIPE_ESCAPE_TOKEN: placeholder '\|' as an ASCII token before parse. Tiptap's serializer drops the backslash; the token round-trips intact. - restoreSoftBreaks: recognise list-item-with-lazy-continuation pairs (list-header followed by 2-space-indented prose line) and try the de-indented form of the continuation when looking for Tiptap's joined output. Result: 24/24 strict + 7/7 edit-diff = 31/31. All 8 skill files in the stress test now byte-exact. * telemetry: rename tool name property to tool_name The capture_call_tool send-site was emitting 'name' as the tool-name property, which collides with GA4's reserved 'name' parameter when passed verbatim. Rename to 'tool_name' at the call site (server.ts) and have capture_call_tool's high-volume routing read either field, falling back to 'name' for backwards compat with any in-flight callers. No behaviour change; pure rename. --------- Co-authored-by: edgarssskore <edgars.skore@gmail.com>
1 parent a029d37 commit fc6b143

9 files changed

Lines changed: 2675 additions & 167 deletions

File tree

package-lock.json

Lines changed: 666 additions & 119 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,10 @@
8989
"@supabase/supabase-js": "^2.89.0",
9090
"@tiptap/core": "^3.22.3",
9191
"@tiptap/extension-image": "^3.22.3",
92+
"@tiptap/extension-table": "^3.22.4",
93+
"@tiptap/extension-table-cell": "^3.22.4",
94+
"@tiptap/extension-table-header": "^3.22.4",
95+
"@tiptap/extension-table-row": "^3.22.4",
9296
"@tiptap/pm": "^3.22.3",
9397
"@tiptap/starter-kit": "^3.22.3",
9498
"@vscode/ripgrep": "^1.15.9",
@@ -124,6 +128,7 @@
124128
"commander": "^13.1.0",
125129
"esbuild": "^0.27.2",
126130
"js-tiktoken": "^1.0.21",
131+
"jsdom": "^24.1.3",
127132
"nexe": "^5.0.0-beta.4",
128133
"nodemon": "^3.0.2",
129134
"shx": "^0.3.4",

src/server.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1175,7 +1175,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
11751175

11761176
try {
11771177
// Prepare telemetry data - add config key for set_config_value
1178-
const telemetryData: any = { name };
1178+
const telemetryData: any = { tool_name: name };
11791179
// Extract metadata from _meta field if present
11801180
const metadata = request.params._meta as any;
11811181
if (metadata && typeof metadata === 'object') {

src/ui/file-preview/src/markdown/controller.ts

Lines changed: 163 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { getDocumentFullscreenAvailability, parseReadRange, shouldAutoLoadDocume
33
import type { MarkdownWorkspaceState, RenderBodyResult, RenderPayload } from '../model.js';
44
import { assertSuccessfulEditBlockResult, extractRenderPayload, extractToolText } from '../payload-utils.js';
55
import { getAncestorDirectories, getParentDirectory, toPosixRelativePath } from '../path-utils.js';
6-
import { mountMarkdownEditor, renderMarkdownEditorShell, type MarkdownEditorHandle, type MarkdownEditorView, type MarkdownLinkHeading, type MarkdownLinkSearchItem } from './editor.js';
6+
import { mountMarkdownEditor, renderMarkdownEditorShell, type MarkdownEditRange, type MarkdownEditorHandle, type MarkdownEditorView, type MarkdownLinkHeading, type MarkdownLinkSearchItem } from './editor.js';
77
import type { OpenConflictDialogOptions } from './conflict-dialog.js';
88
import { resolveMarkdownLink } from './linking.js';
99
import { extractMarkdownOutline } from './outline.js';
@@ -38,6 +38,13 @@ interface DiffHunk {
3838
newEnd: number;
3939
}
4040

41+
interface EditBlock {
42+
old_string: string;
43+
new_string: string;
44+
}
45+
46+
const MAX_EDIT_BLOCK_LINES = 40;
47+
4148
function areOutlineItemsEqual(
4249
left: MarkdownWorkspaceState['outline'],
4350
right: MarkdownWorkspaceState['outline']
@@ -74,10 +81,6 @@ function computeDiffHunks(oldLines: string[], newLines: string[]): DiffHunk[] {
7481
const oldLength = oldLines.length;
7582
const newLength = newLines.length;
7683

77-
if (oldLength * newLength > 1_000_000) {
78-
return [{ oldStart: 0, oldEnd: oldLength, newStart: 0, newEnd: newLength }];
79-
}
80-
8184
const dp: number[][] = Array.from({ length: oldLength + 1 }, () => Array(newLength + 1).fill(0) as number[]);
8285
for (let i = 1; i <= oldLength; i += 1) {
8386
for (let j = 1; j <= newLength; j += 1) {
@@ -138,26 +141,135 @@ function mergeCloseHunks(hunks: DiffHunk[], minGap: number): DiffHunk[] {
138141
return merged;
139142
}
140143

141-
function computeEditBlocks(oldText: string, newText: string): Array<{ old_string: string; new_string: string }> {
144+
function mergeLineRanges(ranges: MarkdownEditRange[]): MarkdownEditRange[] {
145+
const sorted = ranges
146+
.map((range) => ({ fromLine: Math.max(1, Math.floor(range.fromLine)), toLine: Math.max(1, Math.floor(range.toLine)) }))
147+
.sort((left, right) => left.fromLine - right.fromLine || left.toLine - right.toLine);
148+
const merged: MarkdownEditRange[] = [];
149+
150+
for (const range of sorted) {
151+
const normalized = {
152+
fromLine: Math.min(range.fromLine, range.toLine),
153+
toLine: Math.max(range.fromLine, range.toLine),
154+
};
155+
const previous = merged[merged.length - 1];
156+
if (previous && normalized.fromLine <= previous.toLine + 1) {
157+
previous.toLine = Math.max(previous.toLine, normalized.toLine);
158+
} else {
159+
merged.push(normalized);
160+
}
161+
}
162+
163+
return merged;
164+
}
165+
166+
function hunkIntersectsRanges(hunk: DiffHunk, ranges: MarkdownEditRange[]): boolean {
167+
if (ranges.length === 0) {
168+
return true;
169+
}
170+
const fromLine = Math.min(hunk.oldStart, hunk.newStart) + 1;
171+
const toLine = Math.max(hunk.oldEnd, hunk.newEnd) + 1;
172+
return ranges.some((range) => fromLine <= range.toLine && toLine >= range.fromLine);
173+
}
174+
175+
function computeLineByLineHunks(oldLines: string[], newLines: string[]): DiffHunk[] {
176+
return computeAnchoredDiffHunks(oldLines, newLines, 0, oldLines.length, 0, newLines.length);
177+
}
178+
179+
function computeAnchoredDiffHunks(
180+
oldLines: string[],
181+
newLines: string[],
182+
oldStart: number,
183+
oldEnd: number,
184+
newStart: number,
185+
newEnd: number
186+
): DiffHunk[] {
187+
while (oldStart < oldEnd && newStart < newEnd && oldLines[oldStart] === newLines[newStart]) {
188+
oldStart++;
189+
newStart++;
190+
}
191+
while (oldStart < oldEnd && newStart < newEnd && oldLines[oldEnd - 1] === newLines[newEnd - 1]) {
192+
oldEnd--;
193+
newEnd--;
194+
}
195+
if (oldStart === oldEnd && newStart === newEnd) {
196+
return [];
197+
}
198+
199+
const oldLineCounts = new Map<string, { count: number; index: number }>();
200+
const newLineCounts = new Map<string, { count: number; index: number }>();
201+
for (let index = oldStart; index < oldEnd; index += 1) {
202+
const current = oldLineCounts.get(oldLines[index]);
203+
oldLineCounts.set(oldLines[index], { count: (current?.count ?? 0) + 1, index });
204+
}
205+
for (let index = newStart; index < newEnd; index += 1) {
206+
const current = newLineCounts.get(newLines[index]);
207+
newLineCounts.set(newLines[index], { count: (current?.count ?? 0) + 1, index });
208+
}
209+
210+
for (let oldIndex = oldStart; oldIndex < oldEnd; oldIndex += 1) {
211+
const oldEntry = oldLineCounts.get(oldLines[oldIndex]);
212+
const newEntry = newLineCounts.get(oldLines[oldIndex]);
213+
if (oldEntry?.count === 1 && newEntry?.count === 1) {
214+
return [
215+
...computeAnchoredDiffHunks(oldLines, newLines, oldStart, oldIndex, newStart, newEntry.index),
216+
...computeAnchoredDiffHunks(oldLines, newLines, oldIndex + 1, oldEnd, newEntry.index + 1, newEnd),
217+
];
218+
}
219+
}
220+
221+
return [{ oldStart, oldEnd, newStart, newEnd }];
222+
}
223+
224+
function splitOversizedEditBlock(oldText: string, newText: string): EditBlock[] {
225+
const oldLines = oldText.split('\n');
226+
const newLines = newText.split('\n');
227+
const blockCount = Math.ceil(Math.max(oldLines.length, newLines.length) / MAX_EDIT_BLOCK_LINES);
228+
const blocks: EditBlock[] = [];
229+
230+
for (let blockIndex = 0; blockIndex < blockCount; blockIndex += 1) {
231+
const oldStart = Math.floor((blockIndex * oldLines.length) / blockCount);
232+
const oldEnd = Math.floor(((blockIndex + 1) * oldLines.length) / blockCount);
233+
const newStart = Math.floor((blockIndex * newLines.length) / blockCount);
234+
const newEnd = Math.floor(((blockIndex + 1) * newLines.length) / blockCount);
235+
const old_string = oldLines.slice(oldStart, oldEnd).join('\n');
236+
const new_string = newLines.slice(newStart, newEnd).join('\n');
237+
if (old_string !== new_string) {
238+
blocks.push({ old_string, new_string });
239+
}
240+
}
241+
242+
return blocks;
243+
}
244+
245+
function splitOversizedEditBlocks(blocks: EditBlock[]): EditBlock[] {
246+
return blocks.flatMap((block) => {
247+
const lineCount = Math.max(block.old_string.split('\n').length, block.new_string.split('\n').length);
248+
return lineCount > MAX_EDIT_BLOCK_LINES
249+
? splitOversizedEditBlock(block.old_string, block.new_string)
250+
: [block];
251+
});
252+
}
253+
254+
export function computeEditBlocks(oldText: string, newText: string, changedRanges: MarkdownEditRange[] = []): EditBlock[] {
142255
if (oldText === newText) {
143256
return [];
144257
}
145258

146259
const oldLines = oldText.split('\n');
147260
const newLines = newText.split('\n');
148-
const hunks = computeDiffHunks(oldLines, newLines);
261+
const hunks = oldLines.length * newLines.length > 1_000_000
262+
? computeLineByLineHunks(oldLines, newLines)
263+
: computeDiffHunks(oldLines, newLines);
149264
if (hunks.length === 0) {
150265
return [];
151266
}
152267

153268
const context = 3;
154-
const merged = mergeCloseHunks(hunks, context * 2 + 1);
155-
const totalChanged = merged.reduce((sum, hunk) => sum + (hunk.oldEnd - hunk.oldStart), 0);
156-
if (totalChanged > oldLines.length * 0.7) {
157-
return [{ old_string: oldText, new_string: newText }];
158-
}
269+
const normalizedRanges = mergeLineRanges(changedRanges);
270+
const merged = mergeCloseHunks(hunks, context * 2 + 1).filter((hunk) => hunkIntersectsRanges(hunk, normalizedRanges));
159271

160-
return merged.map((hunk) => {
272+
const blocks = merged.map((hunk) => {
161273
const contextBefore = Math.max(0, hunk.oldStart - context);
162274
const contextAfter = Math.min(oldLines.length, hunk.oldEnd + context);
163275

@@ -170,6 +282,16 @@ function computeEditBlocks(oldText: string, newText: string): Array<{ old_string
170282

171283
return { old_string: oldBlock, new_string: newBlock };
172284
}).filter((block) => block.old_string !== block.new_string);
285+
286+
if (blocks.length === 1 && blocks[0].old_string === oldText && blocks[0].new_string === newText) {
287+
return splitOversizedEditBlock(oldText, newText);
288+
}
289+
290+
return splitOversizedEditBlocks(blocks);
291+
}
292+
293+
function applyEditBlocksToText(text: string, blocks: EditBlock[]): string {
294+
return blocks.reduce((current, block) => current.replace(block.old_string, block.new_string), text);
173295
}
174296

175297
function isToolErrorResult(value: unknown): value is ToolErrorResult {
@@ -240,6 +362,7 @@ export function createMarkdownController(dependencies: MarkdownControllerDepende
240362
state.draftContent = nextDraftContent;
241363
state.outline = extractMarkdownOutline(content);
242364
state.dirty = nextDraftContent !== content;
365+
state.dirtyLineRanges = [];
243366
state.fileDeleted = false;
244367
if (!state.outline.some((item) => item.id === state.activeHeadingId)) {
245368
state.activeHeadingId = state.outline[0]?.id ?? null;
@@ -289,6 +412,7 @@ export function createMarkdownController(dependencies: MarkdownControllerDepende
289412
outline,
290413
mode: 'edit',
291414
dirty: false,
415+
dirtyLineRanges: [],
292416
activeHeadingId: outline[0]?.id ?? null,
293417
pendingAnchor: null,
294418
notice: null,
@@ -657,6 +781,7 @@ export function createMarkdownController(dependencies: MarkdownControllerDepende
657781
const filePath = workspaceState.filePath;
658782
workspaceState.draftContent = workspaceState.fullDocumentContent;
659783
workspaceState.dirty = false;
784+
workspaceState.dirtyLineRanges = [];
660785
workspaceState.error = null;
661786
workspaceState.notice = null;
662787
dependencies.rerender();
@@ -678,11 +803,12 @@ export function createMarkdownController(dependencies: MarkdownControllerDepende
678803
state.notice = null;
679804

680805
try {
681-
const blocks = computeEditBlocks(state.fullDocumentContent, state.draftContent);
806+
const blocks = computeEditBlocks(state.fullDocumentContent, state.draftContent, state.dirtyLineRanges);
682807
if (blocks.length === 0) {
683808
state.saving = false;
684809
state.saveIndicator = 'idle';
685810
state.dirty = false;
811+
state.dirtyLineRanges = [];
686812
return;
687813
}
688814

@@ -728,17 +854,19 @@ export function createMarkdownController(dependencies: MarkdownControllerDepende
728854
throw err;
729855
}
730856

731-
state.fullDocumentContent = state.draftContent;
732-
state.sourceContent = state.draftContent;
857+
const savedContent = applyEditBlocksToText(state.fullDocumentContent, blocks);
858+
state.fullDocumentContent = savedContent;
859+
state.sourceContent = savedContent;
860+
state.draftContent = savedContent;
733861
state.outline = extractMarkdownOutline(state.sourceContent);
734862
state.dirty = false;
863+
state.dirtyLineRanges = [];
735864
state.saving = false;
736865
state.saveIndicator = 'saved';
737866
if (!state.outline.some((item) => item.id === state.activeHeadingId)) {
738867
state.activeHeadingId = state.outline[0]?.id ?? null;
739868
}
740869

741-
const savedContent = state.draftContent;
742870
const currentPayload = dependencies.getCurrentPayload();
743871
if (currentPayload) {
744872
const statusLineMatch = currentPayload.content.match(/^(\[Reading [^\]]+\]\r?\n(?:\r?\n)?)/);
@@ -919,9 +1047,23 @@ export function createMarkdownController(dependencies: MarkdownControllerDepende
9191047
currentFilePath: payload.filePath,
9201048
searchLinks: (query) => searchLinkTargets(payload.filePath, query),
9211049
loadHeadings: (targetPath) => loadLinkHeadings(payload.filePath, targetPath),
922-
onChange: (value) => {
1050+
onChange: (value, editRanges) => {
1051+
if (value === state.draftContent) {
1052+
return;
1053+
}
9231054
state.draftContent = value;
9241055
state.dirty = value !== state.fullDocumentContent;
1056+
if (state.dirty) {
1057+
const nextRanges = editRanges && editRanges.length > 0
1058+
? editRanges
1059+
: [{ fromLine: 1, toLine: value.split('\n').length }];
1060+
state.dirtyLineRanges = mergeLineRanges([
1061+
...state.dirtyLineRanges,
1062+
...nextRanges,
1063+
]);
1064+
} else {
1065+
state.dirtyLineRanges = [];
1066+
}
9251067
if (state.dirty && !editStartedFired) {
9261068
editStartedFired = true;
9271069
dependencies.trackUiEvent?.('markdown_edit_started', {
@@ -949,6 +1091,9 @@ export function createMarkdownController(dependencies: MarkdownControllerDepende
9491091
}
9501092
},
9511093
onBlur: () => {
1094+
if (!state.dirty) {
1095+
return;
1096+
}
9521097
cancelAutosave();
9531098
void saveDocument();
9541099
},

0 commit comments

Comments
 (0)