diff --git a/src/sql/workbench/contrib/notebook/test/browser/notebookViewModel.test.ts b/src/sql/workbench/contrib/notebook/test/browser/notebookViewModel.test.ts new file mode 100644 index 0000000000..49d47b5b34 --- /dev/null +++ b/src/sql/workbench/contrib/notebook/test/browser/notebookViewModel.test.ts @@ -0,0 +1,280 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as TypeMoq from 'typemoq'; +import { nb } from 'azdata'; +import * as assert from 'assert'; + +import { INotificationService } from 'vs/platform/notification/common/notification'; +import { TestNotificationService } from 'vs/platform/notification/test/common/testNotificationService'; +import { URI } from 'vs/base/common/uri'; + +import { NotebookManagerStub } from 'sql/workbench/contrib/notebook/test/stubs'; +import { NotebookModel } from 'sql/workbench/services/notebook/browser/models/notebookModel'; +import { ModelFactory } from 'sql/workbench/services/notebook/browser/models/modelFactory'; +import { INotebookModelOptions } from 'sql/workbench/services/notebook/browser/models/modelInterfaces'; +import { Memento } from 'vs/workbench/common/memento'; +import { TestCapabilitiesService } from 'sql/platform/capabilities/test/common/testCapabilitiesService'; +import { ICapabilitiesService } from 'sql/platform/capabilities/common/capabilitiesService'; +import { TestStorageService } from 'vs/workbench/test/common/workbenchTestServices'; +import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; +import { InstantiationService } from 'vs/platform/instantiation/common/instantiationService'; +import { ServiceCollection } from 'vs/platform/instantiation/common/serviceCollection'; +import { NullLogService } from 'vs/platform/log/common/log'; +import { TestConnectionManagementService } from 'sql/platform/connection/test/common/testConnectionManagementService'; +import { NotebookEditorContentManager } from 'sql/workbench/contrib/notebook/browser/models/notebookInput'; +import { SessionManager } from 'sql/workbench/contrib/notebook/test/emptySessionClasses'; +import { NullAdsTelemetryService } from 'sql/platform/telemetry/common/adsTelemetryService'; +import { CellTypes } from 'sql/workbench/services/notebook/common/contracts'; +import { NotebookViewsExtension } from 'sql/workbench/services/notebook/browser/notebookViews/notebookViewsExtension'; +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'; + +let initialNotebookContent: nb.INotebookContents = { + cells: [{ + cell_type: CellTypes.Code, + source: ['insert into t1 values (c1, c2)'], + metadata: { language: 'python' }, + execution_count: 1 + }, { + cell_type: CellTypes.Markdown, + source: ['I am *markdown*'], + metadata: { language: 'python' }, + execution_count: 1 + }], + metadata: { + kernelspec: { + name: 'mssql', + language: 'sql' + }, + }, + nbformat: 4, + nbformat_minor: 5 +}; + +let notebookContentWithoutMeta: nb.INotebookContents = { + cells: [{ + cell_type: CellTypes.Code, + source: ['insert into t1 values (c1, c2)'], + execution_count: 1 + }, { + cell_type: CellTypes.Markdown, + source: ['I am *markdown*'], + execution_count: 1 + }], + metadata: {}, + nbformat: 4, + nbformat_minor: 5 +}; + +let defaultUri = URI.file('/some/path.ipynb'); +let notificationService: TypeMoq.Mock; +let capabilitiesService: TypeMoq.Mock; +let instantiationService: IInstantiationService; +let configurationService: IConfigurationService; + +suite('NotebookViewModel', function (): void { + let defaultViewName = 'Default New View'; + let notebookManagers = [new NotebookManagerStub()]; + let mockSessionManager: TypeMoq.Mock; + let memento: TypeMoq.Mock; + let queryConnectionService: TypeMoq.Mock; + let defaultModelOptions: INotebookModelOptions; + const logService = new NullLogService(); + setup(() => { + setupServices(); + }); + + test('initialize', async function (): Promise { + let notebookViews = await initializeNotebookViewsExtension(initialNotebookContent); + let viewModel = new NotebookViewModel(defaultViewName, notebookViews); + viewModel.initialize(); + + let cellsWithNewView = notebookViews.getCells().filter(cell => cell.views.find(v => v.guid === viewModel.guid)); + + assert.equal(cellsWithNewView.length, 2); + assert.equal(viewModel.cells.length, 2); + assert.equal(viewModel.hiddenCells.length, 0); + assert.equal(viewModel.name, defaultViewName); + }); + + test('initialize notebook with no metadata', async function (): Promise { + let notebookViews = await initializeNotebookViewsExtension(notebookContentWithoutMeta); + let viewModel = new NotebookViewModel(defaultViewName, notebookViews); + viewModel.initialize(); + + let cellsWithNewView = notebookViews.getCells().filter(cell => cell.views.find(v => v.guid === viewModel.guid)); + + assert.equal(cellsWithNewView.length, 2); + assert.equal(viewModel.cells.length, 2); + assert.equal(viewModel.hiddenCells.length, 0); + assert.equal(viewModel.name, defaultViewName); + }); + + test('rename', async function (): Promise { + let exceptionThrown = false; + let notebookViews = await initializeNotebookViewsExtension(initialNotebookContent); + + const view = notebookViews.createNewView(defaultViewName); + + try { + view.name = `${defaultViewName} 1`; + } catch (e) { + exceptionThrown = true; + } + + assert.equal(view.name, `${defaultViewName} 1`); + assert(!exceptionThrown); + }); + + test('duplicate name', async function (): Promise { + let exceptionThrown = false; + let notebookViews = await initializeNotebookViewsExtension(initialNotebookContent); + + notebookViews.createNewView(defaultViewName); + let viewModel2 = notebookViews.createNewView(`${defaultViewName} 1`); + + + try { + viewModel2.name = defaultViewName; + } catch (e) { + exceptionThrown = true; + } + + assert(exceptionThrown); + }); + + test('hide cell', async function (): Promise { + let notebookViews = await initializeNotebookViewsExtension(initialNotebookContent); + let viewModel = new NotebookViewModel(defaultViewName, notebookViews); + viewModel.initialize(); + + let cellToHide = viewModel.cells[0]; + + viewModel.hideCell(cellToHide); + + assert.equal(viewModel.hiddenCells.length, 1); + assert(viewModel.hiddenCells.includes(cellToHide)); + }); + + test('insert cell', async function (): Promise { + let notebookViews = await initializeNotebookViewsExtension(initialNotebookContent); + let viewModel = new NotebookViewModel(defaultViewName, notebookViews); + viewModel.initialize(); + + let cellToInsert = viewModel.cells[0]; + + viewModel.hideCell(cellToInsert); + assert(viewModel.hiddenCells.includes(cellToInsert)); + + viewModel.insertCell(cellToInsert); + assert(!viewModel.hiddenCells.includes(cellToInsert)); + }); + + test('move cell', async function (): Promise { + let notebookViews = await initializeNotebookViewsExtension(initialNotebookContent); + let viewModel = new NotebookViewModel(defaultViewName, notebookViews); + viewModel.initialize(); + + let cellToMove = viewModel.cells[0]; + + viewModel.moveCell(cellToMove, 98, 99); + let cellMeta = viewModel.getCellMetadata(cellToMove); + + assert.equal(cellMeta.x, 98); + assert.equal(cellMeta.y, 99); + }); + + test('resize cell', async function (): Promise { + let notebookViews = await initializeNotebookViewsExtension(initialNotebookContent); + let viewModel = new NotebookViewModel(defaultViewName, notebookViews); + viewModel.initialize(); + + let cellToResize = viewModel.cells[0]; + + viewModel.resizeCell(cellToResize, 3, 4); + let cellMeta = viewModel.getCellMetadata(cellToResize); + + assert.equal(cellMeta.width, 3); + assert.equal(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.getCellMetadata(cell); + + assert(!isUndefinedOrNull(cellMeta.views.find(v => v.guid === viewModel.guid))); + assert.deepEqual(viewModel.getCellMetadata(cell), cellMeta.views.find(v => v.guid === viewModel.guid)); + }); + + test('delete', async function (): Promise { + let notebookViews = await initializeNotebookViewsExtension(initialNotebookContent); + let viewModel = new NotebookViewModel(defaultViewName, notebookViews); + viewModel.initialize(); + + let CreateOnDeletedPromise = () => { + return new Promise((resolve, reject) => { + setTimeout(() => resolve(false), 2000); + viewModel.onDeleted(() => { + resolve(true); + }); + }); + }; + + let onDeletedPromise = CreateOnDeletedPromise(); + viewModel.delete(); + + let onDeletedCalled = await onDeletedPromise; + let hasView = notebookViews.getViews().find(view => view.name === defaultViewName); + + assert(onDeletedCalled, 'onDelete event not called'); + assert(!hasView); + }); + + function setupServices() { + mockSessionManager = TypeMoq.Mock.ofType(SessionManager); + notebookManagers[0].sessionManager = mockSessionManager.object; + notificationService = TypeMoq.Mock.ofType(TestNotificationService, TypeMoq.MockBehavior.Loose); + capabilitiesService = TypeMoq.Mock.ofType(TestCapabilitiesService); + memento = TypeMoq.Mock.ofType(Memento, TypeMoq.MockBehavior.Loose, ''); + memento.setup(x => x.getMemento(TypeMoq.It.isAny())).returns(() => void 0); + queryConnectionService = TypeMoq.Mock.ofType(TestConnectionManagementService, TypeMoq.MockBehavior.Loose, memento.object, undefined, new TestStorageService()); + queryConnectionService.callBase = true; + let serviceCollection = new ServiceCollection(); + instantiationService = new InstantiationService(serviceCollection, true); + configurationService = new TestConfigurationService(); + defaultModelOptions = { + notebookUri: defaultUri, + factory: new ModelFactory(instantiationService), + notebookManagers, + contentManager: undefined, + notificationService: notificationService.object, + connectionService: queryConnectionService.object, + providerId: 'SQL', + cellMagicMapper: undefined, + defaultKernel: undefined, + layoutChanged: undefined, + capabilitiesService: capabilitiesService.object + }; + } + + async function initializeNotebookViewsExtension(contents: nb.INotebookContents): Promise { + let mockContentManager = TypeMoq.Mock.ofType(NotebookEditorContentManager); + mockContentManager.setup(c => c.loadContent()).returns(() => Promise.resolve(contents)); + defaultModelOptions.contentManager = mockContentManager.object; + + let model = new NotebookModel(defaultModelOptions, undefined, logService, undefined, new NullAdsTelemetryService(), queryConnectionService.object, configurationService); + await model.loadContents(); + await model.requestModelLoad(); + + return new NotebookViewsExtension(model); + } +}); diff --git a/src/sql/workbench/contrib/notebook/test/browser/notebookViewsExtension.test.ts b/src/sql/workbench/contrib/notebook/test/browser/notebookViewsExtension.test.ts new file mode 100644 index 0000000000..6c644ff16c --- /dev/null +++ b/src/sql/workbench/contrib/notebook/test/browser/notebookViewsExtension.test.ts @@ -0,0 +1,170 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as TypeMoq from 'typemoq'; +import { nb } from 'azdata'; +import * as assert from 'assert'; + +import { INotificationService } from 'vs/platform/notification/common/notification'; +import { TestNotificationService } from 'vs/platform/notification/test/common/testNotificationService'; +import { URI } from 'vs/base/common/uri'; + +import { NotebookManagerStub } from 'sql/workbench/contrib/notebook/test/stubs'; +import { NotebookModel } from 'sql/workbench/services/notebook/browser/models/notebookModel'; +import { ModelFactory } from 'sql/workbench/services/notebook/browser/models/modelFactory'; +import { INotebookModelOptions } from 'sql/workbench/services/notebook/browser/models/modelInterfaces'; +import { Memento } from 'vs/workbench/common/memento'; +import { TestCapabilitiesService } from 'sql/platform/capabilities/test/common/testCapabilitiesService'; +import { ICapabilitiesService } from 'sql/platform/capabilities/common/capabilitiesService'; +import { TestStorageService } from 'vs/workbench/test/common/workbenchTestServices'; +import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; +import { InstantiationService } from 'vs/platform/instantiation/common/instantiationService'; +import { ServiceCollection } from 'vs/platform/instantiation/common/serviceCollection'; +import { NullLogService } from 'vs/platform/log/common/log'; +import { TestConnectionManagementService } from 'sql/platform/connection/test/common/testConnectionManagementService'; +import { NotebookEditorContentManager } from 'sql/workbench/contrib/notebook/browser/models/notebookInput'; +import { SessionManager } from 'sql/workbench/contrib/notebook/test/emptySessionClasses'; +import { NullAdsTelemetryService } from 'sql/platform/telemetry/common/adsTelemetryService'; +import { CellTypes } from 'sql/workbench/services/notebook/common/contracts'; +import { NotebookViewsExtension } from 'sql/workbench/services/notebook/browser/notebookViews/notebookViewsExtension'; +import { TestConfigurationService } from 'sql/platform/connection/test/common/testConfigurationService'; +import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; + +let initialNotebookContent: nb.INotebookContents = { + cells: [{ + cell_type: CellTypes.Code, + source: ['insert into t1 values (c1, c2)'], + metadata: { language: 'python' }, + execution_count: 1 + }, { + cell_type: CellTypes.Markdown, + source: ['I am *markdown*'], + metadata: { language: 'python' }, + execution_count: 1 + }], + metadata: { + kernelspec: { + name: 'mssql', + language: 'sql' + }, + }, + nbformat: 4, + nbformat_minor: 5 +}; + +let defaultUri = URI.file('/some/path.ipynb'); +let notificationService: TypeMoq.Mock; +let capabilitiesService: TypeMoq.Mock; +let instantiationService: IInstantiationService; +let configurationService: IConfigurationService; + +suite('NotebookViews', function (): void { + let defaultViewName = 'Default New View'; + let notebookManagers = [new NotebookManagerStub()]; + let mockSessionManager: TypeMoq.Mock; + let memento: TypeMoq.Mock; + let queryConnectionService: TypeMoq.Mock; + let defaultModelOptions: INotebookModelOptions; + let serviceCollection = new ServiceCollection(); + let logService = new NullLogService(); + let notebookViews: NotebookViewsExtension; + setup(async () => { + setupServices(); + notebookViews = await initializeExtension(); + }); + + test('create new view', async function (): Promise { + assert.equal(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.equal(newView.name, defaultViewName, 'view was not created with its given name'); + assert.equal(newView.cells.length, 2, 'view did not contain the same number of cells as the notebook used to create it'); + assert.equal(cellsWithMatchingGuid.length, newView.cells.length, 'cell metadata was not created for all cells in view'); + }); + + test('remove view', async function (): Promise { + let newView = notebookViews.createNewView(defaultViewName); + + notebookViews.removeView(newView.guid); + + let cellsWithNewView = notebookViews.getCells().filter(cell => cell.views.find(v => v.guid === newView.guid)); + + assert.equal(notebookViews.getViews().length, 0, 'view not removed from notebook metadata'); + assert.equal(cellsWithNewView.length, 0, 'view not removed from cells'); + }); + + test('default view name', async function (): Promise { + let newView = notebookViews.createNewView(); + assert.equal(newView.name, NotebookViewsExtension.defaultViewName); + + let newView1 = notebookViews.createNewView(); + assert.equal(newView1.name, `${NotebookViewsExtension.defaultViewName} 1`); + }); + + test('active view', async function (): Promise { + let newView = notebookViews.createNewView(); + notebookViews.setActiveView(newView); + + assert.equal(notebookViews.getActiveView(), newView); + }); + + test('update cell', async function (): Promise { + let newView = notebookViews.createNewView(); + let c1 = newView.cells[0]; + + let cellData = newView.getCellMetadata(c1); + cellData = { ...cellData, x: 0, y: 0, hidden: true, width: 0, height: 0 }; + notebookViews.updateCell(c1, newView, cellData); + + 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'); + + cellData = { ...cellData, x: 3 }; + notebookViews.updateCell(c1, newView, { x: 3 }); + assert.deepStrictEqual(newView.getCellMetadata(c1), cellData, 'update should only override set values'); + }); + + function setupServices() { + mockSessionManager = TypeMoq.Mock.ofType(SessionManager); + notebookManagers[0].sessionManager = mockSessionManager.object; + notificationService = TypeMoq.Mock.ofType(TestNotificationService, TypeMoq.MockBehavior.Loose); + capabilitiesService = TypeMoq.Mock.ofType(TestCapabilitiesService); + memento = TypeMoq.Mock.ofType(Memento, TypeMoq.MockBehavior.Loose, ''); + memento.setup(x => x.getMemento(TypeMoq.It.isAny())).returns(() => void 0); + queryConnectionService = TypeMoq.Mock.ofType(TestConnectionManagementService, TypeMoq.MockBehavior.Loose, memento.object, undefined, new TestStorageService()); + queryConnectionService.callBase = true; + + instantiationService = new InstantiationService(serviceCollection, true); + configurationService = new TestConfigurationService(); + defaultModelOptions = { + notebookUri: defaultUri, + factory: new ModelFactory(instantiationService), + notebookManagers, + contentManager: undefined, + notificationService: notificationService.object, + connectionService: queryConnectionService.object, + providerId: 'SQL', + cellMagicMapper: undefined, + defaultKernel: undefined, + layoutChanged: undefined, + capabilitiesService: capabilitiesService.object + }; + } + + async function initializeExtension(): Promise { + let mockContentManager = TypeMoq.Mock.ofType(NotebookEditorContentManager); + mockContentManager.setup(c => c.loadContent()).returns(() => Promise.resolve(initialNotebookContent)); + defaultModelOptions.contentManager = mockContentManager.object; + + let model = new NotebookModel(defaultModelOptions, undefined, logService, undefined, new NullAdsTelemetryService(), queryConnectionService.object, configurationService); + await model.loadContents(); + await model.requestModelLoad(); + + return new NotebookViewsExtension(model); + } +}); diff --git a/src/sql/workbench/contrib/notebook/test/electron-browser/notebookModel.test.ts b/src/sql/workbench/contrib/notebook/test/electron-browser/notebookModel.test.ts index edbfb45894..f0cdf5e237 100644 --- a/src/sql/workbench/contrib/notebook/test/electron-browser/notebookModel.test.ts +++ b/src/sql/workbench/contrib/notebook/test/electron-browser/notebookModel.test.ts @@ -368,7 +368,23 @@ suite('notebook model', function (): void { model.deleteCell(model.cells[0]); assert.equal(errorCount, 2, 'Error count should be 2 after trying to delete a cell that does not exist a second time'); assert(isUndefinedOrNull(notebookContentChange), 'There still should be no content change after an error is recorded'); + }); + test('Should notify cell on metadata change', async function (): Promise { + let mockContentManager = TypeMoq.Mock.ofType(NotebookEditorContentManager); + mockContentManager.setup(c => c.loadContent()).returns(() => Promise.resolve(expectedNotebookContent)); + defaultModelOptions.contentManager = mockContentManager.object; + + // When I initalize the model + let model = new NotebookModel(defaultModelOptions, undefined, logService, undefined, new NullAdsTelemetryService(), queryConnectionService.object, configurationService); + await model.loadContents(); + + let notebookContentChange: NotebookContentChange; + model.contentChanged(c => notebookContentChange = c); + + model.cells[0].metadata = { 'test-field': 'test-value' }; + assert(!isUndefinedOrNull(notebookContentChange)); + assert.equal(notebookContentChange.changeType, NotebookChangeType.CellMetadataUpdated, 'notebookContentChange changeType should indicate '); }); test('Should load contents but then go to error state if client session startup fails', async function (): Promise { diff --git a/src/sql/workbench/contrib/notebook/test/stubs.ts b/src/sql/workbench/contrib/notebook/test/stubs.ts index 71bfba9d9c..9f009fcb0c 100644 --- a/src/sql/workbench/contrib/notebook/test/stubs.ts +++ b/src/sql/workbench/contrib/notebook/test/stubs.ts @@ -100,6 +100,12 @@ export class NotebookModelStub implements INotebookModel { set viewMode(mode: ViewMode) { throw new Error('Method not implemented.'); } + setMetaValue(key: string, value: any) { + throw new Error('Method not implemented.'); + } + getMetaValue(key: string) { + throw new Error('Method not implemented.'); + } addCell(cellType: CellType, index?: number): void { throw new Error('Method not implemented.'); } diff --git a/src/sql/workbench/services/notebook/browser/models/cell.ts b/src/sql/workbench/services/notebook/browser/models/cell.ts index e5daa93959..cb5fa0c3ca 100644 --- a/src/sql/workbench/services/notebook/browser/models/cell.ts +++ b/src/sql/workbench/services/notebook/browser/models/cell.ts @@ -128,6 +128,15 @@ export class CellModel extends Disposable implements ICellModel { return this._onCellModeChanged.event; } + public set metadata(data: any) { + this._metadata = data; + this.sendChangeToNotebook(NotebookChangeType.CellMetadataUpdated); + } + + public get metadata(): any { + return this._metadata; + } + public get isEditMode(): boolean { return this._isEditMode; } diff --git a/src/sql/workbench/services/notebook/browser/models/modelInterfaces.ts b/src/sql/workbench/services/notebook/browser/models/modelInterfaces.ts index bc4f7fb2b4..b2d75e768c 100644 --- a/src/sql/workbench/services/notebook/browser/models/modelInterfaces.ts +++ b/src/sql/workbench/services/notebook/browser/models/modelInterfaces.ts @@ -345,6 +345,16 @@ export interface INotebookModel { */ viewMode: ViewMode; + /** + * Add custom metadata values to the notebook + */ + setMetaValue(key: string, value: any); + + /** + * Get a custom metadata value from the notebook + */ + getMetaValue(key: string): any; + /** * Change the current kernel from the Kernel dropdown * @param displayName kernel name (as displayed in Kernel dropdown) @@ -476,6 +486,7 @@ export interface ICellModel { source: string | string[]; cellType: CellType; trustedMode: boolean; + metadata: any | undefined; active: boolean; hover: boolean; executionCount: number | undefined; diff --git a/src/sql/workbench/services/notebook/browser/models/notebookExtension.ts b/src/sql/workbench/services/notebook/browser/models/notebookExtension.ts new file mode 100644 index 0000000000..2eff0741b2 --- /dev/null +++ b/src/sql/workbench/services/notebook/browser/models/notebookExtension.ts @@ -0,0 +1,37 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { INotebookModel, ICellModel } from 'sql/workbench/services/notebook/browser/models/modelInterfaces'; +import { NotebookChangeType } from 'sql/workbench/services/notebook/common/contracts'; + +export class NotebookExtension { + readonly version = 1; + readonly extensionName = 'azuredatastudio'; + readonly extensionNamespace = 'extensions'; + + public getNotebookMetadata(notebook: INotebookModel): TNotebookMeta { + const metadata = notebook.getMetaValue(this.extensionNamespace) || {}; + return metadata[this.extensionName] as TNotebookMeta; + } + + public setNotebookMetadata(notebook: INotebookModel, metadata: TNotebookMeta) { + const meta = {}; + meta[this.extensionName] = metadata; + notebook.setMetaValue(this.extensionNamespace, meta); + notebook.serializationStateChanged(NotebookChangeType.MetadataChanged); + } + + public getCellMetadata(cell: ICellModel): TCellMeta { + const namespaceMeta = cell.metadata[this.extensionNamespace] || {}; + return namespaceMeta[this.extensionName] as TCellMeta; + } + + public setCellMetadata(cell: ICellModel, metadata: TCellMeta) { + const meta = {}; + meta[this.extensionName] = metadata; + cell.metadata[this.extensionNamespace] = meta; + cell.sendChangeToNotebook(NotebookChangeType.CellsModified); + } +} diff --git a/src/sql/workbench/services/notebook/browser/models/notebookModel.ts b/src/sql/workbench/services/notebook/browser/models/notebookModel.ts index a2214cea74..1cb4270a08 100644 --- a/src/sql/workbench/services/notebook/browser/models/notebookModel.ts +++ b/src/sql/workbench/services/notebook/browser/models/notebookModel.ts @@ -284,6 +284,26 @@ export class NotebookModel extends Disposable implements INotebookModel { return this._viewMode; } + /** + * Add custom metadata values to the notebook + */ + public setMetaValue(key: string, value: any) { + this._existingMetadata[key] = value; + let changeInfo: NotebookContentChange = { + changeType: NotebookChangeType.MetadataChanged, + isDirty: true, + cells: [], + }; + this._contentChangedEmitter.fire(changeInfo); + } + + /** + * Get a custom metadata value from the notebook + */ + public getMetaValue(key: string): any { + return this._existingMetadata[key]; + } + public set viewMode(mode: ViewMode) { if (mode !== this._viewMode) { this._viewMode = mode; diff --git a/src/sql/workbench/services/notebook/browser/notebookViews/notebookViewModel.ts b/src/sql/workbench/services/notebook/browser/notebookViews/notebookViewModel.ts new file mode 100644 index 0000000000..265e28e270 --- /dev/null +++ b/src/sql/workbench/services/notebook/browser/notebookViews/notebookViewModel.ts @@ -0,0 +1,107 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { 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 { generateUuid } from 'vs/base/common/uuid'; + +export const DEFAULT_VIEW_CARD_HEIGHT = 4; +export const DEFAULT_VIEW_CARD_WIDTH = 12; + +export class ViewNameTakenError extends Error { } + +export class NotebookViewModel implements INotebookView { + private _onDeleted = new Emitter(); + + public readonly guid: string; + public readonly onDeleted = this._onDeleted.event; + + constructor( + protected _name: string, + private _notebookViews: NotebookViewsExtension + ) { + this.guid = generateUuid(); + } + + public initialize(): void { + const cells = this._notebookViews.notebook.cells; + cells.forEach((cell, idx) => { this.initializeCell(cell, idx); }); + } + + protected initializeCell(cell: ICellModel, idx: number) { + let meta = this._notebookViews.getCellMetadata(cell); + + if (!meta) { + this._notebookViews.initializeCell(cell); + meta = this._notebookViews.getCellMetadata(cell); + } + + meta.views.push({ + guid: this.guid, + hidden: false, + y: idx * DEFAULT_VIEW_CARD_HEIGHT, + x: 0, + }); + } + + public get name(): string { + return this._name; + } + + public set name(name: string) { + if (this.name !== name && this._notebookViews.viewNameIsTaken(name)) { + throw new ViewNameTakenError(localize('notebookView.nameTaken', 'A view with the name {0} already exists in this notebook.', name)); + } + this._name = name; + } + + public nameAvailable(name: string): boolean { + return !this._notebookViews.viewNameIsTaken(name); + } + + public getCellMetadata(cell: ICellModel): INotebookViewCell { + const meta = this._notebookViews.getCellMetadata(cell); + return meta?.views?.find(view => view.guid === this.guid); + } + + public get hiddenCells(): Readonly { + return this.cells.filter(cell => this.getCellMetadata(cell)?.hidden); + } + + public get cells(): Readonly { + return this._notebookViews.notebook.cells; + } + + public getCell(guid: string): Readonly { + return this._notebookViews.notebook.cells.find(cell => cell.cellGuid === guid); + } + + public insertCell(cell: ICellModel) { + this._notebookViews.updateCell(cell, this, { hidden: false }); + } + + public hideCell(cell: ICellModel) { + this._notebookViews.updateCell(cell, this, { hidden: true }); + } + + public moveCell(cell: ICellModel, x: number, y: number) { + this._notebookViews.updateCell(cell, this, { x, y }); + } + + public resizeCell(cell: ICellModel, width: number, height: number) { + this._notebookViews.updateCell(cell, this, { width, height }); + } + + public save() { + this._notebookViews.commit(); + } + + public delete() { + this._notebookViews.removeView(this.guid); + this._onDeleted.fire(this); + } +} diff --git a/src/sql/workbench/services/notebook/browser/notebookViews/notebookViews.d.ts b/src/sql/workbench/services/notebook/browser/notebookViews/notebookViews.d.ts new file mode 100644 index 0000000000..9dc0fbd3ce --- /dev/null +++ b/src/sql/workbench/services/notebook/browser/notebookViews/notebookViews.d.ts @@ -0,0 +1,60 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { ICellModel } from 'sql/workbench/services/notebook/browser/models/modelInterfaces'; +import { Event } from 'vs/base/common/event'; + +export type CellChangeEventType = 'hide' | 'insert' | 'active'; + +export type CellChangeEvent = { + cell: ICellModel, + event: CellChangeEventType +}; + +export interface INotebookView { + readonly guid: string; + readonly onDeleted: Event; + + cells: Readonly; + hiddenCells: Readonly; + name: string; + initialize(): void; + nameAvailable(name: string): boolean; + getCellMetadata(cell: ICellModel): INotebookViewCell; + hideCell(cell: ICellModel): void; + moveCell(cell: ICellModel, x: number, y: number): void; + resizeCell(cell: ICellModel, width: number, height: number): void; + getCell(guid: string): Readonly; + insertCell(cell: ICellModel): void; + save(): void; + delete(): void; +} + +export interface INotebookViewCell { + readonly guid?: string; + hidden?: boolean; + x?: number; + y?: number; + width?: number; + height?: number; +} + +/* + * Represents the metadata that will be stored for the + * view at the notebook level. + */ +export interface INotebookViewMetadata { + version: number; + activeView: string; + views: INotebookView[]; +} + +/* + * Represents the metadata that will be stored for the + * view at the cell level. + */ +export interface INotebookViewCellMetadata { + views: INotebookViewCell[]; +} diff --git a/src/sql/workbench/services/notebook/browser/notebookViews/notebookViewsExtension.ts b/src/sql/workbench/services/notebook/browser/notebookViews/notebookViewsExtension.ts new file mode 100644 index 0000000000..dbc4bc6efc --- /dev/null +++ b/src/sql/workbench/services/notebook/browser/notebookViews/notebookViewsExtension.ts @@ -0,0 +1,150 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { INotebookModel, ICellModel } from 'sql/workbench/services/notebook/browser/models/modelInterfaces'; +import { generateUuid } from 'vs/base/common/uuid'; +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 } from 'sql/workbench/services/notebook/browser/notebookViews/notebookViews'; + +export class NotebookViewsExtension extends NotebookExtension { + static readonly defaultViewName = localize('notebookView.untitledView', "Untitled View"); + + readonly maxNameIterationAttempts = 100; + readonly extension = 'azuredatastudio'; + readonly version = 1; + + protected _metadata: INotebookViewMetadata; + private _onViewDeleted = new Emitter(); + + constructor(protected _notebook: INotebookModel) { + super(); + this.loadOrInitialize(); + } + + public loadOrInitialize() { + this._metadata = this.getNotebookMetadata(this._notebook); + + if (!this._metadata) { + this.initializeNotebook(); + this.initializeCells(); + this.commit(); + } + } + + protected initializeNotebook() { + this._metadata = { + version: this.version, + activeView: undefined, + views: [] + }; + } + + protected initializeCells() { + const cells = this._notebook.cells; + cells.forEach((cell) => { + this.initializeCell(cell); + }); + } + + public initializeCell(cell: ICellModel) { + const meta: INotebookViewCellMetadata = { + views: [] + }; + + this.setCellMetadata(cell, meta); + } + + public createNewView(name?: string): INotebookView { + const viewName = name || this.generateDefaultViewName(); + + const view = new NotebookViewModel(viewName, this); + view.initialize(); + + this._metadata.views.push(view); + + return view; + } + + public removeView(guid: string) { + let viewToRemove = this._metadata.views.findIndex(view => view.guid === guid); + if (viewToRemove !== -1) { + let removedView = this._metadata.views.splice(viewToRemove, 1); + + // Remove view data for each cell + if (removedView.length) { + this._notebook?.cells.forEach((cell) => { + let meta = this.getCellMetadata(cell); + meta.views.splice(viewToRemove, 1); + this.setCellMetadata(cell, meta); + }); + } + + this.setNotebookMetadata(this.notebook, this._metadata); + } + + if (guid === this._metadata.activeView) { + this._metadata.activeView = undefined; + } + + this._onViewDeleted.fire(); + this.commit(); + } + + public generateDefaultViewName(): string { + let i = 1; + let name = NotebookViewsExtension.defaultViewName; + + while (this.viewNameIsTaken(name) && i <= this.maxNameIterationAttempts) { + name = `${NotebookViewsExtension.defaultViewName} ${i++}`; + } + + return i <= this.maxNameIterationAttempts ? name : generateUuid(); + } + + public updateCell(cell: ICellModel, currentView: INotebookView, cellData: INotebookViewCell, override: boolean = false) { + const cellMetadata = this.getCellMetadata(cell); + const viewToUpdate = cellMetadata.views.findIndex(view => view.guid === currentView.guid); + + if (viewToUpdate >= 0) { + cellMetadata.views[viewToUpdate] = override ? cellData : { ...cellMetadata.views[viewToUpdate], ...cellData }; + this.setCellMetadata(cell, cellMetadata); + } + } + + public get notebook(): INotebookModel { + return this._notebook; + } + + public getViews(): INotebookView[] { + return this._metadata.views; + } + + public getCells(): INotebookViewCellMetadata[] { + return this._notebook.cells.map(cell => this.getCellMetadata(cell)); + } + + public getActiveView(): INotebookView { + return this.getViews().find(view => view.guid === this._metadata.activeView); + } + + public setActiveView(view: INotebookView) { + this._metadata.activeView = view.guid; + } + + public commit() { + this.setNotebookMetadata(this._notebook, this._metadata); + } + + public viewNameIsTaken(name: string): boolean { + return !!this.getViews().find(v => v.name.toLowerCase() === name.toLowerCase()); + } + + public get onViewDeleted(): Event { + return this._onViewDeleted.event; + } +} diff --git a/src/sql/workbench/services/notebook/common/contracts.ts b/src/sql/workbench/services/notebook/common/contracts.ts index 4c9cf66717..3ff7cbe8fc 100644 --- a/src/sql/workbench/services/notebook/common/contracts.ts +++ b/src/sql/workbench/services/notebook/common/contracts.ts @@ -41,6 +41,7 @@ export enum NotebookChangeType { CellOutputUpdated, DirtyStateChanged, KernelChanged, + MetadataChanged, TrustChanged, Saved, CellExecuted,