Skip to content

Commit ab81de5

Browse files
authored
Support for deleteLeft for notebook multi-select (microsoft#226680)
support deleteLeft multicursor
1 parent c06299a commit ab81de5

File tree

1 file changed

+130
-38
lines changed

1 file changed

+130
-38
lines changed

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

Lines changed: 130 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
* Licensed under the MIT License. See License.txt in the project root for license information.
44
*--------------------------------------------------------------------------------------------*/
55

6+
import { localize } from 'vs/nls';
67
import { Emitter, Event } from 'vs/base/common/event';
78
import { KeyCode, KeyMod } from 'vs/base/common/keyCodes';
89
import { Disposable, DisposableStore } from 'vs/base/common/lifecycle';
@@ -15,7 +16,8 @@ import { Position } from 'vs/editor/common/core/position';
1516
import { Range } from 'vs/editor/common/core/range';
1617
import { Selection, SelectionDirection } from 'vs/editor/common/core/selection';
1718
import { IWordAtPosition, USUAL_WORD_SEPARATORS } from 'vs/editor/common/core/wordHelper';
18-
import { CursorsController } from 'vs/editor/common/cursor/cursor';
19+
import { CommandExecutor, CursorsController } from 'vs/editor/common/cursor/cursor';
20+
import { DeleteOperations } from 'vs/editor/common/cursor/cursorDeleteOperations';
1921
import { CursorConfiguration, ICursorSimpleModel } from 'vs/editor/common/cursorCommon';
2022
import { CursorChangeReason } from 'vs/editor/common/cursorEvents';
2123
import { ILanguageConfigurationService } from 'vs/editor/common/languages/languageConfigurationRegistry';
@@ -24,7 +26,6 @@ import { indentOfLine } from 'vs/editor/common/model/textModel';
2426
import { ITextModelService } from 'vs/editor/common/services/resolverService';
2527
import { ICoordinatesConverter } from 'vs/editor/common/viewModel';
2628
import { ViewModelEventsCollector } from 'vs/editor/common/viewModelEventDispatcher';
27-
import { localize } from 'vs/nls';
2829
import { IAccessibilityService } from 'vs/platform/accessibility/common/accessibility';
2930
import { MenuId, registerAction2 } from 'vs/platform/actions/common/actions';
3031
import { IConfigurationService } from 'vs/platform/configuration/common/configuration';
@@ -35,15 +36,11 @@ import { INotebookActionContext, NotebookAction } from 'vs/workbench/contrib/not
3536
import { getNotebookEditorFromEditorPane, ICellViewModel, INotebookEditor, INotebookEditorContribution } from 'vs/workbench/contrib/notebook/browser/notebookBrowser';
3637
import { registerNotebookContribution } from 'vs/workbench/contrib/notebook/browser/notebookEditorExtensions';
3738
import { CellEditorOptions } from 'vs/workbench/contrib/notebook/browser/view/cellParts/cellEditorOptions';
38-
import { NOTEBOOK_IS_ACTIVE_EDITOR, NOTEBOOK_CELL_EDITOR_FOCUSED } from 'vs/workbench/contrib/notebook/common/notebookContextKeys';
39+
import { NOTEBOOK_CELL_EDITOR_FOCUSED, NOTEBOOK_IS_ACTIVE_EDITOR } from 'vs/workbench/contrib/notebook/common/notebookContextKeys';
3940
import { IEditorService } from 'vs/workbench/services/editor/common/editorService';
4041

4142
const NOTEBOOK_ADD_FIND_MATCH_TO_SELECTION_ID = 'notebook.addFindMatchToSelection';
4243

43-
export const NOTEBOOK_MULTI_SELECTION_CONTEXT = {
44-
IsNotebookMultiSelect: new RawContextKey<boolean>('isNotebookMultiSelect', false),
45-
};
46-
4744
enum NotebookMultiCursorState {
4845
Idle,
4946
Selecting,
@@ -52,11 +49,17 @@ enum NotebookMultiCursorState {
5249

5350
interface TrackedMatch {
5451
cellViewModel: ICellViewModel;
55-
selections: Selection[];
52+
initialSelection: Selection;
53+
wordSelections: Selection[];
5654
config: IEditorConfiguration;
5755
decorationIds: string[];
5856
}
5957

58+
export const NOTEBOOK_MULTI_SELECTION_CONTEXT = {
59+
IsNotebookMultiSelect: new RawContextKey<boolean>('isNotebookMultiSelect', false),
60+
NotebookMultiSelectState: new RawContextKey<NotebookMultiCursorState>('notebookMultiSelectState', NotebookMultiCursorState.Idle),
61+
};
62+
6063
export class NotebookMultiCursorController extends Disposable implements INotebookEditorContribution {
6164

6265
static readonly id: string = 'notebook.multiCursorController';
@@ -75,6 +78,8 @@ export class NotebookMultiCursorController extends Disposable implements INotebo
7578
private cursorsControllers: ResourceMap<CursorsController> = new ResourceMap<CursorsController>();
7679

7780
private _nbIsMultiSelectSession = NOTEBOOK_MULTI_SELECTION_CONTEXT.IsNotebookMultiSelect.bindTo(this.contextKeyService);
81+
private _nbMultiSelectState = NOTEBOOK_MULTI_SELECTION_CONTEXT.NotebookMultiSelectState.bindTo(this.contextKeyService);
82+
7883

7984
constructor(
8085
private readonly notebookEditor: INotebookEditor,
@@ -104,28 +109,23 @@ export class NotebookMultiCursorController extends Disposable implements INotebo
104109
private updateCursorsControllers() {
105110
this.cursorsDisposables.clear();
106111
this.trackedMatches.forEach(async match => {
107-
// skip this for the anchor cell, there is already a controller for it since it's the focused editor
108-
if (match.cellViewModel.handle === this.anchorCell?.[0].handle) {
109-
return;
110-
}
111-
112112
const textModelRef = await this.textModelService.createModelReference(match.cellViewModel.uri);
113113
const textModel = textModelRef.object.textEditorModel;
114114
if (!textModel) {
115115
return;
116116
}
117117

118+
const cursorSimpleModel = this.constructCursorSimpleModel(match.cellViewModel);
119+
const converter = this.constructCoordinatesConverter();
118120
const editorConfig = match.config;
119121

120-
const converter = this.constructCoordinatesConverter();
121-
const cursorSimpleModel = this.constructCursorSimpleModel(match.cellViewModel);
122122
const controller = this.cursorsDisposables.add(new CursorsController(
123123
textModel,
124124
cursorSimpleModel,
125125
converter,
126126
new CursorConfiguration(textModel.getLanguageId(), textModel.getOptions(), editorConfig, this.languageConfigurationService)
127127
));
128-
controller.setSelections(new ViewModelEventsCollector(), undefined, match.selections, CursorChangeReason.Explicit);
128+
controller.setSelections(new ViewModelEventsCollector(), undefined, match.wordSelections, CursorChangeReason.Explicit);
129129
this.cursorsControllers.set(match.cellViewModel.uri, controller);
130130
});
131131
}
@@ -200,43 +200,74 @@ export class NotebookMultiCursorController extends Disposable implements INotebo
200200

201201
// typing
202202
this.anchorDisposables.add(this.anchorCell[1].onWillType((input) => {
203-
this.state = NotebookMultiCursorState.Editing; // typing will continue to work as normal across ranges, just preps for another cmd+d
204-
this.cursorsControllers.forEach(cursorController => {
205-
cursorController.type(new ViewModelEventsCollector(), input, 'keyboard');
206-
203+
const collector = new ViewModelEventsCollector();
204+
this.trackedMatches.forEach(match => {
205+
const controller = this.cursorsControllers.get(match.cellViewModel.uri);
206+
if (!controller) {
207+
// should not happen
208+
return;
209+
}
210+
if (match.cellViewModel.handle !== this.anchorCell?.[0].handle) { // don't relay to active cell, already has a controller for typing
211+
controller.type(collector, input, 'keyboard');
212+
}
207213
});
208214
}));
209215

210216
this.anchorDisposables.add(this.anchorCell[1].onDidType(() => {
211-
this.state = NotebookMultiCursorState.Idle;
217+
this.state = NotebookMultiCursorState.Editing; // typing will continue to work as normal across ranges, just preps for another cmd+d
218+
this._nbMultiSelectState.set(NotebookMultiCursorState.Editing);
219+
220+
const anchorController = this.cursorsControllers.get(this.anchorCell![0].uri);
221+
if (!anchorController) {
222+
return;
223+
}
224+
const activeSelections = this.notebookEditor.activeCodeEditor?.getSelections();
225+
if (!activeSelections) {
226+
return;
227+
}
228+
229+
// need to keep anchor cursor controller in sync manually (for delete usage), since we don't relay type event to it
230+
anchorController.setSelections(new ViewModelEventsCollector(), 'keyboard', activeSelections, CursorChangeReason.Explicit);
231+
232+
this.trackedMatches.forEach(match => {
233+
const controller = this.cursorsControllers.get(match.cellViewModel.uri);
234+
if (!controller) {
235+
return;
236+
}
237+
238+
// this is used upon exiting the multicursor session to set the selections back to the correct cursor state
239+
match.initialSelection = controller.getSelection();
240+
// clear tracked selection data as it is invalid once typing begins
241+
match.wordSelections = [];
242+
});
243+
212244
this.updateLazyDecorations();
213245
}));
214246

215247
// exit mode
216248
this.anchorDisposables.add(this.anchorCell[1].onDidChangeCursorSelection((e) => {
217-
if (e.source === 'mouse' || e.source === 'deleteLeft' || e.source === 'deleteRight') {
249+
if (e.source === 'mouse' || e.source === 'deleteRight') {
218250
this.resetToIdleState();
219251
}
220252
}));
221253

222254
this.anchorDisposables.add(this.anchorCell[1].onDidBlurEditorWidget(() => {
223-
if (this.state === NotebookMultiCursorState.Editing || this.state === NotebookMultiCursorState.Selecting) {
255+
if (this.state === NotebookMultiCursorState.Selecting || this.state === NotebookMultiCursorState.Editing) {
224256
this.resetToIdleState();
225257
}
226258
}));
227259
}
228260

229261
public resetToIdleState() {
230262
this.state = NotebookMultiCursorState.Idle;
263+
this._nbMultiSelectState.set(NotebookMultiCursorState.Idle);
231264
this._nbIsMultiSelectSession.set(false);
232265

233266
this.trackedMatches.forEach(match => {
234267
this.clearDecorations(match);
268+
match.cellViewModel.setSelections([match.initialSelection]); // correct cursor placement upon exiting cmd-d session
235269
});
236270

237-
// todo: polish -- store the precise first selection the user makes. this just sets to the end of the word (due to idle->selecting state transition logic)
238-
this.trackedMatches[0].cellViewModel.setSelections([this.trackedMatches[0].selections[0]]);
239-
240271
this.anchorDisposables.clear();
241272
this.cursorsDisposables.clear();
242273
this.cursorsControllers.clear();
@@ -277,7 +308,8 @@ export class NotebookMultiCursorController extends Disposable implements INotebo
277308
const editorConfig = this.constructCellEditorOptions(this.anchorCell[0]);
278309
const newMatch: TrackedMatch = {
279310
cellViewModel: cell,
280-
selections: [newSelection],
311+
initialSelection: inputSelection,
312+
wordSelections: [newSelection],
281313
config: editorConfig, // cache this in the match so we can create new cursors controllers with the correct language config
282314
decorationIds: []
283315
};
@@ -286,6 +318,7 @@ export class NotebookMultiCursorController extends Disposable implements INotebo
286318
this.initializeMultiSelectDecorations(newMatch);
287319
this._nbIsMultiSelectSession.set(true);
288320
this.state = NotebookMultiCursorState.Selecting;
321+
this._nbMultiSelectState.set(NotebookMultiCursorState.Selecting);
289322
this._onDidChangeAnchorCell.fire();
290323

291324
} else if (this.state === NotebookMultiCursorState.Selecting) { // use the word we stored from idle state transition to find next match, track it
@@ -320,6 +353,7 @@ export class NotebookMultiCursorController extends Disposable implements INotebo
320353
await this.notebookEditor.revealRangeInViewAsync(resultCellViewModel, findResult.match.range);
321354
this.notebookEditor.focusNotebookCell(resultCellViewModel, 'editor');
322355

356+
const initialSelection = resultCellViewModel.getSelections()[0];
323357
const newSelection = Selection.fromRange(findResult.match.range, SelectionDirection.LTR);
324358
resultCellViewModel.setSelections([newSelection]);
325359

@@ -330,7 +364,8 @@ export class NotebookMultiCursorController extends Disposable implements INotebo
330364

331365
newMatch = {
332366
cellViewModel: resultCellViewModel,
333-
selections: [newSelection],
367+
initialSelection: initialSelection,
368+
wordSelections: [newSelection],
334369
config: this.constructCellEditorOptions(this.anchorCell[0]),
335370
decorationIds: []
336371
} satisfies TrackedMatch;
@@ -340,14 +375,38 @@ export class NotebookMultiCursorController extends Disposable implements INotebo
340375

341376
} else { // match is in the same cell, find tracked entry, update and set selections
342377
newMatch = this.trackedMatches.find(match => match.cellViewModel.handle === findResult.cell.handle)!;
343-
newMatch.selections.push(Selection.fromRange(findResult.match.range, SelectionDirection.LTR));
344-
resultCellViewModel.setSelections(newMatch.selections);
378+
newMatch.wordSelections.push(Selection.fromRange(findResult.match.range, SelectionDirection.LTR));
379+
resultCellViewModel.setSelections(newMatch.wordSelections);
345380
}
346381

347382
this.initializeMultiSelectDecorations(newMatch);
348383
}
349384
}
350385

386+
public async deleteLeft(): Promise<void> {
387+
this.trackedMatches.forEach(match => {
388+
const controller = this.cursorsControllers.get(match.cellViewModel.uri);
389+
if (!controller) {
390+
// should not happen
391+
return;
392+
}
393+
394+
const [, commands] = DeleteOperations.deleteLeft(
395+
controller.getPrevEditOperationType(),
396+
controller.context.cursorConfig,
397+
controller.context.model,
398+
controller.getSelections(),
399+
controller.getAutoClosedCharacters(),
400+
);
401+
402+
const delSelections = CommandExecutor.executeCommands(controller.context.model, controller.getSelections(), commands);
403+
if (!delSelections) {
404+
return;
405+
}
406+
controller.setSelections(new ViewModelEventsCollector(), undefined, delSelections, CursorChangeReason.Explicit);
407+
});
408+
}
409+
351410
private constructCellEditorOptions(cell: ICellViewModel): EditorConfiguration {
352411
const cellEditorOptions = new CellEditorOptions(this.notebookEditor.getBaseCellEditorOptions(cell.language), this.notebookEditor.notebookOptions, this.configurationService);
353412
const options = cellEditorOptions.getUpdatedValue(cell.internalMetadata, cell.uri);
@@ -362,7 +421,7 @@ export class NotebookMultiCursorController extends Disposable implements INotebo
362421
private initializeMultiSelectDecorations(match: TrackedMatch) {
363422
const decorations: IModelDeltaDecoration[] = [];
364423

365-
match.selections.forEach(selection => {
424+
match.wordSelections.forEach(selection => {
366425
decorations.push({
367426
range: selection,
368427
options: {
@@ -379,22 +438,20 @@ export class NotebookMultiCursorController extends Disposable implements INotebo
379438
}
380439

381440
private updateLazyDecorations() {
382-
// const visibleRange = this.notebookEditor.visibleRanges;
383-
384441
// for every tracked match that is not in the visible range, dispose of their decorations and update them based off the cursorcontroller
385442
this.trackedMatches.forEach(match => {
386443
const cellIndex = this.notebookEditor.getCellIndex(match.cellViewModel);
387444
if (cellIndex === undefined) {
388445
return;
389446
}
390447

391-
let selections;
392448
const controller = this.cursorsControllers.get(match.cellViewModel.uri);
393-
if (!controller) { // active cell doesn't get a stored controller from us
394-
selections = this.notebookEditor.activeCodeEditor?.getSelections();
395-
} else {
396-
selections = controller.getSelections();
449+
if (!controller) {
450+
// should not happen
451+
return;
397452
}
453+
const selections = controller.getSelections();
454+
398455

399456
const newDecorations = selections?.map(selection => {
400457
return {
@@ -511,6 +568,41 @@ class NotebookExitMultiSelectionAction extends NotebookAction {
511568
}
512569
}
513570

571+
class NotebookDeleteLeftMultiSelectionAction extends NotebookAction {
572+
constructor() {
573+
super({
574+
id: 'noteMultiCursor.deleteLeft',
575+
title: localize('deleteLeftMultiSelection', "Delete Left"),
576+
keybinding: {
577+
when: ContextKeyExpr.and(
578+
ContextKeyExpr.equals('config.notebook.multiSelect.enabled', true),
579+
NOTEBOOK_IS_ACTIVE_EDITOR,
580+
NOTEBOOK_MULTI_SELECTION_CONTEXT.IsNotebookMultiSelect,
581+
ContextKeyExpr.or(
582+
NOTEBOOK_MULTI_SELECTION_CONTEXT.NotebookMultiSelectState.isEqualTo(NotebookMultiCursorState.Selecting),
583+
NOTEBOOK_MULTI_SELECTION_CONTEXT.NotebookMultiSelectState.isEqualTo(NotebookMultiCursorState.Editing)
584+
)
585+
),
586+
primary: KeyCode.Backspace,
587+
weight: KeybindingWeight.WorkbenchContrib
588+
}
589+
});
590+
}
591+
592+
override async runWithContext(accessor: ServicesAccessor, context: INotebookActionContext): Promise<void> {
593+
const editorService = accessor.get(IEditorService);
594+
const editor = getNotebookEditorFromEditorPane(editorService.activeEditorPane);
595+
596+
if (!editor) {
597+
return;
598+
}
599+
600+
const controller = editor.getContribution<NotebookMultiCursorController>(NotebookMultiCursorController.id);
601+
controller.deleteLeft();
602+
}
603+
}
604+
514605
registerNotebookContribution(NotebookMultiCursorController.id, NotebookMultiCursorController);
515606
registerAction2(NotebookAddMatchToMultiSelectionAction);
516607
registerAction2(NotebookExitMultiSelectionAction);
608+
registerAction2(NotebookDeleteLeftMultiSelectionAction);

0 commit comments

Comments
 (0)