From 671c062e59dc65f2024f36ef9eae7ad649b4eef7 Mon Sep 17 00:00:00 2001 From: rajeshka Date: Wed, 6 Oct 2021 18:18:22 -0700 Subject: [PATCH] Split notebookcell (#17185) * Initial Split code * minor change * minor changes * Added split cell button to initActionBar, created split cell class to pss cell context. * added changes * fixed index * Split Cell Working in markdown mode * Fixed highlighting * Preserve the edit state * Added new icon and updated styles and cellToolbar component with new icon name. * Addressed PR * Addressed PR * Added back isEditMode flag * Moved split action to after edit toggle. * Fixed typo * Addressed PR * Addressed PR * Removed deletion of the cell * fixed the comments Co-authored-by: Hale Rankin --- src/sql/media/icons/common-icons.css | 9 ++ src/sql/media/icons/split_cell.svg | 3 + .../notebook/browser/cellToolbarActions.ts | 24 +++- .../cellViews/cellToolbar.component.ts | 10 +- .../test/browser/notebookViewModel.test.ts | 2 +- .../test/browser/notebookViewsActions.test.ts | 2 +- .../browser/notebookViewsExtension.test.ts | 2 +- .../notebookEditorModel.test.ts | 2 +- .../notebookFindModel.test.ts | 2 +- .../notebook/browser/models/notebookModel.ts | 128 +++++++++++++++++- 10 files changed, 172 insertions(+), 12 deletions(-) create mode 100644 src/sql/media/icons/split_cell.svg diff --git a/src/sql/media/icons/common-icons.css b/src/sql/media/icons/common-icons.css index 5f18584766..621fbe45f3 100644 --- a/src/sql/media/icons/common-icons.css +++ b/src/sql/media/icons/common-icons.css @@ -524,6 +524,15 @@ Includes non-masked style declarations. */ background-image: url('start-outline.svg'); } +.codicon:not(.masked-icon).icon-split-cell { + background-image: url("split_cell.svg"); +} + +.codicon.masked-icon.icon-split-cell:before { + -webkit-mask-image: url("split_cell.svg"); + mask-image: url("split_cell.svg"); +} + /* Masked element inside pseudo element */ .masked-pseudo { background-image: none !important; diff --git a/src/sql/media/icons/split_cell.svg b/src/sql/media/icons/split_cell.svg new file mode 100644 index 0000000000..cab46f46ec --- /dev/null +++ b/src/sql/media/icons/split_cell.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/sql/workbench/contrib/notebook/browser/cellToolbarActions.ts b/src/sql/workbench/contrib/notebook/browser/cellToolbarActions.ts index 23f06b3575..5247ebeadc 100644 --- a/src/sql/workbench/contrib/notebook/browser/cellToolbarActions.ts +++ b/src/sql/workbench/contrib/notebook/browser/cellToolbarActions.ts @@ -18,7 +18,6 @@ import { INotebookService } from 'sql/workbench/services/notebook/browser/notebo import { IContextMenuService } from 'vs/platform/contextview/browser/contextView'; import { StandardKeyboardEvent } from 'vs/base/browser/keyboardEvent'; import { MoveDirection } from 'sql/workbench/services/notebook/browser/models/modelInterfaces'; - const moreActionsLabel = localize('moreActionsLabel', "More"); export class EditCellAction extends ToggleableAction { @@ -58,6 +57,29 @@ export class EditCellAction extends ToggleableAction { } } +export class SplitCellAction extends CellActionBase { + public cellType: CellType; + + constructor( + id: string, + label: string, + cssClass: string, + @INotificationService notificationService: INotificationService, + @INotebookService private notebookService: INotebookService, + ) { + super(id, label, cssClass, notificationService); + this._cssClass = cssClass; + this._tooltip = label; + this._label = ''; + } + doRun(context: CellContext): Promise { + let model = context.model; + let index = model.cells.findIndex((cell) => cell.id === context.cell.id); + context.model?.splitCell(context.cell.cellType, this.notebookService, index); + return Promise.resolve(); + } +} + export class MoveCellAction extends CellActionBase { constructor( id: string, diff --git a/src/sql/workbench/contrib/notebook/browser/cellViews/cellToolbar.component.ts b/src/sql/workbench/contrib/notebook/browser/cellViews/cellToolbar.component.ts index 61885a0db8..f6b0f6537c 100644 --- a/src/sql/workbench/contrib/notebook/browser/cellViews/cellToolbar.component.ts +++ b/src/sql/workbench/contrib/notebook/browser/cellViews/cellToolbar.component.ts @@ -10,7 +10,7 @@ import { localize } from 'vs/nls'; import { Taskbar, ITaskbarContent } from 'sql/base/browser/ui/taskbar/taskbar'; import { IContextMenuService } from 'vs/platform/contextview/browser/contextView'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; -import { DeleteCellAction, EditCellAction, CellToggleMoreActions, MoveCellAction } from 'sql/workbench/contrib/notebook/browser/cellToolbarActions'; +import { DeleteCellAction, EditCellAction, CellToggleMoreActions, MoveCellAction, SplitCellAction } from 'sql/workbench/contrib/notebook/browser/cellToolbarActions'; import { AddCellAction } from 'sql/workbench/contrib/notebook/browser/notebookActions'; import { CellTypes } from 'sql/workbench/services/notebook/common/contracts'; import { DropdownMenuActionViewItem } from 'sql/base/browser/ui/buttonMenu/buttonMenu'; @@ -33,6 +33,7 @@ export class CellToolbarComponent { public buttonMoveDown = localize('buttonMoveDown', "Move cell down"); public buttonMoveUp = localize('buttonMoveUp', "Move cell up"); public buttonDelete = localize('buttonDelete', "Delete"); + public buttonSplitCell = localize('splitCell', "Split cell"); @Input() cellModel: ICellModel; @Input() model: NotebookModel; @@ -58,6 +59,8 @@ export class CellToolbarComponent { this._actionBar = new Taskbar(taskbar); this._actionBar.context = context; + let splitCellButton = this.instantiationService.createInstance(SplitCellAction, 'notebook.SplitCellAtCursor', this.buttonSplitCell, 'masked-icon icon-split-cell'); + let addCellsButton = this.instantiationService.createInstance(AddCellAction, 'notebook.AddCodeCell', localize('codeCellsPreview', "Add cell"), 'masked-pseudo code'); let addCodeCellButton = this.instantiationService.createInstance(AddCellAction, 'notebook.AddCodeCell', localize('codePreview', "Code cell"), 'masked-pseudo code'); @@ -96,9 +99,12 @@ export class CellToolbarComponent { let taskbarContent: ITaskbarContent[] = []; if (this.cellModel?.cellType === CellTypes.Markdown) { - taskbarContent.push({ action: this._editCellAction }); + taskbarContent.push( + { action: this._editCellAction } + ); } taskbarContent.push( + { action: splitCellButton }, { element: addCellDropdownContainer }, { action: moveCellDownButton }, { action: moveCellUpButton }, diff --git a/src/sql/workbench/contrib/notebook/test/browser/notebookViewModel.test.ts b/src/sql/workbench/contrib/notebook/test/browser/notebookViewModel.test.ts index 8004bf16cd..9eb229415b 100644 --- a/src/sql/workbench/contrib/notebook/test/browser/notebookViewModel.test.ts +++ b/src/sql/workbench/contrib/notebook/test/browser/notebookViewModel.test.ts @@ -271,7 +271,7 @@ suite('NotebookViewModel', function (): void { mockContentManager.setup(c => c.loadContent()).returns(() => Promise.resolve(contents)); defaultModelOptions.contentLoader = mockContentManager.object; - let model = new NotebookModel(defaultModelOptions, undefined, logService, undefined, new NullAdsTelemetryService(), queryConnectionService.object, configurationService); + let model = new NotebookModel(defaultModelOptions, undefined, logService, undefined, new NullAdsTelemetryService(), queryConnectionService.object, configurationService, undefined); await model.loadContents(); await model.requestModelLoad(); diff --git a/src/sql/workbench/contrib/notebook/test/browser/notebookViewsActions.test.ts b/src/sql/workbench/contrib/notebook/test/browser/notebookViewsActions.test.ts index a2ee031ece..68aa29f59e 100644 --- a/src/sql/workbench/contrib/notebook/test/browser/notebookViewsActions.test.ts +++ b/src/sql/workbench/contrib/notebook/test/browser/notebookViewsActions.test.ts @@ -196,7 +196,7 @@ suite('Notebook Views Actions', function (): void { mockContentManager.setup(c => c.loadContent()).returns(() => Promise.resolve(contents)); defaultModelOptions.contentLoader = mockContentManager.object; - let model = new NotebookModel(defaultModelOptions, undefined, logService, undefined, new NullAdsTelemetryService(), queryConnectionService.object, configurationService); + let model = new NotebookModel(defaultModelOptions, undefined, logService, undefined, new NullAdsTelemetryService(), queryConnectionService.object, configurationService, undefined); await model.loadContents(); await model.requestModelLoad(); diff --git a/src/sql/workbench/contrib/notebook/test/browser/notebookViewsExtension.test.ts b/src/sql/workbench/contrib/notebook/test/browser/notebookViewsExtension.test.ts index bf630b4445..38d3864833 100644 --- a/src/sql/workbench/contrib/notebook/test/browser/notebookViewsExtension.test.ts +++ b/src/sql/workbench/contrib/notebook/test/browser/notebookViewsExtension.test.ts @@ -163,7 +163,7 @@ suite('NotebookViews', function (): void { mockContentManager.setup(c => c.loadContent()).returns(() => Promise.resolve(initialNotebookContent)); defaultModelOptions.contentLoader = mockContentManager.object; - let model = new NotebookModel(defaultModelOptions, undefined, logService, undefined, new NullAdsTelemetryService(), queryConnectionService.object, configurationService); + let model = new NotebookModel(defaultModelOptions, undefined, logService, undefined, new NullAdsTelemetryService(), queryConnectionService.object, configurationService, undefined); await model.loadContents(); await model.requestModelLoad(); diff --git a/src/sql/workbench/contrib/notebook/test/electron-browser/notebookEditorModel.test.ts b/src/sql/workbench/contrib/notebook/test/electron-browser/notebookEditorModel.test.ts index 1a8da8e6aa..da0157e02e 100644 --- a/src/sql/workbench/contrib/notebook/test/electron-browser/notebookEditorModel.test.ts +++ b/src/sql/workbench/contrib/notebook/test/electron-browser/notebookEditorModel.test.ts @@ -978,7 +978,7 @@ suite('Notebook Editor Model', function (): void { let options: INotebookModelOptions = Object.assign({}, defaultModelOptions, >{ factory: mockModelFactory.object }); - notebookModel = new NotebookModel(options, undefined, logService, undefined, new NullAdsTelemetryService(), queryConnectionService.object, configurationService); + notebookModel = new NotebookModel(options, undefined, logService, undefined, new NullAdsTelemetryService(), queryConnectionService.object, configurationService, undefined); await notebookModel.loadContents(); } diff --git a/src/sql/workbench/contrib/notebook/test/electron-browser/notebookFindModel.test.ts b/src/sql/workbench/contrib/notebook/test/electron-browser/notebookFindModel.test.ts index 84a70698e6..94a5adb7e3 100644 --- a/src/sql/workbench/contrib/notebook/test/electron-browser/notebookFindModel.test.ts +++ b/src/sql/workbench/contrib/notebook/test/electron-browser/notebookFindModel.test.ts @@ -439,7 +439,7 @@ suite('Notebook Find Model', function (): void { mockContentManager.setup(c => c.loadContent()).returns(() => Promise.resolve(contents)); defaultModelOptions.contentLoader = mockContentManager.object; // Initialize the model - model = new NotebookModel(defaultModelOptions, undefined, logService, undefined, new NullAdsTelemetryService(), queryConnectionService.object, configurationService); + model = new NotebookModel(defaultModelOptions, undefined, logService, undefined, new NullAdsTelemetryService(), queryConnectionService.object, configurationService, undefined); await model.loadContents(); await model.requestModelLoad(); } diff --git a/src/sql/workbench/services/notebook/browser/models/notebookModel.ts b/src/sql/workbench/services/notebook/browser/models/notebookModel.ts index 10b29642ec..9f3e436a93 100644 --- a/src/sql/workbench/services/notebook/browser/models/notebookModel.ts +++ b/src/sql/workbench/services/notebook/browser/models/notebookModel.ts @@ -14,7 +14,7 @@ import { NotebookChangeType, CellType, CellTypes } from 'sql/workbench/services/ import { KernelsLanguage, nbversion } from 'sql/workbench/services/notebook/common/notebookConstants'; import * as notebookUtils from 'sql/workbench/services/notebook/browser/models/notebookUtils'; import * as TelemetryKeys from 'sql/platform/telemetry/common/telemetryKeys'; -import { IExecuteManager, SQL_NOTEBOOK_PROVIDER, DEFAULT_NOTEBOOK_PROVIDER, ISerializationManager } from 'sql/workbench/services/notebook/browser/notebookService'; +import { IExecuteManager, SQL_NOTEBOOK_PROVIDER, DEFAULT_NOTEBOOK_PROVIDER, ISerializationManager, INotebookService } from 'sql/workbench/services/notebook/browser/notebookService'; import { NotebookContexts } from 'sql/workbench/services/notebook/browser/models/notebookContexts'; import { IConnectionProfile } from 'sql/platform/connection/common/interfaces'; import { INotification, Severity, INotificationService } from 'vs/platform/notification/common/notification'; @@ -32,6 +32,9 @@ import { IConnectionManagementService } from 'sql/platform/connection/common/con import { values } from 'vs/base/common/collections'; import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; import { isUUID } from 'vs/base/common/uuid'; +import { TextModel } from 'vs/editor/common/model/textModel'; +import { QueryTextEditor } from 'sql/workbench/browser/modelComponents/queryTextEditor'; +import { CodeEditorWidget } from 'vs/editor/browser/widget/codeEditorWidget'; /* * Used to control whether a message in a dialog/wizard is displayed as an error, @@ -121,7 +124,6 @@ export class NotebookModel extends Disposable implements INotebookModel { @IConnectionManagementService private connectionManagementService: IConnectionManagementService, @IConfigurationService private configurationService: IConfigurationService, @ICapabilitiesService private _capabilitiesService?: ICapabilitiesService - ) { super(); if (!_notebookOptions || !_notebookOptions.notebookUri || !_notebookOptions.executeManagers) { @@ -538,8 +540,127 @@ export class NotebookModel extends Disposable implements INotebookModel { if (this.inErrorState) { return undefined; } - let cell = this.createCell(cellType); + let cell = this.createCell(cellType); + return this.insertCell(cell, index); + } + + public splitCell(cellType: CellType, notebookService: INotebookService, index?: number): ICellModel | undefined { + if (this.inErrorState) { + return undefined; + } + + let notebookEditor = notebookService.findNotebookEditor(this.notebookUri); + let cellEditorProvider = notebookEditor.cellEditors.find(e => e.cellGuid() === this.cells[index].cellGuid); + //Only split the cell if the markdown editor is open. + //TODO: Need to handle splitting of cell if the selection is on webview + if (cellEditorProvider) { + let editor = cellEditorProvider.getEditor() as QueryTextEditor; + if (editor) { + let editorControl = editor.getControl() as CodeEditorWidget; + + let model = editorControl.getModel() as TextModel; + let range = model.getFullModelRange(); + let selection = editorControl.getSelection(); + let source = this.cells[index].source; + let newCell = undefined, tailCell = undefined, partialSource = undefined; + let newCellIndex = index; + let tailCellIndex = index; + + // Save UI state + let showMarkdown = this.cells[index].showMarkdown; + let showPreview = this.cells[index].showPreview; + + //Get selection value from current cell + let newCellContent = model.getValueInRange(selection); + + //Get content after selection + let tailRange = range.setStartPosition(selection.endLineNumber, selection.endColumn); + let tailCellContent = model.getValueInRange(tailRange); + + //Get content before selection + let headRange = range.setEndPosition(selection.startLineNumber, selection.startColumn); + let headContent = model.getValueInRange(headRange); + + // If the selection is equal to entire content then do nothing + if (headContent.length === 0 && tailCellContent.length === 0) { + return undefined; + } + + //Set content before selection if the selection is not the same as original content + if (headContent.length) { + let headsource = source.slice(range.startLineNumber - 1, selection.startLineNumber - 1); + if (selection.startColumn > 1) { + partialSource = source.slice(selection.startLineNumber - 1, selection.startLineNumber)[0].slice(0, selection.startColumn - 1); + headsource = headsource.concat(partialSource.toString()); + } + this.cells[index].source = headsource; + } + + if (newCellContent.length) { + let newSource = source.slice(selection.startLineNumber - 1, selection.endLineNumber) as string[]; + if (selection.startColumn > 1) { + partialSource = source.slice(selection.startLineNumber - 1)[0].slice(selection.startColumn - 1); + newSource.splice(0, 1, partialSource); + } + if (selection.endColumn !== source[selection.endLineNumber - 1].length) { + let splicestart = 0; + if (selection.startLineNumber === selection.endLineNumber) { + splicestart = selection.startColumn - 1; + } + let partial = source.slice(selection.endLineNumber - 1, selection.endLineNumber)[0].slice(splicestart, selection.endColumn - 1); + newSource.splice(newSource.length - 1, 1, partial); + } + //If the selection is not from the start of the cell, create a new cell. + if (headContent.length) { + newCell = this.createCell(cellType); + newCell.source = newSource; + newCellIndex++; + this.insertCell(newCell, newCellIndex); + } + else { //update the existing cell + this.cells[index].source = newSource; + } + } + + if (tailCellContent.length) { + //tail cell will be of original cell type. + tailCell = this.createCell(this._cells[index].cellType); + let tailSource = source.slice(tailRange.startLineNumber - 1) as string[]; + if (selection.endColumn > 1) { + partialSource = source.slice(tailRange.startLineNumber - 1, tailRange.startLineNumber)[0].slice(tailRange.startColumn - 1); + tailSource.splice(0, 1, partialSource); + } + tailCell.source = tailSource; + tailCellIndex = newCellIndex + 1; + this.insertCell(tailCell, tailCellIndex); + } + + let activeCell = newCell ? newCell : (headContent.length ? tailCell : this.cells[index]); + let activeCellIndex = newCell ? newCellIndex : (headContent.length ? tailCellIndex : index); + + //make new cell Active + this.updateActiveCell(activeCell); + activeCell.isEditMode = true; + this._contentChangedEmitter.fire({ + changeType: NotebookChangeType.CellsModified, + cells: [activeCell], + cellIndex: activeCellIndex + }); + activeCell.showMarkdown = showMarkdown; + activeCell.showPreview = showPreview; + + //return inserted cell + return activeCell; + } + } + return undefined; + } + + public insertCell(cell: ICellModel, index?: number): ICellModel | undefined { + if (this.inErrorState) { + return undefined; + } if (index !== undefined && index !== null && index >= 0 && index < this._cells.length) { this._cells.splice(index, 0, cell); } else { @@ -554,7 +675,6 @@ export class NotebookModel extends Disposable implements INotebookModel { cells: [cell], cellIndex: index }); - return cell; }