Skip to content

Commit a24463c

Browse files
committed
Experimental undo/redo handling for notebook multicursor
1 parent 0c743d4 commit a24463c

File tree

1 file changed

+162
-6
lines changed

1 file changed

+162
-6
lines changed

src/vs/workbench/contrib/notebook/browser/contrib/multicursor/notebookMulticursor.ts

Lines changed: 162 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { Disposable, DisposableStore } from 'vs/base/common/lifecycle';
99
import { ResourceMap } from 'vs/base/common/map';
1010
import { EditorConfiguration } from 'vs/editor/browser/config/editorConfiguration';
1111
import { ICodeEditor } from 'vs/editor/browser/editorBrowser';
12+
import { RedoCommand, UndoCommand } from 'vs/editor/browser/editorExtensions';
1213
import { CodeEditorWidget } from 'vs/editor/browser/widget/codeEditor/codeEditorWidget';
1314
import { IEditorConfiguration } from 'vs/editor/common/config/editorConfiguration';
1415
import { Position } from 'vs/editor/common/core/position';
@@ -31,6 +32,8 @@ import { IConfigurationService } from 'vs/platform/configuration/common/configur
3132
import { ContextKeyExpr, IContextKeyService, RawContextKey } from 'vs/platform/contextkey/common/contextkey';
3233
import { ServicesAccessor } from 'vs/platform/instantiation/common/instantiation';
3334
import { KeybindingWeight } from 'vs/platform/keybinding/common/keybindingsRegistry';
35+
import { IPastFutureElements, IUndoRedoElement, IUndoRedoService, UndoRedoElementType } from 'vs/platform/undoRedo/common/undoRedo';
36+
import { registerWorkbenchContribution2, WorkbenchPhase } from 'vs/workbench/common/contributions';
3437
import { INotebookActionContext, NotebookAction } from 'vs/workbench/contrib/notebook/browser/controller/coreActions';
3538
import { getNotebookEditorFromEditorPane, ICellViewModel, INotebookEditor, INotebookEditorContribution } from 'vs/workbench/contrib/notebook/browser/notebookBrowser';
3639
import { registerNotebookContribution } from 'vs/workbench/contrib/notebook/browser/notebookEditorExtensions';
@@ -55,6 +58,7 @@ interface TrackedMatch {
5558
selections: Selection[];
5659
config: IEditorConfiguration;
5760
decorationIds: string[];
61+
undoRedoHistory: IPastFutureElements;
5862
}
5963

6064
export class NotebookMultiCursorController extends Disposable implements INotebookEditorContribution {
@@ -72,7 +76,7 @@ export class NotebookMultiCursorController extends Disposable implements INotebo
7276

7377
private readonly anchorDisposables = this._register(new DisposableStore());
7478
private readonly cursorsDisposables = this._register(new DisposableStore());
75-
private cursorsControllers: ResourceMap<CursorsController> = new ResourceMap<CursorsController>();
79+
private cursorsControllers: ResourceMap<[ITextModel, CursorsController]> = new ResourceMap<[ITextModel, CursorsController]>();
7680

7781
private _nbIsMultiSelectSession = NOTEBOOK_MULTI_SELECTION_CONTEXT.IsNotebookMultiSelect.bindTo(this.contextKeyService);
7882

@@ -83,6 +87,7 @@ export class NotebookMultiCursorController extends Disposable implements INotebo
8387
@ILanguageConfigurationService private readonly languageConfigurationService: ILanguageConfigurationService,
8488
@IAccessibilityService private readonly accessibilityService: IAccessibilityService,
8589
@IConfigurationService private readonly configurationService: IConfigurationService,
90+
@IUndoRedoService private readonly undoRedoService: IUndoRedoService,
8691
) {
8792
super();
8893

@@ -126,7 +131,7 @@ export class NotebookMultiCursorController extends Disposable implements INotebo
126131
new CursorConfiguration(textModel.getLanguageId(), textModel.getOptions(), editorConfig, this.languageConfigurationService)
127132
));
128133
controller.setSelections(new ViewModelEventsCollector(), undefined, match.selections, CursorChangeReason.Explicit);
129-
this.cursorsControllers.set(match.cellViewModel.uri, controller);
134+
this.cursorsControllers.set(match.cellViewModel.uri, [textModel, controller]);
130135
});
131136
}
132137

