diff --git a/extensions/notebook/package.json b/extensions/notebook/package.json index 4170eab514..e0f25badd4 100644 --- a/extensions/notebook/package.json +++ b/extensions/notebook/package.json @@ -145,6 +145,11 @@ "title": "%title.PreviewLocalizedBook%", "category": "%books-preview-category%" }, + { + "command": "notebook.command.revealInBooksViewlet", + "title": "%title.revealInBooksViewlet%", + "category": "%books-preview-category%" + }, { "command": "notebook.command.saveBook", "title": "%title.saveJupyterBook%", @@ -282,6 +287,10 @@ { "command": "notebook.command.searchUntitledBook", "when": "false" + }, + { + "command": "notebook.command.revealInBooksViewlet", + "when": "false" } ], "touchBar": [ diff --git a/extensions/notebook/package.nls.json b/extensions/notebook/package.nls.json index 8a0aca3aaf..f2c74625d2 100644 --- a/extensions/notebook/package.nls.json +++ b/extensions/notebook/package.nls.json @@ -35,5 +35,6 @@ "title.UnsavedBooks": "Unsaved Books", "title.PreviewLocalizedBook": "Get localized SQL Server 2019 guide", "title.openJupyterBook": "Open Jupyter Book", + "title.revealInBooksViewlet": "Reveal in Books", "title.createJupyterBook": "Create Book" } diff --git a/extensions/notebook/src/book/bookModel.ts b/extensions/notebook/src/book/bookModel.ts index 01b8ca69af..4b9b448419 100644 --- a/extensions/notebook/src/book/bookModel.ts +++ b/extensions/notebook/src/book/bookModel.ts @@ -41,10 +41,14 @@ export class BookModel implements azdata.nb.NavigationProvider { await this.readBooks(); } - public getAllBooks(): Map { + public getAllNotebooks(): Map { return this._allNotebooks; } + public getNotebook(uri: string): BookTreeItem | undefined { + return this._allNotebooks.get(uri); + } + public async getTableOfContentFiles(folderPath: string): Promise { let notebookConfig = vscode.workspace.getConfiguration(notebookConfigKey); let maxDepth = notebookConfig[maxBookSearchDepth]; diff --git a/extensions/notebook/src/book/bookTreeView.ts b/extensions/notebook/src/book/bookTreeView.ts index c814315ea6..394076f713 100644 --- a/extensions/notebook/src/book/bookTreeView.ts +++ b/extensions/notebook/src/book/bookTreeView.ts @@ -13,6 +13,7 @@ import { BookTreeItem } from './bookTreeItem'; import { BookModel } from './bookModel'; import { Deferred } from '../common/promise'; import * as loc from '../common/localizedConstants'; +import { ApiWrapper } from '../common/apiWrapper'; const Content = 'content'; @@ -25,20 +26,21 @@ export class BookTreeViewProvider implements vscode.TreeDataProvider = new Deferred(); - + private _bookViewer: vscode.TreeView; private _openAsUntitled: boolean; + private _apiWrapper: ApiWrapper; public viewId: string; public books: BookModel[]; public currentBook: BookModel; - constructor(workspaceFolders: vscode.WorkspaceFolder[], extensionContext: vscode.ExtensionContext, openAsUntitled: boolean, view: string) { + constructor(apiWrapper: ApiWrapper, workspaceFolders: vscode.WorkspaceFolder[], extensionContext: vscode.ExtensionContext, openAsUntitled: boolean, view: string) { this._openAsUntitled = openAsUntitled; this._extensionContext = extensionContext; this.books = []; this.initialize(workspaceFolders).catch(e => console.error(e)); this.viewId = view; this.prompter = new CodeAdapter(); - + this._apiWrapper = apiWrapper ? apiWrapper : new ApiWrapper(); } private async initialize(workspaceFolders: vscode.WorkspaceFolder[]): Promise { @@ -89,6 +91,15 @@ export class BookTreeViewProvider implements vscode.TreeDataProvider { + if (e.visible) { + this.revealActiveDocumentInViewlet(); + } + }); + azdata.nb.onDidChangeActiveNotebookEditor(e => { + this.revealActiveDocumentInViewlet(e.document.uri, false); + }); } async showPreviewFile(urlToOpen?: string): Promise { @@ -121,6 +132,26 @@ export class BookTreeViewProvider implements vscode.TreeDataProvider { + let bookItem: BookTreeItem; + // If no uri is passed in, try to use the current active notebook editor + if (!uri) { + let openDocument = azdata.nb.activeNotebookEditor; + if (openDocument) { + bookItem = this.currentBook.getNotebook(openDocument.document.uri.fsPath); + } + } else if (uri.fsPath) { + bookItem = this.currentBook.getNotebook(uri.fsPath); + } + if (bookItem) { + // Select + focus item in viewlet if books viewlet is already open, or if we pass in variable + if (shouldReveal || this._bookViewer.visible) { + // Note: 3 is the maximum number of levels that the vscode APIs let you expand to + await this._bookViewer.reveal(bookItem, { select: true, focus: true, expand: 3 }); + } + } + } + openMarkdown(resource: string): void { this.runThrottledAction(resource, () => { try { @@ -289,7 +320,7 @@ export class BookTreeViewProvider implements vscode.TreeDataProvider(viewId: string, options: vscode.TreeViewOptions): vscode.TreeView { + return vscode.window.createTreeView(viewId, options); + } } diff --git a/extensions/notebook/src/extension.ts b/extensions/notebook/src/extension.ts index a8e751baa4..4e4af8c725 100644 --- a/extensions/notebook/src/extension.ts +++ b/extensions/notebook/src/extension.ts @@ -109,6 +109,9 @@ export async function activate(extensionContext: vscode.ExtensionContext): Promi await vscode.commands.executeCommand('vscode.open', vscode.Uri.parse(urlToOpen)); })); + extensionContext.subscriptions.push(vscode.commands.registerCommand('notebook.command.revealInBooksViewlet', (uri: vscode.Uri, shouldReveal: boolean) => bookTreeViewProvider.revealActiveDocumentInViewlet(uri, shouldReveal))); + extensionContext.subscriptions.push(vscode.commands.registerCommand('notebook.command.revealInUntitledBooksViewlet', (uri: vscode.Uri, shouldReveal: boolean) => untitledBookTreeViewProvider.revealActiveDocumentInViewlet(uri, shouldReveal))); + let appContext = new AppContext(extensionContext, new ApiWrapper()); controller = new JupyterController(appContext); let result = await controller.activate(); @@ -117,14 +120,11 @@ export async function activate(extensionContext: vscode.ExtensionContext): Promi } let workspaceFolders = vscode.workspace.workspaceFolders?.slice() ?? []; - const bookTreeViewProvider = new BookTreeViewProvider(workspaceFolders, extensionContext, false, BOOKS_VIEWID); + const bookTreeViewProvider = new BookTreeViewProvider(appContext.apiWrapper, workspaceFolders, extensionContext, false, BOOKS_VIEWID); await bookTreeViewProvider.initialized; - const untitledBookTreeViewProvider = new BookTreeViewProvider([], extensionContext, true, READONLY_BOOKS_VIEWID); + const untitledBookTreeViewProvider = new BookTreeViewProvider(appContext.apiWrapper, [], extensionContext, true, READONLY_BOOKS_VIEWID); await untitledBookTreeViewProvider.initialized; - extensionContext.subscriptions.push(vscode.window.registerTreeDataProvider(BOOKS_VIEWID, bookTreeViewProvider)); - extensionContext.subscriptions.push(vscode.window.registerTreeDataProvider(READONLY_BOOKS_VIEWID, untitledBookTreeViewProvider)); - return { getJupyterController() { return controller; diff --git a/extensions/notebook/src/test/book/book.test.ts b/extensions/notebook/src/test/book/book.test.ts index 50a5d63f40..c5aa2b1181 100644 --- a/extensions/notebook/src/test/book/book.test.ts +++ b/extensions/notebook/src/test/book/book.test.ts @@ -15,6 +15,8 @@ import { BookTreeItem } from '../../book/bookTreeItem'; import { promisify } from 'util'; import { MockExtensionContext } from '../common/stubs'; import { exists } from '../../common/utils'; +import { AppContext } from '../../common/appContext'; +import { ApiWrapper } from '../../common/apiWrapper'; export interface IExpectedBookItem { title: string; @@ -52,6 +54,7 @@ describe('BookTreeViewProviderTests', function () { let expectedMarkdown: IExpectedBookItem; let expectedExternalLink: IExpectedBookItem; let expectedBook: IExpectedBookItem; + let appContext: AppContext; this.beforeAll(async () => { mockExtensionContext = new MockExtensionContext(); @@ -98,6 +101,8 @@ describe('BookTreeViewProviderTests', function () { sections: [expectedNotebook1, expectedMarkdown, expectedExternalLink], title: 'Test Book' }; + appContext = new AppContext(mockExtensionContext, new ApiWrapper()); + await fs.mkdir(rootFolderPath); await fs.mkdir(bookFolderPath); await fs.mkdir(nonBookFolderPath); @@ -112,7 +117,7 @@ describe('BookTreeViewProviderTests', function () { }); it('should initialize correctly with empty workspace array', async () => { - const bookTreeViewProvider = new BookTreeViewProvider([], mockExtensionContext, false, 'bookTreeView'); + const bookTreeViewProvider = new BookTreeViewProvider(appContext.apiWrapper, [], mockExtensionContext, false, 'bookTreeView'); await bookTreeViewProvider.initialized; }); @@ -122,7 +127,7 @@ describe('BookTreeViewProviderTests', function () { name: '', index: 0 }; - const bookTreeViewProvider = new BookTreeViewProvider([folder], mockExtensionContext, false, 'bookTreeView'); + const bookTreeViewProvider = new BookTreeViewProvider(appContext.apiWrapper, [folder], mockExtensionContext, false, 'bookTreeView'); await bookTreeViewProvider.initialized; }); @@ -137,7 +142,7 @@ describe('BookTreeViewProviderTests', function () { name: '', index: 0 }; - const bookTreeViewProvider = new BookTreeViewProvider([book, nonBook], mockExtensionContext, false, 'bookTreeView'); + const bookTreeViewProvider = new BookTreeViewProvider(appContext.apiWrapper, [book, nonBook], mockExtensionContext, false, 'bookTreeView'); await bookTreeViewProvider.initialized; should(bookTreeViewProvider.books.length).equal(1, 'Expected book was not initialized'); }); @@ -153,7 +158,7 @@ describe('BookTreeViewProviderTests', function () { name: '', index: 0 }; - bookTreeViewProvider = new BookTreeViewProvider([folder], mockExtensionContext, false, 'bookTreeView'); + bookTreeViewProvider = new BookTreeViewProvider(appContext.apiWrapper, [folder], mockExtensionContext, false, 'bookTreeView'); 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'); })]); }); @@ -204,6 +209,7 @@ describe('BookTreeViewProviderTests', function () { let tableOfContentsFile: string; let bookTreeViewProvider: BookTreeViewProvider; let folder: vscode.WorkspaceFolder; + let appContext: AppContext; this.beforeAll(async () => { rootFolderPath = path.join(os.tmpdir(), `BookTestData_${uuid.v4()}`); @@ -220,7 +226,8 @@ describe('BookTreeViewProviderTests', function () { name: '', index: 0 }; - bookTreeViewProvider = new BookTreeViewProvider([folder], mockExtensionContext, false, 'bookTreeView'); + appContext = new AppContext(mockExtensionContext, new ApiWrapper()); + bookTreeViewProvider = new BookTreeViewProvider(appContext.apiWrapper, [folder], mockExtensionContext, false, 'bookTreeView'); 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'); })]); }); @@ -246,6 +253,7 @@ describe('BookTreeViewProviderTests', function () { let folder: vscode.WorkspaceFolder; let bookTreeViewProvider: BookTreeViewProvider; let tocFile: string; + let appContext: AppContext; this.beforeAll(async () => { rootFolderPath = path.join(os.tmpdir(), `BookTestData_${uuid.v4()}`); @@ -261,7 +269,8 @@ describe('BookTreeViewProviderTests', function () { name: '', index: 0 }; - bookTreeViewProvider = new BookTreeViewProvider([folder], mockExtensionContext, false, 'bookTreeView'); + appContext = new AppContext(mockExtensionContext, new ApiWrapper()); + bookTreeViewProvider = new BookTreeViewProvider(appContext.apiWrapper, [folder], mockExtensionContext, false, 'bookTreeView'); 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'); })]); }); @@ -291,6 +300,7 @@ describe('BookTreeViewProviderTests', function () { let bookTreeViewProvider: BookTreeViewProvider; let folder: vscode.WorkspaceFolder; let expectedNotebook2: IExpectedBookItem; + let appContext: AppContext; this.beforeAll(async () => { rootFolderPath = path.join(os.tmpdir(), `BookTestData_${uuid.v4()}`); @@ -318,7 +328,8 @@ describe('BookTreeViewProviderTests', function () { name: '', index: 0 }; - bookTreeViewProvider = new BookTreeViewProvider([folder], mockExtensionContext, false, 'bookTreeView'); + appContext = new AppContext(mockExtensionContext, new ApiWrapper()); + bookTreeViewProvider = new BookTreeViewProvider(appContext.apiWrapper, [folder], mockExtensionContext, false, 'bookTreeView'); 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'); })]); }); diff --git a/src/sql/azdata.proposed.d.ts b/src/sql/azdata.proposed.d.ts index 1b313141cf..89e68738aa 100644 --- a/src/sql/azdata.proposed.d.ts +++ b/src/sql/azdata.proposed.d.ts @@ -206,4 +206,12 @@ declare module 'azdata' { export interface CheckBoxProperties { required?: boolean; } + + export namespace nb { + /** + * An event that is emitted when the active Notebook editor is changed. + */ + export const onDidChangeActiveNotebookEditor: vscode.Event; + } } + diff --git a/src/sql/workbench/api/common/sqlExtHost.api.impl.ts b/src/sql/workbench/api/common/sqlExtHost.api.impl.ts index 01ef0c69a2..025000e6b3 100644 --- a/src/sql/workbench/api/common/sqlExtHost.api.impl.ts +++ b/src/sql/workbench/api/common/sqlExtHost.api.impl.ts @@ -488,6 +488,9 @@ export function createAdsApiFactory(accessor: ServicesAccessor): IAdsExtensionAp get onDidOpenNotebookDocument() { return extHostNotebookDocumentsAndEditors.onDidOpenNotebookDocument; }, + get onDidChangeActiveNotebookEditor() { + return extHostNotebookDocumentsAndEditors.onDidChangeActiveNotebookEditor; + }, get onDidChangeNotebookCell() { return extHostNotebookDocumentsAndEditors.onDidChangeNotebookCell; }, diff --git a/src/sql/workbench/contrib/notebook/browser/notebook.component.ts b/src/sql/workbench/contrib/notebook/browser/notebook.component.ts index afe8920d04..09e7ac2285 100644 --- a/src/sql/workbench/contrib/notebook/browser/notebook.component.ts +++ b/src/sql/workbench/contrib/notebook/browser/notebook.component.ts @@ -54,6 +54,7 @@ import { find, firstIndex } from 'vs/base/common/arrays'; import { CodeCellComponent } from 'sql/workbench/contrib/notebook/browser/cellViews/codeCell.component'; import { TextCellComponent } from 'sql/workbench/contrib/notebook/browser/cellViews/textCell.component'; import { NotebookInput } from 'sql/workbench/contrib/notebook/browser/models/notebookInput'; +import { ICommandService } from 'vs/platform/commands/common/commands'; export const NOTEBOOK_SELECTOR: string = 'notebook-component'; @@ -102,7 +103,8 @@ export class NotebookComponent extends AngularDisposable implements OnInit, OnDe @Inject(ICapabilitiesService) private capabilitiesService: ICapabilitiesService, @Inject(ITextFileService) private textFileService: ITextFileService, @Inject(ILogService) private readonly logService: ILogService, - @Inject(ITelemetryService) private telemetryService: ITelemetryService + @Inject(ITelemetryService) private telemetryService: ITelemetryService, + @Inject(ICommandService) private commandService: ICommandService ) { super(); this.updateProfile(); @@ -226,10 +228,10 @@ export class NotebookComponent extends AngularDisposable implements OnInit, OnDe private setScrollPosition(): void { if (this._notebookParams && this._notebookParams.input) { - this._notebookParams.input.layoutChanged(() => { + this._register(this._notebookParams.input.layoutChanged(() => { let containerElement = this.container.nativeElement; containerElement.scrollTop = this._scrollTop; - }); + })); } } @@ -437,6 +439,8 @@ export class NotebookComponent extends AngularDisposable implements OnInit, OnDe this._navProvider = this.notebookService.getNavigationProvider(this._notebookParams.notebookUri); if (this.contextKeyService.getContextKeyValue('bookOpened') && this._navProvider) { + // If there's a book opened but the current notebook isn't part of the book, this is a no-op + this.commandService.executeCommand('notebook.command.revealInBooksViewlet', this._notebookParams.notebookUri, false); this._navProvider.getNavigation(this._notebookParams.notebookUri).then(result => { this.navigationResult = result; this.addButton(localize('previousButtonLabel', "< Previous"),