From ae830d9e64f4ce36170004dae4bb04caf36b776b Mon Sep 17 00:00:00 2001
From: Maddy <12754347+MaddyDev@users.noreply.github.com>
Date: Mon, 31 Aug 2020 08:53:11 -0700
Subject: [PATCH] Pinning Notebooks on Notebooks view (#11963)
* initial commit
* added tests
* code cleanup and more tests
* add missed util test
* changes to address comments
* remove pin from resources
---
extensions/notebook/package.json | 46 +++++
extensions/notebook/package.nls.json | 6 +-
.../notebook/resources/dark/unpin_inverse.svg | 3 +
extensions/notebook/resources/light/unpin.svg | 3 +
.../notebook/src/book/bookPinManager.ts | 89 ++++++++++
extensions/notebook/src/book/bookTreeItem.ts | 3 +
extensions/notebook/src/book/bookTreeView.ts | 96 +++++++---
extensions/notebook/src/common/appContext.ts | 4 +-
extensions/notebook/src/common/constants.ts | 5 +
.../notebook/src/common/localizedConstants.ts | 2 +
extensions/notebook/src/common/utils.ts | 17 +-
extensions/notebook/src/extension.ts | 11 ++
.../notebook/src/test/book/book.test.ts | 62 +++++++
.../src/test/book/bookPinManager.test.ts | 167 ++++++++++++++++++
.../notebook/src/test/common/utils.test.ts | 18 ++
15 files changed, 506 insertions(+), 26 deletions(-)
create mode 100644 extensions/notebook/resources/dark/unpin_inverse.svg
create mode 100644 extensions/notebook/resources/light/unpin.svg
create mode 100644 extensions/notebook/src/book/bookPinManager.ts
create mode 100644 extensions/notebook/src/test/book/bookPinManager.test.ts
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');
+ });
+ });
});