diff --git a/src/vs/workbench/contrib/notebook/browser/contrib/coreActions.ts b/src/vs/workbench/contrib/notebook/browser/contrib/coreActions.ts index ac04132c2e17a..fbd01fd7e9271 100644 --- a/src/vs/workbench/contrib/notebook/browser/contrib/coreActions.ts +++ b/src/vs/workbench/contrib/notebook/browser/contrib/coreActions.ts @@ -55,6 +55,9 @@ const PASTE_CELL_COMMAND_ID = 'notebook.cell.paste'; const PASTE_CELL_ABOVE_COMMAND_ID = 'notebook.cell.pasteAbove'; const COPY_CELL_UP_COMMAND_ID = 'notebook.cell.copyUp'; const COPY_CELL_DOWN_COMMAND_ID = 'notebook.cell.copyDown'; +const SPLIT_CELL_COMMAND_ID = 'notebook.cell.split'; +const JOIN_CELL_ABOVE_COMMAND_ID = 'notebook.cell.joinAbove'; +const JOIN_CELL_BELOW_COMMAND_ID = 'notebook.cell.joinBelow'; const EXECUTE_CELL_COMMAND_ID = 'notebook.cell.execute'; const CANCEL_CELL_COMMAND_ID = 'notebook.cell.cancelExecution'; @@ -72,6 +75,7 @@ const enum CellToolbarOrder { MoveCellUp, MoveCellDown, EditCell, + SplitCell, SaveCell, ClearCellOutput, InsertCell, @@ -1396,3 +1400,96 @@ registerAction2(class extends Action2 { editor.viewModel.notebookDocument.clearAllCellOutputs(); } }); + +async function splitCell(context: INotebookCellActionContext): Promise { + if (context.cell.cellKind === CellKind.Code) { + const newCells = context.notebookEditor.splitNotebookCell(context.cell); + if (newCells) { + context.notebookEditor.focusNotebookCell(newCells[newCells.length - 1], true); + } + } +} + +registerAction2(class extends Action2 { + constructor() { + super( + { + id: SPLIT_CELL_COMMAND_ID, + title: localize('notebookActions.splitCell', "Split Cell"), + category: NOTEBOOK_ACTIONS_CATEGORY, + menu: { + id: MenuId.NotebookCellTitle, + when: ContextKeyExpr.and(NOTEBOOK_EDITOR_FOCUSED, NOTEBOOK_CELL_TYPE.isEqualTo('code'), NOTEBOOK_EDITOR_EDITABLE, InputFocusedContext), + order: CellToolbarOrder.SplitCell + }, + icon: { id: 'codicon/split-vertical' }, + f1: true + }); + } + + async run(accessor: ServicesAccessor, context?: INotebookCellActionContext) { + if (!isCellActionContext(context)) { + context = getActiveCellContext(accessor); + if (!context) { + return; + } + } + + return splitCell(context); + } +}); + + +async function joinCells(context: INotebookCellActionContext, direction: 'above' | 'below'): Promise { + const cell = await context.notebookEditor.joinNotebookCells(context.cell, direction, CellKind.Code); + if (cell) { + context.notebookEditor.focusNotebookCell(cell, true); + } +} + +registerAction2(class extends Action2 { + constructor() { + super( + { + id: JOIN_CELL_ABOVE_COMMAND_ID, + title: localize('notebookActions.joinCellAbove', "Join with Previous Cell"), + category: NOTEBOOK_ACTIONS_CATEGORY, + f1: true + }); + } + + async run(accessor: ServicesAccessor, context?: INotebookCellActionContext) { + if (!isCellActionContext(context)) { + context = getActiveCellContext(accessor); + if (!context) { + return; + } + } + + return joinCells(context, 'above'); + } +}); + +registerAction2(class extends Action2 { + constructor() { + super( + { + id: JOIN_CELL_BELOW_COMMAND_ID, + title: localize('notebookActions.joinCellBelow', "Join with Next Cell"), + category: NOTEBOOK_ACTIONS_CATEGORY, + f1: true + }); + } + + async run(accessor: ServicesAccessor, context?: INotebookCellActionContext) { + if (!isCellActionContext(context)) { + context = getActiveCellContext(accessor); + if (!context) { + return; + } + } + + return joinCells(context, 'below'); + } +}); + diff --git a/src/vs/workbench/contrib/notebook/browser/notebookBrowser.ts b/src/vs/workbench/contrib/notebook/browser/notebookBrowser.ts index 6751b0544a3eb..c4e437ec0d897 100644 --- a/src/vs/workbench/contrib/notebook/browser/notebookBrowser.ts +++ b/src/vs/workbench/contrib/notebook/browser/notebookBrowser.ts @@ -16,6 +16,7 @@ import { URI } from 'vs/base/common/uri'; import { CodeEditorWidget } from 'vs/editor/browser/widget/codeEditorWidget'; import { BareFontInfo } from 'vs/editor/common/config/fontInfo'; import { Range } from 'vs/editor/common/core/range'; +import { IPosition } from 'vs/editor/common/core/position'; import { FindMatch } from 'vs/editor/common/model'; import { RawContextKey, ContextKeyExpr } from 'vs/platform/contextkey/common/contextkey'; import { OutputRenderer } from 'vs/workbench/contrib/notebook/browser/view/output/outputRenderer'; @@ -105,6 +106,9 @@ export interface ICellViewModel { save(): void; metadata: NotebookCellMetadata | undefined; getEvaluatedMetadata(documentMetadata: NotebookDocumentMetadata | undefined): NotebookCellMetadata; + getSelectionsStartPosition(): IPosition[] | undefined; + setLinesContent(value: string[]): void; + getLinesContent(): string[]; } export interface INotebookEditorMouseEvent { @@ -168,6 +172,16 @@ export interface INotebookEditor { */ insertNotebookCell(cell: ICellViewModel | undefined, type: CellKind, direction?: 'above' | 'below', initialText?: string, ui?: boolean): CellViewModel | null; + /** + * Split a given cell into multiple cells of the same type using the selection start positions. + */ + splitNotebookCell(cell: ICellViewModel): CellViewModel[] | null; + + /** + * Joins the given cell either with the cell above or the one below depending on the given direction. + */ + joinNotebookCells(cell: ICellViewModel, direction: 'above' | 'below', constraint?: CellKind): Promise; + /** * Delete a cell from the notebook */ diff --git a/src/vs/workbench/contrib/notebook/browser/notebookEditor.ts b/src/vs/workbench/contrib/notebook/browser/notebookEditor.ts index 4a9b3e9a4ac48..53e8583e2b872 100644 --- a/src/vs/workbench/contrib/notebook/browser/notebookEditor.ts +++ b/src/vs/workbench/contrib/notebook/browser/notebookEditor.ts @@ -45,6 +45,7 @@ import { getExtraColor } from 'vs/workbench/contrib/welcome/walkThrough/common/w import { IEditorGroup, IEditorGroupsService } from 'vs/workbench/services/editor/common/editorGroupsService'; import { NotebookEditorExtensionsRegistry } from 'vs/workbench/contrib/notebook/browser/notebookEditorExtensions'; import { onUnexpectedError } from 'vs/base/common/errors'; +import { IPosition, Position } from 'vs/editor/common/core/position'; const $ = DOM.$; const NOTEBOOK_EDITOR_VIEW_STATE_PREFERENCE_KEY = 'NotebookEditorViewState'; @@ -783,6 +784,159 @@ export class NotebookEditor extends BaseEditor implements INotebookEditor { return newCell; } + private isAtEOL(p: IPosition, lines: string[]) { + const line = lines[p.lineNumber - 1]; + return line.length + 1 === p.column; + } + + private pushIfAbsent(positions: IPosition[], p: IPosition) { + const last = positions.length > 0 ? positions[positions.length - 1] : undefined; + if (!last || last.lineNumber !== p.lineNumber || last.column !== p.column) { + positions.push(p); + } + } + + /** + * Add split point at the beginning and the end; + * Move end of line split points to the beginning of the next line; + * Avoid duplicate split points + */ + private splitPointsToBoundaries(splitPoints: IPosition[], lines: string[]): IPosition[] | null { + const boundaries: IPosition[] = []; + + // split points need to be sorted + splitPoints = splitPoints.sort((l, r) => { + const lineDiff = l.lineNumber - r.lineNumber; + const columnDiff = l.column - r.column; + return lineDiff !== 0 ? lineDiff : columnDiff; + }); + + // eat-up any split point at the beginning, i.e. we ignore the split point at the very beginning + this.pushIfAbsent(boundaries, new Position(1, 1)); + + for (let sp of splitPoints) { + if (this.isAtEOL(sp, lines) && sp.lineNumber < lines.length) { + sp = new Position(sp.lineNumber + 1, 1); + } + this.pushIfAbsent(boundaries, sp); + } + + // eat-up any split point at the beginning, i.e. we ignore the split point at the very end + this.pushIfAbsent(boundaries, new Position(lines.length, lines[lines.length - 1].length + 1)); + + // if we only have two then they describe the whole range and nothing needs to be split + return boundaries.length > 2 ? boundaries : null; + } + + private computeCellLinesContents(cell: ICellViewModel, splitPoints: IPosition[]): string[][] | null { + const lines = cell.getLinesContent(); + const rangeBoundaries = this.splitPointsToBoundaries(splitPoints, lines); + if (!rangeBoundaries) { + return null; + } + const newLineModels: string[][] = []; + for (let i = 1; i < rangeBoundaries.length; i++) { + const start = rangeBoundaries[i - 1]; + const end = rangeBoundaries[i]; + // get the right lines + const newLines = lines.slice(start.lineNumber - 1, end.lineNumber); + if (start.lineNumber === end.lineNumber) { + // cut the line at the beginning and the end + let line = newLines[0]; + line = line.slice(start.column - 1, end.column - 1); + newLines[0] = line; + } + else { + // cut last line at the end + let lastLine = newLines[newLines.length - 1]; + lastLine = lastLine.slice(0, end.column - 1); + if (lastLine) { + newLines[newLines.length - 1] = lastLine; + } else { + newLines.pop(); + } + + // cut first line at the beginning + let firstLine = newLines[0]; + firstLine = firstLine.slice(start.column - 1); + if (firstLine) { + newLines[0] = firstLine; + } else { + newLines.shift(); + } + } + newLineModels.push(newLines); + } + return newLineModels; + } + + splitNotebookCell(cell: ICellViewModel): CellViewModel[] | null { + if (!this.notebookViewModel!.metadata.editable) { + return null; + } + + let splitPoints = cell.getSelectionsStartPosition(); + if (splitPoints && splitPoints.length > 0) { + let newLinesContents = this.computeCellLinesContents(cell, splitPoints); + if (newLinesContents) { + + // update the contents of the first cell + cell.setLinesContent(newLinesContents[0]); + + // create new cells based on the new text models + const language = cell.model.language; + const kind = cell.cellKind; + let insertIndex = this.notebookViewModel!.getCellIndex(cell) + 1; + const newCells = []; + for (let j = 1; j < newLinesContents.length; j++, insertIndex++) { + newCells.push(this.notebookViewModel!.createCell(insertIndex, newLinesContents[j], language, kind, true)); + } + return newCells; + } + } + + return null; + } + + async joinNotebookCells(cell: ICellViewModel, direction: 'above' | 'below', constraint?: CellKind): Promise { + if (!this.notebookViewModel!.metadata.editable) { + return null; + } + + if (constraint && cell.cellKind !== constraint) { + return null; + } + + const index = this.notebookViewModel!.getCellIndex(cell); + if (index === 0 && direction === 'above') { + return null; + } + + if (index === this.notebookViewModel!.length - 1 && direction === 'below') { + return null; + } + + if (direction === 'above') { + const above = this.notebookViewModel!.viewCells[index - 1]; + if (constraint && above.cellKind !== constraint) { + return null; + } + const newContent = above.getLinesContent().concat(cell.getLinesContent()); + above.setLinesContent(newContent); + await this.deleteNotebookCell(cell); + return above; + } else { + const below = this.notebookViewModel!.viewCells[index + 1]; + if (constraint && below.cellKind !== constraint) { + return null; + } + const newContent = cell.getLinesContent().concat(below.getLinesContent()); + cell.setLinesContent(newContent); + await this.deleteNotebookCell(below); + return cell; + } + } + async deleteNotebookCell(cell: ICellViewModel): Promise { if (!this.notebookViewModel!.metadata.editable) { return false; diff --git a/src/vs/workbench/contrib/notebook/browser/view/renderers/markdownCell.ts b/src/vs/workbench/contrib/notebook/browser/view/renderers/markdownCell.ts index b4f498a3ab426..a952cb7af4795 100644 --- a/src/vs/workbench/contrib/notebook/browser/view/renderers/markdownCell.ts +++ b/src/vs/workbench/contrib/notebook/browser/view/renderers/markdownCell.ts @@ -241,7 +241,7 @@ export class StatefullMarkdownCell extends Disposable { bindEditorListeners(model: ITextModel, dimension?: IDimension) { this.localDisposables.add(model.onDidChangeContent(() => { - this.viewCell.setText(model.getLinesContent()); + this.viewCell.setLinesContent(model.getLinesContent()); let clientHeight = this.markdownContainer.clientHeight; this.markdownContainer.innerHTML = ''; let renderedHTML = this.viewCell.getHTML(); diff --git a/src/vs/workbench/contrib/notebook/browser/viewModel/baseCellViewModel.ts b/src/vs/workbench/contrib/notebook/browser/viewModel/baseCellViewModel.ts index a46f27d754dfb..0ff66da32b91a 100644 --- a/src/vs/workbench/contrib/notebook/browser/viewModel/baseCellViewModel.ts +++ b/src/vs/workbench/contrib/notebook/browser/viewModel/baseCellViewModel.ts @@ -8,6 +8,7 @@ import { Emitter, Event } from 'vs/base/common/event'; import { Disposable, IDisposable } from 'vs/base/common/lifecycle'; import { ICodeEditor } from 'vs/editor/browser/editorBrowser'; import { Range } from 'vs/editor/common/core/range'; +import { IPosition } from 'vs/editor/common/core/position'; import * as editorCommon from 'vs/editor/common/editorCommon'; import * as model from 'vs/editor/common/model'; import { SearchParams } from 'vs/editor/common/model/textModelSearch'; @@ -199,6 +200,23 @@ export abstract class BaseCellViewModel extends Disposable implements ICellViewM return this.model.source.join('\n'); } + getLinesContent(): string[] { + if (this._textModel) { + return this._textModel.getLinesContent(); + } + + return this.model.source; + } + + setLinesContent(value: string[]) { + if (this._textModel) { + // TODO @rebornix we should avoid creating a new string here + return this._textModel.setValue(value.join('\n')); + } else { + this.model.source = value; + } + } + abstract save(): void; private saveViewState(): void { @@ -271,6 +289,16 @@ export abstract class BaseCellViewModel extends Disposable implements ICellViewM this._textEditor?.setSelection(range); } + getSelectionsStartPosition(): IPosition[] | undefined { + if (this._textEditor) { + const selections = this._textEditor.getSelections(); + return selections?.map(s => s.getStartPosition()); + } else { + const selections = this._editorViewStates?.cursorState; + return selections?.map(s => s.selectionStart); + } + } + getLineScrollTopOffset(line: number): number { if (!this._textEditor) { return 0; diff --git a/src/vs/workbench/contrib/notebook/browser/viewModel/markdownCellViewModel.ts b/src/vs/workbench/contrib/notebook/browser/viewModel/markdownCellViewModel.ts index 045831a6dcee4..2f9223a72591d 100644 --- a/src/vs/workbench/contrib/notebook/browser/viewModel/markdownCellViewModel.ts +++ b/src/vs/workbench/contrib/notebook/browser/viewModel/markdownCellViewModel.ts @@ -112,7 +112,7 @@ export class MarkdownCellViewModel extends BaseCellViewModel implements ICellVie } } - setText(strs: string[]) { + setLinesContent(strs: string[]) { this.model.source = strs; this._html = null; } diff --git a/src/vs/workbench/contrib/notebook/test/testNotebookEditor.ts b/src/vs/workbench/contrib/notebook/test/testNotebookEditor.ts index e319821cfa33a..100b8ced56b4d 100644 --- a/src/vs/workbench/contrib/notebook/test/testNotebookEditor.ts +++ b/src/vs/workbench/contrib/notebook/test/testNotebookEditor.ts @@ -117,6 +117,14 @@ export class TestNotebookEditor implements INotebookEditor { throw new Error('Method not implemented.'); } + splitNotebookCell(cell: ICellViewModel): CellViewModel[] | null { + throw new Error('Method not implemented.'); + } + + joinNotebookCells(cell: ICellViewModel, direction: 'above' | 'below', constraint?: CellKind): Promise { + throw new Error('Method not implemented.'); + } + setSelection(cell: CellViewModel, selection: Range): void { throw new Error('Method not implemented.'); }