From 4fd2f92e27200a018ae02d5cbfbf812b45d7bbae Mon Sep 17 00:00:00 2001 From: Daniel Grajeda Date: Mon, 6 Jun 2022 03:07:08 -0700 Subject: [PATCH] Introduce tabs for notebook views (#19526) * Introduce tabs for notebook views Cards have been restructured to contain tabs instead of cells directly. Tabs then contain the cards that are displayed. Cards may contain one or more cards. The panel component has been reused to implement the cells. There is still some cleanup left to do of unused functions, but I want to reduce the size of the PR as much as possible. --- .../notebook/browser/notebook.module.ts | 7 +- .../notebookViewsCard.component.html | 29 ++- .../notebookViewsCard.component.ts | 120 ++++++++++-- .../notebookViewsGrid.component.html | 4 +- .../notebookViewsGrid.component.ts | 99 +++++----- .../notebookViews/notebookViewsGrid.css | 13 ++ .../test/browser/notebookViewModel.test.ts | 83 +++----- .../browser/notebookViewsExtension.test.ts | 29 ++- .../workbench/contrib/notebook/test/stubs.ts | 12 +- .../browser/models/notebookExtension.ts | 2 +- .../notebookViews/notebookViewModel.ts | 178 ++++++++++++++---- .../browser/notebookViews/notebookViews.d.ts | 31 ++- .../notebookViews/notebookViewsExtension.ts | 46 +++-- .../notebookViews/notebookViewsUpgrades.ts | 65 +++++++ 14 files changed, 509 insertions(+), 209 deletions(-) create mode 100644 src/sql/workbench/services/notebook/browser/notebookViews/notebookViewsUpgrades.ts diff --git a/src/sql/workbench/contrib/notebook/browser/notebook.module.ts b/src/sql/workbench/contrib/notebook/browser/notebook.module.ts index 044ff2e6d1..03534ff13c 100644 --- a/src/sql/workbench/contrib/notebook/browser/notebook.module.ts +++ b/src/sql/workbench/contrib/notebook/browser/notebook.module.ts @@ -39,7 +39,9 @@ import { NotebookViewsCardComponent } from 'sql/workbench/contrib/notebook/brows import { NotebookViewsGridComponent } from 'sql/workbench/contrib/notebook/browser/notebookViews/notebookViewsGrid.component'; import { TextCellComponent } from 'sql/workbench/contrib/notebook/browser/cellViews/textCell.component'; import { NotebookViewsModalComponent } from 'sql/workbench/contrib/notebook/browser/notebookViews/notebookViewsModal.component'; - +import { TabComponent } from 'sql/base/browser/ui/panel/tab.component'; +import { PanelComponent } from 'sql/base/browser/ui/panel/panel.component'; +import { TabHeaderComponent } from 'sql/base/browser/ui/panel/tabHeader.component'; const outputComponentRegistry = Registry.as(OutputComponentExtensions.CellComponentContributions); export const NotebookModule = (params, selector: string, instantiationService: IInstantiationService): any => { @@ -66,6 +68,9 @@ export const NotebookModule = (params, selector: string, instantiationService: I NotebookViewsGridComponent, NotebookViewsCodeCellComponent, NotebookViewsModalComponent, + TabComponent, + TabHeaderComponent, + PanelComponent, ComponentHostDirective, OutputAreaComponent, OutputComponent, diff --git a/src/sql/workbench/contrib/notebook/browser/notebookViews/notebookViewsCard.component.html b/src/sql/workbench/contrib/notebook/browser/notebookViews/notebookViewsCard.component.html index d93cdc2393..6d0b0e7e92 100644 --- a/src/sql/workbench/contrib/notebook/browser/notebookViews/notebookViewsCard.component.html +++ b/src/sql/workbench/contrib/notebook/browser/notebookViews/notebookViewsCard.component.html @@ -6,28 +6,39 @@ -->
-
+
- - - - + + + + + +
+
+ + + + +
+
+
+
+
+
+
diff --git a/src/sql/workbench/contrib/notebook/browser/notebookViews/notebookViewsCard.component.ts b/src/sql/workbench/contrib/notebook/browser/notebookViews/notebookViewsCard.component.ts index 377d443081..8e2fe4665a 100644 --- a/src/sql/workbench/contrib/notebook/browser/notebookViews/notebookViewsCard.component.ts +++ b/src/sql/workbench/contrib/notebook/browser/notebookViews/notebookViewsCard.component.ts @@ -4,11 +4,11 @@ *--------------------------------------------------------------------------------------------*/ import 'vs/css!./cellToolbar'; import * as DOM from 'vs/base/browser/dom'; -import { Component, OnInit, Input, ViewChild, TemplateRef, ElementRef, Inject, Output, EventEmitter, ChangeDetectorRef, forwardRef, SimpleChanges } from '@angular/core'; +import { Component, OnInit, Input, ViewChild, TemplateRef, ElementRef, Inject, Output, EventEmitter, ChangeDetectorRef, forwardRef, SimpleChange } from '@angular/core'; import { CellExecutionState, ICellModel } from 'sql/workbench/services/notebook/browser/models/modelInterfaces'; import { NotebookModel } from 'sql/workbench/services/notebook/browser/models/notebookModel'; -import { DEFAULT_VIEW_CARD_HEIGHT, DEFAULT_VIEW_CARD_WIDTH } from 'sql/workbench/services/notebook/browser/notebookViews/notebookViewModel'; -import { CellChangeEventType, INotebookView, INotebookViewCell } from 'sql/workbench/services/notebook/browser/notebookViews/notebookViews'; +import { DEFAULT_VIEW_CARD_HEIGHT, DEFAULT_VIEW_CARD_WIDTH, ViewsTab } from 'sql/workbench/services/notebook/browser/notebookViews/notebookViewModel'; +import { CellChangeEventType, INotebookView, INotebookViewCard } from 'sql/workbench/services/notebook/browser/notebookViews/notebookViews'; import { ITaskbarContent, Taskbar } from 'sql/base/browser/ui/taskbar/taskbar'; import { CellContext } from 'sql/workbench/contrib/notebook/browser/cellViews/codeActions'; import { RunCellAction, HideCellAction, ViewCellToggleMoreActions } from 'sql/workbench/contrib/notebook/browser/notebookViews/notebookViewsActions'; @@ -17,22 +17,27 @@ import { CellTypes } from 'sql/workbench/services/notebook/common/contracts'; import { AngularDisposable } from 'sql/base/browser/lifecycle'; import { IColorTheme, ICssStyleCollector, registerThemingParticipant } from 'vs/platform/theme/common/themeService'; import { cellBorder, notebookToolbarSelectBackground } from 'sql/platform/theme/common/colorRegistry'; +import { TabComponent } from 'sql/base/browser/ui/panel/tab.component'; +import { EDITOR_GROUP_HEADER_TABS_BACKGROUND, TAB_ACTIVE_BACKGROUND, TAB_BORDER, TAB_INACTIVE_BACKGROUND } from 'vs/workbench/common/theme'; @Component({ selector: 'view-card-component', templateUrl: decodeURI(require.toUrl('./notebookViewsCard.component.html')) }) export class NotebookViewsCardComponent extends AngularDisposable implements OnInit { + cell: ICellModel; + private _actionbar: Taskbar; - private _metadata: INotebookViewCell; private _executionState: CellExecutionState; private _pendingReinitialize: boolean = false; public _cellToggleMoreActions: ViewCellToggleMoreActions; - @Input() cell: ICellModel; + @Input() card: INotebookViewCard; + @Input() cells: ICellModel[]; @Input() model: NotebookModel; @Input() activeView: INotebookView; + @Input() activeTab: ViewsTab; @Input() meta: boolean; @Input() ready: boolean; @Output() onChange: EventEmitter = new EventEmitter(); @@ -52,19 +57,11 @@ export class NotebookViewsCardComponent extends AngularDisposable implements OnI this.initialize(); } - ngOnChanges(changes: SimpleChanges) { - if (this.activeView && changes['activeView'] && changes['activeView'].currentValue?.guid !== changes['activeView'].previousValue?.guid) { - this._metadata = this.activeView.getCellMetadata(this.cell); - this._pendingReinitialize = true; - } + ngOnChanges(changes: { [propKey: string]: SimpleChange }) { this.detectChanges(); } - ngAfterContentInit() { - if (this.activeView) { - this._metadata = this.activeView.getCellMetadata(this.cell); - this._pendingReinitialize = true; - } + ngAfterViewInit() { this.detectChanges(); } @@ -75,6 +72,27 @@ export class NotebookViewsCardComponent extends AngularDisposable implements OnI } } + handleTabChange(selectedTab: TabComponent) { + const tab = this.tabs.find(t => t.id === selectedTab.identifier); + if (tab && this.cell?.cellGuid !== tab.cell?.guid) { + this.cell = this.cells.find(c => c.cellGuid === tab.cell.guid); + this.model.updateActiveCell(this.cell); + this.changed('active'); + + this.initActionBar(); + } + } + + handleTabClose(selectedTab: TabComponent) { + const tab = this.tabs.find(t => t.id === selectedTab.identifier); + if (tab) { + const cell = this.cells.find(c => c.cellGuid === tab.cell.guid); + if (cell) { + this.activeView.hideCell(cell); + } + } + } + override ngOnDestroy() { if (this._actionbar) { this._actionbar.dispose(); @@ -83,9 +101,17 @@ export class NotebookViewsCardComponent extends AngularDisposable implements OnI public initialize(): void { this.initActionBar(); + if (this.card.activeTab !== undefined) { + this.cell = this.cells.find(c => c.cellGuid === this.card.activeTab.cell.guid); + } + this.detectChanges(); } + public get tabs(): ViewsTab[] { + return this.card?.tabs ?? []; + } + initActionBar() { if (this._actionbarRef) { let taskbarContent: ITaskbarContent[] = []; @@ -119,10 +145,12 @@ export class NotebookViewsCardComponent extends AngularDisposable implements OnI return this._item; } + changed(event: CellChangeEventType) { this.onChange.emit({ cell: this.cell, event: event }); } + get displayInputModal(): boolean { return this.awaitingInput; } @@ -154,8 +182,13 @@ export class NotebookViewsCardComponent extends AngularDisposable implements OnI this.changed('hide'); } - public get metadata(): INotebookViewCell { - return this._metadata; + + public get metadata(): INotebookViewCard { + return this.card; + } + + public get guid(): string { + return this.metadata.guid; } public get width(): number { @@ -182,7 +215,7 @@ export class NotebookViewsCardComponent extends AngularDisposable implements OnI return true; } - if (!this._metadata) { //Means not initialized + if (!this.cell) { //Means not initialized return false; } @@ -204,8 +237,8 @@ export class NotebookViewsCardComponent extends AngularDisposable implements OnI registerThemingParticipant((theme: IColorTheme, collector: ICssStyleCollector) => { const cellBorderColor = theme.getColor(cellBorder); if (cellBorderColor) { - collector.addRule(`.notebookEditor .nb-grid-stack .notebook-cell.active .actionbar { border-color: ${cellBorderColor};}`); - collector.addRule(`.notebookEditor .nb-grid-stack .notebook-cell.active .actionbar .codicon:before { background-color: ${cellBorderColor};}`); + collector.addRule(`.notebookEditor .nb-grid-stack .actionbar { border-color: ${cellBorderColor};}`); + collector.addRule(`.notebookEditor .nb-grid-stack .actionbar .codicon:before { background-color: ${cellBorderColor};}`); } // Cell toolbar background @@ -213,4 +246,51 @@ registerThemingParticipant((theme: IColorTheme, collector: ICssStyleCollector) = if (notebookToolbarSelectBackgroundColor) { collector.addRule(`.notebookEditor .nb-grid-stack .notebook-cell.active .actionbar { background-color: ${notebookToolbarSelectBackgroundColor};}`); } + + + const tabBorder = theme.getColor(TAB_BORDER); + const tabBackground = theme.getColor(TAB_INACTIVE_BACKGROUND); + const tabActiveBackground = theme.getColor(TAB_ACTIVE_BACKGROUND); + + const headerBackground = theme.getColor(EDITOR_GROUP_HEADER_TABS_BACKGROUND); + if (headerBackground) { + collector.addRule(` + .notebook-cell .grid-stack-header { + background-color: ${headerBackground.toString()}; + } + `); + } + + if (tabBackground && tabBorder) { + collector.addRule(` + .notebook-cell .tabbedPanel.horizontal > .title .tabList { + border-color: ${tabBorder.toString()}; + background-color: ${tabBackground.toString()}; + } + + .notebook-cell .tabbedPanel.horizontal > .title .tabList .tab-header { + border-right: 1px solid ${tabBorder.toString()}; + background-color: ${tabBackground.toString()}; + margin: 0; + } + + .notebook-cell .tabbedPanel.horizontal > .title .tabList a.action-label.codicon.close { + background-size: 9px 9px !important; + margin-top: -1px; + } + + .notebook-cell .tabbedPanel.horizontal > .title .tabList .actions-container { + margin-right: 0px; + margin-left: 8px; + } + `); + } + + if (tabActiveBackground) { + collector.addRule(` + .notebook-cell .tabbedPanel.horizontal > .title .tabList .tab-header.active { + background-color: ${tabActiveBackground.toString()}; + } + `); + } }); diff --git a/src/sql/workbench/contrib/notebook/browser/notebookViews/notebookViewsGrid.component.html b/src/sql/workbench/contrib/notebook/browser/notebookViews/notebookViewsGrid.component.html index 9bd11d8562..bf7b5c0658 100644 --- a/src/sql/workbench/contrib/notebook/browser/notebookViews/notebookViewsGrid.component.html +++ b/src/sql/workbench/contrib/notebook/browser/notebookViews/notebookViewsGrid.component.html @@ -5,8 +5,8 @@ *--------------------------------------------------------------------------------------------*/ -->
- - + +
diff --git a/src/sql/workbench/contrib/notebook/browser/notebookViews/notebookViewsGrid.component.ts b/src/sql/workbench/contrib/notebook/browser/notebookViews/notebookViewsGrid.component.ts index a5ef038feb..a783279057 100644 --- a/src/sql/workbench/contrib/notebook/browser/notebookViews/notebookViewsGrid.component.ts +++ b/src/sql/workbench/contrib/notebook/browser/notebookViews/notebookViewsGrid.component.ts @@ -4,16 +4,15 @@ *--------------------------------------------------------------------------------------------*/ import 'vs/css!./notebookViewsGrid'; -import { Component, OnInit, ViewChildren, QueryList, Input, Inject, forwardRef, ChangeDetectorRef, ViewEncapsulation, ChangeDetectionStrategy } from '@angular/core'; +import { Component, OnInit, ViewChildren, QueryList, Input, Inject, forwardRef, ChangeDetectorRef, ViewEncapsulation, ChangeDetectionStrategy, ComponentFactoryResolver, ViewChild, ViewContainerRef } from '@angular/core'; import { NotebookViewsCardComponent } from 'sql/workbench/contrib/notebook/browser/notebookViews/notebookViewsCard.component'; import { ICellModel } from 'sql/workbench/services/notebook/browser/models/modelInterfaces'; import { NotebookModel } from 'sql/workbench/services/notebook/browser/models/notebookModel'; import { GridItemHTMLElement, GridStack, GridStackEvent, GridStackNode } from 'gridstack'; import { localize } from 'vs/nls'; import { NotebookViewsExtension } from 'sql/workbench/services/notebook/browser/notebookViews/notebookViewsExtension'; -import { CellChangeEvent, INotebookView, INotebookViewCell } from 'sql/workbench/services/notebook/browser/notebookViews/notebookViews'; +import { CellChangeEvent, INotebookView, INotebookViewCard } from 'sql/workbench/services/notebook/browser/notebookViews/notebookViews'; import { AngularDisposable } from 'sql/base/browser/lifecycle'; -import { generateLayout } from 'sql/workbench/services/notebook/browser/notebookViews/autodash'; export interface INotebookViewsGridOptions { cellHeight?: number; @@ -31,6 +30,7 @@ export class NotebookViewsGridComponent extends AngularDisposable implements OnI @Input() activeView: INotebookView; @Input() views: NotebookViewsExtension; + @ViewChild('divContainer', { read: ViewContainerRef }) _containerRef: ViewContainerRef; @ViewChildren(NotebookViewsCardComponent) private _items: QueryList; protected _grid: GridStack; @@ -45,13 +45,18 @@ export class NotebookViewsGridComponent extends AngularDisposable implements OnI constructor( @Inject(forwardRef(() => ChangeDetectorRef)) private _changeRef: ChangeDetectorRef, + @Inject(forwardRef(() => ComponentFactoryResolver)) private _componentFactoryResolver: ComponentFactoryResolver ) { super(); this._loaded = false; } + public get cards(): INotebookViewCard[] { + return this.activeView ? this.activeView.cards : []; + } + public get empty(): boolean { - return !this._items || !this._items.find(item => item.visible); + return !this._items?.length; } public get hiddenItems(): NotebookViewsCardComponent[] { @@ -89,11 +94,6 @@ export class NotebookViewsGridComponent extends AngularDisposable implements OnI this._grid = undefined; } } - - if (this.model?.activeCell?.id !== this._activeCell?.id) { - this._activeCell = this.model.activeCell; - this.detectChanges(); - } } ngAfterViewChecked() { @@ -135,11 +135,12 @@ export class NotebookViewsGridComponent extends AngularDisposable implements OnI } this._grid = GridStack.init({ - alwaysShowResizeHandle: false, + alwaysShowResizeHandle: true, styleInHead: true, - margin: 2, + margin: 5, cellHeight: this._options.cellHeight, staticGrid: false, + handleClass: 'grid-stack-header' }); @@ -157,41 +158,48 @@ export class NotebookViewsGridComponent extends AngularDisposable implements OnI } this._grid.batchUpdate(); - this.activeView.cells.forEach(cell => { - const el = this._grid.getGridItems().find(x => x.getAttribute('data-cell-id') === cell.cellGuid); + this.activeView.cards.forEach(card => { + const el = this._grid.getGridItems().find(x => x.getAttribute('data-card-guid') === card.guid); if (el) { - const cellData = this.activeView.getCellMetadata(cell); - this._grid.update(el, { x: cellData.x, y: cellData.y, w: cellData.width, h: cellData.height }); - - if (cellData?.hidden) { - this._grid.removeWidget(el, false); // Do not trigger event for batch update - } + this._grid.update(el, { x: card.x, y: card.y, w: card.width, h: card.height }); } }); this._grid.commit(); } - private resizeCells(): void { + private resizeCards(): void { this._items.forEach((i: NotebookViewsCardComponent) => { if (i.elementRef) { const cellHeight = this._options.cellHeight; + let maxTabHeight = 30; - const naturalHeight = i.elementRef.nativeElement.clientHeight; - const heightInCells = Math.ceil(naturalHeight / cellHeight); + const cardContainers: HTMLCollection = i.elementRef.nativeElement.getElementsByClassName('card-container'); + if (cardContainers) { + maxTabHeight = Array.from(cardContainers).reduce((accum, cardContainer) => { + return Math.max(cardContainer.children[0]?.clientHeight ?? 0, accum); + }, maxTabHeight); + } - const update: INotebookViewCell = { + const cardNaturalHeight = i.elementRef.nativeElement.clientHeight + maxTabHeight; + const heightInCells = Math.ceil(cardNaturalHeight / cellHeight); + + const update: INotebookViewCard = { height: heightInCells }; - this.views.updateCell(i.cell, this.activeView, update); + this.views.updateCard(i, update, this.activeView); } }); } private runAutoLayout(view: INotebookView): void { //Resize the cells before regenerating layout so that we know the natural height of the cells - this.resizeCells(); - generateLayout(view); + this.resizeCards(); + //generateLayout(view); + } + + trackByCardId(index, item) { + return item ? item.guid : undefined; } private detectChanges(): void { @@ -208,20 +216,29 @@ export class NotebookViewsGridComponent extends AngularDisposable implements OnI this.activeView.hideCell(e.cell); } - if (e.cell && e.event === 'insert') { - const component = this._items.find(x => x.cell.cellGuid === e.cell.cellGuid); - this.activeView.insertCell(e.cell); + if (e.cell && e.event === 'insert') { + const card = this.activeView.insertCell(e.cell); + + let cardComponentFactory = this._componentFactoryResolver.resolveComponentFactory(NotebookViewsCardComponent); + let cardComponent = this._containerRef.createComponent(cardComponentFactory); + + cardComponent.instance.ready = true; + cardComponent.instance.activeView = this.activeView; + cardComponent.instance.card = card; + cardComponent.instance.model = this.model; + cardComponent.instance.cells = this.cells; + + cardComponent.instance.initialize(); this.detectChanges(); - const el = this._grid.getGridItems().find(x => x.getAttribute('data-cell-id') === e.cell.cellGuid); - this._grid.makeWidget(el); + const el = this._grid.getGridItems().find(x => x.getAttribute('data-card-guid') === card.guid); + + this._grid.addWidget(el); this._grid.update(el, { x: 0, y: 0 }); this._grid.resizable(el, true); this._grid.movable(el, true); - - component.initialize(); } if (e.cell && e.event === 'update') { @@ -243,25 +260,19 @@ export class NotebookViewsGridComponent extends AngularDisposable implements OnI */ persist(action: GridStackEvent, changedItems: GridStackNode[] = [], grid: GridStack, items: QueryList): void { changedItems.forEach((changedItem) => { - const cellId = changedItem.el.getAttribute('data-cell-id'); - const item = items.toArray().find(item => item.cell.cellGuid === cellId); + const cellId = changedItem.el.getAttribute('data-card-guid'); + const item = items.toArray().find(item => item.metadata.guid === cellId); if (item && this.activeView) { - const update: INotebookViewCell = { - guid: this.activeView.guid, + const update: INotebookViewCard = { + guid: item.guid, x: changedItem.x, y: changedItem.y, width: changedItem.w, height: changedItem.h }; - if (action === 'added') { - update.hidden = false; - } else if (action === 'removed') { - update.hidden = true; - } - - this.views.updateCell(item.cell, this.activeView, update); + this.views.updateCard(item.metadata, update, this.activeView); } }); } diff --git a/src/sql/workbench/contrib/notebook/browser/notebookViews/notebookViewsGrid.css b/src/sql/workbench/contrib/notebook/browser/notebookViews/notebookViewsGrid.css index d70346755a..7e83aac192 100644 --- a/src/sql/workbench/contrib/notebook/browser/notebookViews/notebookViewsGrid.css +++ b/src/sql/workbench/contrib/notebook/browser/notebookViews/notebookViewsGrid.css @@ -30,6 +30,7 @@ .nb-grid-stack > .grid-stack-item > .grid-stack-item-content { margin: 0; + border: 0; position: absolute; width: auto; overflow-x: hidden; @@ -310,6 +311,14 @@ .nb-grid-stack > .grid-stack-item { margin: 0; } +.nb-grid-stack > .grid-stack-item .grid-stack-header { + width: 100%; + height: 20px; +} +.nb-grid-stack > .grid-stack-item .grid-stack-footer { + width: 100%; + height: 20px; +} .nb-grid-stack > .grid-stack-item .grid-stack-item-content { display: flex; cursor: move; @@ -325,9 +334,13 @@ margin-bottom: 0px; } .nb-grid-stack > .grid-stack-item .grid-stack-item-content .grid-stack-content-wrapper-inner { + height: calc(100% - 25px); +} +.nb-grid-stack > .grid-stack-item .grid-stack-item-content .grid-stack-content-wrapper-inner .card-container { overflow-y: auto; height: 100%; cursor: auto; + padding: 0 10px; } .nb-grid-stack > .grid-stack-item .grid-stack-item-content ::-webkit-scrollbar { width: 8px; 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 2d32530341..b8ece2d471 100644 --- a/src/sql/workbench/contrib/notebook/test/browser/notebookViewModel.test.ts +++ b/src/sql/workbench/contrib/notebook/test/browser/notebookViewModel.test.ts @@ -32,7 +32,6 @@ import { NotebookViewsExtension } from 'sql/workbench/services/notebook/browser/ import { TestConfigurationService } from 'sql/platform/connection/test/common/testConfigurationService'; import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; import { NotebookViewModel } from 'sql/workbench/services/notebook/browser/notebookViews/notebookViewModel'; -import { isUndefinedOrNull } from 'vs/base/common/types'; import { SQL_NOTEBOOK_PROVIDER } from 'sql/workbench/services/notebook/browser/notebookService'; import { NBFORMAT, NBFORMAT_MINOR } from 'sql/workbench/common/constants'; @@ -95,25 +94,22 @@ suite('NotebookViewModel', function (): void { test('initialize', async function (): Promise { let notebookViews = await initializeNotebookViewsExtension(initialNotebookContent); let viewModel = new NotebookViewModel(defaultViewName, notebookViews); - viewModel.initialize(); + viewModel.initialize(true); //is new view - let cellsWithNewView = notebookViews.getCells().filter(cell => cell.views.find(v => v.guid === viewModel.guid)); - assert.strictEqual(cellsWithNewView.length, 2); - assert.strictEqual(viewModel.cells.length, 2); - assert.strictEqual(viewModel.name, defaultViewName); + assert.strictEqual(viewModel.cards.length, 2, 'View model was not initialized with the correct number of cards'); + assert.strictEqual(viewModel.cells.length, 2, 'View model was not initialized with the correct number of cells'); + assert.strictEqual(viewModel.name, defaultViewName, 'View model was not inirialized with the correct name'); }); test('initialize notebook with no metadata', async function (): Promise { let notebookViews = await initializeNotebookViewsExtension(notebookContentWithoutMeta); let viewModel = new NotebookViewModel(defaultViewName, notebookViews); - viewModel.initialize(); + viewModel.initialize(true); - let cellsWithNewView = notebookViews.getCells().filter(cell => cell.views.find(v => v.guid === viewModel.guid)); - - assert.strictEqual(cellsWithNewView.length, 2); - assert.strictEqual(viewModel.cells.length, 2); - assert.strictEqual(viewModel.name, defaultViewName); + assert.strictEqual(viewModel.cards.length, 2, 'View model with no metadata was not initialized with the correct number of cards'); + assert.strictEqual(viewModel.cells.length, 2, 'View model with no metadata was not initialized with the correct number of cells'); + assert.strictEqual(viewModel.name, defaultViewName, 'View model with no metadata was not inirialized with the correct name'); }); test('rename', async function (): Promise { @@ -128,7 +124,7 @@ suite('NotebookViewModel', function (): void { exceptionThrown = true; } - assert.strictEqual(view.name, `${defaultViewName} 1`); + assert.strictEqual(view.name, `${defaultViewName} 1`, 'Rename did not result in expected name'); assert(!exceptionThrown); }); @@ -146,74 +142,52 @@ suite('NotebookViewModel', function (): void { exceptionThrown = true; } - assert(exceptionThrown); + assert(exceptionThrown, 'Duplicating a view name should throw an exception'); }); test('hide cell', async function (): Promise { let notebookViews = await initializeNotebookViewsExtension(initialNotebookContent); - let viewModel = new NotebookViewModel(defaultViewName, notebookViews); - viewModel.initialize(); + let viewModel = notebookViews.createNewView(defaultViewName); let cellToHide = viewModel.cells[0]; viewModel.hideCell(cellToHide); - assert.strictEqual(viewModel.hiddenCells.length, 1); - assert(viewModel.hiddenCells.includes(cellToHide)); + assert.strictEqual(viewModel.hiddenCells.length, 1, 'Hiding a cell should add it to hiddenCells'); + assert(viewModel.hiddenCells.includes(cellToHide), 'Hiding a cell should add it to hiddenCells'); }); test('insert cell', async function (): Promise { let notebookViews = await initializeNotebookViewsExtension(initialNotebookContent); - let viewModel = new NotebookViewModel(defaultViewName, notebookViews); - viewModel.initialize(); + let viewModel = notebookViews.createNewView(defaultViewName); let cellToInsert = viewModel.cells[0]; viewModel.hideCell(cellToInsert); - assert(viewModel.hiddenCells.includes(cellToInsert)); + assert(viewModel.hiddenCells.includes(cellToInsert), 'Expecting a hidden cell'); viewModel.insertCell(cellToInsert); - assert(!viewModel.hiddenCells.includes(cellToInsert)); + assert(!viewModel.hiddenCells.includes(cellToInsert), 'Inserting a cell should remove it from hiddenCells'); }); - test('move cell', async function (): Promise { + test('move card', async function (): Promise { let notebookViews = await initializeNotebookViewsExtension(initialNotebookContent); - let viewModel = new NotebookViewModel(defaultViewName, notebookViews); - viewModel.initialize(); + let viewModel = notebookViews.createNewView(defaultViewName); - let cellToMove = viewModel.cells[0]; + viewModel.moveCard(viewModel.cards[0], 98, 99); - viewModel.moveCell(cellToMove, 98, 99); - let cellMeta = viewModel.getCellMetadata(cellToMove); - - assert.strictEqual(cellMeta.x, 98); - assert.strictEqual(cellMeta.y, 99); + assert.strictEqual(viewModel.cards[0].x, 98, 'Card x position did not update on move'); + assert.strictEqual(viewModel.cards[0].y, 99, 'Card y position did not update on move'); }); - test('resize cell', async function (): Promise { + test('resize card', async function (): Promise { let notebookViews = await initializeNotebookViewsExtension(initialNotebookContent); - let viewModel = new NotebookViewModel(defaultViewName, notebookViews); - viewModel.initialize(); + let viewModel = notebookViews.createNewView(defaultViewName); - let cellToResize = viewModel.cells[0]; + viewModel.resizeCard(viewModel.cards[0], 3, 4); - viewModel.resizeCell(cellToResize, 3, 4); - let cellMeta = viewModel.getCellMetadata(cellToResize); - - assert.strictEqual(cellMeta.width, 3); - assert.strictEqual(cellMeta.height, 4); - }); - - test('get cell metadata', async function (): Promise { - let notebookViews = await initializeNotebookViewsExtension(initialNotebookContent); - let viewModel = new NotebookViewModel(defaultViewName, notebookViews); - viewModel.initialize(); - - let cell = viewModel.cells[0]; - let cellMeta = notebookViews.getExtensionCellMetadata(cell); - - assert(!isUndefinedOrNull(cellMeta.views.find(v => v.guid === viewModel.guid))); - assert.deepStrictEqual(viewModel.getCellMetadata(cell), cellMeta.views.find(v => v.guid === viewModel.guid)); + assert.strictEqual(viewModel.cards[0].width, 3, 'Card width did not update on resize'); + assert.strictEqual(viewModel.cards[0].height, 4, 'Card height did not update on resize'); }); test('delete', async function (): Promise { @@ -278,6 +252,9 @@ suite('NotebookViewModel', function (): void { await model.loadContents(); await model.requestModelLoad(); - return new NotebookViewsExtension(model); + const notebookViews = new NotebookViewsExtension(model); + notebookViews.initialize(); + + return notebookViews; } }); 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 bf1c286007..21fadf00fb 100644 --- a/src/sql/workbench/contrib/notebook/test/browser/notebookViewsExtension.test.ts +++ b/src/sql/workbench/contrib/notebook/test/browser/notebookViewsExtension.test.ts @@ -87,10 +87,8 @@ suite('NotebookViews', function (): void { test('should not modify the notebook document until a view is created', async () => { //Create some content notebookViews.notebook.addCell(CellTypes.Code, 0); - const cell = notebookViews.notebook.cells[0]; assert.strictEqual(notebookViews.getExtensionMetadata(), undefined); - assert.strictEqual(notebookViews.getExtensionCellMetadata(cell), undefined); //Check that the view is created notebookViews.createNewView(defaultViewName); @@ -101,11 +99,10 @@ suite('NotebookViews', function (): void { assert.strictEqual(notebookViews.getViews().length, 0, 'notebook should not initially generate any views'); let newView = notebookViews.createNewView(defaultViewName); - let cellsWithMatchingGuid = newView.cells.filter(cell => newView.getCellMetadata(cell).guid === newView.guid); + assert.strictEqual(notebookViews.getViews().length, 1, 'only one view was created'); assert.strictEqual(newView.name, defaultViewName, 'view was not created with its given name'); assert.strictEqual(newView.cells.length, 2, 'view did not contain the same number of cells as the notebook used to create it'); - assert.strictEqual(cellsWithMatchingGuid.length, newView.cells.length, 'cell metadata was not created for all cells in view'); }); test('remove view', async function (): Promise { @@ -113,10 +110,7 @@ suite('NotebookViews', function (): void { notebookViews.removeView(newView.guid); - let cellsWithNewView = notebookViews.getCells().filter(cell => cell.views.find(v => v.guid === newView.guid)); - assert.strictEqual(notebookViews.getViews().length, 0, 'view not removed from notebook metadata'); - assert.strictEqual(cellsWithNewView.length, 0, 'view not removed from cells'); }); test('default view name', async function (): Promise { @@ -134,21 +128,20 @@ suite('NotebookViews', function (): void { assert.strictEqual(notebookViews.getActiveView(), newView); }); - test('update cell', async function (): Promise { + test('update card', async function (): Promise { let newView = notebookViews.createNewView(); - let c1 = newView.cells[0]; + let card = newView.cards[0]; - let cellData = newView.getCellMetadata(c1); - cellData = { ...cellData, x: 0, y: 0, hidden: true, width: 0, height: 0 }; - notebookViews.updateCell(c1, newView, cellData); + let cardData = { ...card, x: 0, y: 0, width: 0, height: 0 }; + notebookViews.updateCard(card, cardData, newView); - cellData = { ...cellData, x: 1, y: 1, hidden: false, width: 1, height: 1 }; - notebookViews.updateCell(c1, newView, cellData); - assert.deepStrictEqual(newView.getCellMetadata(c1), cellData, 'update did not set all values'); + cardData = { ...cardData, x: 1, y: 1, width: 1, height: 1 }; + notebookViews.updateCard(newView.cards[0], cardData, newView); + assert.deepStrictEqual(newView.cards[0], cardData, 'update did not set all values'); - cellData = { ...cellData, x: 3 }; - notebookViews.updateCell(c1, newView, { x: 3 }); - assert.deepStrictEqual(newView.getCellMetadata(c1), cellData, 'update should only override set values'); + cardData = { ...cardData, x: 3 }; + notebookViews.updateCard(newView.cards[0], { x: 3 }, newView); + assert.deepStrictEqual(newView.cards[0], cardData, 'update should only override set values'); }); function setupServices() { diff --git a/src/sql/workbench/contrib/notebook/test/stubs.ts b/src/sql/workbench/contrib/notebook/test/stubs.ts index 69545b4c25..6f3b8c61dd 100644 --- a/src/sql/workbench/contrib/notebook/test/stubs.ts +++ b/src/sql/workbench/contrib/notebook/test/stubs.ts @@ -20,7 +20,7 @@ import { IContextViewProvider, IDelegate } from 'vs/base/browser/ui/contextview/ import { IEditorInput, IEditorPane } from 'vs/workbench/common/editor'; import { INotebookShowOptions } from 'sql/workbench/api/common/sqlExtHost.protocol'; import { NotebookViewsExtension } from 'sql/workbench/services/notebook/browser/notebookViews/notebookViewsExtension'; -import { INotebookView, INotebookViewCell, INotebookViewMetadata, INotebookViews } from 'sql/workbench/services/notebook/browser/notebookViews/notebookViews'; +import { INotebookView, INotebookViewCard, INotebookViewMetadata, INotebookViews } from 'sql/workbench/services/notebook/browser/notebookViews/notebookViews'; import * as TelemetryKeys from 'sql/platform/telemetry/common/telemetryKeys'; import { ITelemetryEventProperties } from 'sql/platform/telemetry/common/telemetry'; import { INotebookEditOperation } from 'sql/workbench/api/common/sqlExtHostTypes'; @@ -799,6 +799,7 @@ export class NotebookViewStub implements INotebookView { isNew: boolean; name: string = ''; guid: string = ''; + cards: INotebookViewCard[]; cells: readonly ICellModel[] = []; hiddenCells: readonly ICellModel[]; displayedCells: readonly ICellModel[]; @@ -811,13 +812,16 @@ export class NotebookViewStub implements INotebookView { nameAvailable(name: string): boolean { throw new Error('Method not implemented.'); } - getCellMetadata(cell: ICellModel): INotebookViewCell { + getCellMetadata(cell: ICellModel): INotebookViewCard { throw new Error('Method not implemented.'); } hideCell(cell: ICellModel): void { throw new Error('Method not implemented.'); } - moveCell(cell: ICellModel, x: number, y: number): void { + moveCard(card: INotebookViewCard, x: number, y: number): void { + throw new Error('Method not implemented.'); + } + resizeCard(card: INotebookViewCard, width: number, height: number): void { throw new Error('Method not implemented.'); } resizeCell(cell: ICellModel, width: number, height: number): void { @@ -832,7 +836,7 @@ export class NotebookViewStub implements INotebookView { getCell(guid: string): Readonly { throw new Error('Method not implemented.'); } - insertCell(cell: ICellModel): void { + insertCell(cell: ICellModel): INotebookViewCard { throw new Error('Method not implemented.'); } save(): void { diff --git a/src/sql/workbench/services/notebook/browser/models/notebookExtension.ts b/src/sql/workbench/services/notebook/browser/models/notebookExtension.ts index c8ef461edf..51b7b77f7a 100644 --- a/src/sql/workbench/services/notebook/browser/models/notebookExtension.ts +++ b/src/sql/workbench/services/notebook/browser/models/notebookExtension.ts @@ -8,7 +8,7 @@ import { NotebookChangeType } from 'sql/workbench/services/notebook/common/contr import { deepClone } from 'vs/base/common/objects'; export class NotebookExtension { - readonly version = 1; + readonly version: number = 1; readonly extensionNamespace = 'extensions'; private _extensionName: string; diff --git a/src/sql/workbench/services/notebook/browser/notebookViews/notebookViewModel.ts b/src/sql/workbench/services/notebook/browser/notebookViews/notebookViewModel.ts index fe191e89d1..9608fc2452 100644 --- a/src/sql/workbench/services/notebook/browser/notebookViews/notebookViewModel.ts +++ b/src/sql/workbench/services/notebook/browser/notebookViews/notebookViewModel.ts @@ -6,8 +6,9 @@ import { NotebookViewsExtension } from 'sql/workbench/services/notebook/browser/ import { ICellModel } from 'sql/workbench/services/notebook/browser/models/modelInterfaces'; import { Emitter } from 'vs/base/common/event'; import { localize } from 'vs/nls'; -import { INotebookView, INotebookViewCell } from 'sql/workbench/services/notebook/browser/notebookViews/notebookViews'; +import { INotebookView, INotebookViewCard, INotebookViewCell, ViewsTabConfig } from 'sql/workbench/services/notebook/browser/notebookViews/notebookViews'; import { generateUuid } from 'vs/base/common/uuid'; +import { IconPath } from 'azdata'; export const DEFAULT_VIEW_CARD_HEIGHT = 4; export const DEFAULT_VIEW_CARD_WIDTH = 12; @@ -15,10 +16,42 @@ export const GRID_COLUMNS = 12; export class ViewNameTakenError extends Error { } -function cellCollides(c1: INotebookViewCell, c2: INotebookViewCell): boolean { +function cellCollides(c1: INotebookViewCard, c2: INotebookViewCard): boolean { return !((c1.y + c1.height <= c2.y) || (c1.x + c1.width <= c2.x) || (c1.x + c1.width <= c2.x) || (c2.x + c2.width <= c1.x)); } +export class ViewsTab implements ViewsTabConfig { + cell: INotebookViewCell; + title: string; + id?: string; + group: string; + icon?: IconPath; + cellModel: ICellModel; + + constructor(config: ViewsTabConfig, cell: ICellModel) { + this.fromJSON(config, cell); + } + + public toJSON() { + return { + cell: this.cell, + title: this.title, + id: this.id, + group: this.group, + icon: this.icon + }; + } + + public fromJSON(config: ViewsTabConfig, cell: ICellModel): void { + this.cell = config.cell; + this.title = config.title; + this.id = config.id; + this.group = config.group; + this.icon = config.icon; + this.cellModel = cell; + } +} + export class NotebookViewModel implements INotebookView { private _onDeleted = new Emitter(); private _onCellVisibilityChanged = new Emitter(); @@ -31,14 +64,22 @@ export class NotebookViewModel implements INotebookView { constructor( protected _name: string, private _notebookViews: NotebookViewsExtension, + private _cards: INotebookViewCard[] = [], guid?: string ) { this.guid = guid ?? generateUuid(); + + + this._cards.forEach(card => { + card.tabs.forEach(tab => { + tab.cellModel = this.cells.find(c => c.cellGuid === tab.cell.guid); + }); + }); } public static load(guid: string, notebookViews: NotebookViewsExtension): INotebookView { const view = notebookViews.getViews().find(v => v.guid === guid); - return new NotebookViewModel(view.name, notebookViews, view.guid); + return new NotebookViewModel(view.name, notebookViews, view.cards, view.guid); } public initialize(isNew?: boolean): void { @@ -46,29 +87,43 @@ export class NotebookViewModel implements INotebookView { this._isNew = isNew; } - const cells = this._notebookViews.notebook.cells; - cells.forEach((cell, idx) => { this.initializeCell(cell, idx); }); + /// Initialize cards + /// 0. Check that the cards object is created, or load them + /// 1. Create a card per cell + /// 2. Create a tab per cell + /// 3. Title the tab with a sequential number i.e., Untitled Tab {1,2,3..} + if (isNew) { + this.initializeCards(); + } } - protected initializeCell(cell: ICellModel, idx: number) { - let meta = this._notebookViews.getExtensionCellMetadata(cell); + public initializeCards() { + const cells = this._notebookViews.notebook.cells; - if (!meta) { - this._notebookViews.initializeCell(cell); - meta = this._notebookViews.getExtensionCellMetadata(cell); - } - - // Ensure that we are not duplicting view entries in cell metadata - if (!meta.views.find(v => v.guid === this.guid)) { - meta.views.push({ - guid: this.guid, - hidden: false, + let card: INotebookViewCard; + cells.forEach((cell, idx) => { + card = { + guid: generateUuid(), y: idx * DEFAULT_VIEW_CARD_HEIGHT, x: 0, width: DEFAULT_VIEW_CARD_WIDTH, - height: DEFAULT_VIEW_CARD_HEIGHT - }); + height: DEFAULT_VIEW_CARD_HEIGHT, + tabs: [] + }; + + this.createTab(cell, card); + + this._cards.push(card); + }); + } + + protected createTab(cell: ICellModel, card: INotebookViewCard): void { + if (card === undefined) { + throw new Error('A card must be specified to create a tab'); } + + const newTab: ViewsTabConfig = { title: localize('Untitled', 'Untitled'), id: generateUuid(), group: card.guid, cell: { guid: cell.cellGuid } }; + card.tabs.push(new ViewsTab(newTab, cell)); } public cellInitialized(cell: ICellModel): boolean { @@ -90,13 +145,18 @@ export class NotebookViewModel implements INotebookView { return !this._notebookViews.viewNameIsTaken(name); } - public getCellMetadata(cell: ICellModel): INotebookViewCell { + public getCellMetadata(cell: ICellModel): INotebookViewCard { const meta = this._notebookViews.getExtensionCellMetadata(cell); return meta?.views?.find(view => view.guid === this.guid); } public get hiddenCells(): Readonly { - return this.cells.filter(cell => this.getCellMetadata(cell)?.hidden !== false); + const allTabs = this.cards.flatMap(card => card.tabs); + return this.cells.filter(cell => !allTabs.find(t => t.cell.guid === cell.cellGuid)); + } + + public get cards(): INotebookViewCard[] { + return this._cards; } public get cells(): Readonly { @@ -111,30 +171,69 @@ export class NotebookViewModel implements INotebookView { return this._notebookViews.notebook.cells.find(cell => cell.cellGuid === guid); } - public updateCell(cell: ICellModel, currentView: INotebookView, cellData: INotebookViewCell, override: boolean = false) { - if (!this.cellInitialized(cell)) { - this.initializeCell(cell, 0); - } - + public updateCell(cell: ICellModel, currentView: INotebookView, cellData: INotebookViewCard, override: boolean = false) { this._notebookViews.updateCell(cell, currentView, cellData, override); } - public insertCell(cell: ICellModel) { - this.updateCell(cell, this, { hidden: false }); + public insertCell(cell: ICellModel): INotebookViewCard { + let card: INotebookViewCard = { + guid: generateUuid(), + y: 0, + x: 0, + width: DEFAULT_VIEW_CARD_WIDTH, + height: DEFAULT_VIEW_CARD_HEIGHT, + tabs: [] + }; + + this.createTab(cell, card); + + this._cards.push(card); + this._onCellVisibilityChanged.fire(cell); + + this.save(); + + return card; } public hideCell(cell: ICellModel) { - this.updateCell(cell, this, { hidden: true }); + this.cards.forEach((card) => { + const updatedTabs = card.tabs.filter(t => t.cell.guid !== cell.cellGuid); + this._notebookViews.updateCard(card, { tabs: updatedTabs }, this); + + // If there are no tabs left in the card, delete the card + if (!updatedTabs.length) { + const index = this.cards.findIndex(c => c.guid === card.guid); + const removedCard = this._cards.splice(index, 1); + if (removedCard.length === 1) { + this.compactCells(); + this.save(); + } + } + }); this._onCellVisibilityChanged.fire(cell); } - public moveCell(cell: ICellModel, x: number, y: number) { - this.updateCell(cell, this, { x, y }); + public moveCard(card: INotebookViewCard, x: number, y: number) { + this._notebookViews.updateCard(card, { x, y }, this); + } + + public resizeCard(card: INotebookViewCard, width?: number, height?: number) { + let data: INotebookViewCard = {}; + + if (width) { + data.width = width; + } + + if (height) { + data.height = height; + } + + this._notebookViews.updateCard(card, data, this); } public resizeCell(cell: ICellModel, width?: number, height?: number) { - let data: INotebookViewCell = {}; + let data: INotebookViewCard = {}; if (width) { data.width = width; @@ -153,22 +252,21 @@ export class NotebookViewModel implements INotebookView { } public compactCells() { - let cellsPlaced: INotebookViewCell[] = []; + let cardsPlaced: INotebookViewCard[] = []; - this.displayedCells.forEach((cell: ICellModel) => { - const c1 = this.getCellMetadata(cell); + this.cards.forEach((card: INotebookViewCard) => { for (let i = 0; ; i++) { const row = i % GRID_COLUMNS; const column = Math.floor(i / GRID_COLUMNS); - if (row + c1.width > GRID_COLUMNS) { + if (row + card.width > GRID_COLUMNS) { continue; } - if (!cellsPlaced.find((c2) => cellCollides(c2, { ...c1, x: row, y: column }))) { - this._notebookViews.updateCell(cell, this, { x: row, y: column }); - cellsPlaced.push({ ...c1, x: row, y: column }); + if (!cardsPlaced.find((c2) => cellCollides(c2, { ...card, x: row, y: column }))) { + this._notebookViews.updateCard(card, { x: row, y: column }, this); + cardsPlaced.push({ ...card, x: row, y: column }); break; } } @@ -193,6 +291,6 @@ export class NotebookViewModel implements INotebookView { } public toJSON() { - return { guid: this.guid, name: this._name } as NotebookViewModel; + return { guid: this.guid, name: this._name, cards: this.cards } as INotebookView; } } diff --git a/src/sql/workbench/services/notebook/browser/notebookViews/notebookViews.d.ts b/src/sql/workbench/services/notebook/browser/notebookViews/notebookViews.d.ts index 1861e78e08..b2990c5a1b 100644 --- a/src/sql/workbench/services/notebook/browser/notebookViews/notebookViews.d.ts +++ b/src/sql/workbench/services/notebook/browser/notebookViews/notebookViews.d.ts @@ -3,7 +3,10 @@ * Licensed under the Source EULA. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { TabConfig } from 'sql/workbench/browser/modelComponents/tabbedPanel.component'; import { ICellModel, INotebookModel } from 'sql/workbench/services/notebook/browser/models/modelInterfaces'; +import { ViewsTab } from 'sql/workbench/services/notebook/browser/notebookViews/notebookViewModel'; +import { NotebookViewsExtension } from 'sql/workbench/services/notebook/browser/notebookViews/notebookViewsExtension'; import { Event } from 'vs/base/common/event'; export type CellChangeEventType = 'hide' | 'insert' | 'active' | 'execution' | 'update'; @@ -13,6 +16,13 @@ export type CellChangeEvent = { event: CellChangeEventType }; +export interface INotebookViewsExtensionUpgrade { + readonly sourceVersion: number; + readonly targetVersion: number; + versionCheck(version: number): boolean; + apply(extension: NotebookViewsExtension): void; +} + export interface INotebookViews { onActiveViewChanged: Event; createNewView(name?: string): INotebookView; @@ -29,19 +39,21 @@ export interface INotebookView { readonly onCellVisibilityChanged: Event; isNew: boolean; + cards: INotebookViewCard[]; cells: Readonly; hiddenCells: Readonly; displayedCells: Readonly; name: string; initialize(isNew?: boolean): void; nameAvailable(name: string): boolean; - getCellMetadata(cell: ICellModel): INotebookViewCell; + getCellMetadata(cell: ICellModel): INotebookViewCard; hideCell(cell: ICellModel): void; - moveCell(cell: ICellModel, x: number, y: number): void; + moveCard(card: INotebookViewCard, x: number, y: number): void; compactCells(); + resizeCard(card: INotebookViewCard, width: number, height: number): void; resizeCell(cell: ICellModel, width: number, height: number): void; getCell(guid: string): Readonly; - insertCell(cell: ICellModel): void; + insertCell(cell: ICellModel): INotebookViewCard; markAsViewed(): void; save(): void; delete(): void; @@ -49,11 +61,22 @@ export interface INotebookView { export interface INotebookViewCell { readonly guid?: string; +} + + +export interface INotebookViewCard { + readonly guid?: string; hidden?: boolean; x?: number; y?: number; width?: number; height?: number; + tabs?: ViewsTab[]; + activeTab?: ViewsTab; +} + +interface ViewsTabConfig extends TabConfig { + cell: INotebookViewCell } /* @@ -71,7 +94,7 @@ export interface INotebookViewMetadata { * view at the cell level. */ export interface INotebookViewCellMetadata { - views: INotebookViewCell[]; + views: INotebookViewCard[]; } export interface INotebookViews { diff --git a/src/sql/workbench/services/notebook/browser/notebookViews/notebookViewsExtension.ts b/src/sql/workbench/services/notebook/browser/notebookViews/notebookViewsExtension.ts index b188626d3e..6a71365f33 100644 --- a/src/sql/workbench/services/notebook/browser/notebookViews/notebookViewsExtension.ts +++ b/src/sql/workbench/services/notebook/browser/notebookViews/notebookViewsExtension.ts @@ -9,14 +9,20 @@ import { Emitter, Event } from 'vs/base/common/event'; import { localize } from 'vs/nls'; import { NotebookViewModel } from 'sql/workbench/services/notebook/browser/notebookViews/notebookViewModel'; import { NotebookExtension } from 'sql/workbench/services/notebook/browser/models/notebookExtension'; -import { INotebookView, INotebookViewCell, INotebookViewCellMetadata, INotebookViewMetadata, INotebookViews } from 'sql/workbench/services/notebook/browser/notebookViews/notebookViews'; +import { INotebookView, INotebookViewCard, INotebookViewCellMetadata, INotebookViewMetadata, INotebookViews, INotebookViewsExtensionUpgrade } from 'sql/workbench/services/notebook/browser/notebookViews/notebookViews'; +import { NotebookViewsUpgrades } from 'sql/workbench/services/notebook/browser/notebookViews/notebookViewsUpgrades'; + + export class NotebookViewsExtension extends NotebookExtension implements INotebookViews { static readonly defaultViewName = localize('notebookView.untitledView', "Untitled View"); static readonly extension = 'notebookviews'; + static readonly upgrades: Array = [ + new NotebookViewsUpgrades.V1ToV2NotebookViewsExtensionUpgrade() + ]; readonly maxNameIterationAttempts = 100; - override readonly version = 1; + override readonly version: number = 2; protected _metadata: INotebookViewMetadata | undefined; private _initialized: boolean = false; @@ -32,6 +38,13 @@ export class NotebookViewsExtension extends NotebookExtension { + if (upgrade.versionCheck(this._metadata.version)) { + upgrade.apply(this); + this._metadata = this.getExtensionMetadata(); + } + }); + this._metadata.views = this._metadata.views.map(view => NotebookViewModel.load(view.guid, this)); this._initialized = true; } @@ -94,16 +107,7 @@ export class NotebookViewsExtension extends NotebookExtension view.guid === guid); if (viewToRemove >= 0) { - let removedView = this._metadata?.views.splice(viewToRemove, 1); - - // Remove view data for each cell - if (removedView.length === 1) { - this._notebook?.cells.forEach((cell) => { - let meta = this.getExtensionCellMetadata(cell); - meta.views = meta.views.filter(x => x.guid !== removedView[0].guid); - this.setExtensionCellMetadata(cell, meta); - }); - } + this._metadata?.views.splice(viewToRemove, 1); } if (guid === this._metadata?.activeView) { @@ -127,7 +131,7 @@ export class NotebookViewsExtension extends NotebookExtension view.guid === currentView.guid); @@ -139,6 +143,22 @@ export class NotebookViewsExtension extends NotebookExtension view.guid === currentView.guid); + + if (viewToUpdate >= 0) { + const cardToUpdate = notebookMetadata.views[viewToUpdate].cards.findIndex(c => c.guid === card.guid); + + if (cardToUpdate >= 0) { + notebookMetadata.views[viewToUpdate].cards[cardToUpdate] = override ? cardData : { ...notebookMetadata.views[viewToUpdate].cards[cardToUpdate], ...cardData }; + this.setExtensionMetadata(this._notebook, notebookMetadata); + } + } + } + } + public get notebook(): INotebookModel { return this._notebook; } diff --git a/src/sql/workbench/services/notebook/browser/notebookViews/notebookViewsUpgrades.ts b/src/sql/workbench/services/notebook/browser/notebookViews/notebookViewsUpgrades.ts new file mode 100644 index 0000000000..f1cd6028a8 --- /dev/null +++ b/src/sql/workbench/services/notebook/browser/notebookViews/notebookViewsUpgrades.ts @@ -0,0 +1,65 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { NotebookViewsExtension } from 'sql/workbench/services/notebook/browser/notebookViews/notebookViewsExtension'; +import { generateUuid } from 'vs/base/common/uuid'; + +export namespace NotebookViewsUpgrades { + export class V1ToV2NotebookViewsExtensionUpgrade { + sourceVersion = 1; + targetVersion = 2; + + versionCheck(version: number): boolean { + return version > this.targetVersion; + } + + apply(notebookViewsExtension: NotebookViewsExtension): void { + const extensions = notebookViewsExtension.notebook.getMetaValue('extensions'); + const notebookviews = extensions['notebookviews']; + const views = notebookviews['views']; + + const newmeta = { + version: 2, + activeView: null, + views: [] + }; + + views.forEach((view, viewIdx) => { + const viewData = { + guid: view.guid, + name: view.name, + cards: [] + }; + + const cells = notebookViewsExtension.notebook.cells; + cells.forEach((cell) => { + const cellmeta = cell.metadata['extensions']?.['notebookviews']?.['views']?.[viewIdx]; + if (cellmeta && !cellmeta?.hidden) { + const card = { + guid: generateUuid(), + y: cellmeta.y, + x: cellmeta.x, + width: cellmeta.width, + height: cellmeta.height, + tabs: [{ + title: 'Untitled', + guid: generateUuid(), + cell: { + guid: cell.cellGuid + } + }] + }; + + viewData.cards.push(card); + } + }); + + newmeta.views.push(viewData); + }); + + notebookViewsExtension.setExtensionMetadata(notebookViewsExtension.notebook, newmeta); + } + } +}