Path: blob/main/src/vs/workbench/contrib/notebook/browser/controller/cellOperations.ts
3296 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 { IBulkEditService, ResourceEdit, ResourceTextEdit } from '../../../../../editor/browser/services/bulkEditService.js';6import { IPosition, Position } from '../../../../../editor/common/core/position.js';7import { Range } from '../../../../../editor/common/core/range.js';8import { EndOfLinePreference, IReadonlyTextBuffer } from '../../../../../editor/common/model.js';9import { PLAINTEXT_LANGUAGE_ID } from '../../../../../editor/common/languages/modesRegistry.js';10import { ILanguageService } from '../../../../../editor/common/languages/language.js';11import { ResourceNotebookCellEdit } from '../../../bulkEdit/browser/bulkCellEdits.js';12import { INotebookActionContext, INotebookCellActionContext } from './coreActions.js';13import { CellEditState, CellFocusMode, expandCellRangesWithHiddenCells, IActiveNotebookEditor, ICellViewModel } from '../notebookBrowser.js';14import { CellViewModel, NotebookViewModel } from '../viewModel/notebookViewModelImpl.js';15import { cloneNotebookCellTextModel } from '../../common/model/notebookCellTextModel.js';16import { CellEditType, CellKind, ICellEditOperation, ICellReplaceEdit, IOutputDto, ISelectionState, NotebookCellMetadata, SelectionStateType } from '../../common/notebookCommon.js';17import { cellRangeContains, cellRangesToIndexes, ICellRange } from '../../common/notebookRange.js';18import { localize } from '../../../../../nls.js';19import { INotificationService } from '../../../../../platform/notification/common/notification.js';20import { INotebookKernelHistoryService } from '../../common/notebookKernelService.js';2122export async function changeCellToKind(kind: CellKind, context: INotebookActionContext, language?: string, mime?: string): Promise<void> {23const { notebookEditor } = context;24if (!notebookEditor.hasModel()) {25return;26}2728if (notebookEditor.isReadOnly) {29return;30}3132if (context.ui && context.cell) {33// action from UI34const { cell } = context;3536if (cell.cellKind === kind) {37return;38}3940const text = cell.getText();41const idx = notebookEditor.getCellIndex(cell);4243if (language === undefined) {44const availableLanguages = notebookEditor.activeKernel?.supportedLanguages ?? [];45language = availableLanguages[0] ?? PLAINTEXT_LANGUAGE_ID;46}4748notebookEditor.textModel.applyEdits([49{50editType: CellEditType.Replace,51index: idx,52count: 1,53cells: [{54cellKind: kind,55source: text,56language: language,57mime: mime ?? cell.mime,58outputs: cell.model.outputs,59metadata: cell.metadata,60}]61}62], true, {63kind: SelectionStateType.Index,64focus: notebookEditor.getFocus(),65selections: notebookEditor.getSelections()66}, () => {67return {68kind: SelectionStateType.Index,69focus: notebookEditor.getFocus(),70selections: notebookEditor.getSelections()71};72}, undefined, true);73const newCell = notebookEditor.cellAt(idx);74await notebookEditor.focusNotebookCell(newCell, cell.getEditState() === CellEditState.Editing ? 'editor' : 'container');75} else if (context.selectedCells) {76const selectedCells = context.selectedCells;77const rawEdits: ICellEditOperation[] = [];7879selectedCells.forEach(cell => {80if (cell.cellKind === kind) {81return;82}83const text = cell.getText();84const idx = notebookEditor.getCellIndex(cell);8586if (language === undefined) {87const availableLanguages = notebookEditor.activeKernel?.supportedLanguages ?? [];88language = availableLanguages[0] ?? PLAINTEXT_LANGUAGE_ID;89}9091rawEdits.push(92{93editType: CellEditType.Replace,94index: idx,95count: 1,96cells: [{97cellKind: kind,98source: text,99language: language,100mime: mime ?? cell.mime,101outputs: cell.model.outputs,102metadata: cell.metadata,103}]104}105);106});107108notebookEditor.textModel.applyEdits(rawEdits, true, {109kind: SelectionStateType.Index,110focus: notebookEditor.getFocus(),111selections: notebookEditor.getSelections()112}, () => {113return {114kind: SelectionStateType.Index,115focus: notebookEditor.getFocus(),116selections: notebookEditor.getSelections()117};118}, undefined, true);119}120}121122export function runDeleteAction(editor: IActiveNotebookEditor, cell: ICellViewModel) {123const textModel = editor.textModel;124const selections = editor.getSelections();125const targetCellIndex = editor.getCellIndex(cell);126const containingSelection = selections.find(selection => selection.start <= targetCellIndex && targetCellIndex < selection.end);127128const computeUndoRedo = !editor.isReadOnly || textModel.viewType === 'interactive';129if (containingSelection) {130const edits: ICellReplaceEdit[] = selections.reverse().map(selection => ({131editType: CellEditType.Replace, index: selection.start, count: selection.end - selection.start, cells: []132}));133134const nextCellAfterContainingSelection = containingSelection.end >= editor.getLength() ? undefined : editor.cellAt(containingSelection.end);135136textModel.applyEdits(edits, true, { kind: SelectionStateType.Index, focus: editor.getFocus(), selections: editor.getSelections() }, () => {137if (nextCellAfterContainingSelection) {138const cellIndex = textModel.cells.findIndex(cell => cell.handle === nextCellAfterContainingSelection.handle);139return { kind: SelectionStateType.Index, focus: { start: cellIndex, end: cellIndex + 1 }, selections: [{ start: cellIndex, end: cellIndex + 1 }] };140} else {141if (textModel.length) {142const lastCellIndex = textModel.length - 1;143return { kind: SelectionStateType.Index, focus: { start: lastCellIndex, end: lastCellIndex + 1 }, selections: [{ start: lastCellIndex, end: lastCellIndex + 1 }] };144145} else {146return { kind: SelectionStateType.Index, focus: { start: 0, end: 0 }, selections: [{ start: 0, end: 0 }] };147}148}149}, undefined, computeUndoRedo);150} else {151const focus = editor.getFocus();152const edits: ICellReplaceEdit[] = [{153editType: CellEditType.Replace, index: targetCellIndex, count: 1, cells: []154}];155156const finalSelections: ICellRange[] = [];157for (let i = 0; i < selections.length; i++) {158const selection = selections[i];159160if (selection.end <= targetCellIndex) {161finalSelections.push(selection);162} else if (selection.start > targetCellIndex) {163finalSelections.push({ start: selection.start - 1, end: selection.end - 1 });164} else {165finalSelections.push({ start: targetCellIndex, end: targetCellIndex + 1 });166}167}168169if (editor.cellAt(focus.start) === cell) {170// focus is the target, focus is also not part of any selection171const newFocus = focus.end === textModel.length ? { start: focus.start - 1, end: focus.end - 1 } : focus;172173textModel.applyEdits(edits, true, { kind: SelectionStateType.Index, focus: editor.getFocus(), selections: editor.getSelections() }, () => ({174kind: SelectionStateType.Index, focus: newFocus, selections: finalSelections175}), undefined, computeUndoRedo);176} else {177// users decide to delete a cell out of current focus/selection178const newFocus = focus.start > targetCellIndex ? { start: focus.start - 1, end: focus.end - 1 } : focus;179180textModel.applyEdits(edits, true, { kind: SelectionStateType.Index, focus: editor.getFocus(), selections: editor.getSelections() }, () => ({181kind: SelectionStateType.Index, focus: newFocus, selections: finalSelections182}), undefined, computeUndoRedo);183}184}185}186187export async function moveCellRange(context: INotebookActionContext, direction: 'up' | 'down'): Promise<void> {188if (!context.notebookEditor.hasModel()) {189return;190}191const editor = context.notebookEditor;192const textModel = editor.textModel;193194if (editor.isReadOnly) {195return;196}197198let range: ICellRange | undefined = undefined;199200if (context.cell) {201const idx = editor.getCellIndex(context.cell);202range = { start: idx, end: idx + 1 };203} else {204const selections = editor.getSelections();205const modelRanges = expandCellRangesWithHiddenCells(editor, selections);206range = modelRanges[0];207}208209if (!range || range.start === range.end) {210return;211}212213if (direction === 'up') {214if (range.start === 0) {215return;216}217218const indexAbove = range.start - 1;219const finalSelection = { start: range.start - 1, end: range.end - 1 };220const focus = context.notebookEditor.getFocus();221const newFocus = cellRangeContains(range, focus) ? { start: focus.start - 1, end: focus.end - 1 } : { start: range.start - 1, end: range.start };222textModel.applyEdits([223{224editType: CellEditType.Move,225index: indexAbove,226length: 1,227newIdx: range.end - 1228}],229true,230{231kind: SelectionStateType.Index,232focus: editor.getFocus(),233selections: editor.getSelections()234},235() => ({ kind: SelectionStateType.Index, focus: newFocus, selections: [finalSelection] }),236undefined,237true238);239const focusRange = editor.getSelections()[0] ?? editor.getFocus();240editor.revealCellRangeInView(focusRange);241} else {242if (range.end >= textModel.length) {243return;244}245246const indexBelow = range.end;247const finalSelection = { start: range.start + 1, end: range.end + 1 };248const focus = editor.getFocus();249const newFocus = cellRangeContains(range, focus) ? { start: focus.start + 1, end: focus.end + 1 } : { start: range.start + 1, end: range.start + 2 };250251textModel.applyEdits([252{253editType: CellEditType.Move,254index: indexBelow,255length: 1,256newIdx: range.start257}],258true,259{260kind: SelectionStateType.Index,261focus: editor.getFocus(),262selections: editor.getSelections()263},264() => ({ kind: SelectionStateType.Index, focus: newFocus, selections: [finalSelection] }),265undefined,266true267);268269const focusRange = editor.getSelections()[0] ?? editor.getFocus();270editor.revealCellRangeInView(focusRange);271}272}273274export async function copyCellRange(context: INotebookCellActionContext, direction: 'up' | 'down'): Promise<void> {275const editor = context.notebookEditor;276if (!editor.hasModel()) {277return;278}279280const textModel = editor.textModel;281282if (editor.isReadOnly) {283return;284}285286let range: ICellRange | undefined = undefined;287288if (context.ui) {289const targetCell = context.cell;290const targetCellIndex = editor.getCellIndex(targetCell);291range = { start: targetCellIndex, end: targetCellIndex + 1 };292} else {293const selections = editor.getSelections();294const modelRanges = expandCellRangesWithHiddenCells(editor, selections);295range = modelRanges[0];296}297298if (!range || range.start === range.end) {299return;300}301302if (direction === 'up') {303// insert up, without changing focus and selections304const focus = editor.getFocus();305const selections = editor.getSelections();306textModel.applyEdits([307{308editType: CellEditType.Replace,309index: range.end,310count: 0,311cells: cellRangesToIndexes([range]).map(index => cloneNotebookCellTextModel(editor.cellAt(index)!.model))312}],313true,314{315kind: SelectionStateType.Index,316focus: focus,317selections: selections318},319() => ({ kind: SelectionStateType.Index, focus: focus, selections: selections }),320undefined,321true322);323} else {324// insert down, move selections325const focus = editor.getFocus();326const selections = editor.getSelections();327const newCells = cellRangesToIndexes([range]).map(index => cloneNotebookCellTextModel(editor.cellAt(index)!.model));328const countDelta = newCells.length;329const newFocus = context.ui ? focus : { start: focus.start + countDelta, end: focus.end + countDelta };330const newSelections = context.ui ? selections : [{ start: range.start + countDelta, end: range.end + countDelta }];331textModel.applyEdits([332{333editType: CellEditType.Replace,334index: range.end,335count: 0,336cells: cellRangesToIndexes([range]).map(index => cloneNotebookCellTextModel(editor.cellAt(index)!.model))337}],338true,339{340kind: SelectionStateType.Index,341focus: focus,342selections: selections343},344() => ({ kind: SelectionStateType.Index, focus: newFocus, selections: newSelections }),345undefined,346true347);348349const focusRange = editor.getSelections()[0] ?? editor.getFocus();350editor.revealCellRangeInView(focusRange);351}352}353354export async function joinSelectedCells(bulkEditService: IBulkEditService, notificationService: INotificationService, context: INotebookCellActionContext): Promise<void> {355const editor = context.notebookEditor;356if (editor.isReadOnly) {357return;358}359360const edits: ResourceEdit[] = [];361const cells: ICellViewModel[] = [];362for (const selection of editor.getSelections()) {363cells.push(...editor.getCellsInRange(selection));364}365366if (cells.length <= 1) {367return;368}369370// check if all cells are of the same kind371const cellKind = cells[0].cellKind;372const isSameKind = cells.every(cell => cell.cellKind === cellKind);373if (!isSameKind) {374// cannot join cells of different kinds375// show warning and quit376const message = localize('notebookActions.joinSelectedCells', "Cannot join cells of different kinds");377return notificationService.warn(message);378}379380// merge all cells content into first cell381const firstCell = cells[0];382const insertContent = cells.map(cell => cell.getText()).join(firstCell.textBuffer.getEOL());383const firstSelection = editor.getSelections()[0];384edits.push(385new ResourceNotebookCellEdit(editor.textModel.uri,386{387editType: CellEditType.Replace,388index: firstSelection.start,389count: firstSelection.end - firstSelection.start,390cells: [{391cellKind: firstCell.cellKind,392source: insertContent,393language: firstCell.language,394mime: firstCell.mime,395outputs: firstCell.model.outputs,396metadata: firstCell.metadata,397}]398}399)400);401402for (const selection of editor.getSelections().slice(1)) {403edits.push(new ResourceNotebookCellEdit(editor.textModel.uri,404{405editType: CellEditType.Replace,406index: selection.start,407count: selection.end - selection.start,408cells: []409}));410}411412if (edits.length) {413await bulkEditService.apply(414edits,415{ quotableLabel: localize('notebookActions.joinSelectedCells.label', "Join Notebook Cells") }416);417}418}419420export async function joinNotebookCells(editor: IActiveNotebookEditor, range: ICellRange, direction: 'above' | 'below', constraint?: CellKind): Promise<{ edits: ResourceEdit[]; cell: ICellViewModel; endFocus: ICellRange; endSelections: ICellRange[] } | null> {421if (editor.isReadOnly) {422return null;423}424425const textModel = editor.textModel;426const cells = editor.getCellsInRange(range);427428if (!cells.length) {429return null;430}431432if (range.start === 0 && direction === 'above') {433return null;434}435436if (range.end === textModel.length && direction === 'below') {437return null;438}439440for (let i = 0; i < cells.length; i++) {441const cell = cells[i];442443if (constraint && cell.cellKind !== constraint) {444return null;445}446}447448if (direction === 'above') {449const above = editor.cellAt(range.start - 1) as CellViewModel;450if (constraint && above.cellKind !== constraint) {451return null;452}453454const insertContent = cells.map(cell => (cell.textBuffer.getEOL() ?? '') + cell.getText()).join('');455const aboveCellLineCount = above.textBuffer.getLineCount();456const aboveCellLastLineEndColumn = above.textBuffer.getLineLength(aboveCellLineCount);457458return {459edits: [460new ResourceTextEdit(above.uri, { range: new Range(aboveCellLineCount, aboveCellLastLineEndColumn + 1, aboveCellLineCount, aboveCellLastLineEndColumn + 1), text: insertContent }),461new ResourceNotebookCellEdit(textModel.uri,462{463editType: CellEditType.Replace,464index: range.start,465count: range.end - range.start,466cells: []467}468)469],470cell: above,471endFocus: { start: range.start - 1, end: range.start },472endSelections: [{ start: range.start - 1, end: range.start }]473};474} else {475const below = editor.cellAt(range.end) as CellViewModel;476if (constraint && below.cellKind !== constraint) {477return null;478}479480const cell = cells[0];481const restCells = [...cells.slice(1), below];482const insertContent = restCells.map(cl => (cl.textBuffer.getEOL() ?? '') + cl.getText()).join('');483484const cellLineCount = cell.textBuffer.getLineCount();485const cellLastLineEndColumn = cell.textBuffer.getLineLength(cellLineCount);486487return {488edits: [489new ResourceTextEdit(cell.uri, { range: new Range(cellLineCount, cellLastLineEndColumn + 1, cellLineCount, cellLastLineEndColumn + 1), text: insertContent }),490new ResourceNotebookCellEdit(textModel.uri,491{492editType: CellEditType.Replace,493index: range.start + 1,494count: range.end - range.start,495cells: []496}497)498],499cell,500endFocus: { start: range.start, end: range.start + 1 },501endSelections: [{ start: range.start, end: range.start + 1 }]502};503}504}505506export async function joinCellsWithSurrounds(bulkEditService: IBulkEditService, context: INotebookCellActionContext, direction: 'above' | 'below'): Promise<void> {507const editor = context.notebookEditor;508const textModel = editor.textModel;509const viewModel = editor.getViewModel() as NotebookViewModel;510let ret: {511edits: ResourceEdit[];512cell: ICellViewModel;513endFocus: ICellRange;514endSelections: ICellRange[];515} | null = null;516517if (context.ui) {518const focusMode = context.cell.focusMode;519const cellIndex = editor.getCellIndex(context.cell);520ret = await joinNotebookCells(editor, { start: cellIndex, end: cellIndex + 1 }, direction);521if (!ret) {522return;523}524525await bulkEditService.apply(526ret?.edits,527{ quotableLabel: 'Join Notebook Cells' }528);529viewModel.updateSelectionsState({ kind: SelectionStateType.Index, focus: ret.endFocus, selections: ret.endSelections });530ret.cell.updateEditState(CellEditState.Editing, 'joinCellsWithSurrounds');531editor.revealCellRangeInView(editor.getFocus());532if (focusMode === CellFocusMode.Editor) {533ret.cell.focusMode = CellFocusMode.Editor;534}535} else {536const selections = editor.getSelections();537if (!selections.length) {538return;539}540541const focus = editor.getFocus();542const focusMode = editor.cellAt(focus.start)?.focusMode;543544const edits: ResourceEdit[] = [];545let cell: ICellViewModel | null = null;546const cells: ICellViewModel[] = [];547548for (let i = selections.length - 1; i >= 0; i--) {549const selection = selections[i];550const containFocus = cellRangeContains(selection, focus);551552if (553selection.end >= textModel.length && direction === 'below'554|| selection.start === 0 && direction === 'above'555) {556if (containFocus) {557cell = editor.cellAt(focus.start)!;558}559560cells.push(...editor.getCellsInRange(selection));561continue;562}563564const singleRet = await joinNotebookCells(editor, selection, direction);565566if (!singleRet) {567return;568}569570edits.push(...singleRet.edits);571cells.push(singleRet.cell);572573if (containFocus) {574cell = singleRet.cell;575}576}577578if (!edits.length) {579return;580}581582if (!cell || !cells.length) {583return;584}585586await bulkEditService.apply(587edits,588{ quotableLabel: 'Join Notebook Cells' }589);590591cells.forEach(cell => {592cell.updateEditState(CellEditState.Editing, 'joinCellsWithSurrounds');593});594595viewModel.updateSelectionsState({ kind: SelectionStateType.Handle, primary: cell.handle, selections: cells.map(cell => cell.handle) });596editor.revealCellRangeInView(editor.getFocus());597const newFocusedCell = editor.cellAt(editor.getFocus().start);598if (focusMode === CellFocusMode.Editor && newFocusedCell) {599newFocusedCell.focusMode = CellFocusMode.Editor;600}601}602}603604function _splitPointsToBoundaries(splitPoints: IPosition[], textBuffer: IReadonlyTextBuffer): IPosition[] | null {605const boundaries: IPosition[] = [];606const lineCnt = textBuffer.getLineCount();607const getLineLen = (lineNumber: number) => {608return textBuffer.getLineLength(lineNumber);609};610611// split points need to be sorted612splitPoints = splitPoints.sort((l, r) => {613const lineDiff = l.lineNumber - r.lineNumber;614const columnDiff = l.column - r.column;615return lineDiff !== 0 ? lineDiff : columnDiff;616});617618for (let sp of splitPoints) {619if (getLineLen(sp.lineNumber) + 1 === sp.column && sp.column !== 1 /** empty line */ && sp.lineNumber < lineCnt) {620sp = new Position(sp.lineNumber + 1, 1);621}622_pushIfAbsent(boundaries, sp);623}624625if (boundaries.length === 0) {626return null;627}628629// boundaries already sorted and not empty630const modelStart = new Position(1, 1);631const modelEnd = new Position(lineCnt, getLineLen(lineCnt) + 1);632return [modelStart, ...boundaries, modelEnd];633}634635function _pushIfAbsent(positions: IPosition[], p: IPosition) {636const last = positions.length > 0 ? positions[positions.length - 1] : undefined;637if (!last || last.lineNumber !== p.lineNumber || last.column !== p.column) {638positions.push(p);639}640}641642export function computeCellLinesContents(cell: ICellViewModel, splitPoints: IPosition[]): string[] | null {643const rangeBoundaries = _splitPointsToBoundaries(splitPoints, cell.textBuffer);644if (!rangeBoundaries) {645return null;646}647const newLineModels: string[] = [];648for (let i = 1; i < rangeBoundaries.length; i++) {649const start = rangeBoundaries[i - 1];650const end = rangeBoundaries[i];651652newLineModels.push(cell.textBuffer.getValueInRange(new Range(start.lineNumber, start.column, end.lineNumber, end.column), EndOfLinePreference.TextDefined));653}654655return newLineModels;656}657658export function insertCell(659languageService: ILanguageService,660editor: IActiveNotebookEditor,661index: number,662type: CellKind,663direction: 'above' | 'below' = 'above',664initialText: string = '',665ui: boolean = false,666kernelHistoryService?: INotebookKernelHistoryService667) {668const viewModel = editor.getViewModel() as NotebookViewModel;669const activeKernel = editor.activeKernel;670if (viewModel.options.isReadOnly) {671return null;672}673674const cell = editor.cellAt(index);675const nextIndex = ui ? viewModel.getNextVisibleCellIndex(index) : index + 1;676let language;677if (type === CellKind.Code) {678const supportedLanguages = activeKernel?.supportedLanguages ?? languageService.getRegisteredLanguageIds();679const defaultLanguage = supportedLanguages[0] || PLAINTEXT_LANGUAGE_ID;680681if (cell?.cellKind === CellKind.Code) {682language = cell.language;683} else if (cell?.cellKind === CellKind.Markup) {684const nearestCodeCellIndex = viewModel.nearestCodeCellIndex(index);685if (nearestCodeCellIndex > -1) {686language = viewModel.cellAt(nearestCodeCellIndex)!.language;687} else {688language = defaultLanguage;689}690} else if (!cell && viewModel.length === 0) {691// No cells in notebook - check kernel history692const lastKernels = kernelHistoryService?.getKernels(viewModel.notebookDocument);693if (lastKernels?.all.length) {694const lastKernel = lastKernels.all[0];695language = lastKernel.supportedLanguages[0] || defaultLanguage;696} else {697language = defaultLanguage;698}699} else {700if (cell === undefined && direction === 'above') {701// insert cell at the very top702language = viewModel.viewCells.find(cell => cell.cellKind === CellKind.Code)?.language || defaultLanguage;703} else {704language = defaultLanguage;705}706}707708if (!supportedLanguages.includes(language)) {709// the language no longer exists710language = defaultLanguage;711}712} else {713language = 'markdown';714}715716const insertIndex = cell ?717(direction === 'above' ? index : nextIndex) :718index;719return insertCellAtIndex(viewModel, insertIndex, initialText, language, type, undefined, [], true, true);720}721722export function insertCellAtIndex(viewModel: NotebookViewModel, index: number, source: string, language: string, type: CellKind, metadata: NotebookCellMetadata | undefined, outputs: IOutputDto[], synchronous: boolean, pushUndoStop: boolean): CellViewModel {723const endSelections: ISelectionState = { kind: SelectionStateType.Index, focus: { start: index, end: index + 1 }, selections: [{ start: index, end: index + 1 }] };724viewModel.notebookDocument.applyEdits([725{726editType: CellEditType.Replace,727index,728count: 0,729cells: [730{731cellKind: type,732language: language,733mime: undefined,734outputs: outputs,735metadata: metadata,736source: source737}738]739}740], synchronous, { kind: SelectionStateType.Index, focus: viewModel.getFocus(), selections: viewModel.getSelections() }, () => endSelections, undefined, pushUndoStop && !viewModel.options.isReadOnly);741return viewModel.cellAt(index)!;742}743744745