···660660 const oldLines = oldStr.split('\n');
661661 const newLines = newStr.split('\n');
662662663663- // Build line-level LCS table (Wagner-Fischer).
664663 const m = oldLines.length, n = newLines.length;
665664 const dp = Array.from({length: m + 1}, () => new Array(n + 1).fill(0));
666665 for (let i = m - 1; i >= 0; i--) {
···671670 }
672671 }
673672674674- // Line lengths in the original document.
675675- // All lines except the last have a trailing \n; the last does not.
676676- const oldLineLengths = oldLines.map((l, idx) => l.length + (idx < oldLines.length - 1 ? 1 : 0));
673673+ // Line length in old string. Last line has no trailing \n.
674674+ const oldLineLengths = oldLines.map((l, idx) =>
675675+ l.length + (idx < oldLines.length - 1 ? 1 : 0));
677676678678- // Trace back to produce raw diff ops.
679679- // Traceback preference: insert before delete. charOffset tracks the
680680- // character position in the ORIGINAL document (only advances on matches
681681- // and deletes, never on inserts — inserts reference the pre-delete offset).
682682- const rawOps = [];
683683- let i = 0, j = 0;
684684- let charOffset = 0;
677677+ const ops = [];
678678+ let i = 0, j = 0, charOffset = 0;
685679686680 while (i < m || j < n) {
687681 if (i < m && j < n && oldLines[i] === newLines[j]) {
688688- // Matching line — advance both pointers and the offset.
682682+ // Matching line — advance past it.
689683 charOffset += oldLineLengths[i];
690684 i++; j++;
691691- } else if (j < n && (i >= m || dp[i+1][j] <= dp[i][j+1])) {
692692- // Insert newLines[j] at the current position in the old doc.
693693- // Append \n if more new lines follow OR if there are still old
694694- // lines remaining after this insertion point (line is mid-doc).
695695- const insertText = newLines[j] + (j < n - 1 || i < m ? '\n' : '');
696696- rawOps.push({ from: charOffset, to: charOffset, insert: insertText });
697697- j++;
698685 } else {
699699- // Delete oldLines[i].
700700- const deleteLen = oldLineLengths[i];
701701- rawOps.push({ from: charOffset, to: charOffset + deleteLen, insert: '' });
702702- charOffset += deleteLen;
703703- i++;
704704- }
705705- }
686686+ // Non-matching run: collect all consecutive deletions and
687687+ // insertions into a single replace op so positions stay consistent.
688688+ const hunkFrom = charOffset;
689689+ let hunkTo = charOffset;
690690+ let hunkInsert = '';
691691+692692+ while (i < m && (j >= n || dp[i][j] === dp[i+1][j])) {
693693+ hunkTo += oldLineLengths[i];
694694+ charOffset += oldLineLengths[i];
695695+ i++;
696696+ }
697697+ while (j < n && (i >= m || dp[i][j] === dp[i][j+1])) {
698698+ hunkInsert += newLines[j] + (j < newLines.length - 1 || hunkTo > hunkFrom ? '\n' : '');
699699+ j++;
700700+ }
706701707707- // Post-process: merge adjacent insert-at-X + delete-at-X pairs into a
708708- // single replace op. The traceback emits insert before delete for the
709709- // same position (insert doesn't advance charOffset, so the delete that
710710- // follows still references the same offset). Merging is required because
711711- // the server applies ops sequentially — a separate insert followed by a
712712- // separate delete would hit the wrong bytes after the insert shifts things.
713713- function mergeOps(raw) {
714714- const merged = [];
715715- for (let k = 0; k < raw.length; k++) {
716716- const op = raw[k];
717717- const next = raw[k + 1];
718718- // Insert at X (pure insert: to === from) followed immediately by
719719- // delete starting at X (pure delete: insert === '').
720720- if (op.to === op.from && next && next.insert === '' && next.from === op.from) {
721721- merged.push({ from: op.from, to: next.to, insert: op.insert });
722722- k++; // skip next
723723- } else {
724724- merged.push(op);
702702+ // Only emit if something actually changed.
703703+ if (hunkTo > hunkFrom || hunkInsert !== '') {
704704+ ops.push({ from: hunkFrom, to: hunkTo, insert: hunkInsert });
725705 }
726706 }
727727- return merged;
728707 }
729708730730- const ops = mergeOps(rawOps);
731731-732732- // Fallback: if diff is too fragmented, send a single full replacement.
733733- if (ops.length > 20) {
734734- return [{ from: 0, to: -1, insert: newStr }];
735735- }
736736-709709+ if (ops.length > 20) return [{ from: 0, to: -1, insert: newStr }];
737710 return ops;
738711 }
739712