@@ -202,7 +207,7 @@ export class NotebookMultiCursorController extends Disposable implements INotebo
202207
this.anchorDisposables.add(this.anchorCell[1].onWillType((input) => {
203208
this.state = NotebookMultiCursorState.Editing; // typing will continue to work as normal across ranges, just preps for another cmd+d
204209
this.cursorsControllers.forEach(cursorController => {
205-
cursorController.type(new ViewModelEventsCollector(), input, 'keyboard');
210+
cursorController[1].type(new ViewModelEventsCollector(), input, 'keyboard');
206211

207212
});
208213
}));
@@ -226,9 +231,73 @@ export class NotebookMultiCursorController extends Disposable implements INotebo
226231
}));
227232
}
228233

234+
private updateFinalUndoRedo() {
235+
const anchorCellModel = this.anchorCell?.[1].getModel();
236+
if (!anchorCellModel) {
237+
// should not happen
238+
return;
239+
}
240+
241+
const textModels = [anchorCellModel];
242+
this.cursorsControllers.forEach(controller => {
243+
const model = controller[0];
244+
textModels.push(model);
245+
});
246+
247+
const newElementsMap: ResourceMap<IUndoRedoElement[]> = new ResourceMap<IUndoRedoElement[]>();
248+
249+
textModels.forEach(model => {
250+
const trackedMatch = this.trackedMatches.find(match => match.cellViewModel.uri.toString() === model.uri.toString());
251+
if (!trackedMatch) {
252+
return;
253+
}
254+
const undoRedoState = trackedMatch.undoRedoHistory;
255+
if (!undoRedoState) {
256+
return;
257+
}
258+
259+
const currentPastElements = this.undoRedoService.getElements(model.uri).past.slice();
260+
const oldPastElements = trackedMatch.undoRedoHistory.past.slice();
261+
const newElements = currentPastElements.slice(oldPastElements.length);
262+
if (newElements.length === 0) {
263+
return;
264+
}
265+
266+
newElementsMap.set(model.uri, newElements);
267+
268+
this.undoRedoService.removeElements(model.uri);
269+
oldPastElements.forEach(element => {
270+
this.undoRedoService.pushElement(element);
271+
});
272+
});
273+
274+
this.undoRedoService.pushElement({
275+
type: UndoRedoElementType.Workspace,
276+
resources: textModels.map(model => model.uri),
277+
label: 'Multi Cursor Edit',
278+
code: 'multiCursorEdit',
279+
confirmBeforeUndo: false,
280+
undo: async () => {
281+
newElementsMap.forEach(async value => {
282+
value.reverse().forEach(async element => {
283+
await element.undo();
284+
});
285+
});
286+
},
287+
redo: async () => {
288+
newElementsMap.forEach(async value => {
289+
value.forEach(async element => {
290+
await element.redo();
291+
});
292+
});
293+
}
294+
});
295+
}
296+
229297
public resetToIdleState() {
230298
this.state = NotebookMultiCursorState.Idle;
231299
this._nbIsMultiSelectSession.set(false);
300+
this.updateFinalUndoRedo();
232301

233302
this.trackedMatches.forEach(match => {
234303
this.clearDecorations(match);
@@ -273,13 +342,16 @@ export class NotebookMultiCursorController extends Disposable implements INotebo
273342
throw new Error('Active cell is not an instance of CodeEditorWidget');
274343
}
275344

345+
textModel.pushStackElement();
346+
276347
this.trackedMatches = [];
277348
const editorConfig = this.constructCellEditorOptions(this.anchorCell[0]);
278349
const newMatch: TrackedMatch = {
279350
cellViewModel: cell,
280351
selections: [newSelection],
281352
config: editorConfig, // cache this in the match so we can create new cursors controllers with the correct language config
282-
decorationIds: []
353+
decorationIds: [],
354+
undoRedoHistory: this.undoRedoService.getElements(cell.uri)
283355
};
284356
this.trackedMatches.push(newMatch);
285357

@@ -328,11 +400,15 @@ export class NotebookMultiCursorController extends Disposable implements INotebo
328400
throw new Error('Active cell is not an instance of CodeEditorWidget');
329401
}
330402

403+
const textModel = await resultCellViewModel.resolveTextModel();
404+
textModel.pushStackElement();
405+
331406
newMatch = {
332407
cellViewModel: resultCellViewModel,
333408
selections: [newSelection],
334409
config: this.constructCellEditorOptions(this.anchorCell[0]),
335-
decorationIds: []
410+
decorationIds: [],
411+
undoRedoHistory: this.undoRedoService.getElements(resultCellViewModel.uri)
336412
} satisfies TrackedMatch;
337413
this.trackedMatches.push(newMatch);
338414

@@ -393,7 +469,7 @@ export class NotebookMultiCursorController extends Disposable implements INotebo
393469
if (!controller) { // active cell doesn't get a stored controller from us
394470
selections = this.notebookEditor.activeCodeEditor?.getSelections();
395471
} else {
396-
selections = controller.getSelections();
472+
selections = controller[1].getSelections();
397473
}
398474

399475
const newDecorations = selections?.map(selection => {
@@ -420,6 +496,38 @@ export class NotebookMultiCursorController extends Disposable implements INotebo
420496
);
421497
}
422498

499+
async undo() {
500+
const anchorCellModel = this.anchorCell?.[1].getModel();
501+
if (!anchorCellModel) {
502+
// should not happen
503+
return;
504+
}
505+
506+
const models = [anchorCellModel];
507+
this.cursorsControllers.forEach(controller => {
508+
const model = controller[0];
509+
models.push(model);
510+
});
511+
512+
await Promise.all(models.map(model => model.undo()));
513+
}
514+
515+
async redo() {
516+
const anchorCellModel = this.anchorCell?.[1].getModel();
517+
if (!anchorCellModel) {
518+
// should not happen
519+
return;
520+
}
521+
522+
const models = [anchorCellModel];
523+
this.cursorsControllers.forEach(controller => {
524+
const model = controller[0];
525+
models.push(model);
526+
});
527+
528+
await Promise.all(models.map(model => model.redo()));
529+
}
530+
423531
private getWord(selection: Selection, model: ITextModel): IWordAtPosition | null {
424532
const lineNumber = selection.startLineNumber;
425533
const startColumn = selection.startColumn;
@@ -511,6 +619,54 @@ class NotebookExitMultiSelectionAction extends NotebookAction {
511619
}
512620
}
513621

622+
class NotebookMultiCursorUndoRedoContribution extends Disposable {
623+
624+
static readonly ID = 'workbench.contrib.notebook.multiCursorUndoRedo';
625+
626+
constructor(@IEditorService private readonly _editorService: IEditorService) {
627+
super();
628+
629+
const PRIORITY = 10005;
630+
this._register(UndoCommand.addImplementation(PRIORITY, 'notebook-multicursor-undo-redo', () => {
631+
const editor = getNotebookEditorFromEditorPane(this._editorService.activeEditorPane);
632+
if (!editor) {
633+
return false;
634+
}
635+
636+
if (!editor.hasModel()) {
637+
return false;
638+
}
639+
640+
const controller = editor.getContribution<NotebookMultiCursorController>(NotebookMultiCursorController.id);
641+
642+
return controller.undo();
643+
}, ContextKeyExpr.and(
644+
ContextKeyExpr.equals('config.notebook.multiSelect.enabled', true),
645+
NOTEBOOK_IS_ACTIVE_EDITOR,
646+
NOTEBOOK_MULTI_SELECTION_CONTEXT.IsNotebookMultiSelect,
647+
)));
648+
649+
this._register(RedoCommand.addImplementation(PRIORITY, 'notebook-multicursor-undo-redo', () => {
650+
const editor = getNotebookEditorFromEditorPane(this._editorService.activeEditorPane);
651+
if (!editor) {
652+
return false;
653+
}
654+
655+
if (!editor.hasModel()) {
656+
return false;
657+
}
658+
659+
const controller = editor.getContribution<NotebookMultiCursorController>(NotebookMultiCursorController.id);
660+
return controller.redo();
661+
}, ContextKeyExpr.and(
662+
ContextKeyExpr.equals('config.notebook.multiSelect.enabled', true),
663+
NOTEBOOK_IS_ACTIVE_EDITOR,
664+
NOTEBOOK_MULTI_SELECTION_CONTEXT.IsNotebookMultiSelect,
665+
)));
666+
}
667+
}
668+
514669
registerNotebookContribution(NotebookMultiCursorController.id, NotebookMultiCursorController);
515670
registerAction2(NotebookAddMatchToMultiSelectionAction);
516671
registerAction2(NotebookExitMultiSelectionAction);
672+
registerWorkbenchContribution2(NotebookMultiCursorUndoRedoContribution.ID, NotebookMultiCursorUndoRedoContribution, WorkbenchPhase.BlockRestore);

0 commit comments

Comments
 (0)