Skip to content

Commit

Permalink
Merge pull request #96156 from microsoft/kieferrm/split-cell
Browse files Browse the repository at this point in the history
[Notebook] split and join
  • Loading branch information
rebornix authored May 4, 2020
2 parents 2785134 + 2d0f820 commit ec616b3
Show file tree
Hide file tree
Showing 7 changed files with 303 additions and 2 deletions.
97 changes: 97 additions & 0 deletions src/vs/workbench/contrib/notebook/browser/contrib/coreActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -72,6 +75,7 @@ const enum CellToolbarOrder {
MoveCellUp,
MoveCellDown,
EditCell,
SplitCell,
SaveCell,
ClearCellOutput,
InsertCell,
Expand Down Expand Up @@ -1396,3 +1400,96 @@ registerAction2(class extends Action2 {
editor.viewModel.notebookDocument.clearAllCellOutputs();
}
});

async function splitCell(context: INotebookCellActionContext): Promise<void> {
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<void> {
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');
}
});

14 changes: 14 additions & 0 deletions src/vs/workbench/contrib/notebook/browser/notebookBrowser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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<ICellViewModel | null>;

/**
* Delete a cell from the notebook
*/
Expand Down
154 changes: 154 additions & 0 deletions src/vs/workbench/contrib/notebook/browser/notebookEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<ICellViewModel | null> {
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<boolean> {
if (!this.notebookViewModel!.metadata.editable) {
return false;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ export class MarkdownCellViewModel extends BaseCellViewModel implements ICellVie
}
}

setText(strs: string[]) {
setLinesContent(strs: string[]) {
this.model.source = strs;
this._html = null;
}
Expand Down
8 changes: 8 additions & 0 deletions src/vs/workbench/contrib/notebook/test/testNotebookEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ICellViewModel | null> {
throw new Error('Method not implemented.');
}

setSelection(cell: CellViewModel, selection: Range): void {
throw new Error('Method not implemented.');
}
Expand Down

0 comments on commit ec616b3

Please sign in to comment.