Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
494b08e
chore: empty commit for beta branch
amadeus May 29, 2026
0fc557b
Editor (#601)
ije May 29, 2026
3e2a835
chore: update @pierre/diffs to `1.3.0-beta.1`
amadeus May 29, 2026
4966f62
chore: update @pierre/diffs to `1.3.0-beta.2`
amadeus Jun 1, 2026
471302c
feat(editor): Add rounded selection (#757)
ije Jun 2, 2026
c971d01
refactor(editor): refactor search panel (#759)
ije Jun 2, 2026
075b888
refactor(editor): Reshape search panel UI (#764)
ije Jun 3, 2026
f9a6781
pref(editor): Introduce `postponeBackgroundTokenizeToNextFrame` metho…
ije Jun 5, 2026
42e9eaa
Homepage Editor demo (#761)
mdo Jun 8, 2026
e3b2dc4
chore: update @pierre/diffs to `1.3.0-beta.3`
amadeus Jun 9, 2026
25ae1d0
fix(diffs/editor): Add input support of `deleteSoftLineBackward` and…
ije Jun 9, 2026
00af2a9
refactor(editor): realtime update on diff editing (#774)
ije Jun 9, 2026
e648af5
refactor(diffs/editor): Rename "Quick Edit" to "Selection Action" (#790)
ije Jun 9, 2026
1c26a18
feat(diffs/editor): Add marker support (#787)
ije Jun 10, 2026
e8d0767
[diffs/editor] Fix cursor move (#799)
ije Jun 10, 2026
cf651e3
chore: bump @pierre/diffs to `1.3.0-beta.4`
amadeus Jun 11, 2026
f9625ef
[diff/editor] Add auto-surround input
ije Jun 15, 2026
499035e
Add `autoSurround` option
ije Jun 15, 2026
a543fe3
Refactor `shouldAutoSurroundChar` function
ije Jun 15, 2026
8ac88b1
fix test
ije Jun 15, 2026
8e9cf80
Merge branch 'beta-1.3' into editor/auto-completion
ije Jun 16, 2026
f73ac58
Fix merge
ije Jun 16, 2026
f4ca62d
Fix autoSurround selection
ije Jun 16, 2026
f960185
Merge branch 'beta-1.3' into editor/auto-completion
ije Jun 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 38 additions & 13 deletions packages/diffs/src/editor/editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import {
type SearchPanelMode,
SearchPanelWidget,
} from './searchPanel';
import type { EditorSelection } from './selection';
import type { AutoSurround, EditorSelection } from './selection';
import {
applyDeleteHardLineForwardToSelections,
applyDeleteSoftLineBackwardToSelections,
Expand All @@ -49,6 +49,7 @@ import {
extendSelection,
extendSelections,
findNexMatch,
getAutoSurroundReplacementTexts,
getCaretPosition,
getDocumentBoundarySelection,
getDocumentFullSelection,
Expand Down Expand Up @@ -96,6 +97,11 @@ export interface EditorOptions<LAnnotation> {
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. */
Expand Down Expand Up @@ -607,9 +613,9 @@ export class Editor<LAnnotation> implements DiffsEditor<LAnnotation> {
}

#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;
Expand Down Expand Up @@ -1433,9 +1439,22 @@ export class Editor<LAnnotation> implements DiffsEditor<LAnnotation> {
// 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');
Expand Down Expand Up @@ -1639,11 +1658,19 @@ export class Editor<LAnnotation> implements DiffsEditor<LAnnotation> {
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'));
Expand Down Expand Up @@ -1671,17 +1698,15 @@ export class Editor<LAnnotation> implements DiffsEditor<LAnnotation> {
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) {
Expand Down
119 changes: 111 additions & 8 deletions packages/diffs/src/editor/selection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -375,6 +375,34 @@ export function applyTextChangeToSelections<LAnnotation>(
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<unknown>,
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.
*/
Expand Down Expand Up @@ -438,14 +466,15 @@ export function applyTextReplaceToSelections<LAnnotation>(
}
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;
}
Expand Down Expand Up @@ -482,7 +511,7 @@ export function applyTextReplaceToSelections<LAnnotation>(
if (next === caret) {
next += delta;
}
nextSelectionOffsets[entry.index] = next;
nextSelectionOffsetPairs[entry.index] = [next, next];
}
} else {
edits = [];
Expand All @@ -503,16 +532,26 @@ export function applyTextReplaceToSelections<LAnnotation>(
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);
}
}

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) {
Expand All @@ -531,6 +570,70 @@ export function applyTextReplaceToSelections<LAnnotation>(
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<LAnnotation>(
textDocument: TextDocument<LAnnotation>,
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.
Expand Down
Loading