Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
13 changes: 3 additions & 10 deletions apps/docs/app/(diffs)/docs/Editor/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -775,22 +775,15 @@ editor.cleanUp();
editor.cleanUp(true);

// Apply text edits to the attached document. Positions are zero-based.
// Pass true as the second argument to push the edits onto the undo stack.
// Edits always join the undo stack, exactly like typed input. The optional
// updateHistory argument defaults to true; false remaps live selections instead
// of restoring snapshots but keeps the text edit undoable.
editor.applyEdits([
{
range: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } },
newText: 'Hello, world!',
},
]);
editor.applyEdits(
[
{
range: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } },
newText: 'Hello, world!',
},
],
true
);

// Live FileContents for the attached document. Undefined when nothing is
// attached.
Expand Down
15 changes: 10 additions & 5 deletions apps/docs/app/(diffs)/docs/Editor/content.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -210,11 +210,16 @@ while you edit. Pass an empty array to clear them.

#### History

The editor keeps an undo stack for user edits and for programmatic edits applied
with `applyEdits(..., true)`. Use `editor.canUndo` and `editor.canRedo` to
enable or disable toolbar buttons, and call `editor.undo()` or `editor.redo()`
to step through history from code. These methods share the same stack as the
keyboard shortcuts (<kbd>Cmd/Ctrl-Z</kbd> and <kbd>Cmd/Ctrl-Shift-Z</kbd>).
The editor keeps a single undo stack covering user edits and programmatic edits
alike — `applyEdits` joins the same timeline as typed input, so a mixed sequence
undoes exactly like an all-local one. Its optional `updateHistory` argument
defaults to `true`; passing `false` remaps live selections instead of restoring
snapshots but keeps the text edit undoable. Use `editor.canUndo` and
`editor.canRedo` to enable or disable toolbar buttons, and call `editor.undo()`
or `editor.redo()` to step through history from code. These methods share the
same stack as the keyboard shortcuts (<kbd>Cmd/Ctrl-Z</kbd> and

<kbd>Cmd/Ctrl-Shift-Z</kbd>).

`undo()` and `redo()` are no-ops when there is nothing to undo or redo. Both run
through the same change path as a normal edit, so your `onChange` handler fires
Expand Down
71 changes: 54 additions & 17 deletions packages/diffs/src/editor/editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -447,9 +447,16 @@ export class Editor<LAnnotation> implements DiffsEditor<LAnnotation> {
}

