Something went wrong. Try again.
Ghostty for the web with xterm.js API compatibility
Something went wrong. Try again.
16 kB · 532 lines
TypeScript
at renderstate
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533/** * Selection Manager Tests * * Tests for text selection functionality including: * - Basic selection operations * - Absolute coordinate system for scroll persistence * - Selection clearing behavior * - Auto-scroll during drag selection * - Copy functionality with scrollback * * Test Isolation Pattern: * Uses createIsolatedTerminal() to ensure each test gets its own WASM instance. */
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';import type { Terminal } from './terminal';import { createIsolatedTerminal } from './test-helpers';
/** * Helper to set selection using absolute coordinates */function setSelectionAbsolute( term: Terminal, startCol: number, startAbsRow: number, endCol: number, endAbsRow: number): void { const selMgr = (term as any).selectionManager; if (selMgr) { (selMgr as any).selectionStart = { col: startCol, absoluteRow: startAbsRow }; (selMgr as any).selectionEnd = { col: endCol, absoluteRow: endAbsRow }; }}
/** * Helper to convert viewport row to absolute row */function viewportToAbsolute(term: Terminal, viewportRow: number): number { const scrollbackLength = term.wasmTerm?.getScrollbackLength() ?? 0; const viewportY = term.getViewportY(); return scrollbackLength + viewportRow - Math.floor(viewportY);}
describe('SelectionManager', () => { let container: HTMLElement | null = null;
beforeEach(async () => { if (typeof document !== 'undefined') { container = document.createElement('div'); document.body.appendChild(container); } });
afterEach(() => { if (container && container.parentNode) { container.parentNode.removeChild(container); container = null; } });
describe('Construction', () => { test('creates without errors', async () => { const term = await createIsolatedTerminal({ cols: 80, rows: 24 }); expect(term).toBeDefined(); }); });
describe('API', () => { test('has required public methods', async () => { if (!container) return;
const term = await createIsolatedTerminal({ cols: 80, rows: 24 }); term.open(container);
const selMgr = (term as any).selectionManager; expect(typeof selMgr.getSelection).toBe('function'); expect(typeof selMgr.hasSelection).toBe('function'); expect(typeof selMgr.clearSelection).toBe('function'); expect(typeof selMgr.selectAll).toBe('function'); expect(typeof selMgr.getSelectionCoords).toBe('function'); expect(typeof selMgr.dispose).toBe('function'); expect(typeof selMgr.getDirtySelectionRows).toBe('function'); expect(typeof selMgr.clearDirtySelectionRows).toBe('function');
term.dispose(); }); });
describe('Selection with absolute coordinates', () => { test('hasSelection returns false when no selection', async () => { if (!container) return;
const term = await createIsolatedTerminal({ cols: 80, rows: 24 }); term.open(container);
const selMgr = (term as any).selectionManager; expect(selMgr.hasSelection()).toBe(false);
term.dispose(); });
test('hasSelection returns true when selection exists', async () => { if (!container) return;
const term = await createIsolatedTerminal({ cols: 80, rows: 24 }); term.open(container);
term.write('Hello World\r\n');
// Set selection using absolute coordinates setSelectionAbsolute(term, 0, 0, 5, 0);
const selMgr = (term as any).selectionManager; expect(selMgr.hasSelection()).toBe(true);
term.dispose(); });
test('hasSelection returns false for single cell selection', async () => { if (!container) return;
const term = await createIsolatedTerminal({ cols: 80, rows: 24 }); term.open(container);
// Same start and end = no real selection setSelectionAbsolute(term, 5, 0, 5, 0);
const selMgr = (term as any).selectionManager; expect(selMgr.hasSelection()).toBe(false);
term.dispose(); });
test('clearSelection clears selection and marks rows dirty', async () => { if (!container) return;
const term = await createIsolatedTerminal({ cols: 80, rows: 24 }); term.open(container);
term.write('Line 1\r\nLine 2\r\nLine 3\r\n');
const scrollbackLen = term.wasmTerm!.getScrollbackLength(); setSelectionAbsolute(term, 0, scrollbackLen, 5, scrollbackLen + 2);
const selMgr = (term as any).selectionManager; expect(selMgr.hasSelection()).toBe(true);
selMgr.clearSelection();
expect(selMgr.hasSelection()).toBe(false); // Dirty rows should be marked for redraw const dirtyRows = selMgr.getDirtySelectionRows(); expect(dirtyRows.size).toBeGreaterThan(0);
term.dispose(); }); });
describe('Selection text extraction', () => { test('getSelection returns empty string when no selection', async () => { if (!container) return;
const term = await createIsolatedTerminal({ cols: 80, rows: 24 }); term.open(container);
const selMgr = (term as any).selectionManager; expect(selMgr.getSelection()).toBe('');
term.dispose(); });
test('getSelection extracts text from screen buffer', async () => { if (!container) return;
const term = await createIsolatedTerminal({ cols: 80, rows: 24 }); term.open(container);
term.write('Hello World\r\n');
const scrollbackLen = term.wasmTerm!.getScrollbackLength(); // Select "Hello" (first 5 characters) setSelectionAbsolute(term, 0, scrollbackLen, 4, scrollbackLen);
const selMgr = (term as any).selectionManager; expect(selMgr.getSelection()).toBe('Hello');
term.dispose(); });
test('getSelection extracts multi-line text', async () => { if (!container) return;
const term = await createIsolatedTerminal({ cols: 80, rows: 24 }); term.open(container);
term.write('Line 1\r\nLine 2\r\nLine 3\r\n');
const scrollbackLen = term.wasmTerm!.getScrollbackLength(); // Select all three lines setSelectionAbsolute(term, 0, scrollbackLen, 5, scrollbackLen + 2);
const selMgr = (term as any).selectionManager; const text = selMgr.getSelection();
expect(text).toContain('Line 1'); expect(text).toContain('Line 2'); expect(text).toContain('Line 3');
term.dispose(); });
test('getSelection extracts text from scrollback', async () => { if (!container) return;
const term = await createIsolatedTerminal({ cols: 80, rows: 24, scrollback: 1000 }); term.open(container);
// Write enough lines to create scrollback for (let i = 0; i < 50; i++) { term.write(`Line ${i.toString().padStart(3, '0')}\r\n`); }
const scrollbackLen = term.wasmTerm!.getScrollbackLength(); expect(scrollbackLen).toBeGreaterThan(0);
// Select from scrollback (first few lines) setSelectionAbsolute(term, 0, 0, 10, 2);
const selMgr = (term as any).selectionManager; const text = selMgr.getSelection();
expect(text).toContain('Line 000'); expect(text).toContain('Line 001'); expect(text).toContain('Line 002');
term.dispose(); });
test('getSelection extracts text spanning scrollback and screen', async () => { if (!container) return;
const term = await createIsolatedTerminal({ cols: 80, rows: 24, scrollback: 1000 }); term.open(container);
// Write enough lines to fill scrollback and screen for (let i = 0; i < 50; i++) { term.write(`Line ${i.toString().padStart(3, '0')}\r\n`); }
const scrollbackLen = term.wasmTerm!.getScrollbackLength();
// Select spanning scrollback and screen // End of scrollback through beginning of screen setSelectionAbsolute(term, 0, scrollbackLen - 2, 10, scrollbackLen + 2);
const selMgr = (term as any).selectionManager; const text = selMgr.getSelection();
// Should contain lines from both regions expect(text.split('\n').length).toBeGreaterThanOrEqual(4);
term.dispose(); }); });
describe('Selection persistence during scroll', () => { test('selection coordinates are preserved when scrolling', async () => { if (!container) return;
const term = await createIsolatedTerminal({ cols: 80, rows: 24, scrollback: 1000 }); term.open(container);
// Write content for (let i = 0; i < 50; i++) { term.write(`Line ${i.toString().padStart(3, '0')}\r\n`); }
const scrollbackLen = term.wasmTerm!.getScrollbackLength();
// Set selection at specific absolute position const startAbsRow = scrollbackLen + 5; const endAbsRow = scrollbackLen + 10; setSelectionAbsolute(term, 0, startAbsRow, 10, endAbsRow);
const selMgr = (term as any).selectionManager; const textBefore = selMgr.getSelection();
// Scroll up term.scrollLines(-10);
// Selection should still return the same text const textAfter = selMgr.getSelection(); expect(textAfter).toBe(textBefore);
term.dispose(); });
test('selection coords convert correctly after scrolling', async () => { if (!container) return;
const term = await createIsolatedTerminal({ cols: 80, rows: 24, scrollback: 1000 }); term.open(container);
// Write content for (let i = 0; i < 50; i++) { term.write(`Line ${i.toString().padStart(3, '0')}\r\n`); }
const scrollbackLen = term.wasmTerm!.getScrollbackLength();
// Set selection in screen buffer area setSelectionAbsolute(term, 0, scrollbackLen, 10, scrollbackLen + 5);
const selMgr = (term as any).selectionManager;
// Get viewport coords before scroll const coordsBefore = selMgr.getSelectionCoords(); expect(coordsBefore).not.toBeNull();
// Scroll up 10 lines term.scrollLines(-10);
// Get viewport coords after scroll - they should have shifted const coordsAfter = selMgr.getSelectionCoords(); expect(coordsAfter).not.toBeNull();
// Viewport row should have increased by the scroll amount expect(coordsAfter!.startRow).toBe(coordsBefore!.startRow + 10); expect(coordsAfter!.endRow).toBe(coordsBefore!.endRow + 10);
term.dispose(); });
test('selection outside viewport returns null coords but preserves text', async () => { if (!container) return;
const term = await createIsolatedTerminal({ cols: 80, rows: 24, scrollback: 1000 }); term.open(container);
// Write content for (let i = 0; i < 100; i++) { term.write(`Line ${i.toString().padStart(3, '0')}\r\n`); }
// Select near the bottom of the buffer const scrollbackLen = term.wasmTerm!.getScrollbackLength(); setSelectionAbsolute(term, 0, scrollbackLen + 10, 10, scrollbackLen + 15);
const selMgr = (term as any).selectionManager; const text = selMgr.getSelection();
// Scroll to top - selection should be way off screen term.scrollToTop();
// Coords should be null (off screen) but text should still work const coords = selMgr.getSelectionCoords(); expect(coords).toBeNull();
// Text extraction should still work expect(selMgr.getSelection()).toBe(text);
term.dispose(); }); });
describe('Dirty row tracking', () => { test('getDirtySelectionRows returns empty set initially', async () => { if (!container) return;
const term = await createIsolatedTerminal({ cols: 80, rows: 24 }); term.open(container);
const selMgr = (term as any).selectionManager; expect(selMgr.getDirtySelectionRows().size).toBe(0);
term.dispose(); });
test('clearSelection marks selection rows as dirty', async () => { if (!container) return;
const term = await createIsolatedTerminal({ cols: 80, rows: 24 }); term.open(container);
term.write('Test content\r\n');
const scrollbackLen = term.wasmTerm!.getScrollbackLength(); setSelectionAbsolute(term, 0, scrollbackLen, 5, scrollbackLen + 3);
const selMgr = (term as any).selectionManager; selMgr.clearSelection();
const dirtyRows = selMgr.getDirtySelectionRows(); expect(dirtyRows.size).toBeGreaterThan(0);
term.dispose(); });
test('clearDirtySelectionRows clears the set', async () => { if (!container) return;
const term = await createIsolatedTerminal({ cols: 80, rows: 24 }); term.open(container);
term.write('Test\r\n');
const scrollbackLen = term.wasmTerm!.getScrollbackLength(); setSelectionAbsolute(term, 0, scrollbackLen, 5, scrollbackLen);
const selMgr = (term as any).selectionManager; selMgr.clearSelection();
expect(selMgr.getDirtySelectionRows().size).toBeGreaterThan(0);
selMgr.clearDirtySelectionRows();
expect(selMgr.getDirtySelectionRows().size).toBe(0);
term.dispose(); }); });
describe('Backward selection', () => { test('handles selection from right to left', async () => { if (!container) return;
const term = await createIsolatedTerminal({ cols: 80, rows: 24 }); term.open(container);
term.write('Hello World\r\n');
const scrollbackLen = term.wasmTerm!.getScrollbackLength(); // Select backwards (end before start) setSelectionAbsolute(term, 10, scrollbackLen, 0, scrollbackLen);
const selMgr = (term as any).selectionManager; const text = selMgr.getSelection();
expect(text).toBe('Hello World');
term.dispose(); });
test('handles selection from bottom to top', async () => { if (!container) return;
const term = await createIsolatedTerminal({ cols: 80, rows: 24 }); term.open(container);
term.write('Line 1\r\nLine 2\r\nLine 3\r\n');
const scrollbackLen = term.wasmTerm!.getScrollbackLength(); // Select backwards (end row before start row) setSelectionAbsolute(term, 5, scrollbackLen + 2, 0, scrollbackLen);
const selMgr = (term as any).selectionManager; const text = selMgr.getSelection();
expect(text).toContain('Line 1'); expect(text).toContain('Line 2'); expect(text).toContain('Line 3');
term.dispose(); }); });
describe('selectAll', () => { test('selectAll selects entire viewport', async () => { if (!container) return;
const term = await createIsolatedTerminal({ cols: 80, rows: 24 }); term.open(container);
term.write('Hello\r\nWorld\r\n');
const selMgr = (term as any).selectionManager; selMgr.selectAll();
expect(selMgr.hasSelection()).toBe(true);
const coords = selMgr.getSelectionCoords(); expect(coords).not.toBeNull(); expect(coords!.startRow).toBe(0); expect(coords!.startCol).toBe(0); expect(coords!.endRow).toBe(23); // rows - 1
term.dispose(); }); });
describe('select() API', () => { test('select() creates selection at specified position', async () => { if (!container) return;
const term = await createIsolatedTerminal({ cols: 80, rows: 24 }); term.open(container);
term.write('Hello World\r\n');
const selMgr = (term as any).selectionManager; selMgr.select(0, 0, 5);
expect(selMgr.hasSelection()).toBe(true); expect(selMgr.getSelection()).toBe('Hello');
term.dispose(); }); });
describe('selectLines() API', () => { test('selectLines() selects entire lines', async () => { if (!container) return;
const term = await createIsolatedTerminal({ cols: 80, rows: 24 }); term.open(container);
term.write('Line 1\r\nLine 2\r\nLine 3\r\n');
const selMgr = (term as any).selectionManager; selMgr.selectLines(0, 1);
expect(selMgr.hasSelection()).toBe(true);
const text = selMgr.getSelection(); expect(text).toContain('Line 1'); expect(text).toContain('Line 2');
term.dispose(); }); });});