diff --git a/packages/diffs/src/editor/editor.ts b/packages/diffs/src/editor/editor.ts index 831bfe150..e9d100c41 100644 --- a/packages/diffs/src/editor/editor.ts +++ b/packages/diffs/src/editor/editor.ts @@ -30,7 +30,7 @@ import { type SearchPanelMode, SearchPanelWidget, } from './searchPanel'; -import type { EditorSelection } from './selection'; +import type { AutoSurround, EditorSelection } from './selection'; import { applyDeleteHardLineForwardToSelections, applyDeleteSoftLineBackwardToSelections, @@ -49,6 +49,7 @@ import { extendSelection, extendSelections, findNexMatch, + getAutoSurroundReplacementTexts, getCaretPosition, getDocumentBoundarySelection, getDocumentFullSelection, @@ -96,6 +97,11 @@ export interface EditorOptions { historyMaxEntries?: number; /** Render rounded corners for selection ranges, default is true. */ roundedSelection?: boolean; + /** + * Controls auto-surround when typing quotes or brackets over a selection. + * Default is `"default"` (both quotes and brackets). + */ + autoSurround?: AutoSurround; /** Show the clickable selection action icon, default is disabled. */ enabledSelectionAction?: boolean; /** Render the selection action widget element. */ @@ -607,9 +613,9 @@ export class Editor implements DiffsEditor { } #resetState(): void { + this.#setSelectedLinesSafe(null); this.#gutterWidthCache = undefined; this.#contentWidthCache = undefined; - this.#fileInstance?.setSelectedLines(null); this.#shouldIgnoreSelectionChange = false; this.#overlayElements?.forEach((el) => el.remove()); this.#overlayElements = undefined; @@ -1433,9 +1439,22 @@ export class Editor implements DiffsEditor { // input type doc: https://developer.mozilla.org/en-US/docs/Web/API/InputEvent/inputType #handleInput(inputType: string, data: string | null) { switch (inputType) { - case 'insertText': - this.#replaceSelectionText(data ?? ''); + case 'insertText': { + const text = data ?? ''; + const textDocument = this.#textDocument; + const selections = this.#selections; + const autoSurroundTexts = + textDocument !== undefined && selections !== undefined + ? getAutoSurroundReplacementTexts( + textDocument, + selections, + text, + this.#options.autoSurround + ) + : undefined; + this.#replaceSelectionText(autoSurroundTexts ?? text); break; + } case 'insertParagraph': // TODO(@ije): use document.EOF instead of '\n' this.#replaceSelectionText('\n'); @@ -1639,11 +1658,19 @@ export class Editor implements DiffsEditor { virtualCaret.remove(); } + #setSelectedLinesSafe(range: { start: number; end: number } | null): void { + try { + this.#fileInstance?.setSelectedLines(range); + } catch { + // InteractionManager.renderSelection can throw while editor DOM is updating. + } + } + #updateSelections(selections: EditorSelection[]) { this.__postponeBackgroundTokenizeToNextFrame(); this.#primaryCaretElement = undefined; - this.#fileInstance?.setSelectedLines(null); + this.#setSelectedLinesSafe(null); this.#gutterElement ?.querySelectorAll('[data-active]') .forEach((el) => el.removeAttribute('data-active')); @@ -1671,17 +1698,15 @@ export class Editor implements DiffsEditor { this.#selections = normalizedSelections; if (isCollapsedSelection(primarySelection)) { const line = primarySelection.start.line + 1; - this.#fileInstance?.setSelectedLines({ + this.#setSelectedLinesSafe({ start: line, end: line, }); - } else { - if (this.#gutterElement !== undefined) { - const pos = getCaretPosition(primarySelection); - this.#gutterElement - .querySelector(`[data-column-number="${pos.line + 1}"]`) - ?.setAttribute('data-active', ''); - } + } else if (this.#gutterElement !== undefined) { + const pos = getCaretPosition(primarySelection); + this.#gutterElement + .querySelector(`[data-column-number="${pos.line + 1}"]`) + ?.setAttribute('data-active', ''); } for (const selection of normalizedSelections) { diff --git a/packages/diffs/src/editor/selection.ts b/packages/diffs/src/editor/selection.ts index 00729328d..e3b4b612a 100644 --- a/packages/diffs/src/editor/selection.ts +++ b/packages/diffs/src/editor/selection.ts @@ -375,6 +375,34 @@ export function applyTextChangeToSelections( return { nextSelections, change }; } +/** + * Returns the next anchor/focus offsets after replacing a selection range. + * When the inserted text still contains the original selection (auto-surround), + * the inner range is reselected to match VS Code/CodeMirror behavior. + */ +function getNextSelectionOffsetPairAfterReplace( + textDocument: TextDocument, + entry: { start: number; end: number }, + offsetDelta: number, + newText: string +): [number, number] { + const insertStart = entry.start + offsetDelta; + const insertEnd = insertStart + newText.length; + const originalLength = entry.end - entry.start; + if (originalLength > 0) { + const originalText = textDocument.getText().slice(entry.start, entry.end); + const preservedOffset = newText.indexOf(originalText); + if ( + preservedOffset !== -1 && + preservedOffset + originalText.length <= newText.length + ) { + const rangeStart = insertStart + preservedOffset; + return [rangeStart, rangeStart + originalText.length]; + } + } + return [insertEnd, insertEnd]; +} + /** * Applies a text replace to multiple selections. */ @@ -438,14 +466,15 @@ export function applyTextReplaceToSelections( } const allDeletes = texts.every((text) => text === ''); let edits: ResolvedTextEdit[]; - const nextSelectionOffsets: number[] = Array.from({ - length: selections.length, - }); + const nextSelectionOffsetPairs: Array<[number, number] | undefined> = + Array.from({ + length: selections.length, + }); if (allDeletes) { edits = []; let hasEffect = false; for (const entry of ordered) { - nextSelectionOffsets[entry.index] = entry.end; + nextSelectionOffsetPairs[entry.index] = [entry.end, entry.end]; if (entry.start >= entry.end) { continue; } @@ -482,7 +511,7 @@ export function applyTextReplaceToSelections( if (next === caret) { next += delta; } - nextSelectionOffsets[entry.index] = next; + nextSelectionOffsetPairs[entry.index] = [next, next]; } } else { edits = []; @@ -503,8 +532,13 @@ export function applyTextReplaceToSelections( end: entry.end, text: newText, }); - nextSelectionOffsets[entry.index] = - entry.start + offsetDelta + newText.length; + nextSelectionOffsetPairs[entry.index] = + getNextSelectionOffsetPairAfterReplace( + textDocument, + entry, + offsetDelta, + newText + ); offsetDelta += newText.length - (entry.end - entry.start); } } @@ -512,7 +546,12 @@ export function applyTextReplaceToSelections( const change = textDocument.applyResolvedEdits(edits, true, selections); const nextSelections = createSelectionsFromOffsetPairs( textDocument, - nextSelectionOffsets.map((offset) => [offset, offset]) + nextSelectionOffsetPairs.map((offsets) => { + if (offsets === undefined) { + throw new Error('Missing next selection offsets'); + } + return offsets; + }) ); textDocument.setLastUndoSelectionsAfter(nextSelections); if (change !== undefined && lineAnnotations !== undefined) { @@ -531,6 +570,70 @@ export function applyTextReplaceToSelections( return { nextSelections, change }; } +const SURROUNDING_PAIRS: Array<[openChar: string, closeChar: string]> = [ + ["'", "'"], + ['"', '"'], + ['`', '`'], + ['{', '}'], + ['[', ']'], + ['<', '>'], + ['(', ')'], +]; + +const AUTO_SURROUND_CLOSE_CHARS = new Map(SURROUNDING_PAIRS); +const AUTO_SURROUND_QUOTE_CHARS = new Set(["'", '"', '`']); +const AUTO_SURROUND_BRACKET_CHARS = new Set(['{', '[', '(', '<']); + +export type AutoSurround = + | 'default' + | 'never' + | 'brackets' + | 'quotes' + | 'languageDefined'; + +function shouldAutoSurroundChar( + autoSurround: AutoSurround | undefined, + char: string +): boolean { + if (autoSurround === 'never') { + return false; + } + if (autoSurround === 'brackets') { + return AUTO_SURROUND_BRACKET_CHARS.has(char); + } + if (autoSurround === 'quotes') { + return AUTO_SURROUND_QUOTE_CHARS.has(char); + } + return true; +} + +/** + * Returns per-selection replacement text when typing a surround character over + * non-collapsed selections, matching VS Code auto-surround behavior. + */ +export function getAutoSurroundReplacementTexts( + textDocument: TextDocument, + selections: EditorSelection[], + char: string, + autoSurround?: AutoSurround +): string[] | undefined { + if (char.length !== 1 || selections.length === 0) { + return undefined; + } + const closeChar = AUTO_SURROUND_CLOSE_CHARS.get(char); + if (closeChar === undefined || !shouldAutoSurroundChar(autoSurround, char)) { + return undefined; + } + const replacements: string[] = []; + for (const selection of selections) { + if (isCollapsedSelection(selection)) { + return undefined; + } + replacements.push(char + textDocument.getText(selection) + closeChar); + } + return replacements; +} + /** * Swaps the two characters adjacent to a collapsed selection, matching browser * insertTranspose (Ctrl+T) behavior. diff --git a/packages/diffs/test/editorSelection.test.ts b/packages/diffs/test/editorSelection.test.ts index 06cf2c695..1c237768e 100644 --- a/packages/diffs/test/editorSelection.test.ts +++ b/packages/diffs/test/editorSelection.test.ts @@ -15,6 +15,7 @@ import { expandCollapsedSelectionToWord, extendSelection, findNexMatch, + getAutoSurroundReplacementTexts, getCaretPosition, getSelectionAnchor, mapCursorMove, @@ -768,6 +769,156 @@ describe('createSelectionFrom', () => { }); }); +describe('getAutoSurroundReplacementTexts', () => { + test('wraps selected text with matching quote pairs', () => { + const textDocument = new TextDocument('inmemory://1', 'hello world'); + const selections = [createSelection(0, 0, 0, 5, DirectionForward)]; + expect( + getAutoSurroundReplacementTexts(textDocument, selections, '"') + ).toEqual(['"hello"']); + expect( + getAutoSurroundReplacementTexts(textDocument, selections, "'") + ).toEqual(["'hello'"]); + expect( + getAutoSurroundReplacementTexts(textDocument, selections, '`') + ).toEqual(['`hello`']); + }); + + test('wraps selected text with bracket pairs', () => { + const textDocument = new TextDocument('inmemory://1', 'hello world'); + const selections = [createSelection(0, 0, 0, 5, DirectionForward)]; + expect( + getAutoSurroundReplacementTexts(textDocument, selections, '{') + ).toEqual(['{hello}']); + expect( + getAutoSurroundReplacementTexts(textDocument, selections, '[') + ).toEqual(['[hello]']); + expect( + getAutoSurroundReplacementTexts(textDocument, selections, '(') + ).toEqual(['(hello)']); + expect( + getAutoSurroundReplacementTexts(textDocument, selections, '<') + ).toEqual(['']); + }); + + test('returns undefined for collapsed selections', () => { + const textDocument = new TextDocument('inmemory://1', 'hello'); + const selections = [createSelection(0, 2, 0, 2)]; + expect( + getAutoSurroundReplacementTexts(textDocument, selections, '"') + ).toBeUndefined(); + }); + + test('returns undefined for unsupported characters', () => { + const textDocument = new TextDocument('inmemory://1', 'hello'); + const selections = [createSelection(0, 0, 0, 5, DirectionForward)]; + expect( + getAutoSurroundReplacementTexts(textDocument, selections, 'x') + ).toBeUndefined(); + }); + + test('applies auto-surround across multiple non-collapsed selections', () => { + const textDocument = new TextDocument('inmemory://1', 'foo bar baz'); + const selections = [ + createSelection(0, 0, 0, 3, DirectionForward), + createSelection(0, 4, 0, 7, DirectionForward), + ]; + const texts = getAutoSurroundReplacementTexts( + textDocument, + selections, + '"' + ); + expect(texts).toEqual(['"foo"', '"bar"']); + const { nextSelections } = applyTextReplaceToSelections( + textDocument, + selections, + texts! + ); + expect(textDocument.getText()).toBe('"foo" "bar" baz'); + expect(nextSelections).toEqual([ + createSelection(0, 1, 0, 4, DirectionForward), + createSelection(0, 7, 0, 10, DirectionForward), + ]); + }); + + test('reselects wrapped text after auto-surround', () => { + const textDocument = new TextDocument('inmemory://1', 'hello world'); + const selections = [createSelection(0, 0, 0, 11, DirectionForward)]; + const texts = getAutoSurroundReplacementTexts( + textDocument, + selections, + '"' + ); + const { nextSelections } = applyTextReplaceToSelections( + textDocument, + selections, + texts! + ); + expect(textDocument.getText()).toBe('"hello world"'); + expect(nextSelections).toEqual([ + createSelection(0, 1, 0, 12, DirectionForward), + ]); + }); + + test('never disables auto-surround for quotes and brackets', () => { + const textDocument = new TextDocument('inmemory://1', 'hello'); + const selections = [createSelection(0, 0, 0, 5, DirectionForward)]; + expect( + getAutoSurroundReplacementTexts(textDocument, selections, '"', 'never') + ).toBeUndefined(); + expect( + getAutoSurroundReplacementTexts(textDocument, selections, '{', 'never') + ).toBeUndefined(); + }); + + test('languageDefined behaves like default', () => { + const textDocument = new TextDocument('inmemory://1', 'hello'); + const selections = [createSelection(0, 0, 0, 5, DirectionForward)]; + expect( + getAutoSurroundReplacementTexts( + textDocument, + selections, + '"', + 'languageDefined' + ) + ).toEqual( + getAutoSurroundReplacementTexts(textDocument, selections, '"', 'default') + ); + expect( + getAutoSurroundReplacementTexts( + textDocument, + selections, + '{', + 'languageDefined' + ) + ).toEqual( + getAutoSurroundReplacementTexts(textDocument, selections, '{', 'default') + ); + }); + + test('brackets mode only auto-surrounds bracket pairs', () => { + const textDocument = new TextDocument('inmemory://1', 'hello'); + const selections = [createSelection(0, 0, 0, 5, DirectionForward)]; + expect( + getAutoSurroundReplacementTexts(textDocument, selections, '{', 'brackets') + ).toEqual(['{hello}']); + expect( + getAutoSurroundReplacementTexts(textDocument, selections, '"', 'brackets') + ).toBeUndefined(); + }); + + test('quotes mode only auto-surrounds quote pairs', () => { + const textDocument = new TextDocument('inmemory://1', 'hello'); + const selections = [createSelection(0, 0, 0, 5, DirectionForward)]; + expect( + getAutoSurroundReplacementTexts(textDocument, selections, '"', 'quotes') + ).toEqual(['"hello"']); + expect( + getAutoSurroundReplacementTexts(textDocument, selections, '{', 'quotes') + ).toBeUndefined(); + }); +}); + describe('applyTextChangeToSelections', () => { test('inserts the same text at multiple carets', () => { const textDocument = new TextDocument('inmemory://1', 'a\nb\nc');