diff --git a/extensions/notebook/package.json b/extensions/notebook/package.json index 99472237b7..a8a5b8358f 100644 --- a/extensions/notebook/package.json +++ b/extensions/notebook/package.json @@ -59,6 +59,14 @@ "type": "number", "default": 60000, "description": "%notebook.remoteBookDownloadTimeout.description%" + }, + "notebook.pinnedNotebooks": { + "type": "array", + "default": [], + "description": "%notebook.pinnedNotebooks.description%", + "items": { + "type": "string" + } } } }, @@ -247,6 +255,21 @@ "dark": "resources/dark/open_notebook_inverse.svg", "light": "resources/light/open_notebook.svg" } + }, + { + "command": "notebook.command.pinNotebook", + "title": "%title.pinNotebook%", + "category": "%books-preview-category%", + "icon": "$(pinned)" + }, + { + "command": "notebook.command.unpinNotebook", + "title": "%title.unpinNotebook%", + "category": "%books-preview-category%", + "icon": { + "dark": "resources/dark/unpin_inverse.svg", + "light": "resources/light/unpin.svg" + } } ], "languages": [ @@ -364,6 +387,14 @@ { "command": "notebook.command.revealInBooksViewlet", "when": "false" + }, + { + "command": "notebook.command.pinNotebook", + "when": "false" + }, + { + "command": "notebook.command.unpinNotebook", + "when": "false" } ], "notebooks/title": [ @@ -424,6 +455,16 @@ { "command": "notebook.command.closeNotebook", "when": "view == bookTreeView && viewItem == savedNotebook" + }, + { + "command": "notebook.command.pinNotebook", + "when": "view == bookTreeView && viewItem == savedNotebook", + "group": "inline" + }, + { + "command": "notebook.command.unpinNotebook", + "when": "view == pinnedBooksView || view == bookTreeView && viewItem == pinnedNotebook", + "group": "inline" } ], "view/title": [ @@ -454,6 +495,11 @@ }, "views": { "notebooks": [ + { + "id": "pinnedBooksView", + "name": "%title.PinnedBooks%", + "when": "showPinnedbooks" + }, { "id": "bookTreeView", "name": "%title.SavedBooks%" diff --git a/extensions/notebook/package.nls.json b/extensions/notebook/package.nls.json index 8de1680e01..1f998c5727 100644 --- a/extensions/notebook/package.nls.json +++ b/extensions/notebook/package.nls.json @@ -10,6 +10,7 @@ "notebook.maxBookSearchDepth.description": "Maximum depth of subdirectories to search for Books (Enter 0 for infinite)", "notebook.collapseBookItems.description": "Collapse Book items at root level in the Notebooks Viewlet", "notebook.remoteBookDownloadTimeout.description": "Download timeout in milliseconds for GitHub books", + "notebook.pinnedNotebooks.description": "Notebooks that are pinned by the user for the current workspace", "notebook.command.new": "New Notebook", "notebook.command.open": "Open Notebook", "notebook.analyzeJupyterNotebook": "Analyze in Notebook", @@ -37,6 +38,7 @@ "title.searchJupyterBook": "Search Book", "title.SavedBooks": "Notebooks", "title.ProvidedBooks": "Provided Books", + "title.PinnedBooks": "Pinned notebooks", "title.PreviewLocalizedBook": "Get localized SQL Server 2019 guide", "title.openJupyterBook": "Open Jupyter Book", "title.closeJupyterBook": "Close Jupyter Book", @@ -44,5 +46,7 @@ "title.revealInBooksViewlet": "Reveal in Books", "title.createJupyterBook": "Create Book (Preview)", "title.openNotebookFolder": "Open Notebooks in Folder", - "title.openRemoteJupyterBook": "Add Remote Jupyter Book" + "title.openRemoteJupyterBook": "Add Remote Jupyter Book", + "title.pinNotebook": "Pin Notebook", + "title.unpinNotebook": "Unpin Notebook" } diff --git a/extensions/notebook/resources/dark/unpin_inverse.svg b/extensions/notebook/resources/dark/unpin_inverse.svg new file mode 100644 index 0000000000..866214bf04 --- /dev/null +++ b/extensions/notebook/resources/dark/unpin_inverse.svg @@ -0,0 +1,3 @@ + + + diff --git a/extensions/notebook/resources/light/unpin.svg b/extensions/notebook/resources/light/unpin.svg new file mode 100644 index 0000000000..ff7df83d59 --- /dev/null +++ b/extensions/notebook/resources/light/unpin.svg @@ -0,0 +1,3 @@ + + + diff --git a/extensions/notebook/src/book/bookPinManager.ts b/extensions/notebook/src/book/bookPinManager.ts new file mode 100644 index 0000000000..c71aab8635 --- /dev/null +++ b/extensions/notebook/src/book/bookPinManager.ts @@ -0,0 +1,89 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import * as path from 'path'; +import * as vscode from 'vscode'; +import * as constants from './../common/constants'; +import { BookTreeItem } from './bookTreeItem'; +import { getPinnedNotebooks } from '../common/utils'; + +export interface IBookPinManager { + pinNotebook(notebook: BookTreeItem): boolean; + unpinNotebook(notebook: BookTreeItem): boolean; +} + +enum PinBookOperation { + Pin, + Unpin +} + +export class BookPinManager implements IBookPinManager { + + constructor() { + this.setPinnedSectionContext(); + } + + setPinnedSectionContext(): void { + if (getPinnedNotebooks().length > 0) { + vscode.commands.executeCommand(constants.BuiltInCommands.SetContext, constants.showPinnedBooksContextKey, true); + } else { + vscode.commands.executeCommand(constants.BuiltInCommands.SetContext, constants.showPinnedBooksContextKey, false); + } + } + + isNotebookPinned(notebookPath: string): boolean { + if (getPinnedNotebooks().findIndex(x => x === notebookPath) > -1) { + return true; + } + return false; + } + + pinNotebook(notebook: BookTreeItem): boolean { + return this.isNotebookPinned(notebook.book.contentPath) ? false : this.updatePinnedBooks(notebook, PinBookOperation.Pin); + } + + unpinNotebook(notebook: BookTreeItem): boolean { + return this.updatePinnedBooks(notebook, PinBookOperation.Unpin); + } + + getPinnedBookPathsInConfig(): string[] { + let config: vscode.WorkspaceConfiguration = vscode.workspace.getConfiguration(constants.notebookConfigKey); + let pinnedBookDirectories: string[] = config.get(constants.pinnedBooksConfigKey); + + return pinnedBookDirectories; + } + + setPinnedBookPathsInConfig(bookPaths: string[]) { + let config: vscode.WorkspaceConfiguration = vscode.workspace.getConfiguration(constants.notebookConfigKey); + let storeInWorspace: boolean = this.hasWorkspaceFolders(); + + config.update(constants.pinnedBooksConfigKey, bookPaths, storeInWorspace ? false : vscode.ConfigurationTarget.Global); + } + + hasWorkspaceFolders(): boolean { + let workspaceFolders = vscode.workspace.workspaceFolders; + return workspaceFolders && workspaceFolders.length > 0; + } + + updatePinnedBooks(notebook: BookTreeItem, operation: PinBookOperation) { + let modifiedPinnedBooks = false; + let bookPathToChange: string = notebook.book.contentPath; + + let pinnedBooks: string[] = this.getPinnedBookPathsInConfig(); + let existingBookIndex = pinnedBooks.map(pinnedBookPath => path.normalize(pinnedBookPath)).indexOf(bookPathToChange); + + if (existingBookIndex !== -1 && operation === PinBookOperation.Unpin) { + pinnedBooks.splice(existingBookIndex, 1); + modifiedPinnedBooks = true; + } else if (existingBookIndex === -1 && operation === PinBookOperation.Pin) { + pinnedBooks.push(bookPathToChange); + modifiedPinnedBooks = true; + } + + this.setPinnedBookPathsInConfig(pinnedBooks); + this.setPinnedSectionContext(); + + return modifiedPinnedBooks; + } +} diff --git a/extensions/notebook/src/book/bookTreeItem.ts b/extensions/notebook/src/book/bookTreeItem.ts index 0013ba6e0b..c0bcdcd49d 100644 --- a/extensions/notebook/src/book/bookTreeItem.ts +++ b/extensions/notebook/src/book/bookTreeItem.ts @@ -8,6 +8,7 @@ import * as path from 'path'; import * as fs from 'fs'; import { IJupyterBookSection, IJupyterBookToc } from '../contracts/content'; import * as loc from '../common/localizedConstants'; +import { isBookItemPinned } from '../common/utils'; export enum BookTreeItemType { Book = 'Book', @@ -54,6 +55,8 @@ export class BookTreeItem extends vscode.TreeItem { } else { this.contextValue = 'savedNotebook'; } + } else { + this.contextValue = book.type === BookTreeItemType.Notebook ? (isBookItemPinned(book.contentPath) ? 'pinnedNotebook' : 'savedNotebook') : 'section'; } this.setPageVariables(); this.setCommand(); diff --git a/extensions/notebook/src/book/bookTreeView.ts b/extensions/notebook/src/book/bookTreeView.ts index c77a9afaa0..3b2570c710 100644 --- a/extensions/notebook/src/book/bookTreeView.ts +++ b/extensions/notebook/src/book/bookTreeView.ts @@ -17,7 +17,8 @@ import { IBookTrustManager, BookTrustManager } from './bookTrustManager'; import * as loc from '../common/localizedConstants'; import * as glob from 'fast-glob'; import { isNullOrUndefined } from 'util'; -import { debounce } from '../common/utils'; +import { debounce, getPinnedNotebooks } from '../common/utils'; +import { IBookPinManager, BookPinManager } from './bookPinManager'; const Content = 'content'; @@ -34,6 +35,7 @@ export class BookTreeViewProvider implements vscode.TreeDataProvider = new Deferred(); private _openAsUntitled: boolean; private _bookTrustManager: IBookTrustManager; + public bookPinManager: IBookPinManager; private _bookViewer: vscode.TreeView; public viewId: string; @@ -44,8 +46,9 @@ export class BookTreeViewProvider implements vscode.TreeDataProvider console.error(e)); + this.bookPinManager = new BookPinManager(); this.viewId = view; + this.initialize(workspaceFolders).catch(e => console.error(e)); this.prompter = new CodeAdapter(); this._bookTrustManager = new BookTrustManager(this.books); @@ -53,13 +56,24 @@ export class BookTreeViewProvider implements vscode.TreeDataProvider { - await Promise.all(workspaceFolders.map(async (workspaceFolder) => { - try { - await this.loadNotebooksInFolder(workspaceFolder.uri.fsPath); - } catch { - // no-op, not all workspace folders are going to be valid books - } - })); + if (this.viewId === constants.PINNED_BOOKS_VIEWID) { + await Promise.all(getPinnedNotebooks().map(async (notebookPath) => { + try { + await this.createAndAddBookModel(notebookPath, true); + } catch { + // no-op, not all workspace folders are going to be valid books + } + })); + } else { + await Promise.all(workspaceFolders.map(async (workspaceFolder) => { + try { + await this.loadNotebooksInFolder(workspaceFolder.uri.fsPath); + } catch { + // no-op, not all workspace folders are going to be valid books + } + })); + } + this._initializeDeferred.resolve(); } @@ -97,6 +111,26 @@ export class BookTreeViewProvider implements vscode.TreeDataProvider { + let bookPathToUpdate = bookTreeItem.book?.contentPath; + if (bookPathToUpdate) { + let pinStatusChanged = this.bookPinManager.pinNotebook(bookTreeItem); + if (pinStatusChanged) { + bookTreeItem.contextValue = 'pinnedNotebook'; + } + } + } + + async unpinNotebook(bookTreeItem: BookTreeItem): Promise { + let bookPathToUpdate = bookTreeItem.book?.contentPath; + if (bookPathToUpdate) { + let pinStatusChanged = this.bookPinManager.unpinNotebook(bookTreeItem); + if (pinStatusChanged) { + bookTreeItem.contextValue = 'savedNotebook'; + } + } + } + async openBook(bookPath: string, urlToOpen?: string, showPreview?: boolean, isNotebook?: boolean): Promise { try { // Convert path to posix style for easier comparisons @@ -132,6 +166,20 @@ export class BookTreeViewProvider implements vscode.TreeDataProvider { + let notebookPath: string = bookItem.book.contentPath; + if (notebookPath) { + await this.createAndAddBookModel(notebookPath, true); + } + } + + async removeNotebookFromPinnedView(bookItem: BookTreeItem): Promise { + let notebookPath: string = bookItem.book.contentPath; + if (notebookPath) { + await this.closeBook(bookItem); + } + } + @debounce(1500) async fireBookRefresh(book: BookModel): Promise { await book.initializeContents().then(() => { @@ -169,21 +217,23 @@ export class BookTreeViewProvider implements vscode.TreeDataProvider { - const book: BookModel = new BookModel(bookPath, this._openAsUntitled, isNotebook, this._extensionContext); - await book.initializeContents(); - this.books.push(book); - if (!this.currentBook) { - this.currentBook = book; - } - this._bookViewer = vscode.window.createTreeView(this.viewId, { showCollapseAll: true, treeDataProvider: this }); - this._bookViewer.onDidChangeVisibility(e => { - let openDocument = azdata.nb.activeNotebookEditor; - let notebookPath = openDocument?.document.uri; - // call reveal only once on the correct view - if (e.visible && ((!this._openAsUntitled && notebookPath?.scheme !== 'untitled') || (this._openAsUntitled && notebookPath?.scheme === 'untitled'))) { - this.revealActiveDocumentInViewlet(); + if (!this.books.find(x => x.bookPath === bookPath)) { + const book: BookModel = new BookModel(bookPath, this._openAsUntitled, isNotebook, this._extensionContext); + await book.initializeContents(); + this.books.push(book); + if (!this.currentBook) { + this.currentBook = book; } - }); + this._bookViewer = vscode.window.createTreeView(this.viewId, { showCollapseAll: true, treeDataProvider: this }); + this._bookViewer.onDidChangeVisibility(e => { + let openDocument = azdata.nb.activeNotebookEditor; + let notebookPath = openDocument?.document.uri; + // call reveal only once on the correct view + if (e.visible && ((!this._openAsUntitled && notebookPath?.scheme !== 'untitled') || (this._openAsUntitled && notebookPath?.scheme === 'untitled'))) { + this.revealActiveDocumentInViewlet(); + } + }); + } } async showPreviewFile(urlToOpen?: string): Promise { diff --git a/extensions/notebook/src/common/appContext.ts b/extensions/notebook/src/common/appContext.ts index f1017970c2..83b42630b5 100644 --- a/extensions/notebook/src/common/appContext.ts +++ b/extensions/notebook/src/common/appContext.ts @@ -6,7 +6,7 @@ import * as vscode from 'vscode'; import { NotebookUtils } from './notebookUtils'; import { BookTreeViewProvider } from '../book/bookTreeView'; -import { NavigationProviders, BOOKS_VIEWID, PROVIDED_BOOKS_VIEWID, extensionOutputChannelName } from './constants'; +import { NavigationProviders, BOOKS_VIEWID, PROVIDED_BOOKS_VIEWID, PINNED_BOOKS_VIEWID, extensionOutputChannelName } from './constants'; /** * Global context for the application @@ -16,6 +16,7 @@ export class AppContext { public readonly notebookUtils: NotebookUtils; public readonly bookTreeViewProvider: BookTreeViewProvider; public readonly providedBookTreeViewProvider: BookTreeViewProvider; + public readonly pinnedBookTreeViewProvider: BookTreeViewProvider; public readonly outputChannel: vscode.OutputChannel; constructor(public readonly extensionContext: vscode.ExtensionContext) { @@ -24,6 +25,7 @@ export class AppContext { let workspaceFolders = vscode.workspace.workspaceFolders?.slice() ?? []; this.bookTreeViewProvider = new BookTreeViewProvider(workspaceFolders, extensionContext, false, BOOKS_VIEWID, NavigationProviders.NotebooksNavigator); this.providedBookTreeViewProvider = new BookTreeViewProvider([], extensionContext, true, PROVIDED_BOOKS_VIEWID, NavigationProviders.ProvidedBooksNavigator); + this.pinnedBookTreeViewProvider = new BookTreeViewProvider([], extensionContext, false, PINNED_BOOKS_VIEWID, NavigationProviders.NotebooksNavigator); this.outputChannel = vscode.window.createOutputChannel(extensionOutputChannelName); } } diff --git a/extensions/notebook/src/common/constants.ts b/extensions/notebook/src/common/constants.ts index 16eb341f60..1f382e2e25 100644 --- a/extensions/notebook/src/common/constants.ts +++ b/extensions/notebook/src/common/constants.ts @@ -19,6 +19,7 @@ export const pythonPathConfigKey = 'pythonPath'; export const existingPythonConfigKey = 'useExistingPython'; export const notebookConfigKey = 'notebook'; export const trustedBooksConfigKey = 'trustedBooks'; +export const pinnedBooksConfigKey = 'pinnedNotebooks'; export const maxBookSearchDepth = 'maxBookSearchDepth'; export const remoteBookDownloadTimeout = 'remoteBookDownloadTimeout'; export const collapseBookItems = 'collapseBookItems'; @@ -45,10 +46,13 @@ export const sparkScalaDisplayName = 'Spark | Scala'; export const sparkRDisplayName = 'Spark | R'; export const powershellDisplayName = 'PowerShell'; export const allKernelsName = 'All Kernels'; + export const BOOKS_VIEWID = 'bookTreeView'; export const PROVIDED_BOOKS_VIEWID = 'providedBooksView'; +export const PINNED_BOOKS_VIEWID = 'pinnedBooksView'; export const visitedNotebooksMementoKey = 'notebooks.visited'; +export const pinnedNotebooksMementoKey = 'notebooks.pinned'; export enum BuiltInCommands { SetContext = 'setContext' @@ -69,6 +73,7 @@ export enum NavigationProviders { } export const unsavedBooksContextKey = 'unsavedBooks'; +export const showPinnedBooksContextKey = 'showPinnedbooks'; export const pythonWindowsInstallUrl = 'https://go.microsoft.com/fwlink/?linkid=2110625'; export const pythonMacInstallUrl = 'https://go.microsoft.com/fwlink/?linkid=2128152'; diff --git a/extensions/notebook/src/common/localizedConstants.ts b/extensions/notebook/src/common/localizedConstants.ts index e8ff782c4d..bfcabe83c6 100644 --- a/extensions/notebook/src/common/localizedConstants.ts +++ b/extensions/notebook/src/common/localizedConstants.ts @@ -25,6 +25,8 @@ export const msgBookTrusted = localize('msgBookTrusted', "Book is now trusted in export const msgBookAlreadyTrusted = localize('msgBookAlreadyTrusted', "Book is already trusted in this workspace."); export const msgBookUntrusted = localize('msgBookUntrusted', "Book is no longer trusted in this workspace"); export const msgBookAlreadyUntrusted = localize('msgBookAlreadyUntrusted', "Book is already untrusted in this workspace."); +export function msgBookPinned(book: string): string { return localize('msgBookPinned', "Book {0} is now pinned in the workspace.", book); } +export function msgBookUnpinned(book: string): string { return localize('msgBookUnpinned', "Book {0} is no longer pinned in this workspace", book); } export const missingTocError = localize('bookInitializeFailed', "Failed to find a Table of Contents file in the specified book."); export const noBooksSelectedError = localize('noBooksSelected', "No books are currently selected in the viewlet."); diff --git a/extensions/notebook/src/common/utils.ts b/extensions/notebook/src/common/utils.ts index d6b9828a7c..aa55f723a9 100644 --- a/extensions/notebook/src/common/utils.ts +++ b/extensions/notebook/src/common/utils.ts @@ -9,7 +9,7 @@ import * as nls from 'vscode-nls'; import * as vscode from 'vscode'; import * as azdata from 'azdata'; import * as crypto from 'crypto'; -import { notebookLanguages } from './constants'; +import { notebookLanguages, notebookConfigKey, pinnedBooksConfigKey } from './constants'; const localize = nls.loadMessageBundle(); @@ -323,3 +323,18 @@ export async function getRandomToken(size: number = 24): Promise { }); }); } + +export function isBookItemPinned(notebookPath: string): boolean { + let pinnedNotebooks: string[] = getPinnedNotebooks(); + if (pinnedNotebooks?.indexOf(notebookPath) > -1) { + return true; + } + return false; +} + +export function getPinnedNotebooks(): string[] { + let config: vscode.WorkspaceConfiguration = vscode.workspace.getConfiguration(notebookConfigKey); + let pinnedNotebooks: string[] = config.get(pinnedBooksConfigKey) ?? []; + + return pinnedNotebooks; +} diff --git a/extensions/notebook/src/extension.ts b/extensions/notebook/src/extension.ts index 036d765f76..247572162c 100644 --- a/extensions/notebook/src/extension.ts +++ b/extensions/notebook/src/extension.ts @@ -39,6 +39,15 @@ export async function activate(extensionContext: vscode.ExtensionContext): Promi extensionContext.subscriptions.push(vscode.commands.registerCommand('notebook.command.closeBook', (book: any) => bookTreeViewProvider.closeBook(book))); extensionContext.subscriptions.push(vscode.commands.registerCommand('notebook.command.closeNotebook', (book: any) => bookTreeViewProvider.closeBook(book))); extensionContext.subscriptions.push(vscode.commands.registerCommand('notebook.command.openNotebookFolder', (folderPath?: string, urlToOpen?: string, showPreview?: boolean,) => bookTreeViewProvider.openNotebookFolder(folderPath, urlToOpen, showPreview))); + extensionContext.subscriptions.push(vscode.commands.registerCommand('notebook.command.pinNotebook', async (book: any) => { + await bookTreeViewProvider.pinNotebook(book); + await pinnedBookTreeViewProvider.addNotebookToPinnedView(book); + })); + extensionContext.subscriptions.push(vscode.commands.registerCommand('notebook.command.unpinNotebook', async (book: any) => { + await bookTreeViewProvider.unpinNotebook(book); + await pinnedBookTreeViewProvider.removeNotebookFromPinnedView(book); + })); + extensionContext.subscriptions.push(vscode.commands.registerCommand('notebook.command.createBook', async () => { let untitledFileName: vscode.Uri = vscode.Uri.parse(`untitled:${createBookPath}`); await vscode.workspace.openTextDocument(createBookPath).then((document) => { @@ -128,6 +137,8 @@ export async function activate(extensionContext: vscode.ExtensionContext): Promi await bookTreeViewProvider.initialized; const providedBookTreeViewProvider = appContext.providedBookTreeViewProvider; await providedBookTreeViewProvider.initialized; + const pinnedBookTreeViewProvider = appContext.pinnedBookTreeViewProvider; + await pinnedBookTreeViewProvider.initialized; azdata.nb.onDidChangeActiveNotebookEditor(e => { if (e.document.uri.scheme === 'untitled') { diff --git a/extensions/notebook/src/test/book/book.test.ts b/extensions/notebook/src/test/book/book.test.ts index 6755744ba3..2a637efe64 100644 --- a/extensions/notebook/src/test/book/book.test.ts +++ b/extensions/notebook/src/test/book/book.test.ts @@ -124,6 +124,7 @@ describe('BooksTreeViewTests', function () { should(appContext).not.be.undefined(); should(appContext.bookTreeViewProvider).not.be.undefined(); should(appContext.providedBookTreeViewProvider).not.be.undefined(); + should(appContext.pinnedBookTreeViewProvider).not.be.undefined(); }); it('should initialize correctly with empty workspace array', async () => { @@ -346,6 +347,67 @@ describe('BooksTreeViewTests', function () { }); + describe('pinnedBookTreeViewProvider', function (): void { + let pinnedTreeViewProvider: BookTreeViewProvider; + let bookTreeViewProvider: BookTreeViewProvider; + let bookItem: BookTreeItem; + + this.beforeAll(async () => { + pinnedTreeViewProvider = appContext.pinnedBookTreeViewProvider; + bookTreeViewProvider = appContext.bookTreeViewProvider; + let errorCase = new Promise((resolve, reject) => setTimeout(() => resolve(), 5000)); + await Promise.race([bookTreeViewProvider.initialized, errorCase.then(() => { throw new Error('BookTreeViewProvider did not initialize in time'); })]); + await Promise.race([pinnedTreeViewProvider.initialized, errorCase.then(() => { throw new Error('PinnedTreeViewProvider did not initialize in time'); })]); + await bookTreeViewProvider.openBook(bookFolderPath, undefined, false, false); + bookItem = bookTreeViewProvider.books[0].bookItems[0]; + }); + + afterEach(function (): void { + sinon.restore(); + }); + + it('pinnedBookTreeViewProvider should not have any books when there are no pinned notebooks', async function (): Promise { + const notebooks = pinnedTreeViewProvider.books; + should(notebooks.length).equal(0, 'Pinned Notebooks view should not have any notebooks'); + }); + + it('pinNotebook should add notebook to pinnedBookTreeViewProvider', async function (): Promise { + await vscode.commands.executeCommand('notebook.command.pinNotebook', bookItem); + const notebooks = pinnedTreeViewProvider.books; + should(notebooks.length).equal(1, 'Pinned Notebooks view should have a notebook'); + }); + + it('unpinNotebook should remove notebook from pinnedBookTreeViewProvider', async function (): Promise { + await vscode.commands.executeCommand('notebook.command.unpinNotebook', pinnedTreeViewProvider.books[0].bookItems[0]); + const notebooks = pinnedTreeViewProvider.books; + should(notebooks.length).equal(0, 'Pinned Notebooks view should not have any notebooks'); + }); + + it('pinNotebook should invoke bookPinManagers pinNotebook method', async function (): Promise { + let pinBookSpy = sinon.spy(bookTreeViewProvider.bookPinManager, 'pinNotebook'); + await bookTreeViewProvider.pinNotebook(bookItem); + should(pinBookSpy.calledOnce).be.true('Should invoke bookPinManagers pinNotebook to update pinnedNotebooks config'); + }); + + it('unpinNotebook should invoke bookPinManagers unpinNotebook method', async function (): Promise { + let unpinNotebookSpy = sinon.spy(bookTreeViewProvider.bookPinManager, 'unpinNotebook'); + await bookTreeViewProvider.unpinNotebook(bookItem); + should(unpinNotebookSpy.calledOnce).be.true('Should invoke bookPinManagers unpinNotebook to update pinnedNotebooks config'); + }); + + it('addNotebookToPinnedView should add notebook to the TreeViewProvider', async function (): Promise { + let notebooks = pinnedTreeViewProvider.books.length; + await pinnedTreeViewProvider.addNotebookToPinnedView(bookItem); + should(pinnedTreeViewProvider.books.length).equal(notebooks + 1, 'Should add the notebook as new item to the TreeViewProvider'); + }); + + it('removeNotebookFromPinnedView should remove notebook from the TreeViewProvider', async function (): Promise { + let notebooks = pinnedTreeViewProvider.books.length; + await pinnedTreeViewProvider.removeNotebookFromPinnedView(pinnedTreeViewProvider.books[0].bookItems[0]); + should(pinnedTreeViewProvider.books.length).equal(notebooks - 1, 'Should remove the notebook from the TreeViewProvider'); + }); + }); + this.afterAll(async function (): Promise { console.log('Removing temporary files...'); if (await exists(rootFolderPath)) { diff --git a/extensions/notebook/src/test/book/bookPinManager.test.ts b/extensions/notebook/src/test/book/bookPinManager.test.ts new file mode 100644 index 0000000000..539a4669ea --- /dev/null +++ b/extensions/notebook/src/test/book/bookPinManager.test.ts @@ -0,0 +1,167 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as should from 'should'; +import * as path from 'path'; +import * as TypeMoq from 'typemoq'; +import * as constants from '../../common/constants'; +import { IBookPinManager, BookPinManager } from '../../book/bookPinManager'; +import { BookTreeItem, BookTreeItemFormat, BookTreeItemType } from '../../book/bookTreeItem'; +import * as vscode from 'vscode'; +import { BookModel } from '../../book/bookModel'; +import * as sinon from 'sinon'; +import { isBookItemPinned } from '../../common/utils'; + +describe('BookPinManagerTests', function () { + + describe('PinningNotebooks', () => { + let bookPinManager: IBookPinManager; + let pinnedNotebooks: string[]; + let books: BookModel[]; + + afterEach(function (): void { + sinon.restore(); + }); + + beforeEach(() => { + pinnedNotebooks = ['/temp/SubFolder/content/sample/notebook1.ipynb', '/temp/SubFolder/content/sample/notebook2.ipynb']; + + // Mock Workspace Configuration + let workspaceConfigurtionMock: TypeMoq.IMock = TypeMoq.Mock.ofType(); + workspaceConfigurtionMock.setup(config => config.get(TypeMoq.It.isValue(constants.pinnedBooksConfigKey))).returns(() => [].concat(pinnedNotebooks)); + workspaceConfigurtionMock.setup(config => config.update(TypeMoq.It.isValue(constants.pinnedBooksConfigKey), TypeMoq.It.isAny(), TypeMoq.It.isValue(false))).returns((key: string, newValues: string[]) => { + pinnedNotebooks.splice(0, pinnedNotebooks.length, ...newValues); + return Promise.resolve(); + }); + + sinon.replaceGetter(vscode.workspace, 'workspaceFolders', () => { + return [{ + uri: { + fsPath: '/temp/' + }, + }, + { + uri: { + fsPath: '/temp2/' + } + }, + ]; + }); + + sinon.stub(vscode.workspace, 'getConfiguration').returns(workspaceConfigurtionMock.object); + + // Mock Book Data + let bookTreeItemFormat1: BookTreeItemFormat = { + contentPath: '/temp/SubFolder/content/sample/notebook1.ipynb', + root: '/temp/SubFolder/', + tableOfContents: { + sections: [ + { + url: path.join(path.sep, 'sample', 'notebook1') + }, + { + url: path.join(path.sep, 'sample', 'notebook2') + } + ] + }, + isUntitled: undefined, + page: undefined, + title: undefined, + treeItemCollapsibleState: undefined, + type: BookTreeItemType.Book + }; + + let bookTreeItemFormat2: BookTreeItemFormat = { + contentPath: '/temp/SubFolder2/content/sample/notebook.ipynb', + root: '/temp/SubFolder2/', + tableOfContents: { + sections: [ + { + url: path.join(path.sep, 'sample', 'notebook') + } + ] + }, + isUntitled: undefined, + page: undefined, + title: undefined, + treeItemCollapsibleState: undefined, + type: BookTreeItemType.Book + }; + + let bookTreeItemFormat3: BookTreeItemFormat = { + contentPath: '/temp2/SubFolder3/content/sample/notebook.ipynb', + root: '/temp2/SubFolder3/', + tableOfContents: { + sections: [ + { + url: path.join(path.sep, 'sample', 'notebook') + } + ] + }, + isUntitled: undefined, + page: undefined, + title: undefined, + treeItemCollapsibleState: undefined, + type: BookTreeItemType.Book + }; + + let bookModel1Mock: TypeMoq.IMock = TypeMoq.Mock.ofType(); + bookModel1Mock.setup(model => model.bookItems).returns(() => [new BookTreeItem(bookTreeItemFormat1, undefined), new BookTreeItem(bookTreeItemFormat2, undefined)]); + bookModel1Mock.setup(model => model.getNotebook(TypeMoq.It.isValue(path.join(path.sep, 'temp', 'SubFolder', 'content', 'sample', 'notebook.ipynb')))).returns((uri: string) => TypeMoq.Mock.ofType().object); + bookModel1Mock.setup(model => model.getNotebook(TypeMoq.It.isValue(path.join(path.sep, 'temp', 'SubFolder', 'content', 'sample', 'notebook2.ipynb')))).returns((uri: string) => TypeMoq.Mock.ofType().object); + bookModel1Mock.setup(model => model.getNotebook(TypeMoq.It.isValue(path.join(path.sep, 'temp', 'SubFolder2', 'content', 'sample', 'notebook.ipynb')))).returns((uri: string) => TypeMoq.Mock.ofType().object); + bookModel1Mock.setup(model => model.getNotebook(TypeMoq.It.isAnyString())).returns((uri: string) => undefined); + + let bookModel2Mock: TypeMoq.IMock = TypeMoq.Mock.ofType(); + bookModel2Mock.setup(model => model.bookItems).returns(() => [new BookTreeItem(bookTreeItemFormat3, undefined)]); + bookModel2Mock.setup(model => model.getNotebook(TypeMoq.It.isValue(path.join(path.sep, 'temp2', 'SubFolder', 'content', 'sample', 'notebook.ipynb')))).returns((uri: string) => TypeMoq.Mock.ofType().object); + bookModel2Mock.setup(model => model.getNotebook(TypeMoq.It.isAnyString())).returns((uri: string) => undefined); + + books = [bookModel1Mock.object, bookModel2Mock.object]; + + bookPinManager = new BookPinManager(); + }); + + it('should have notebooks in the pinnedBooksConfigKey when pinned within a workspace', async () => { + let notebookUri1 = books[0].bookItems[0].book.contentPath; + + let isNotebook1Pinned = isBookItemPinned(notebookUri1); + + should(isNotebook1Pinned).be.true('Notebook 1 should be pinned'); + }); + + it('should NOT pin a notebook that is not pinned within a workspace', async () => { + let notebookUri = path.join(path.sep, 'temp', 'SubFolder2', 'content', 'sample', 'notebook.ipynb'); + let isNotebookPinned = isBookItemPinned(notebookUri); + + should(isNotebookPinned).be.false('Notebook should not be pinned'); + }); + + it('should pin notebook after book has been pinned from viewlet within a workspace', async () => { + let notebookUri = books[0].bookItems[1].book.contentPath; + + let isNotebookPinnedBeforeChange = isBookItemPinned(notebookUri); + should(isNotebookPinnedBeforeChange).be.false('Notebook should NOT be pinned'); + + // mock pin book item from viewlet + bookPinManager.pinNotebook(books[0].bookItems[1]); + + let isNotebookPinnedAfterChange = isBookItemPinned(notebookUri); + should(isNotebookPinnedAfterChange).be.true('Notebook should be pinned'); + }); + + it('should NOT pin a notebook when unpinned from viewlet within a workspace', async () => { + let notebookUri = books[0].bookItems[0].book.contentPath; + let isNotebookPinned = isBookItemPinned(notebookUri); + + should(isNotebookPinned).be.true('Notebook should be pinned'); + + bookPinManager.unpinNotebook(books[0].bookItems[0]); + let isNotebookPinnedAfterChange = isBookItemPinned(notebookUri); + + should(isNotebookPinnedAfterChange).be.false('Notebook should not be pinned after notebook is unpinned'); + }); + }); +}); diff --git a/extensions/notebook/src/test/common/utils.test.ts b/extensions/notebook/src/test/common/utils.test.ts index d929443722..7375ebea8b 100644 --- a/extensions/notebook/src/test/common/utils.test.ts +++ b/extensions/notebook/src/test/common/utils.test.ts @@ -327,4 +327,22 @@ describe('Utils Tests', function () { } }); }); + + describe('isBookItemPinned', function (): void { + it('Should NOT pin an unknown book within a workspace', async function (): Promise { + + let notebookUri = path.join(path.sep, 'randomfolder', 'randomsubfolder', 'content', 'randomnotebook.ipynb'); + let isNotebookPinned = utils.isBookItemPinned(notebookUri); + + should(isNotebookPinned).be.false('Random notebooks should not be pinned'); + }); + }); + + describe('getPinnedNotebooks', function (): void { + it('Should NOT have any pinned notebooks', async function (): Promise { + let pinnedNotebooks: string[] = utils.getPinnedNotebooks(); + + should(pinnedNotebooks.length).equal(0, 'Should not have any pinned notebooks'); + }); + }); });