Path: blob/main/extensions/copilot/test/simulation/fixtures/fix/issue-7544/notebookMulticursor.ts
13405 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { Emitter, Event } from 'vs/base/common/event';6import { KeyCode, KeyMod } from 'vs/base/common/keyCodes';7import { DisposableStore } from 'vs/base/common/lifecycle';8import { ResourceMap } from 'vs/base/common/map';9import { EditorConfiguration } from 'vs/editor/browser/config/editorConfiguration';10import { ICodeEditor } from 'vs/editor/browser/editorBrowser';11import { RedoCommand, UndoCommand } from 'vs/editor/browser/editorExtensions';12import { CodeEditorWidget } from 'vs/editor/browser/widget/codeEditor/codeEditorWidget';13import { IEditorConfiguration } from 'vs/editor/common/config/editorConfiguration';14import { Position } from 'vs/editor/common/core/position';15import { Range } from 'vs/editor/common/core/range';16import { Selection, SelectionDirection } from 'vs/editor/common/core/selection';17import { IWordAtPosition, USUAL_WORD_SEPARATORS } from 'vs/editor/common/core/wordHelper';18import { CursorsController } from 'vs/editor/common/cursor/cursor';19import { CursorConfiguration, ICursorSimpleModel } from 'vs/editor/common/cursorCommon';20import { CursorChangeReason } from 'vs/editor/common/cursorEvents';21import { ILanguageConfigurationService } from 'vs/editor/common/languages/languageConfigurationRegistry';22import { IModelDeltaDecoration, ITextModel, PositionAffinity } from 'vs/editor/common/model';23import { indentOfLine } from 'vs/editor/common/model/textModel';24import { ITextModelService } from 'vs/editor/common/services/resolverService';25import { ICoordinatesConverter } from 'vs/editor/common/viewModel';26import { ViewModelEventsCollector } from 'vs/editor/common/viewModelEventDispatcher';27import { localize } from 'vs/nls';28import { IAccessibilityService } from 'vs/platform/accessibility/common/accessibility';29import { MenuId, registerAction2 } from 'vs/platform/actions/common/actions';30import { IConfigurationService } from 'vs/platform/configuration/common/configuration';31import { ContextKeyExpr, IContextKeyService, RawContextKey } from 'vs/platform/contextkey/common/contextkey';32import { ServicesAccessor } from 'vs/platform/instantiation/common/instantiation';33import { KeybindingWeight } from 'vs/platform/keybinding/common/keybindingsRegistry';34import { IPastFutureElements, IUndoRedoElement, IUndoRedoService, UndoRedoElementType } from 'vs/platform/undoRedo/common/undoRedo';35import { registerWorkbenchContribution2, WorkbenchPhase } from 'vs/workbench/common/contributions';36import { INotebookActionContext, NotebookAction } from 'vs/workbench/contrib/notebook/browser/controller/coreActions';37import { getNotebookEditorFromEditorPane, ICellViewModel, INotebookEditor, INotebookEditorContribution } from 'vs/workbench/contrib/notebook/browser/notebookBrowser';38import { registerNotebookContribution } from 'vs/workbench/contrib/notebook/browser/notebookEditorExtensions';39import { CellEditorOptions } from 'vs/workbench/contrib/notebook/browser/view/cellParts/cellEditorOptions';40import { NOTEBOOK_CELL_EDITOR_FOCUSED, NOTEBOOK_IS_ACTIVE_EDITOR } from 'vs/workbench/contrib/notebook/common/notebookContextKeys';41import { IEditorService } from 'vs/workbench/services/editor/common/editorService';4243const NOTEBOOK_ADD_FIND_MATCH_TO_SELECTION_ID = 'notebook.addFindMatchToSelection';4445export const NOTEBOOK_MULTI_SELECTION_CONTEXT = {46IsNotebookMultiSelect: new RawContextKey<boolean>('isNotebookMultiSelect', false),47};4849enum NotebookMultiCursorState {50Idle,51Selecting,52Editing,53}5455interface TrackedMatch {56cellViewModel: ICellViewModel;57selections: Selection[];58config: IEditorConfiguration;59decorationIds: string[];60elements: IPastFutureElements;61}6263class Disposable {64protected _register<T>(t: T): T {65return t;66}6768protected dispose() { }69}707172export class NotebookMultiCursorController extends Disposable implements INotebookEditorContribution {737475static readonly id: string = 'notebook.multiCursorController';7677private state: NotebookMultiCursorState = NotebookMultiCursorState.Idle;7879private word: string = '';80private trackedMatches: TrackedMatch[] = [];8182private readonly _onDidChangeAnchorCell = this._register(new Emitter<void>());83readonly onDidChangeAnchorCell: Event<void> = this._onDidChangeAnchorCell.event;84private anchorCell: [ICellViewModel, ICodeEditor] | undefined;8586private readonly anchorDisposables = this._register(new DisposableStore());87private readonly cursorsDisposables = this._register(new DisposableStore());88private cursorsControllers: ResourceMap<[ITextModel, CursorsController]> = new ResourceMap<[ITextModel, CursorsController]>();8990constructor(91private readonly notebookEditor: INotebookEditor,92private readonly contextKeyService: IContextKeyService,93private readonly textModelService: ITextModelService,94private readonly languageConfigurationService: ILanguageConfigurationService,95private readonly accessibilityService: IAccessibilityService,96private readonly configurationService: IConfigurationService,97private readonly undoRedoService: IUndoRedoService,98) {99super();100101if (!this.configurationService.getValue<boolean>('notebook.multiSelect.enabled')) {102return;103}104105this.anchorCell = this.notebookEditor.activeCellAndCodeEditor;106107// anchor cell will catch and relay all type, cut, paste events to the cursors controllers108// need to create new controllers when the anchor cell changes, then update their listeners109// ** cursor controllers need to happen first, because anchor listeners relay to them110this._register(this.onDidChangeAnchorCell(() => {111this.updateCursorsControllers();112this.updateAnchorListeners();113}));114}115116private updateCursorsControllers() {117this.cursorsDisposables.clear();118this.trackedMatches.forEach(async match => {119// skip this for the anchor cell, there is already a controller for it since it's the focused editor120if (match.cellViewModel.handle === this.anchorCell?.[0].handle) {121return;122}123124const textModelRef = await this.textModelService.createModelReference(match.cellViewModel.uri);125const textModel = textModelRef.object.textEditorModel;126if (!textModel) {127return;128}129130const editorConfig = match.config;131132const converter = this.constructCoordinatesConverter();133const cursorSimpleModel = this.constructCursorSimpleModel(match.cellViewModel);134const controller = this.cursorsDisposables.add(new CursorsController(135textModel,136cursorSimpleModel,137converter,138new CursorConfiguration(textModel.getLanguageId(), textModel.getOptions(), editorConfig, this.languageConfigurationService)139));140controller.setSelections(new ViewModelEventsCollector(), undefined, match.selections, CursorChangeReason.Explicit);141this.cursorsControllers.set(match.cellViewModel.uri, [textModel, controller]);142});143}144145private constructCoordinatesConverter(): ICoordinatesConverter {146return {147convertViewPositionToModelPosition(viewPosition: Position): Position {148return viewPosition;149},150convertViewRangeToModelRange(viewRange: Range): Range {151return viewRange;152},153validateViewPosition(viewPosition: Position, expectedModelPosition: Position): Position {154return viewPosition;155},156validateViewRange(viewRange: Range, expectedModelRange: Range): Range {157return viewRange;158},159convertModelPositionToViewPosition(modelPosition: Position, affinity?: PositionAffinity, allowZeroLineNumber?: boolean, belowHiddenRanges?: boolean): Position {160return modelPosition;161},162convertModelRangeToViewRange(modelRange: Range, affinity?: PositionAffinity): Range {163return modelRange;164},165modelPositionIsVisible(modelPosition: Position): boolean {166return true;167},168getModelLineViewLineCount(modelLineNumber: number): number {169return 1;170},171getViewLineNumberOfModelPosition(modelLineNumber: number, modelColumn: number): number {172return modelLineNumber;173}174};175}176177private constructCursorSimpleModel(cell: ICellViewModel): ICursorSimpleModel {178return {179getLineCount(): number {180return cell.textBuffer.getLineCount();181},182getLineContent(lineNumber: number): string {183return cell.textBuffer.getLineContent(lineNumber);184},185getLineMinColumn(lineNumber: number): number {186return cell.textBuffer.getLineMinColumn(lineNumber);187},188getLineMaxColumn(lineNumber: number): number {189return cell.textBuffer.getLineMaxColumn(lineNumber);190},191getLineFirstNonWhitespaceColumn(lineNumber: number): number {192return cell.textBuffer.getLineFirstNonWhitespaceColumn(lineNumber);193},194getLineLastNonWhitespaceColumn(lineNumber: number): number {195return cell.textBuffer.getLineLastNonWhitespaceColumn(lineNumber);196},197normalizePosition(position: Position, affinity: PositionAffinity): Position {198return position;199},200getLineIndentColumn(lineNumber: number): number {201return indentOfLine(cell.textBuffer.getLineContent(lineNumber)) + 1;202}203};204}205206private updateAnchorListeners() {207this.anchorDisposables.clear();208209if (!this.anchorCell) {210throw new Error('Anchor cell is undefined');211}212213// typing214this.anchorDisposables.add(this.anchorCell[1].onWillType((input) => {215this.state = NotebookMultiCursorState.Editing; // typing will continue to work as normal across ranges, just preps for another cmd+d216this.cursorsControllers.forEach(cursorController => {217cursorController[1].type(new ViewModelEventsCollector(), input, 'keyboard');218219});220}));221222this.anchorDisposables.add(this.anchorCell[1].onDidType(() => {223this.state = NotebookMultiCursorState.Idle;224this.updateLazyDecorations();225}));226227// exit mode228this.anchorDisposables.add(this.anchorCell[1].onDidChangeCursorSelection((e) => {229if (e.source === 'mouse' || e.source === 'deleteLeft' || e.source === 'deleteRight') {230this.resetToIdleState();231}232}));233234this.anchorDisposables.add(this.anchorCell[1].onDidBlurEditorWidget(() => {235if (this.state === NotebookMultiCursorState.Editing || this.state === NotebookMultiCursorState.Selecting) {236this.resetToIdleState();237}238}));239}240241private updateFinalUndoRedo() {242const anchorCellModel = this.anchorCell?.[1].getModel();243if (!anchorCellModel) {244// should not happen245return;246}247248const textModels = [anchorCellModel];249this.cursorsControllers.forEach(controller => {250const model = controller[0];251textModels.push(model);252});253254const newElementsMap: ResourceMap<IUndoRedoElement[]> = new ResourceMap<IUndoRedoElement[]>();255256textModels.forEach(model => {257const trackedMatch = this.trackedMatches.find(match => match.cellViewModel.uri.toString() === model.uri.toString());258if (!trackedMatch) {259return;260}261const undoRedoState = trackedMatch.undoRedo;262if (!undoRedoState) {263return;264}265266const currentPastElements = this.undoRedoService.getElements(model.uri).past.slice();267const oldPastElements = trackedMatch.undoRedo.elements.past.slice();268const newElements = currentPastElements.slice(oldPastElements.length);269if (newElements.length === 0) {270return;271}272273newElementsMap.set(model.uri, newElements);274275this.undoRedoService.removeElements(model.uri);276oldPastElements.forEach(element => {277this.undoRedoService.pushElement(element);278});279});280281this.undoRedoService.pushElement({282type: UndoRedoElementType.Workspace,283resources: textModels.map(model => model.uri),284label: 'Multi Cursor Edit',285code: 'multiCursorEdit',286confirmBeforeUndo: false,287undo: async () => {288newElementsMap.forEach(async value => {289value.reverse().forEach(async element => {290await element.undo();291});292});293},294redo: async () => {295newElementsMap.forEach(async value => {296value.forEach(async element => {297await element.redo();298});299});300}301});302}303304public resetToIdleState() {305this.state = NotebookMultiCursorState.Idle;306this.updateFinalUndoRedo();307308this.trackedMatches.forEach(match => {309this.clearDecorations(match);310});311312// 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)313this.trackedMatches[0].cellViewModel.setSelections([this.trackedMatches[0].selections[0]]);314315this.anchorDisposables.clear();316this.cursorsDisposables.clear();317this.cursorsControllers.clear();318this.trackedMatches = [];319}320321public async findAndTrackNextSelection(cell: ICellViewModel): Promise<void> {322if (this.state === NotebookMultiCursorState.Idle) { // move cursor to end of the symbol + track it, transition to selecting state323const textModel = cell.textModel;324if (!textModel) {325return;326}327328const inputSelection = cell.getSelections()[0];329const word = this.getWord(inputSelection, textModel);330if (!word) {331return;332}333this.word = word.word;334335const newSelection = new Selection(336inputSelection.startLineNumber,337word.startColumn,338inputSelection.startLineNumber,339word.endColumn340);341cell.setSelections([newSelection]);342343this.anchorCell = this.notebookEditor.activeCellAndCodeEditor;344if (!this.anchorCell || this.anchorCell[0].handle !== cell.handle) {345throw new Error('Active cell is not the same as the cell passed as context');346}347if (!(this.anchorCell[1] instanceof CodeEditorWidget)) {348throw new Error('Active cell is not an instance of CodeEditorWidget');349}350351textModel.pushStackElement();352353this.trackedMatches = [];354const editorConfig = this.constructCellEditorOptions(this.anchorCell[0]);355const newMatch: TrackedMatch = {356cellViewModel: cell,357selections: [newSelection],358config: editorConfig, // cache this in the match so we can create new cursors controllers with the correct language config359decorationIds: [],360undoRedo: {361elements: this.undoRedoService.getElements(cell.uri),362}363};364this.trackedMatches.push(newMatch);365366this.initializeMultiSelectDecorations(newMatch);367this.state = NotebookMultiCursorState.Selecting;368this._onDidChangeAnchorCell.fire();369370} else if (this.state === NotebookMultiCursorState.Selecting) { // use the word we stored from idle state transition to find next match, track it371const notebookTextModel = this.notebookEditor.textModel;372if (!notebookTextModel) {373return;374}375376const index = this.notebookEditor.getCellIndex(cell);377if (index === undefined) {378return;379}380381const findResult = notebookTextModel.findNextMatch(382this.word,383{ cellIndex: index, position: cell.getSelections()[cell.getSelections().length - 1].getEndPosition() },384false,385true,386USUAL_WORD_SEPARATORS //! might want to get these from the editor config387);388if (!findResult) {389return; //todo: some sort of message to the user alerting them that there are no more matches? editor does not do this390}391392const resultCellViewModel = this.notebookEditor.getCellByHandle(findResult.cell.handle);393if (!resultCellViewModel) {394return;395}396397let newMatch: TrackedMatch;398if (findResult.cell.handle !== cell.handle) { // result is in a different cell, move focus there and apply selection, then update anchor399await this.notebookEditor.revealRangeInViewAsync(resultCellViewModel, findResult.match.range);400this.notebookEditor.focusNotebookCell(resultCellViewModel, 'editor');401402const newSelection = Selection.fromRange(findResult.match.range, SelectionDirection.LTR);403resultCellViewModel.setSelections([newSelection]);404405this.anchorCell = this.notebookEditor.activeCellAndCodeEditor;406if (!this.anchorCell || !(this.anchorCell[1] instanceof CodeEditorWidget)) {407throw new Error('Active cell is not an instance of CodeEditorWidget');408}409410const textModel = await resultCellViewModel.resolveTextModel();411textModel.pushStackElement();412413newMatch = {414cellViewModel: resultCellViewModel,415selections: [newSelection],416config: this.constructCellEditorOptions(this.anchorCell[0]),417decorationIds: [],418undoRedo: {419elements: this.undoRedoService.getElements(resultCellViewModel.uri),420}421} satisfies TrackedMatch;422this.trackedMatches.push(newMatch);423424this._onDidChangeAnchorCell.fire();425426} else { // match is in the same cell, find tracked entry, update and set selections427newMatch = this.trackedMatches.find(match => match.cellViewModel.handle === findResult.cell.handle)!;428newMatch.selections.push(Selection.fromRange(findResult.match.range, SelectionDirection.LTR));429resultCellViewModel.setSelections(newMatch.selections);430}431432this.initializeMultiSelectDecorations(newMatch);433}434}435436private constructCellEditorOptions(cell: ICellViewModel): EditorConfiguration {437const cellEditorOptions = new CellEditorOptions(this.notebookEditor.getBaseCellEditorOptions(cell.language), this.notebookEditor.notebookOptions, this.configurationService);438const options = cellEditorOptions.getUpdatedValue(cell.internalMetadata, cell.uri);439return new EditorConfiguration(false, MenuId.EditorContent, options, null, this.accessibilityService);440}441442/**443* Updates the multicursor selection decorations for a specific matched cell444*445* @param match -- match object containing the viewmodel + selections446*/447private initializeMultiSelectDecorations(match: TrackedMatch) {448const decorations: IModelDeltaDecoration[] = [];449450match.selections.forEach(selection => {451decorations.push({452range: selection,453options: {454description: '',455className: 'nb-multicursor-selection',456}457});458});459460match.decorationIds = match.cellViewModel.deltaModelDecorations(461match.decorationIds,462decorations463);464}465466private updateLazyDecorations() {467// const visibleRange = this.notebookEditor.visibleRanges;468469// for every tracked match that is not in the visible range, dispose of their decorations and update them based off the cursorcontroller470this.trackedMatches.forEach(match => {471const cellIndex = this.notebookEditor.getCellIndex(match.cellViewModel);472if (cellIndex === undefined) {473return;474}475476let selections;477const controller = this.cursorsControllers.get(match.cellViewModel.uri);478if (!controller) { // active cell doesn't get a stored controller from us479selections = this.notebookEditor.activeCodeEditor?.getSelections();480} else {481selections = controller[1].getSelections();482}483484const newDecorations = selections?.map(selection => {485return {486range: selection,487options: {488description: '',489className: 'nb-multicursor-selection',490}491};492});493494match.decorationIds = match.cellViewModel.deltaModelDecorations(495match.decorationIds,496newDecorations ?? []497);498});499}500501private clearDecorations(match: TrackedMatch) {502match.decorationIds = match.cellViewModel.deltaModelDecorations(503match.decorationIds,504[]505);506}507508async undo() {509const anchorCellModel = this.anchorCell?.[1].getModel();510if (!anchorCellModel) {511// should not happen512return;513}514515const models = [anchorCellModel];516this.cursorsControllers.forEach(controller => {517const model = controller[0];518models.push(model);519});520521await Promise.all(models.map(model => model.undo()));522}523524async redo() {525const anchorCellModel = this.anchorCell?.[1].getModel();526if (!anchorCellModel) {527// should not happen528return;529}530531const models = [anchorCellModel];532this.cursorsControllers.forEach(controller => {533const model = controller[0];534models.push(model);535});536537await Promise.all(models.map(model => model.redo()));538}539540private getWord(selection: Selection, model: ITextModel): IWordAtPosition | null {541const lineNumber = selection.startLineNumber;542const startColumn = selection.startColumn;543544if (model.isDisposed()) {545return null;546}547548return model.getWordAtPosition({549lineNumber: lineNumber,550column: startColumn551});552}553554override dispose(): void {555super.dispose();556this.anchorDisposables.dispose();557this.cursorsDisposables.dispose();558559this.trackedMatches.forEach(match => {560this.clearDecorations(match);561});562this.trackedMatches = [];563}564565}566567class NotebookAddMatchToMultiSelectionAction extends NotebookAction {568constructor() {569super({570id: NOTEBOOK_ADD_FIND_MATCH_TO_SELECTION_ID,571title: localize('addFindMatchToSelection', "Add Find Match to Selection"),572keybinding: {573when: ContextKeyExpr.and(574ContextKeyExpr.equals('config.notebook.multiSelect.enabled', true),575NOTEBOOK_IS_ACTIVE_EDITOR,576NOTEBOOK_CELL_EDITOR_FOCUSED,577),578primary: KeyMod.CtrlCmd | KeyCode.KeyD,579weight: KeybindingWeight.WorkbenchContrib580}581});582}583584async runWithContext(accessor: ServicesAccessor, context: INotebookActionContext): Promise<void> {585const editorService = accessor.get(IEditorService);586const editor = getNotebookEditorFromEditorPane(editorService.activeEditorPane);587588if (!editor) {589return;590}591592if (!context.cell) {593return;594}595596const controller = editor.getContribution<NotebookMultiCursorController>(NotebookMultiCursorController.id);597controller.findAndTrackNextSelection(context.cell);598}599}600601class NotebookExitMultiSelectionAction extends NotebookAction {602constructor() {603super({604id: 'noteMultiCursor.exit',605title: localize('exitMultiSelection', "Exit Multi Cursor Mode"),606keybinding: {607when: ContextKeyExpr.and(608ContextKeyExpr.equals('config.notebook.multiSelect.enabled', true),609NOTEBOOK_IS_ACTIVE_EDITOR,610NOTEBOOK_MULTI_SELECTION_CONTEXT.IsNotebookMultiSelect,611),612primary: KeyCode.Escape,613weight: KeybindingWeight.WorkbenchContrib614}615});616}617618async runWithContext(accessor: ServicesAccessor, context: INotebookActionContext): Promise<void> {619const editorService = accessor.get(IEditorService);620const editor = getNotebookEditorFromEditorPane(editorService.activeEditorPane);621622if (!editor) {623return;624}625626const controller = editor.getContribution<NotebookMultiCursorController>(NotebookMultiCursorController.id);627controller.resetToIdleState();628}629}630631class NotebookMultiCursorUndoRedoContribution extends Disposable {632633static readonly ID = 'workbench.contrib.notebook.multiCursorUndoRedo';634635constructor(private readonly _editorService: IEditorService) {636super();637638const PRIORITY = 10005;639this._register(UndoCommand.addImplementation(PRIORITY, 'notebook-multicursor-undo-redo', () => {640const editor = getNotebookEditorFromEditorPane(this._editorService.activeEditorPane);641if (!editor) {642return false;643}644645if (!editor.hasModel()) {646return false;647}648649const controller = editor.getContribution<NotebookMultiCursorController>(NotebookMultiCursorController.id);650651return controller.undo();652}, ContextKeyExpr.and(653ContextKeyExpr.equals('config.notebook.multiSelect.enabled', true),654NOTEBOOK_IS_ACTIVE_EDITOR,655NOTEBOOK_MULTI_SELECTION_CONTEXT.IsNotebookMultiSelect,656)));657658this._register(RedoCommand.addImplementation(PRIORITY, 'notebook-multicursor-undo-redo', () => {659const editor = getNotebookEditorFromEditorPane(this._editorService.activeEditorPane);660if (!editor) {661return false;662}663664if (!editor.hasModel()) {665return false;666}667668const controller = editor.getContribution<NotebookMultiCursorController>(NotebookMultiCursorController.id);669return controller.redo();670}, ContextKeyExpr.and(671ContextKeyExpr.equals('config.notebook.multiSelect.enabled', true),672NOTEBOOK_IS_ACTIVE_EDITOR,673NOTEBOOK_MULTI_SELECTION_CONTEXT.IsNotebookMultiSelect,674)));675}676}677678registerNotebookContribution(NotebookMultiCursorController.id, NotebookMultiCursorController);679registerAction2(NotebookAddMatchToMultiSelectionAction);680registerAction2(NotebookExitMultiSelectionAction);681registerWorkbenchContribution2(NotebookMultiCursorUndoRedoContribution.ID, NotebookMultiCursorUndoRedoContribution, WorkbenchPhase.BlockRestore);682683684