diff --git a/extensions/mssql/src/dashboard/bookWidget.ts b/extensions/mssql/src/dashboard/bookWidget.ts index 72dd35a144..2bd5270ca3 100644 --- a/extensions/mssql/src/dashboard/bookWidget.ts +++ b/extensions/mssql/src/dashboard/bookWidget.ts @@ -70,5 +70,5 @@ export function registerBooksWidget(bookContributionProvider: BookContributionPr } function openBookViewlet(folderUri: vscode.Uri): void { - vscode.commands.executeCommand('bookTreeView.openBook', folderUri.fsPath, true); + vscode.commands.executeCommand('bookTreeView.openBook', folderUri.fsPath, true, undefined); } diff --git a/extensions/notebook/package.json b/extensions/notebook/package.json index 1282b788d1..7f9150bbc9 100644 --- a/extensions/notebook/package.json +++ b/extensions/notebook/package.json @@ -139,6 +139,14 @@ "command": "books.sqlserver2019", "title": "%title.SQL19PreviewBook%", "category": "%books-preview-category%" + }, + { + "command": "notebook.command.saveBook", + "title": "%title.saveJupyterBook%", + "icon": { + "dark": "resources/dark/save_inverse.svg", + "light": "resources/light/save.svg" + } } ], "languages": [ @@ -244,6 +252,13 @@ "group": "1notebook@1" } ], + "view/item/context": [ + { + "command": "notebook.command.saveBook", + "when": "view == untitledBookTreeView && viewItem == untitledBook && untitledBooks && notebookQuality != stable", + "group": "inline" + } + ], "notebook/toolbar": [ { "command": "jupyter.cmd.managePackages", @@ -354,8 +369,11 @@ "books-explorer": [ { "id": "bookTreeView", - "name": "Books", - "when": "notebookQuality != stable" + "name": "%title.SavedBooks%" + }, + { + "id": "untitledBookTreeView", + "name": "%title.UntitledBooks%" } ] } diff --git a/extensions/notebook/package.nls.json b/extensions/notebook/package.nls.json index 10b64cb3f1..38a2fbdd3f 100644 --- a/extensions/notebook/package.nls.json +++ b/extensions/notebook/package.nls.json @@ -28,5 +28,8 @@ "title.configurePython": "Configure Python for Notebooks", "title.managePackages": "Manage Packages", "title.SQL19PreviewBook": "SQL Server 2019 Guide", - "books-preview-category": "Jupyter Books" + "books-preview-category": "Jupyter Books", + "title.saveJupyterBook": "Save Book", + "title.SavedBooks": "Saved Books", + "title.UntitledBooks": "Untitled Books" } diff --git a/extensions/notebook/resources/dark/save_inverse.svg b/extensions/notebook/resources/dark/save_inverse.svg new file mode 100644 index 0000000000..9bb41fe118 --- /dev/null +++ b/extensions/notebook/resources/dark/save_inverse.svg @@ -0,0 +1,5 @@ + + opac_command_icons_bv + + + diff --git a/extensions/notebook/resources/light/save.svg b/extensions/notebook/resources/light/save.svg new file mode 100644 index 0000000000..e3392d6b96 --- /dev/null +++ b/extensions/notebook/resources/light/save.svg @@ -0,0 +1,5 @@ + + opac_command_icons_bv + + + diff --git a/extensions/notebook/src/book/bookModel.ts b/extensions/notebook/src/book/bookModel.ts new file mode 100644 index 0000000000..bf9fecb3f9 --- /dev/null +++ b/extensions/notebook/src/book/bookModel.ts @@ -0,0 +1,231 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as azdata from 'azdata'; +import * as vscode from 'vscode'; +import * as yaml from 'js-yaml'; +import * as glob from 'fast-glob'; +import { BookTreeItem, BookTreeItemType } from './bookTreeItem'; +import { maxBookSearchDepth, notebookConfigKey } from '../common/constants'; +import * as path from 'path'; +import * as fileServices from 'fs'; +import * as fs from 'fs-extra'; +import * as nls from 'vscode-nls'; +import { IJupyterBookToc, IJupyterBookSection } from '../contracts/content'; +import { isNullOrUndefined } from 'util'; + +const localize = nls.loadMessageBundle(); +const fsPromises = fileServices.promises; + +export class BookModel implements azdata.nb.NavigationProvider { + private _bookItems: BookTreeItem[]; + private _allNotebooks = new Map(); + private _tableOfContentPaths: string[] = []; + readonly providerId: string = 'BookNavigator'; + + constructor(public bookPath: string, public openAsUntitled: boolean, private _extensionContext: vscode.ExtensionContext) { + this.bookPath = bookPath; + this.openAsUntitled = openAsUntitled; + this._bookItems = []; + this._extensionContext.subscriptions.push(azdata.nb.registerNavigationProvider(this)); + } + + public async initializeContents(): Promise { + await this.getTableOfContentFiles(this.bookPath); + await this.readBooks(); + } + + public getAllBooks(): Map { + return this._allNotebooks; + } + + public async getTableOfContentFiles(workspacePath: string): Promise { + try { + let notebookConfig = vscode.workspace.getConfiguration(notebookConfigKey); + let maxDepth = notebookConfig[maxBookSearchDepth]; + // Use default value if user enters an invalid value + if (isNullOrUndefined(maxDepth) || maxDepth < 0) { + maxDepth = 5; + } else if (maxDepth === 0) { // No limit of search depth if user enters 0 + maxDepth = undefined; + } + + let p = path.join(workspacePath, '**', '_data', 'toc.yml').replace(/\\/g, '/'); + let tableOfContentPaths = await glob(p, { deep: maxDepth }); + this._tableOfContentPaths = this._tableOfContentPaths.concat(tableOfContentPaths); + let bookOpened: boolean = this._tableOfContentPaths.length > 0; + vscode.commands.executeCommand('setContext', 'bookOpened', bookOpened); + } catch (error) { + console.log(error); + } + } + + public async readBooks(): Promise { + for (const contentPath of this._tableOfContentPaths) { + let root = path.dirname(path.dirname(contentPath)); + try { + let fileContents = await fsPromises.readFile(path.join(root, '_config.yml'), 'utf-8'); + const config = yaml.safeLoad(fileContents.toString()); + fileContents = await fsPromises.readFile(contentPath, 'utf-8'); + const tableOfContents = yaml.safeLoad(fileContents.toString()); + let book = new BookTreeItem({ + title: config.title, + root: root, + tableOfContents: { sections: this.parseJupyterSections(tableOfContents) }, + page: tableOfContents, + type: BookTreeItemType.Book, + treeItemCollapsibleState: vscode.TreeItemCollapsibleState.Expanded, + isUntitled: this.openAsUntitled, + }, + { + light: this._extensionContext.asAbsolutePath('resources/light/book.svg'), + dark: this._extensionContext.asAbsolutePath('resources/dark/book_inverse.svg') + } + ); + this._bookItems.push(book); + } catch (e) { + let error = e instanceof Error ? e.message : e; + vscode.window.showErrorMessage(error); + } + } + return this._bookItems; + } + + public get bookItems(): BookTreeItem[] { + return this._bookItems; + } + + public async getSections(tableOfContents: IJupyterBookToc, sections: IJupyterBookSection[], root: string): Promise { + let notebooks: BookTreeItem[] = []; + for (let i = 0; i < sections.length; i++) { + if (sections[i].url) { + if (sections[i].external) { + let externalLink = new BookTreeItem({ + title: sections[i].title, + root: root, + tableOfContents: tableOfContents, + page: sections[i], + type: BookTreeItemType.ExternalLink, + treeItemCollapsibleState: vscode.TreeItemCollapsibleState.Collapsed, + isUntitled: this.openAsUntitled + }, + { + light: this._extensionContext.asAbsolutePath('resources/light/link.svg'), + dark: this._extensionContext.asAbsolutePath('resources/dark/link_inverse.svg') + } + ); + + notebooks.push(externalLink); + } else { + let pathToNotebook = path.join(root, 'content', sections[i].url.concat('.ipynb')); + let pathToMarkdown = path.join(root, 'content', sections[i].url.concat('.md')); + // Note: Currently, if there is an ipynb and a md file with the same name, Jupyter Books only shows the notebook. + // Following Jupyter Books behavior for now + if (await fs.pathExists(pathToNotebook)) { + let notebook = new BookTreeItem({ + title: sections[i].title, + root: root, + tableOfContents: tableOfContents, + page: sections[i], + type: BookTreeItemType.Notebook, + treeItemCollapsibleState: vscode.TreeItemCollapsibleState.Collapsed, + isUntitled: this.openAsUntitled + }, + { + light: this._extensionContext.asAbsolutePath('resources/light/notebook.svg'), + dark: this._extensionContext.asAbsolutePath('resources/dark/notebook_inverse.svg') + } + ); + + if (this.openAsUntitled) { + if (!this._allNotebooks.get(path.basename(pathToNotebook))) { + this._allNotebooks.set(path.basename(pathToNotebook), notebook); + notebooks.push(notebook); + } + } + else { + if (!this._allNotebooks.get(pathToNotebook)) { + this._allNotebooks.set(pathToNotebook, notebook); + notebooks.push(notebook); + } + } + } else if (await fs.pathExists(pathToMarkdown)) { + let markdown = new BookTreeItem({ + title: sections[i].title, + root: root, + tableOfContents: tableOfContents, + page: sections[i], + type: BookTreeItemType.Markdown, + treeItemCollapsibleState: vscode.TreeItemCollapsibleState.Collapsed, + isUntitled: this.openAsUntitled + }, + { + light: this._extensionContext.asAbsolutePath('resources/light/markdown.svg'), + dark: this._extensionContext.asAbsolutePath('resources/dark/markdown_inverse.svg') + } + ); + notebooks.push(markdown); + } else { + let error = localize('missingFileError', "Missing file : {0}", sections[i].title); + vscode.window.showErrorMessage(error); + } + } + } else { + // TODO: search functionality (#6160) + } + } + return notebooks; + } + + /** + * Recursively parses out a section of a Jupyter Book. + * @param section The input data to parse + */ + private parseJupyterSections(section: any[]): IJupyterBookSection[] { + try { + return section.reduce((acc, val) => Array.isArray(val.sections) ? acc.concat(val).concat(this.parseJupyterSections(val.sections)) : acc.concat(val), []); + } catch (error) { + let err: string = localize('InvalidError.tocFile', "{0}", error); + if (section.length > 0) { + err = localize('Invalid toc.yml', "Error: {0} has an incorrect toc.yml file", section[0].title); //need to find a way to get title. + } + vscode.window.showErrorMessage(err); + throw err; + } + + } + + public get tableOfContentPaths() { + return this._tableOfContentPaths; + } + + getNavigation(uri: vscode.Uri): Thenable { + let notebook = !this.openAsUntitled ? this._allNotebooks.get(uri.fsPath) : this._allNotebooks.get(path.basename(uri.fsPath)); + let result: azdata.nb.NavigationResult; + if (notebook) { + result = { + hasNavigation: true, + previous: notebook.previousUri ? this.openAsUntitled ? this.getPlatformSpecificUri(notebook.previousUri) : vscode.Uri.file(notebook.previousUri) : undefined, + next: notebook.nextUri ? this.openAsUntitled ? this.getPlatformSpecificUri(notebook.nextUri) : vscode.Uri.file(notebook.nextUri) : undefined + }; + } else { + result = { + hasNavigation: false, + previous: undefined, + next: undefined + }; + } + return Promise.resolve(result); + } + + getPlatformSpecificUri(resource: string): vscode.Uri { + if (process.platform === 'win32') { + return vscode.Uri.parse(`untitled:${resource}`); + } + else { + return vscode.Uri.parse(resource).with({ scheme: 'untitled' }); + } + } +} diff --git a/extensions/notebook/src/book/bookTreeItem.ts b/extensions/notebook/src/book/bookTreeItem.ts index 426807bbd1..8ca075e00a 100644 --- a/extensions/notebook/src/book/bookTreeItem.ts +++ b/extensions/notebook/src/book/bookTreeItem.ts @@ -24,6 +24,7 @@ export interface BookTreeItemFormat { page: any; type: BookTreeItemType; treeItemCollapsibleState: number; + isUntitled: boolean; } export class BookTreeItem extends vscode.TreeItem { @@ -39,6 +40,9 @@ export class BookTreeItem extends vscode.TreeItem { if (book.type === BookTreeItemType.Book) { this.collapsibleState = book.treeItemCollapsibleState; this._sections = book.page; + if (book.isUntitled) { + this.contextValue = 'untitledBook'; + } } else { this.setPageVariables(); this.setCommand(); @@ -63,12 +67,12 @@ export class BookTreeItem extends vscode.TreeItem { private setCommand() { if (this.book.type === BookTreeItemType.Notebook) { let pathToNotebook = path.join(this.book.root, 'content', this._uri.concat('.ipynb')); - this.command = { command: 'bookTreeView.openNotebook', title: localize('openNotebookCommand', 'Open Notebook'), arguments: [pathToNotebook], }; + this.command = { command: this.book.isUntitled ? 'bookTreeView.openUntitledNotebook' : 'bookTreeView.openNotebook', title: localize('openNotebookCommand', "Open Notebook"), arguments: [pathToNotebook], }; } else if (this.book.type === BookTreeItemType.Markdown) { let pathToMarkdown = path.join(this.book.root, 'content', this._uri.concat('.md')); - this.command = { command: 'bookTreeView.openMarkdown', title: localize('openMarkdownCommand', 'Open Markdown'), arguments: [pathToMarkdown], }; + this.command = { command: 'bookTreeView.openMarkdown', title: localize('openMarkdownCommand', "Open Markdown"), arguments: [pathToMarkdown], }; } else if (this.book.type === BookTreeItemType.ExternalLink) { - this.command = { command: 'bookTreeView.openExternalLink', title: localize('openExternalLinkCommand', 'Open External Link'), arguments: [this._uri], }; + this.command = { command: 'bookTreeView.openExternalLink', title: localize('openExternalLinkCommand', "Open External Link"), arguments: [this._uri], }; } } diff --git a/extensions/notebook/src/book/bookTreeView.ts b/extensions/notebook/src/book/bookTreeView.ts index 5217f03e9f..3df4ec98fa 100644 --- a/extensions/notebook/src/book/bookTreeView.ts +++ b/extensions/notebook/src/book/bookTreeView.ts @@ -6,99 +6,75 @@ import * as azdata from 'azdata'; import * as vscode from 'vscode'; import * as path from 'path'; -import { promises as fs } from 'fs'; -import * as yaml from 'js-yaml'; -import * as glob from 'fast-glob'; -import { BookTreeItem, BookTreeItemType } from './bookTreeItem'; -import { maxBookSearchDepth, notebookConfigKey } from '../common/constants'; -import { isEditorTitleFree, exists } from '../common/utils'; +import * as fs from 'fs-extra'; +import { IPrompter, QuestionTypes, IQuestion } from '../prompts/question'; +import CodeAdapter from '../prompts/adapter'; +import { BookTreeItem } from './bookTreeItem'; import * as nls from 'vscode-nls'; -import { IJupyterBookToc, IJupyterBookSection } from '../contracts/content'; +import { isEditorTitleFree } from '../common/utils'; +import { BookModel } from './bookModel'; const localize = nls.loadMessageBundle(); -export class BookTreeViewProvider implements vscode.TreeDataProvider, azdata.nb.NavigationProvider { - readonly providerId: string = 'BookNavigator'; +export class BookTreeViewProvider implements vscode.TreeDataProvider { private _onDidChangeTreeData: vscode.EventEmitter = new vscode.EventEmitter(); readonly onDidChangeTreeData: vscode.Event = this._onDidChangeTreeData.event; - private _tableOfContentPaths: string[] = []; - private _allNotebooks = new Map(); - private _extensionContext: vscode.ExtensionContext; private _throttleTimer: any; private _resource: string; + private _extensionContext: vscode.ExtensionContext; + private prompter: IPrompter; + // For testing private _errorMessage: string; private _onReadAllTOCFiles: vscode.EventEmitter = new vscode.EventEmitter(); private _openAsUntitled: boolean; + public viewId: string; + public books: BookModel[]; + public currentBook: BookModel; + + constructor(workspaceFolders: vscode.WorkspaceFolder[], extensionContext: vscode.ExtensionContext, openAsUntitled: boolean, view: string) { + this._openAsUntitled = openAsUntitled; + this._extensionContext = extensionContext; + this.books = []; + this.initialize(workspaceFolders.map(a => a.uri.fsPath)); + vscode.commands.executeCommand('setContext', 'untitledBooks', openAsUntitled); + this.viewId = view; + this.prompter = new CodeAdapter(); - constructor(workspaceFolders: vscode.WorkspaceFolder[], extensionContext: vscode.ExtensionContext) { - this.initialize(workspaceFolders, null, extensionContext); } - private initialize(workspaceFolders: vscode.WorkspaceFolder[], bookPath: string, context: vscode.ExtensionContext): void { - let workspacePaths: string[] = []; - if (bookPath) { - workspacePaths.push(bookPath); - } - else if (workspaceFolders) { - workspacePaths = workspaceFolders.map(a => a.uri.fsPath); - } - this.getTableOfContentFiles(workspacePaths).then(() => undefined, (err) => { console.log(err); }); - this._extensionContext = context; + private async initialize(bookPaths: string[]): Promise { + return Promise.all(bookPaths.map(async (bookPath) => { + let book: BookModel = new BookModel(bookPath, this._openAsUntitled, this._extensionContext); + await book.initializeContents(); + this.books.push(book); + if (!this.currentBook) { + this.currentBook = book; + } + })); } public get onReadAllTOCFiles(): vscode.Event { return this._onReadAllTOCFiles.event; } - async getTableOfContentFiles(workspacePaths: string[]): Promise { - let notebookConfig = vscode.workspace.getConfiguration(notebookConfigKey); - let maxDepth = notebookConfig[maxBookSearchDepth]; - // Use default value if user enters an invalid value - if (maxDepth === undefined || maxDepth < 0) { - maxDepth = 5; - } else if (maxDepth === 0) { // No limit of search depth if user enters 0 - maxDepth = undefined; - } - for (let workspacePath of workspacePaths) { - let p = path.join(workspacePath, '**', '_data', 'toc.yml').replace(/\\/g, '/'); - let tableOfContentPaths = await glob(p, { deep: maxDepth }); - this._tableOfContentPaths = this._tableOfContentPaths.concat(tableOfContentPaths); - } - let bookOpened: boolean = this._tableOfContentPaths.length > 0; - vscode.commands.executeCommand('setContext', 'bookOpened', bookOpened); - this._onReadAllTOCFiles.fire(); - } - async openBook(bookPath: string, openAsUntitled: boolean, urlToOpen?: string): Promise { + + async openBook(bookPath: string, urlToOpen?: string): Promise { try { + let books: BookModel[] = this.books.filter(book => book.bookPath === bookPath) || []; // Check if the book is already open in viewlet. - if (this._tableOfContentPaths.indexOf(path.join(bookPath, '_data', 'toc.yml').replace(/\\/g, '/')) > -1 && this._allNotebooks.size > 0) { - vscode.commands.executeCommand('workbench.books.action.focusBooksExplorer'); + if (books.length > 0 && books[0].bookItems) { + this.currentBook = books[0]; + this.showPreviewFile(urlToOpen); } else { - await this.getTableOfContentFiles([bookPath]); - const bookViewer = vscode.window.createTreeView('bookTreeView', { showCollapseAll: true, treeDataProvider: this }); - await vscode.commands.executeCommand('workbench.books.action.focusBooksExplorer'); - this._openAsUntitled = openAsUntitled; - let books = await this.getBooks(); - if (books && books.length > 0) { - const rootTreeItem = books[0]; - const sectionToOpen = rootTreeItem.findChildSection(urlToOpen); - bookViewer.reveal(rootTreeItem, { expand: vscode.TreeItemCollapsibleState.Expanded, focus: true, select: true }); - const urlPath = sectionToOpen ? sectionToOpen.url : rootTreeItem.tableOfContents.sections[0].url; - const sectionToOpenMarkdown: string = path.join(bookPath, 'content', urlPath.concat('.md')); - const sectionToOpenNotebook: string = path.join(bookPath, 'content', urlPath.concat('.ipynb')); - const markdownExists = await exists(sectionToOpenMarkdown); - const notebookExists = await exists(sectionToOpenNotebook); - if (markdownExists) { - vscode.commands.executeCommand('markdown.showPreview', vscode.Uri.file(sectionToOpenMarkdown)); - } - else if (notebookExists) { - this.openNotebook(sectionToOpenNotebook); - } - } + await this.initialize([bookPath]); + let bookViewer = vscode.window.createTreeView(this.viewId, { showCollapseAll: true, treeDataProvider: this }); + this.currentBook = this.books.filter(book => book.bookPath === bookPath)[0]; + bookViewer.reveal(this.currentBook.bookItems[0], { expand: vscode.TreeItemCollapsibleState.Expanded, focus: true, select: true }); + this.showPreviewFile(urlToOpen); } } catch (e) { vscode.window.showErrorMessage(localize('openBookError', "Open book {0} failed: {1}", @@ -107,6 +83,22 @@ export class BookTreeViewProvider implements vscode.TreeDataProvider { + if (this.currentBook) { + const bookRoot = this.currentBook.bookItems[0]; + const sectionToOpen = bookRoot.findChildSection(urlToOpen); + const urlPath = sectionToOpen ? sectionToOpen.url : bookRoot.tableOfContents.sections[0].url; + const sectionToOpenMarkdown: string = path.join(this.currentBook.bookPath, 'content', urlPath.concat('.md')); + const sectionToOpenNotebook: string = path.join(this.currentBook.bookPath, 'content', urlPath.concat('.ipynb')); + if (await fs.pathExists(sectionToOpenMarkdown)) { + this.openMarkdown(sectionToOpenMarkdown); + } + else if (await fs.pathExists(sectionToOpenNotebook)) { + this.openNotebook(sectionToOpenNotebook); + } + } + } + async openNotebook(resource: string): Promise { try { if (this._openAsUntitled) { @@ -117,7 +109,7 @@ export class BookTreeViewProvider implements vscode.TreeDataProvider { + if (this.currentBook.bookPath) { + const allFilesFilter = localize('allFiles', "All Files"); + let filter: any = {}; + filter[allFilesFilter] = '*'; + let uris = await vscode.window.showOpenDialog({ + filters: filter, + canSelectFiles: false, + canSelectMany: false, + canSelectFolders: true, + openLabel: localize('labelPickFolder', "Pick Folder") + }); + if (uris && uris.length > 0) { + let pickedFolder = uris[0]; + let destinationUri: vscode.Uri = vscode.Uri.file(path.join(pickedFolder.fsPath, path.basename(this.currentBook.bookPath))); + if (destinationUri) { + if (await fs.pathExists(destinationUri.fsPath)) { + let doReplace = await this.confirmReplace(); + if (!doReplace) { + return undefined; + } + else { + //remove folder if exists + await fs.remove(destinationUri.fsPath); + } + } + //make directory for each contribution book. + await fs.mkdir(destinationUri.fsPath); + await fs.copy(this.currentBook.bookPath, destinationUri.fsPath); + + //remove book from the untitled books and open it from Saved books + let untitledBookIndex: number = this.books.indexOf(this.currentBook); + if (untitledBookIndex > -1) { + this.books.splice(untitledBookIndex, 1); + this.currentBook = undefined; + this._onDidChangeTreeData.fire(); + vscode.commands.executeCommand('bookTreeView.openBook', destinationUri.fsPath, false, undefined); + } + } + } + } + + } + private runThrottledAction(resource: string, action: () => void) { const isResourceChange = resource !== this._resource; if (isResourceChange) { @@ -189,168 +225,58 @@ export class BookTreeViewProvider implements vscode.TreeDataProvider { if (element) { if (element.sections) { - return Promise.resolve(this.getSections(element.tableOfContents, element.sections, element.root)); + return Promise.resolve(this.currentBook.getSections(element.tableOfContents, element.sections, element.root).then(sections => { return sections; })); } else { return Promise.resolve([]); } } else { - return Promise.resolve(this.getBooks()); + let booksitems: BookTreeItem[] = []; + this.books.map(book => { + booksitems = booksitems.concat(book.bookItems); + }); + return Promise.resolve(booksitems); } } - /** - * Recursively parses out a section of a Jupyter Book. - * @param array The input data to parse - */ - private parseJupyterSection(array: any[]): IJupyterBookSection[] { - return array.reduce((acc, val) => Array.isArray(val.sections) ? acc.concat(val).concat(this.parseJupyterSection(val.sections)) : acc.concat(val), []); - } - public async getBooks(): Promise { - let books: BookTreeItem[] = []; - for (const contentPath of this._tableOfContentPaths) { - let root = path.dirname(path.dirname(contentPath)); - try { - const config = yaml.safeLoad((await fs.readFile(path.join(root, '_config.yml'), 'utf-8')).toString()); - const tableOfContents = yaml.safeLoad((await fs.readFile(contentPath, 'utf-8')).toString()); - try { - let book = new BookTreeItem({ - title: config.title, - root: root, - tableOfContents: { sections: this.parseJupyterSection(tableOfContents) }, - page: tableOfContents, - type: BookTreeItemType.Book, - treeItemCollapsibleState: vscode.TreeItemCollapsibleState.Expanded, - }, - { - light: this._extensionContext.asAbsolutePath('resources/light/book.svg'), - dark: this._extensionContext.asAbsolutePath('resources/dark/book_inverse.svg') - } - ); - books.push(book); - } catch (e) { - throw Error(localize('invalidTocError', "Error: {0} has an incorrect toc.yml file. {1}", config.title, e instanceof Error ? e.message : e)); + getParent(element?: BookTreeItem): vscode.ProviderResult { + if (element) { + let parentPath; + if (element.root.endsWith('.md')) { + parentPath = path.join(this.currentBook.bookPath, 'content', 'readme.md'); + if (parentPath === element.root) { + return undefined; } - } catch (e) { - let error = e instanceof Error ? e.message : e; - this._errorMessage = error; - vscode.window.showErrorMessage(error); } - } - return books; - } - - public async getSections(tableOfContents: IJupyterBookToc, sections: IJupyterBookSection[], root: string): Promise { - let notebooks: BookTreeItem[] = []; - for (let i = 0; i < sections.length; i++) { - if (sections[i].url) { - if (sections[i].external) { - let externalLink = new BookTreeItem({ - title: sections[i].title, - root: root, - tableOfContents: tableOfContents, - page: sections[i], - type: BookTreeItemType.ExternalLink, - treeItemCollapsibleState: vscode.TreeItemCollapsibleState.Collapsed - }, - { - light: this._extensionContext.asAbsolutePath('resources/light/link.svg'), - dark: this._extensionContext.asAbsolutePath('resources/dark/link_inverse.svg') - } - ); - - notebooks.push(externalLink); - } else { - let pathToNotebook = path.join(root, 'content', sections[i].url.concat('.ipynb')); - let pathToMarkdown = path.join(root, 'content', sections[i].url.concat('.md')); - // Note: Currently, if there is an ipynb and a md file with the same name, Jupyter Books only shows the notebook. - // Following Jupyter Books behavior for now - if (await exists(pathToNotebook)) { - let notebook = new BookTreeItem({ - title: sections[i].title, - root: root, - tableOfContents: tableOfContents, - page: sections[i], - type: BookTreeItemType.Notebook, - treeItemCollapsibleState: vscode.TreeItemCollapsibleState.Collapsed - }, - { - light: this._extensionContext.asAbsolutePath('resources/light/notebook.svg'), - dark: this._extensionContext.asAbsolutePath('resources/dark/notebook_inverse.svg') - } - ); - notebooks.push(notebook); - this._allNotebooks.set(pathToNotebook, notebook); - if (this._openAsUntitled) { - this._allNotebooks.set(path.basename(pathToNotebook), notebook); - } - } else if (await exists(pathToMarkdown)) { - let markdown = new BookTreeItem({ - title: sections[i].title, - root: root, - tableOfContents: tableOfContents, - page: sections[i], - type: BookTreeItemType.Markdown, - treeItemCollapsibleState: vscode.TreeItemCollapsibleState.Collapsed - }, - { - light: this._extensionContext.asAbsolutePath('resources/light/markdown.svg'), - dark: this._extensionContext.asAbsolutePath('resources/dark/markdown_inverse.svg') - } - ); - notebooks.push(markdown); - } else { - let error = localize('missingFileError', 'Missing file : {0}', sections[i].title); - this._errorMessage = error; - vscode.window.showErrorMessage(error); - } - } - } else { - // TODO: search functionality (#6160) + else if (element.root.endsWith('.ipynb')) { + let baseName: string = path.basename(element.root); + parentPath = element.root.replace(baseName, 'readme.md'); } - } - return notebooks; - } - - getNavigation(uri: vscode.Uri): Thenable { - let notebook = uri.scheme !== 'untitled' ? this._allNotebooks.get(uri.fsPath) : this._allNotebooks.get(path.basename(uri.fsPath)); - let result: azdata.nb.NavigationResult; - if (notebook) { - result = { - hasNavigation: true, - previous: notebook.previousUri ? this._openAsUntitled ? vscode.Uri.parse(notebook.previousUri).with({ scheme: 'untitled' }) : vscode.Uri.file(notebook.previousUri) : undefined, - next: notebook.nextUri ? this._openAsUntitled ? vscode.Uri.parse(notebook.nextUri).with({ scheme: 'untitled' }) : vscode.Uri.file(notebook.nextUri) : undefined - }; + else { + return undefined; + } + return this.currentBook.getAllBooks().get(parentPath); } else { - result = { - hasNavigation: false, - previous: undefined, - next: undefined - }; + return undefined; } - return Promise.resolve(result); } public get errorMessage() { return this._errorMessage; } - public get tableOfContentPaths() { - return this._tableOfContentPaths; - } - getUntitledNotebookUri(resource: string): vscode.Uri { let untitledFileName: vscode.Uri; - if (process.platform.indexOf('win') > -1) { + if (process.platform === 'win32') { let title = path.join(path.dirname(resource), this.findNextUntitledFileName(resource)); untitledFileName = vscode.Uri.parse(`untitled:${title}`); } else { untitledFileName = vscode.Uri.parse(resource).with({ scheme: 'untitled' }); } - if (!this._allNotebooks.get(untitledFileName.fsPath) && !this._allNotebooks.get(path.basename(untitledFileName.fsPath))) { - let notebook = this._allNotebooks.get(resource); - this._allNotebooks.set(path.basename(untitledFileName.fsPath), notebook); + if (!this.currentBook.getAllBooks().get(untitledFileName.fsPath) && !this.currentBook.getAllBooks().get(path.basename(untitledFileName.fsPath))) { + let notebook = this.currentBook.getAllBooks().get(resource); + this.currentBook.getAllBooks().set(path.basename(untitledFileName.fsPath), notebook); } return untitledFileName; } @@ -368,4 +294,14 @@ export class BookTreeViewProvider implements vscode.TreeDataProvider { + return await this.prompter.promptSingle({ + type: QuestionTypes.confirm, + message: localize('confirmReplace', "Folder already exists. Are you sure you want to delete and replace this folder?"), + default: false + }); + } + + } diff --git a/extensions/notebook/src/extension.ts b/extensions/notebook/src/extension.ts index 15b0a40ef9..c8f32d7e12 100644 --- a/extensions/notebook/src/extension.ts +++ b/extensions/notebook/src/extension.ts @@ -22,19 +22,22 @@ const localize = nls.loadMessageBundle(); const JUPYTER_NOTEBOOK_PROVIDER = 'jupyter'; const msgSampleCodeDataFrame = localize('msgSampleCodeDataFrame', "This sample code loads the file into a data frame and shows the first 10 results."); const noNotebookVisible = localize('noNotebookVisible', "No notebook editor is active"); - +const BOOKS_VIEWID = 'bookTreeView'; +const READONLY_BOOKS_VIEWID = 'untitledBookTreeView'; let controller: JupyterController; type ChooseCellType = { label: string, id: CellType }; export async function activate(extensionContext: vscode.ExtensionContext): Promise { - const bookTreeViewProvider = new BookTreeViewProvider(vscode.workspace.workspaceFolders || [], extensionContext); - extensionContext.subscriptions.push(vscode.window.registerTreeDataProvider('bookTreeView', bookTreeViewProvider)); - extensionContext.subscriptions.push(azdata.nb.registerNavigationProvider(bookTreeViewProvider)); - extensionContext.subscriptions.push(vscode.commands.registerCommand('bookTreeView.openBook', (bookPath: string, openAsUntitled: boolean, urlToOpen?: string) => bookTreeViewProvider.openBook(bookPath, openAsUntitled, urlToOpen))); - extensionContext.subscriptions.push(vscode.commands.registerCommand('bookTreeView.openNotebookAsUntitled', (resource: string) => bookTreeViewProvider.openNotebookAsUntitled(resource))); - extensionContext.subscriptions.push(vscode.commands.registerCommand('bookTreeView.openNotebook', (resource: string) => bookTreeViewProvider.openNotebook(resource))); - extensionContext.subscriptions.push(vscode.commands.registerCommand('bookTreeView.openMarkdown', (resource: string) => bookTreeViewProvider.openMarkdown(resource))); - extensionContext.subscriptions.push(vscode.commands.registerCommand('bookTreeView.openExternalLink', (resource: string) => bookTreeViewProvider.openExternalLink(resource))); + const bookTreeViewProvider = new BookTreeViewProvider(vscode.workspace.workspaceFolders || [], extensionContext, false, BOOKS_VIEWID); + const untitledBookTreeViewProvider = new BookTreeViewProvider([], extensionContext, true, READONLY_BOOKS_VIEWID); + extensionContext.subscriptions.push(vscode.window.registerTreeDataProvider(BOOKS_VIEWID, bookTreeViewProvider)); + extensionContext.subscriptions.push(vscode.window.registerTreeDataProvider(READONLY_BOOKS_VIEWID, untitledBookTreeViewProvider)); + extensionContext.subscriptions.push(vscode.commands.registerCommand('bookTreeView.openBook', (bookPath: string, openAsUntitled: boolean, urlToOpen?: string) => openAsUntitled ? untitledBookTreeViewProvider.openBook(bookPath, urlToOpen) : bookTreeViewProvider.openBook(bookPath, urlToOpen))); + extensionContext.subscriptions.push(vscode.commands.registerCommand('bookTreeView.openNotebook', (resource) => bookTreeViewProvider.openNotebook(resource))); + extensionContext.subscriptions.push(vscode.commands.registerCommand('bookTreeView.openUntitledNotebook', (resource) => untitledBookTreeViewProvider.openNotebookAsUntitled(resource))); + extensionContext.subscriptions.push(vscode.commands.registerCommand('bookTreeView.openMarkdown', (resource) => bookTreeViewProvider.openMarkdown(resource))); + extensionContext.subscriptions.push(vscode.commands.registerCommand('bookTreeView.openExternalLink', (resource) => bookTreeViewProvider.openExternalLink(resource))); + extensionContext.subscriptions.push(vscode.commands.registerCommand('notebook.command.saveBook', () => untitledBookTreeViewProvider.saveJupyterBooks())); extensionContext.subscriptions.push(vscode.commands.registerCommand('_notebook.command.new', (context?: azdata.ConnectedContext) => { let connectionProfile: azdata.IConnectionProfile = undefined; diff --git a/extensions/notebook/src/test/book/book.test.ts b/extensions/notebook/src/test/book/book.test.ts index e4993e24d7..b2c41725bf 100644 --- a/extensions/notebook/src/test/book/book.test.ts +++ b/extensions/notebook/src/test/book/book.test.ts @@ -111,7 +111,7 @@ describe.skip('BookTreeViewProviderTests', function() { index: 0 }; console.log('Creating BookTreeViewProvider...'); - bookTreeViewProvider = new BookTreeViewProvider([folder], mockExtensionContext.object); + bookTreeViewProvider = new BookTreeViewProvider([folder], mockExtensionContext.object, false, 'bookTreeView'); let tocRead = new Promise((resolve, reject) => bookTreeViewProvider.onReadAllTOCFiles(() => resolve())); let errorCase = new Promise((resolve, reject) => setTimeout(() => resolve(), 5000)); await Promise.race([tocRead, errorCase.then(() => { throw new Error('Table of Contents were not ready in time'); })]); @@ -178,15 +178,15 @@ describe.skip('BookTreeViewProviderTests', function() { name: '', index: 0 }; - bookTreeViewProvider = new BookTreeViewProvider([folder], mockExtensionContext.object); + bookTreeViewProvider = new BookTreeViewProvider([folder], mockExtensionContext.object, false, 'bookTreeView'); let tocRead = new Promise((resolve, reject) => bookTreeViewProvider.onReadAllTOCFiles(() => resolve())); let errorCase = new Promise((resolve, reject) => setTimeout(() => resolve(), 5000)); await Promise.race([tocRead, errorCase.then(() => { throw new Error('Table of Contents were not ready in time'); })]); }); it('should ignore toc.yml files not in _data folder', function(): void { - bookTreeViewProvider.getTableOfContentFiles([folder.uri.toString()]); - for (let p of bookTreeViewProvider.tableOfContentPaths) { + bookTreeViewProvider.currentBook.getTableOfContentFiles(folder.uri.toString()); + for (let p of bookTreeViewProvider.currentBook.tableOfContentPaths) { should(p.toLocaleLowerCase()).equal(tableOfContentsFile.replace(/\\/g, '/').toLocaleLowerCase()); } }); @@ -220,19 +220,19 @@ describe.skip('BookTreeViewProviderTests', function() { name: '', index: 0 }; - bookTreeViewProvider = new BookTreeViewProvider([folder], mockExtensionContext.object); + bookTreeViewProvider = new BookTreeViewProvider([folder], mockExtensionContext.object, false, 'bookTreeView'); let tocRead = new Promise((resolve, reject) => bookTreeViewProvider.onReadAllTOCFiles(() => resolve())); let errorCase = new Promise((resolve, reject) => setTimeout(() => resolve(), 5000)); await Promise.race([tocRead, errorCase.then(() => { throw new Error('Table of Contents were not ready in time'); })]); }); it('should show error message if config.yml file not found', function(): void { - bookTreeViewProvider.getBooks(); + bookTreeViewProvider.currentBook.readBooks(); should(bookTreeViewProvider.errorMessage.toLocaleLowerCase()).equal(('ENOENT: no such file or directory, open \'' + configFile + '\'').toLocaleLowerCase()); }); it('should show error if toc.yml file format is invalid', async function(): Promise { await fs.writeFile(configFile, 'title: Test Book'); - bookTreeViewProvider.getBooks(); + bookTreeViewProvider.currentBook.readBooks(); should(bookTreeViewProvider.errorMessage).equal('Error: Test Book has an incorrect toc.yml file'); }); @@ -277,15 +277,15 @@ describe.skip('BookTreeViewProviderTests', function() { name: '', index: 0 }; - bookTreeViewProvider = new BookTreeViewProvider([folder], mockExtensionContext.object); + bookTreeViewProvider = new BookTreeViewProvider([folder], mockExtensionContext.object, false, 'bookTreeView'); let tocRead = new Promise((resolve, reject) => bookTreeViewProvider.onReadAllTOCFiles(() => resolve())); let errorCase = new Promise((resolve, reject) => setTimeout(() => resolve(), 5000)); await Promise.race([tocRead, errorCase.then(() => { throw new Error('Table of Contents were not ready in time'); })]); }); it('should show error if notebook or markdown file is missing', async function(): Promise { - let books = bookTreeViewProvider.getBooks(); - let children = await bookTreeViewProvider.getSections({ sections: [] }, (await books)[0].sections, rootFolderPath); + let books = bookTreeViewProvider.currentBook.bookItems; + let children = await bookTreeViewProvider.currentBook.getSections({ sections: [] }, books[0].sections, rootFolderPath); should(bookTreeViewProvider.errorMessage).equal('Missing file : Notebook1'); // Rest of book should be detected correctly even with a missing file equalBookItems(children[0], expectedNotebook2); diff --git a/src/sql/workbench/api/browser/mainThreadNotebookDocumentsAndEditors.ts b/src/sql/workbench/api/browser/mainThreadNotebookDocumentsAndEditors.ts index 062698f354..ef20d65753 100644 --- a/src/sql/workbench/api/browser/mainThreadNotebookDocumentsAndEditors.ts +++ b/src/sql/workbench/api/browser/mainThreadNotebookDocumentsAndEditors.ts @@ -708,8 +708,8 @@ export class MainThreadNotebookDocumentsAndEditors extends Disposable implements let result = await this._proxy.$getNavigation(handle, uri); if (result) { if (result.next.scheme === Schemas.untitled) { - let untitledNbName: URI = URI.parse(`untitled:${path.basename(result.next.path)}`); - let content = await this._fileService.readFile(URI.parse(result.next.path)); + let untitledNbName: URI = URI.parse(`untitled:${result.next.path}`); + let content = await this._fileService.readFile(URI.file(result.next.path)); await this.doOpenEditor(untitledNbName, { initialContent: content.value.toString(), initialDirtyState: false }); } else { @@ -721,8 +721,8 @@ export class MainThreadNotebookDocumentsAndEditors extends Disposable implements let result = await this._proxy.$getNavigation(handle, uri); if (result) { if (result.previous.scheme === Schemas.untitled) { - let untitledNbName: URI = URI.parse(`untitled:${path.basename(result.previous.path)}`); - let content = await this._fileService.readFile(URI.parse(result.previous.path)); + let untitledNbName: URI = URI.parse(`untitled:${result.previous.path}`); + let content = await this._fileService.readFile(URI.file(result.previous.path)); await this.doOpenEditor(untitledNbName, { initialContent: content.value.toString(), initialDirtyState: false }); } else { diff --git a/src/sql/workbench/parts/notebook/browser/notebook.common.contribution.ts b/src/sql/workbench/parts/notebook/browser/notebook.common.contribution.ts index cd833c9e67..4b53573c5d 100644 --- a/src/sql/workbench/parts/notebook/browser/notebook.common.contribution.ts +++ b/src/sql/workbench/parts/notebook/browser/notebook.common.contribution.ts @@ -158,14 +158,6 @@ configurationRegistry.registerConfiguration({ } }); -/** -* Explorer viewlet id. -*/ -export const VIEWLET_ID = 'bookTreeView'; -/** -* Explorer viewlet container. -*/ -export const VIEW_CONTAINER: ViewContainer = Registry.as(ViewContainerExtensions.ViewContainersRegistry).registerViewContainer(VIEWLET_ID); registerAction({ id: 'workbench.books.action.focusBooksExplorer', handler: async (accessor) => {