diff --git a/src/utils/Cursor.ts b/src/utils/Cursor.ts index 6968ff473..e9373d2ad 100644 --- a/src/utils/Cursor.ts +++ b/src/utils/Cursor.ts @@ -1290,11 +1290,30 @@ export class MeasuredText { } private measureWrappedText(): WrappedLine[] { - const wrappedText = wrapAnsi(this.text, this.columns, { + // Protect tabs from being expanded by npm wrap-ansi in Node.js dist builds. + // Bun.wrapAnsi preserves tabs, but npm wrap-ansi expands them to spaces, + // which breaks the indexOf-based offset recovery below. + const TAB_PLACEHOLDER_CANDIDATES = ['\u2060', '\u2061', '\u2062', '\u2063'] + const tabPlaceholder = TAB_PLACEHOLDER_CANDIDATES.find( + (ch) => !this.text.includes(ch), + ) + const hasTabs = this.text.includes('\t') + + const textForWrap = + hasTabs && tabPlaceholder + ? this.text.replaceAll('\t', tabPlaceholder) + : this.text + + const wrappedTextRaw = wrapAnsi(textForWrap, this.columns, { hard: true, trim: false, }) + const wrappedText = + hasTabs && tabPlaceholder + ? wrappedTextRaw.replaceAll(tabPlaceholder, '\t') + : wrappedTextRaw + const wrappedLines: WrappedLine[] = [] let searchOffset = 0 let lastNewLinePos = -1 diff --git a/src/utils/__tests__/Cursor.test.ts b/src/utils/__tests__/Cursor.test.ts new file mode 100644 index 000000000..dbb3d8011 --- /dev/null +++ b/src/utils/__tests__/Cursor.test.ts @@ -0,0 +1,89 @@ +import { describe, test, expect } from 'bun:test' +import { Cursor } from '../Cursor' + +describe('MeasuredText tab protection', () => { + test('Cursor.fromText with tabs does not throw', () => { + const cursor = Cursor.fromText('a\tb', 80) + expect(cursor).toBeTruthy() + expect(cursor.text).toBe('a\tb') + }) + + test('tab text preserves original tabs after measurement', () => { + const cursor = Cursor.fromText('hello\tworld', 80) + expect(cursor.text).toBe('hello\tworld') + // Trigger measurement + cursor.getPosition() + expect(cursor.text).toBe('hello\tworld') + }) + + test('multi-line tab text works', () => { + const cursor = Cursor.fromText('foo\n\tbar', 80) + expect(cursor.text).toBe('foo\n\tbar') + expect(() => cursor.getPosition()).not.toThrow() + }) + + test('tabs in narrow columns (triggering visual wrap) does not throw', () => { + // 10 cols is narrow enough that tabs + text will wrap + const cursor = Cursor.fromText('x\t\thello world\tfoo', 10) + expect(() => cursor.getPosition()).not.toThrow() + expect(cursor.text).toBe('x\t\thello world\tfoo') + }) + + test('getPosition returns valid coordinates for tab text', () => { + const cursor = Cursor.fromText('a\tb\tc', 80) + const pos = cursor.getPosition() + expect(pos.line).toBeGreaterThanOrEqual(0) + expect(pos.column).toBeGreaterThanOrEqual(0) + }) + + test('cursor movement on tab text does not throw', () => { + const cursor = Cursor.fromText('abc\tdef\tghi', 80) + // Move right through tab + const right1 = cursor.right() + expect(() => right1.getPosition()).not.toThrow() + const right2 = right1.right() + expect(() => right2.getPosition()).not.toThrow() + // Move to end + const end = cursor.endOfFile() + expect(() => end.getPosition()).not.toThrow() + // Move back + expect(() => end.left().getPosition()).not.toThrow() + }) + + test('tab text in very narrow views (1 col) does not throw', () => { + const cursor = Cursor.fromText('a\tb', 1) + expect(() => cursor.getPosition()).not.toThrow() + }) + + test('text with only tabs does not throw', () => { + const cursor = Cursor.fromText('\t\t', 80) + expect(() => cursor.getPosition()).not.toThrow() + expect(cursor.text).toBe('\t\t') + }) + + test('mixed tabs and newlines', () => { + const cursor = Cursor.fromText('a\tb\nc\td\ne\tf', 80) + expect(() => cursor.getPosition()).not.toThrow() + // Move to last line + const eof = cursor.endOfFile() + expect(eof.text).toBe('a\tb\nc\td\ne\tf') + }) + + test('placeholder characters in text are handled gracefully', () => { + // Text already contains \u2060 (word joiner) + const textWithPlaceholder = 'ab\u2060\tc' + const cursor = Cursor.fromText(textWithPlaceholder, 80) + expect(() => cursor.getPosition()).not.toThrow() + expect(cursor.text).toBe(textWithPlaceholder) + }) + + test('text with all placeholder candidates still works', () => { + // Text contains all four zero-width placeholder candidates + const allPlaceholders = 'a\u2060\u2061\u2062\u2063\tb' + const cursor = Cursor.fromText(allPlaceholders, 80) + expect(() => cursor.getPosition()).not.toThrow() + expect(cursor.text).toBe(allPlaceholders) + }) +}) + +