/**
* Apply edits to current attached file.
* Apply edits to current attached file. Every edit joins the undo timeline:
* a programmatic edit must leave the document and its history exactly as
* the same edit typed by the user would (history equivalence — see
* TextDocument.applyResolvedEdits), so it is undoable like any other edit.
*
* @param updateHistory Whether to record caller selection snapshots for
* exact undo/redo restoration. Defaults to true. When false, live selections
* are remapped during replay and the text edit still joins the undo timeline.
*/
applyEdits(edits: TextEdit[], updateHistory = false): void {
applyEdits(edits: TextEdit[], updateHistory = true): void {
const textDocument = this.#textDocument;
if (textDocument == null) {
throw new Error('Editor is not attached');
Expand Down Expand Up @@ -2446,25 +2453,55 @@ export class Editor<LAnnotation> implements DiffsEditor<LAnnotation> {
break;

case 'undo':
if (this.#textDocument?.canUndo === true) {
const undoResult = this.#textDocument.undo();
if (undoResult !== undefined) {
this.#applyChange(...undoResult);
}
}
break;

case 'redo':
if (this.#textDocument?.canRedo === true) {
const redoResult = this.#textDocument.redo();
if (redoResult !== undefined) {
this.#applyChange(...redoResult);
}
}
this.#applyHistoryChange(command);
break;
}
}

// Replays history and remaps live selections when the entry intentionally
// has no stored selection metadata.
#applyHistoryChange(command: 'undo' | 'redo'): void {
const textDocument = this.#textDocument;
if (textDocument === undefined) {
return;
}
if (
(command === 'undo' && !textDocument.canUndo) ||
(command === 'redo' && !textDocument.canRedo)
) {
return;
}
const selections = this.#selections;
const selectionOffsets = selections?.map(
(selection) =>
[
textDocument.offsetAt(selection.start),
textDocument.offsetAt(selection.end),
] as const
);
const result =
command === 'undo' ? textDocument.undo() : textDocument.redo();
if (result === undefined) {
return;
}
const [change, recordedSelections, lineAnnotations, selectionEdits] =
result;
const nextSelections =
recordedSelections ??
(selections !== undefined &&
selectionOffsets !== undefined &&
selectionEdits !== undefined
? remapSelectionsAfterEdits(
textDocument,
selections,
selectionOffsets,
selectionEdits
)
: undefined);
this.#applyChange(change, nextSelections, lineAnnotations);
}

/** Applies one undoable command batch and records its resulting selections. */
#applyCommandEdits(
edits: TextEdit[],
Expand Down Expand Up @@ -4105,7 +4142,7 @@ export class Editor<LAnnotation> implements DiffsEditor<LAnnotation> {
get selection(): EditorSelection {
return getActiveSelection();
},
applyEdits: (edits: TextEdit[]) => this.applyEdits(edits, true),
applyEdits: (edits: TextEdit[]) => this.applyEdits(edits),
getSelectionText: () =>
this.#textDocument?.getText(getActiveSelection()) ?? '',
replaceSelectionText: (text: string) => {
Expand Down
100 changes: 54 additions & 46 deletions packages/diffs/src/editor/textDocument.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,15 @@ export interface TextDocumentChange {
][];
}

// Metadata-less replay results include the resolved edits so Editor can remap
// its live selections without storing a snapshot on the history entry.
type TextDocumentHistoryResult<LAnnotation> = [
change: TextDocumentChange,
selections?: EditorSelection[],
lineAnnotations?: DiffLineAnnotation<LAnnotation>[],
selectionEdits?: ResolvedTextEdit[],
];

/**
* A vscode-languageserver-textdocument compatible text document.
*/
Expand Down Expand Up @@ -199,7 +208,7 @@ export class TextDocument<LAnnotation> {

applyEdits(
edits: TextEdit[],
updateHistory = false,
updateHistory = true,
selectionsBefore?: EditorSelection[],
selectionsAfter?: EditorSelection[],
undoBoundary = false
Expand All @@ -225,7 +234,7 @@ export class TextDocument<LAnnotation> {

applyResolvedEdits(
edits: ResolvedTextEdit[],
updateHistory = false,
updateHistory = true,
selectionsBefore?: EditorSelection[],
selectionsAfter?: EditorSelection[],
undoBoundary = false
Expand All @@ -244,44 +253,47 @@ export class TextDocument<LAnnotation> {
);
}

// Shared mutation path after public edit coordinates are normalized and
// validated. Undo and redo apply their stored inverse offsets separately.
// Every edit joins the undo timeline: an edit applied with
// `updateHistory=false` affects the edit stack exactly as the identical
// call with `updateHistory=true` and no selections or undo boundary would.
// The flag only controls whether the caller's interaction metadata is
// recorded on the entry; the entry itself is always pushed (clearing any
// pending redo) and coalesces by the normal geometry rules. This keeps a
// mixed tracked/untracked sequence equivalent to the all-tracked sequence —
// undo-to-exhaustion restores the original text, redo-to-exhaustion the
// final text — instead of leaving frozen entries whose offsets go stale.
// Only history replay (undo/redo) writes to the buffer without recording.
#applyResolvedEdits(
resolvedEdits: ResolvedTextEdit[],
updateHistory: boolean,
selectionsBefore: EditorSelection[] | undefined,
selectionsAfter: EditorSelection[] | undefined,
undoBoundary: boolean
): TextDocumentChange {
if (updateHistory) {
const entry = createEditStackEntry(
this,
resolvedEdits,
this.#version,
this.#version + 1,
selectionsBefore,
selectionsAfter
);
if (undoBoundary) {
entry.undoBoundary = true;
}
const previousEntry = this.#editStack.peekUndo();
const change = this.#applyResolvedEditsToBuffer(resolvedEdits);
this.#version++;
if (
change.lineDelta === 0 &&
shouldCoalesceEditStackEntry(previousEntry, entry)
) {
this.#editStack.replaceLastUndo(
coalesceEditStackEntries(previousEntry!, entry)
);
} else {
this.#editStack.push(entry);
}
return change;
const entry = createEditStackEntry(
this,
resolvedEdits,
this.#version,
this.#version + 1,
updateHistory ? selectionsBefore : undefined,
updateHistory ? selectionsAfter : undefined
Comment on lines +278 to +279

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep editor selections coherent for false-history undo

When Editor.applyEdits(..., false) is used while the editor has a caret, this now creates an undoable entry but strips both selection snapshots here. Undo/redo then returns undefined selections, so Editor.#applyChange leaves #selections in the coordinates from the other document version; for example inserting a line above the caret with updateHistory=false remaps the caret down, but undoing the now-undoable edit leaves it on the non-existent lower line and the next input/focus can act on stale coordinates. Since this commit makes these edits user-undoable, the editor still needs some selection remap/restoration path even when caller metadata is omitted.

Useful? React with 👍 / 👎.

);
if (updateHistory && undoBoundary) {
entry.undoBoundary = true;
}
const previousEntry = this.#editStack.peekUndo();
const change = this.#applyResolvedEditsToBuffer(resolvedEdits);
this.#version++;
if (
change.lineDelta === 0 &&
shouldCoalesceEditStackEntry(previousEntry, entry)
) {
this.#editStack.replaceLastUndo(
coalesceEditStackEntries(previousEntry!, entry)
);
} else {
this.#editStack.push(entry);
}
return change;
}

Expand All @@ -299,13 +311,7 @@ export class TextDocument<LAnnotation> {
);
}

undo():
| [
change: TextDocumentChange,
selections?: EditorSelection[],
lineAnnotations?: DiffLineAnnotation<LAnnotation>[],
]
| undefined {
undo(): TextDocumentHistoryResult<LAnnotation> | undefined {
const entry = this.#editStack.popUndoToRedo();
if (entry === undefined) {
return undefined;
Expand All @@ -315,20 +321,18 @@ export class TextDocument<LAnnotation> {
return undefined;
}
this.#version = entry.versionBefore;
const selections = entry.selectionsBefore?.slice();
return [
change,
entry.selectionsBefore?.slice(),
selections,
entry.lineAnnotationsBefore?.slice(),
selections === undefined
? entry.inverseEdits.map((edit) => ({ ...edit }))
: undefined,
];
}

redo():
| [
change: TextDocumentChange,
selections?: EditorSelection[],
lineAnnotations?: DiffLineAnnotation<LAnnotation>[],
]
| undefined {
redo(): TextDocumentHistoryResult<LAnnotation> | undefined {
const entry = this.#editStack.popRedoToUndo();
if (entry === undefined) {
return undefined;
Expand All @@ -338,10 +342,14 @@ export class TextDocument<LAnnotation> {
return undefined;
}
this.#version = entry.versionAfter;
const selections = entry.selectionsAfter?.slice();
return [
change,
entry.selectionsAfter?.slice(),
selections,
entry.lineAnnotationsAfter?.slice(),
selections === undefined
? entry.forwardEdits.map((edit) => ({ ...edit }))
: undefined,
];
}

Expand Down
15 changes: 0 additions & 15 deletions packages/diffs/test/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,18 +112,3 @@ order. If you touch one of these areas, consider adding the missing coverage:
and backspace followed by forward-delete at the same pivot coalesces into a
single undo step instead of getting an undo stop when the delete direction
flips.
- **History equivalence across non-history edits** (7) — edits applied with
`updateHistory=false` are an implementation detail of how an edit reaches
`applyEdits`, not a separate semantic class: a mixed programmatic/local
sequence must leave history equivalent to the same sequence applied all-local,
so undo-to-exhaustion restores the original byte-exact text and
redo-to-exhaustion the final text. Today untracked edits bypass the edit stack
and existing entries apply at stale offsets, so exhaustion corrupts instead: a
stale-offset undo deletes the wrong characters, a replacement batch breaks
across an interleaved untracked insert, an untracked interior insert is erased
while tracked text is stranded, an untracked whole-document replace produces
spliced states that never existed on any timeline, typing coalesced around an
untracked insert unwinds to the untracked remainder instead of the original
text, the unwind that should restore recorded selections verbatim never
reaches the original text, and a pending redo survives an untracked edit
(instead of being cleared like any new edit) and replays at stale offsets.
Loading