diff --git a/src/Contracts/ActionContracts.ts b/src/Contracts/ActionContracts.ts index cf4b66ed6..f8fc956e6 100644 --- a/src/Contracts/ActionContracts.ts +++ b/src/Contracts/ActionContracts.ts @@ -68,6 +68,10 @@ export interface OpenPane extends DataExplorerAction { paneKind: PaneKind | string; } +export interface OpenSampleNotebook extends DataExplorerAction { + path: string; +} + /** * The types of actions that the DataExplorer supports performing upon opening. */ @@ -76,4 +80,5 @@ export enum ActionType { OpenCollectionTab, OpenPane, TransmitCachedData, + OpenSampleNotebook, } diff --git a/src/Explorer/Controls/Settings/__snapshots__/SettingsComponent.test.tsx.snap b/src/Explorer/Controls/Settings/__snapshots__/SettingsComponent.test.tsx.snap index b08834dd6..ab7abac11 100644 --- a/src/Explorer/Controls/Settings/__snapshots__/SettingsComponent.test.tsx.snap +++ b/src/Explorer/Controls/Settings/__snapshots__/SettingsComponent.test.tsx.snap @@ -29,6 +29,8 @@ exports[`SettingsComponent renders 1`] = ` "computedProperties": [Function], "conflictResolutionPolicy": [Function], "container": Explorer { + "_isInitializingNotebooks": false, + "_resetNotebookWorkspace": [Function], "isFixedCollectionWithSharedThroughputSupported": [Function], "isTabsContentExpanded": [Function], "onRefreshDatabasesKeyPress": [Function], @@ -45,8 +47,10 @@ exports[`SettingsComponent renders 1`] = ` "queriesClient": QueriesClient { "container": [Circular], }, + "refreshNotebookList": [Function], "resourceTree": ResourceTreeAdapter { "container": [Circular], + "copyNotebook": [Function], "parameters": [Function], }, }, @@ -103,6 +107,8 @@ exports[`SettingsComponent renders 1`] = ` "computedProperties": [Function], "conflictResolutionPolicy": [Function], "container": Explorer { + "_isInitializingNotebooks": false, + "_resetNotebookWorkspace": [Function], "isFixedCollectionWithSharedThroughputSupported": [Function], "isTabsContentExpanded": [Function], "onRefreshDatabasesKeyPress": [Function], @@ -119,8 +125,10 @@ exports[`SettingsComponent renders 1`] = ` "queriesClient": QueriesClient { "container": [Circular], }, + "refreshNotebookList": [Function], "resourceTree": ResourceTreeAdapter { "container": [Circular], + "copyNotebook": [Function], "parameters": [Function], }, }, @@ -216,6 +224,8 @@ exports[`SettingsComponent renders 1`] = ` "computedProperties": [Function], "conflictResolutionPolicy": [Function], "container": Explorer { + "_isInitializingNotebooks": false, + "_resetNotebookWorkspace": [Function], "isFixedCollectionWithSharedThroughputSupported": [Function], "isTabsContentExpanded": [Function], "onRefreshDatabasesKeyPress": [Function], @@ -232,8 +242,10 @@ exports[`SettingsComponent renders 1`] = ` "queriesClient": QueriesClient { "container": [Circular], }, + "refreshNotebookList": [Function], "resourceTree": ResourceTreeAdapter { "container": [Circular], + "copyNotebook": [Function], "parameters": [Function], }, }, @@ -259,6 +271,8 @@ exports[`SettingsComponent renders 1`] = ` } explorer={ Explorer { + "_isInitializingNotebooks": false, + "_resetNotebookWorkspace": [Function], "isFixedCollectionWithSharedThroughputSupported": [Function], "isTabsContentExpanded": [Function], "onRefreshDatabasesKeyPress": [Function], @@ -275,8 +289,10 @@ exports[`SettingsComponent renders 1`] = ` "queriesClient": QueriesClient { "container": [Circular], }, + "refreshNotebookList": [Function], "resourceTree": ResourceTreeAdapter { "container": [Circular], + "copyNotebook": [Function], "parameters": [Function], }, } diff --git a/src/Explorer/Explorer.tsx b/src/Explorer/Explorer.tsx index 368168a4a..4af478475 100644 --- a/src/Explorer/Explorer.tsx +++ b/src/Explorer/Explorer.tsx @@ -1,3 +1,4 @@ +import { Link } from "@fluentui/react/lib/Link"; import { isPublicInternetAccessAllowed } from "Common/DatabaseAccountUtility"; import { sendMessage } from "Common/MessageHandler"; import { Platform, configContext } from "ConfigContext"; @@ -15,7 +16,7 @@ import shallow from "zustand/shallow"; import { AuthType } from "../AuthType"; import { BindingHandlersRegisterer } from "../Bindings/BindingHandlersRegisterer"; import * as Constants from "../Common/Constants"; -import { Areas, ConnectionStatusType, HttpStatusCodes, PoolIdType } from "../Common/Constants"; +import { Areas, ConnectionStatusType, HttpStatusCodes, Notebook, PoolIdType } from "../Common/Constants"; import { getErrorMessage, getErrorStack, handleError } from "../Common/ErrorHandlingUtils"; import * as Logger from "../Common/Logger"; import { QueriesClient } from "../Common/QueriesClient"; @@ -31,23 +32,34 @@ import { Action, ActionModifiers } from "../Shared/Telemetry/TelemetryConstants" import * as TelemetryProcessor from "../Shared/Telemetry/TelemetryProcessor"; import { isAccountNewerThanThresholdInMs, userContext } from "../UserContext"; import { getCollectionName, getUploadName } from "../Utils/APITypeUtils"; +import { stringToBlob } from "../Utils/BlobUtils"; import { isCapabilityEnabled } from "../Utils/CapabilityUtils"; +import { fromContentUri, toRawContentUri } from "../Utils/GitHubUtils"; +import * as NotificationConsoleUtils from "../Utils/NotificationConsoleUtils"; import { logConsoleError, logConsoleInfo, logConsoleProgress } from "../Utils/NotificationConsoleUtils"; import { update } from "../Utils/arm/generatedClients/cosmos/databaseAccounts"; +import { listByDatabaseAccount } from "../Utils/arm/generatedClients/cosmosNotebooks/notebookWorkspaces"; import { useSidePanel } from "../hooks/useSidePanel"; import { useTabs } from "../hooks/useTabs"; import "./ComponentRegisterer"; import { DialogProps, useDialog } from "./Controls/Dialog"; import { GalleryTab as GalleryTabKind } from "./Controls/NotebookGallery/GalleryViewerComponent"; import { useCommandBar } from "./Menus/CommandBar/CommandBarComponentAdapter"; -import { NotebookContentItem } from "./Notebook/NotebookContentItem"; +import * as FileSystemUtil from "./Notebook/FileSystemUtil"; +import { SnapshotRequest } from "./Notebook/NotebookComponent/types"; +import { NotebookContentItem, NotebookContentItemType } from "./Notebook/NotebookContentItem"; import type NotebookManager from "./Notebook/NotebookManager"; +import { NotebookPaneContent } from "./Notebook/NotebookManager"; +import { NotebookUtil } from "./Notebook/NotebookUtil"; import { useNotebook } from "./Notebook/useNotebook"; import { AddCollectionPanel } from "./Panes/AddCollectionPanel"; import { CassandraAddCollectionPane } from "./Panes/CassandraAddCollectionPane/CassandraAddCollectionPane"; import { ExecuteSprocParamsPane } from "./Panes/ExecuteSprocParamsPane/ExecuteSprocParamsPane"; +import { StringInputPane } from "./Panes/StringInputPane/StringInputPane"; +import { UploadFilePane } from "./Panes/UploadFilePane/UploadFilePane"; import { UploadItemsPane } from "./Panes/UploadItemsPane/UploadItemsPane"; import { CassandraAPIDataClient, TableDataClient, TablesAPIDataClient } from "./Tables/TableDataClient"; +import NotebookV2Tab, { NotebookTabOptions } from "./Tabs/NotebookV2Tab"; import TabsBase from "./Tabs/TabsBase"; import TerminalTab from "./Tabs/TerminalTab"; import Database from "./Tree/Database"; @@ -75,6 +87,7 @@ export default class Explorer { // Notebooks public notebookManager?: NotebookManager; + private _isInitializingNotebooks: boolean; private notebookToImport: { name: string; content: string; @@ -86,6 +99,7 @@ export default class Explorer { const startKey: number = TelemetryProcessor.traceStart(Action.InitializeDataExplorer, { dataExplorerArea: Constants.Areas.ResourceTree, }); + this._isInitializingNotebooks = false; this.phoenixClient = new PhoenixClient(userContext?.databaseAccount?.id); useNotebook.subscribe( @@ -191,10 +205,12 @@ export default class Explorer { container: this, resourceTree: this.resourceTree, refreshCommandBarButtons: () => this.refreshCommandBarButtons(), + refreshNotebookList: () => this.refreshNotebookList(), }); } this.refreshCommandBarButtons(); + this.refreshNotebookList(); } public openEnableSynapseLinkDialog(): void { @@ -357,6 +373,7 @@ export default class Explorer { userContext.authType === AuthType.ResourceToken ? this.refreshDatabaseForResourceToken() : this.refreshAllDatabases(); + this.refreshNotebookList(); }; // Facade @@ -364,6 +381,19 @@ export default class Explorer { window.open(Constants.Urls.feedbackEmail, "_blank"); }; + public async initNotebooks(databaseAccount: DataModels.DatabaseAccount): Promise { + if (!databaseAccount) { + throw new Error("No database account specified"); + } + + if (this._isInitializingNotebooks) { + return; + } + this._isInitializingNotebooks = true; + this.refreshNotebookList(); + this._isInitializingNotebooks = false; + } + public async allocateContainer(poolId: PoolIdType, mode?: string): Promise { const shouldUseNotebookStates = poolId === PoolIdType.DefaultPoolId ? true : false; const notebookServerInfo = shouldUseNotebookStates @@ -442,6 +472,8 @@ export default class Explorer { ? useNotebook.getState().setIsAllocating(false) : useQueryCopilot.getState().setIsAllocatingContainer(false); this.refreshCommandBarButtons(); + this.refreshNotebookList(); + this._isInitializingNotebooks = false; } } } @@ -478,6 +510,104 @@ export default class Explorer { .then((memoryUsageInfo) => useNotebook.getState().setMemoryUsageInfo(memoryUsageInfo)); } + public resetNotebookWorkspace(): void { + if (!useNotebook.getState().isNotebookEnabled || !this.notebookManager?.notebookClient) { + handleError( + "Attempt to reset notebook workspace, but notebook is not enabled", + "Explorer/resetNotebookWorkspace", + ); + return; + } + const dialogContent = useNotebook.getState().isPhoenixNotebooks + ? "Notebooks saved in the temporary workspace will be deleted. Do you want to proceed?" + : "This lets you keep your notebook files and the workspace will be restored to default. Proceed anyway?"; + + const resetConfirmationDialogProps: DialogProps = { + isModal: true, + title: "Reset Workspace", + subText: dialogContent, + primaryButtonText: "OK", + secondaryButtonText: "Cancel", + onPrimaryButtonClick: this._resetNotebookWorkspace, + onSecondaryButtonClick: () => useDialog.getState().closeDialog(), + }; + useDialog.getState().openDialog(resetConfirmationDialogProps); + } + + private async _containsDefaultNotebookWorkspace(databaseAccount: DataModels.DatabaseAccount): Promise { + if (!databaseAccount) { + return false; + } + try { + const { value: workspaces } = await listByDatabaseAccount( + userContext.subscriptionId, + userContext.resourceGroup, + userContext.databaseAccount.name, + ); + return workspaces && workspaces.length > 0 && workspaces.some((workspace) => workspace.name === "default"); + } catch (error) { + Logger.logError(getErrorMessage(error), "Explorer/_containsDefaultNotebookWorkspace"); + return false; + } + } + + private _resetNotebookWorkspace = async () => { + useDialog.getState().closeDialog(); + const clearInProgressMessage = logConsoleProgress("Resetting notebook workspace"); + let connectionStatus: ContainerConnectionInfo; + try { + const notebookServerInfo = useNotebook.getState().notebookServerInfo; + if (!notebookServerInfo || !notebookServerInfo.notebookServerEndpoint) { + const error = "No server endpoint detected"; + Logger.logError(error, "NotebookContainerClient/resetWorkspace"); + logConsoleError(error); + return; + } + TelemetryProcessor.traceStart(Action.PhoenixResetWorkspace, { + dataExplorerArea: Areas.Notebook, + }); + if (useNotebook.getState().isPhoenixNotebooks) { + useTabs.getState().closeAllNotebookTabs(true); + connectionStatus = { + status: ConnectionStatusType.Connecting, + }; + useNotebook.getState().setConnectionInfo(connectionStatus); + } + const connectionInfo = await this.notebookManager?.notebookClient.resetWorkspace(); + if (connectionInfo?.status !== HttpStatusCodes.OK) { + throw new Error(`Reset Workspace: Received status code- ${connectionInfo?.status}`); + } + if (!connectionInfo?.data?.phoenixServiceUrl) { + throw new Error(`Reset Workspace: PhoenixServiceUrl is invalid!`); + } + if (useNotebook.getState().isPhoenixNotebooks) { + await this.setNotebookInfo(true, connectionInfo, connectionStatus); + useNotebook.getState().setIsRefreshed(!useNotebook.getState().isRefreshed); + } + logConsoleInfo("Successfully reset notebook workspace"); + TelemetryProcessor.traceSuccess(Action.PhoenixResetWorkspace, { + dataExplorerArea: Areas.Notebook, + }); + } catch (error) { + logConsoleError(`Failed to reset notebook workspace: ${error}`); + TelemetryProcessor.traceFailure(Action.PhoenixResetWorkspace, { + dataExplorerArea: Areas.Notebook, + error: getErrorMessage(error), + errorStack: getErrorStack(error), + }); + if (useNotebook.getState().isPhoenixNotebooks) { + connectionStatus = { + status: ConnectionStatusType.Failed, + }; + useNotebook.getState().resetContainerConnection(connectionStatus); + useNotebook.getState().setIsRefreshed(!useNotebook.getState().isRefreshed); + } + throw error; + } finally { + clearInProgressMessage(); + } + }; + private getDeltaDatabases( updatedDatabaseList: DataModels.Database[], databases: ViewModels.Database[], @@ -566,6 +696,406 @@ export default class Explorer { } } + public uploadFile( + name: string, + content: string, + parent: NotebookContentItem, + isGithubTree?: boolean, + ): Promise { + if (!useNotebook.getState().isNotebookEnabled || !this.notebookManager?.notebookContentClient) { + const error = "Attempt to upload notebook, but notebook is not enabled"; + handleError(error, "Explorer/uploadFile"); + throw new Error(error); + } + + const promise = this.notebookManager?.notebookContentClient.uploadFileAsync(name, content, parent, isGithubTree); + promise + .then(() => this.resourceTree.triggerRender()) + .catch((reason) => useDialog.getState().showOkModalDialog("Unable to upload file", getErrorMessage(reason))); + return promise; + } + + public async importAndOpen(path: string): Promise { + const name = NotebookUtil.getName(path); + const item = NotebookUtil.createNotebookContentItem(name, path, "file"); + const parent = this.resourceTree.myNotebooksContentRoot; + + if (parent && parent.children && useNotebook.getState().isNotebookEnabled && this.notebookManager?.notebookClient) { + const existingItem = _.find(parent.children, (node) => node.name === name); + if (existingItem) { + return this.openNotebook(existingItem); + } + + const content = await this.readFile(item); + const uploadedItem = await this.uploadFile(name, content, parent); + return this.openNotebook(uploadedItem); + } + + return Promise.resolve(false); + } + + public async importAndOpenContent(name: string, content: string): Promise { + const parent = this.resourceTree.myNotebooksContentRoot; + + if (parent && parent.children && useNotebook.getState().isNotebookEnabled && this.notebookManager?.notebookClient) { + if (this.notebookToImport && this.notebookToImport.name === name && this.notebookToImport.content === content) { + this.notebookToImport = undefined; // we don't want to try opening this notebook again + } + + const existingItem = _.find(parent.children, (node) => node.name === name); + if (existingItem) { + return this.openNotebook(existingItem); + } + + const uploadedItem = await this.uploadFile(name, content, parent); + return this.openNotebook(uploadedItem); + } + + this.notebookToImport = { name, content }; // we'll try opening this notebook later on + return Promise.resolve(false); + } + + public async publishNotebook( + name: string, + content: NotebookPaneContent, + notebookContentRef?: string, + onTakeSnapshot?: (request: SnapshotRequest) => void, + onClosePanel?: () => void, + ): Promise { + if (this.notebookManager) { + await this.notebookManager.openPublishNotebookPane( + name, + content, + notebookContentRef, + onTakeSnapshot, + onClosePanel, + ); + } + } + + public copyNotebook(name: string, content: string): void { + this.notebookManager?.openCopyNotebookPane(name, content); + } + + /** + * Note: To keep it simple, this creates a disconnected NotebookContentItem that is not connected to the resource tree. + * Connecting it to a tree possibly requires the intermediate missing folders if the item is nested in a subfolder. + * Manually creating the missing folders between the root and its parent dir would break the UX: expanding a folder + * will not fetch its content if the children array exists (and has only one child which was manually created). + * Fetching the intermediate folders possibly involves a few chained async calls which isn't ideal. + * + * @param name + * @param path + */ + public createNotebookContentItemFile(name: string, path: string): NotebookContentItem { + return NotebookUtil.createNotebookContentItem(name, path, "file"); + } + + public async openNotebook(notebookContentItem: NotebookContentItem): Promise { + if (!notebookContentItem || !notebookContentItem.path) { + throw new Error(`Invalid notebookContentItem: ${notebookContentItem}`); + } + if (notebookContentItem.type === NotebookContentItemType.Notebook && useNotebook.getState().isPhoenixNotebooks) { + await this.allocateContainer(PoolIdType.DefaultPoolId); + } + + const notebookTabs = useTabs + .getState() + .getTabs( + ViewModels.CollectionTabKind.NotebookV2, + (tab) => + (tab as NotebookV2Tab).notebookPath && + FileSystemUtil.isPathEqual((tab as NotebookV2Tab).notebookPath(), notebookContentItem.path), + ) as NotebookV2Tab[]; + let notebookTab = notebookTabs && notebookTabs[0]; + + if (notebookTab) { + useTabs.getState().activateTab(notebookTab); + } else { + const options: NotebookTabOptions = { + account: userContext.databaseAccount, + tabKind: ViewModels.CollectionTabKind.NotebookV2, + node: undefined, + title: notebookContentItem.name, + tabPath: notebookContentItem.path, + collection: undefined, + masterKey: userContext.masterKey || "", + isTabsContentExpanded: ko.observable(true), + onLoadStartKey: undefined, + container: this, + notebookContentItem, + }; + + try { + const NotebookTabV2 = await import(/* webpackChunkName: "NotebookV2Tab" */ "./Tabs/NotebookV2Tab"); + notebookTab = new NotebookTabV2.default(options); + useTabs.getState().activateNewTab(notebookTab); + } catch (reason) { + console.error("Import NotebookV2Tab failed!", reason); + return false; + } + } + + return true; + } + + public renameNotebook(notebookFile: NotebookContentItem, isGithubTree?: boolean): void { + if (!useNotebook.getState().isNotebookEnabled || !this.notebookManager?.notebookContentClient) { + const error = "Attempt to rename notebook, but notebook is not enabled"; + handleError(error, "Explorer/renameNotebook"); + throw new Error(error); + } + + // Don't delete if tab is open to avoid accidental deletion + const openedNotebookTabs = useTabs + .getState() + .getTabs(ViewModels.CollectionTabKind.NotebookV2, (tab: NotebookV2Tab) => { + return tab.notebookPath && FileSystemUtil.isPathEqual(tab.notebookPath(), notebookFile.path); + }); + if (openedNotebookTabs.length > 0) { + useDialog + .getState() + .showOkModalDialog("Unable to rename file", "This file is being edited. Please close the tab and try again."); + } else { + useSidePanel.getState().openSidePanel( + "Rename Notebook", + { + useSidePanel.getState().closeSidePanel(); + this.resourceTree.triggerRender(); + }} + inputLabel="Enter new notebook name" + submitButtonLabel="Rename" + errorMessage="Could not rename notebook" + inProgressMessage="Renaming notebook to" + successMessage="Renamed notebook to" + paneTitle="Rename Notebook" + defaultInput={FileSystemUtil.stripExtension(notebookFile.name, "ipynb")} + onSubmit={(notebookFile: NotebookContentItem, input: string): Promise => + this.notebookManager?.notebookContentClient.renameNotebook(notebookFile, input, isGithubTree) + } + notebookFile={notebookFile} + />, + ); + } + } + + public onCreateDirectory(parent: NotebookContentItem, isGithubTree?: boolean): void { + if (!useNotebook.getState().isNotebookEnabled || !this.notebookManager?.notebookContentClient) { + const error = "Attempt to create notebook directory, but notebook is not enabled"; + handleError(error, "Explorer/onCreateDirectory"); + throw new Error(error); + } + + useSidePanel.getState().openSidePanel( + "Create new directory", + { + useSidePanel.getState().closeSidePanel(); + this.resourceTree.triggerRender(); + }} + errorMessage="Could not create directory " + inProgressMessage="Creating directory " + successMessage="Created directory " + inputLabel="Enter new directory name" + paneTitle="Create new directory" + submitButtonLabel="Create" + defaultInput="" + onSubmit={(notebookFile: NotebookContentItem, input: string): Promise => + this.notebookManager?.notebookContentClient.createDirectory(notebookFile, input, isGithubTree) + } + notebookFile={parent} + />, + ); + } + + public readFile(notebookFile: NotebookContentItem): Promise { + if (!useNotebook.getState().isNotebookEnabled || !this.notebookManager?.notebookContentClient) { + const error = "Attempt to read file, but notebook is not enabled"; + handleError(error, "Explorer/downloadFile"); + throw new Error(error); + } + + return this.notebookManager?.notebookContentClient.readFileContent(notebookFile.path); + } + + public downloadFile(notebookFile: NotebookContentItem): Promise { + if (!useNotebook.getState().isNotebookEnabled || !this.notebookManager?.notebookContentClient) { + const error = "Attempt to download file, but notebook is not enabled"; + handleError(error, "Explorer/downloadFile"); + throw new Error(error); + } + + const clearMessage = NotificationConsoleUtils.logConsoleProgress(`Downloading ${notebookFile.path}`); + + return this.notebookManager?.notebookContentClient.readFileContent(notebookFile.path).then( + (content: string) => { + const blob = stringToBlob(content, "text/plain"); + if (navigator.msSaveBlob) { + // for IE and Edge + navigator.msSaveBlob(blob, notebookFile.name); + } else { + const downloadLink: HTMLAnchorElement = document.createElement("a"); + const url = URL.createObjectURL(blob); + downloadLink.href = url; + downloadLink.target = "_self"; + downloadLink.download = notebookFile.name; + + // for some reason, FF displays the download prompt only when + // the link is added to the dom so we add and remove it + document.body.appendChild(downloadLink); + downloadLink.click(); + downloadLink.remove(); + } + + clearMessage(); + }, + (error) => { + logConsoleError(`Could not download notebook ${getErrorMessage(error)}`); + clearMessage(); + }, + ); + } + + private refreshNotebookList = async (): Promise => { + if (!useNotebook.getState().isNotebookEnabled || !this.notebookManager?.notebookContentClient) { + return; + } + + await this.resourceTree.initialize(); + await useNotebook.getState().initializeNotebooksTree(this.notebookManager); + + this.notebookManager?.refreshPinnedRepos(); + if (this.notebookToImport) { + this.importAndOpenContent(this.notebookToImport.name, this.notebookToImport.content); + } + }; + + public deleteNotebookFile(item: NotebookContentItem, isGithubTree?: boolean): Promise { + if (!useNotebook.getState().isNotebookEnabled || !this.notebookManager?.notebookContentClient) { + const error = "Attempt to delete notebook file, but notebook is not enabled"; + handleError(error, "Explorer/deleteNotebookFile"); + throw new Error(error); + } + + // Don't delete if tab is open to avoid accidental deletion + const openedNotebookTabs = useTabs + .getState() + .getTabs(ViewModels.CollectionTabKind.NotebookV2, (tab: NotebookV2Tab) => { + return tab.notebookPath && FileSystemUtil.isPathEqual(tab.notebookPath(), item.path); + }); + if (openedNotebookTabs.length > 0) { + useDialog + .getState() + .showOkModalDialog("Unable to delete file", "This file is being edited. Please close the tab and try again."); + return Promise.reject(); + } + + if (item.type === NotebookContentItemType.Directory && item.children && item.children.length > 0) { + useDialog.getState().openDialog({ + isModal: true, + title: "Unable to delete file", + subText: "Directory is not empty.", + primaryButtonText: "Close", + secondaryButtonText: undefined, + onPrimaryButtonClick: () => useDialog.getState().closeDialog(), + onSecondaryButtonClick: undefined, + }); + return Promise.reject(); + } + + return this.notebookManager?.notebookContentClient.deleteContentItem(item, isGithubTree).then( + () => logConsoleInfo(`Successfully deleted: ${item.path}`), + (reason) => logConsoleError(`Failed to delete "${item.path}": ${JSON.stringify(reason)}`), + ); + } + + /** + * This creates a new notebook file, then opens the notebook + */ + public async onNewNotebookClicked(parent?: NotebookContentItem, isGithubTree?: boolean): Promise { + if (!useNotebook.getState().isNotebookEnabled || !this.notebookManager?.notebookContentClient) { + const error = "Attempt to create new notebook, but notebook is not enabled"; + handleError(error, "Explorer/onNewNotebookClicked"); + throw new Error(error); + } + if (useNotebook.getState().isPhoenixNotebooks) { + if (isGithubTree) { + await this.allocateContainer(PoolIdType.DefaultPoolId); + parent = parent || this.resourceTree.myNotebooksContentRoot; + this.createNewNoteBook(parent, isGithubTree); + } else { + useDialog.getState().showOkCancelModalDialog( + Notebook.newNotebookModalTitle, + undefined, + "Create", + async () => { + await this.allocateContainer(PoolIdType.DefaultPoolId); + parent = parent || this.resourceTree.myNotebooksContentRoot; + this.createNewNoteBook(parent, isGithubTree); + }, + "Cancel", + undefined, + this.getNewNoteWarningText(), + ); + } + } else { + parent = parent || this.resourceTree.myNotebooksContentRoot; + this.createNewNoteBook(parent, isGithubTree); + } + } + + private getNewNoteWarningText(): JSX.Element { + return ( + <> +

{Notebook.newNotebookModalContent1}

+
+

+ {Notebook.newNotebookModalContent2} + + {Notebook.learnMore} + +

+ + ); + } + + private createNewNoteBook(parent?: NotebookContentItem, isGithubTree?: boolean): void { + const clearInProgressMessage = logConsoleProgress(`Creating new notebook in ${parent.path}`); + const startKey: number = TelemetryProcessor.traceStart(Action.CreateNewNotebook, { + dataExplorerArea: Constants.Areas.Notebook, + }); + + this.notebookManager?.notebookContentClient + .createNewNotebookFile(parent, isGithubTree) + .then((newFile: NotebookContentItem) => { + logConsoleInfo(`Successfully created: ${newFile.name}`); + TelemetryProcessor.traceSuccess( + Action.CreateNewNotebook, + { + dataExplorerArea: Constants.Areas.Notebook, + }, + startKey, + ); + return this.openNotebook(newFile); + }) + .then(() => this.resourceTree.triggerRender()) + .catch((error) => { + const errorMessage = `Failed to create a new notebook: ${getErrorMessage(error)}`; + logConsoleError(errorMessage); + TelemetryProcessor.traceFailure( + Action.CreateNewNotebook, + { + dataExplorerArea: Constants.Areas.Notebook, + error: errorMessage, + errorStack: getErrorStack(error), + }, + startKey, + ); + }) + .finally(clearInProgressMessage); + } + // TODO: Delete this function when ResourceTreeAdapter is removed. public async refreshContentItem(item: NotebookContentItem): Promise { if (!useNotebook.getState().isNotebookEnabled || !this.notebookManager?.notebookContentClient) { @@ -722,6 +1252,32 @@ export default class Explorer { } } + public async handleOpenFileAction(path: string): Promise { + if (useNotebook.getState().isPhoenixNotebooks === undefined) { + await useNotebook.getState().getPhoenixStatus(); + } + if (useNotebook.getState().isPhoenixNotebooks) { + await this.allocateContainer(PoolIdType.DefaultPoolId); + } + + // We still use github urls like https://github.com/Azure-Samples/cosmos-notebooks/blob/master/CSharp_quickstarts/GettingStarted_CSharp.ipynb + // when launching a notebook quickstart from Portal. In future we should just use gallery id and use Juno to fetch instead of directly + // calling GitHub. For now convert this url to a raw url and download content. + const gitHubInfo = fromContentUri(path); + if (gitHubInfo) { + const rawUrl = toRawContentUri(gitHubInfo.owner, gitHubInfo.repo, gitHubInfo.branch, gitHubInfo.path); + const response = await fetch(rawUrl); + if (response.status === Constants.HttpStatusCodes.OK) { + this.notebookToImport = { + name: NotebookUtil.getName(path), + content: await response.text(), + }; + + this.importAndOpenContent(this.notebookToImport.name, this.notebookToImport.content); + } + } + } + public openUploadItemsPanePane(): void { useSidePanel.getState().openSidePanel("Upload " + getUploadName(), ); } @@ -731,6 +1287,54 @@ export default class Explorer { .openSidePanel("Input parameters", ); } + public openUploadFilePanel(parent?: NotebookContentItem): void { + if (useNotebook.getState().isPhoenixNotebooks) { + useDialog.getState().showOkCancelModalDialog( + Notebook.newNotebookUploadModalTitle, + undefined, + "Upload", + async () => { + await this.allocateContainer(PoolIdType.DefaultPoolId); + parent = parent || this.resourceTree.myNotebooksContentRoot; + this.uploadFilePanel(parent); + }, + "Cancel", + undefined, + this.getNewNoteWarningText(), + ); + } else { + parent = parent || this.resourceTree.myNotebooksContentRoot; + this.uploadFilePanel(parent); + } + } + + private uploadFilePanel(parent?: NotebookContentItem): void { + useSidePanel + .getState() + .openSidePanel( + "Upload file to notebook server", + this.uploadFile(name, content, parent)} />, + ); + } + + public getDownloadModalConent(fileName: string): JSX.Element { + if (useNotebook.getState().isPhoenixNotebooks) { + return ( + <> +

{Notebook.galleryNotebookDownloadContent1}

+
+

+ {Notebook.galleryNotebookDownloadContent2} + + {Notebook.learnMore} + +

+ + ); + } + return

Download {fileName} from gallery as a copy to your notebooks to run and/or edit the notebook.

; + } + public async refreshExplorer(): Promise { if (userContext.apiType !== "Postgres" && userContext.apiType !== "VCoreMongo") { userContext.authType === AuthType.ResourceToken @@ -755,6 +1359,10 @@ export default class Explorer { dataExplorerArea: Constants.Areas.Notebook, }); + if (useNotebook.getState().isPhoenixNotebooks) { + await this.initNotebooks(userContext.databaseAccount); + } + await this.refreshSampleData(); } diff --git a/src/Explorer/Notebook/NotebookManager.tsx b/src/Explorer/Notebook/NotebookManager.tsx index 45afe6061..3ccbefcaf 100644 --- a/src/Explorer/Notebook/NotebookManager.tsx +++ b/src/Explorer/Notebook/NotebookManager.tsx @@ -12,13 +12,15 @@ import * as Logger from "../../Common/Logger"; import { GitHubClient } from "../../GitHub/GitHubClient"; import { GitHubContentProvider } from "../../GitHub/GitHubContentProvider"; import { GitHubOAuthService } from "../../GitHub/GitHubOAuthService"; +import { useSidePanel } from "../../hooks/useSidePanel"; import { JunoClient } from "../../Juno/JunoClient"; import { Action, ActionModifiers } from "../../Shared/Telemetry/TelemetryConstants"; import * as TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor"; +import { userContext } from "../../UserContext"; import { getFullName } from "../../Utils/UserUtils"; -import { useSidePanel } from "../../hooks/useSidePanel"; import { useDialog } from "../Controls/Dialog"; import Explorer from "../Explorer"; +import { CopyNotebookPane } from "../Panes/CopyNotebookPane/CopyNotebookPane"; import { GitHubReposPanel } from "../Panes/GitHubReposPanel/GitHubReposPanel"; import { PublishNotebookPane } from "../Panes/PublishNotebookPane/PublishNotebookPane"; import { ResourceTreeAdapter } from "../Tree/ResourceTreeAdapter"; @@ -38,6 +40,7 @@ export interface NotebookManagerOptions { container: Explorer; resourceTree: ResourceTreeAdapter; refreshCommandBarButtons: () => void; + refreshNotebookList: () => void; } export default class NotebookManager { @@ -78,6 +81,10 @@ export default class NotebookManager { contents.JupyterContentProvider, ); + this.notebookClient = new NotebookContainerClient(() => + this.params.container.initNotebooks(userContext?.databaseAccount), + ); + this.notebookContentClient = new NotebookContentClient(this.notebookContentProvider); this.gitHubOAuthService.getTokenObservable().subscribe((token) => { @@ -99,9 +106,11 @@ export default class NotebookManager { } this.params.refreshCommandBarButtons(); + this.params.refreshNotebookList(); }); this.junoClient.subscribeToPinnedRepos((pinnedRepos) => { + this.params.resourceTree.initializeGitHubRepos(pinnedRepos); this.params.resourceTree.triggerRender(); useNotebook.getState().initializeGitHubRepos(pinnedRepos); }); @@ -140,6 +149,22 @@ export default class NotebookManager { ); } + public openCopyNotebookPane(name: string, content: string): void { + const { container } = this.params; + useSidePanel + .getState() + .openSidePanel( + "Copy Notebook", + , + ); + } + // Octokit's error handler uses any // eslint-disable-next-line @typescript-eslint/no-explicit-any private onGitHubClientError = (error: any): void => { diff --git a/src/Explorer/OpenActions/OpenActions.tsx b/src/Explorer/OpenActions/OpenActions.tsx index e2059cdba..f3ef288c8 100644 --- a/src/Explorer/OpenActions/OpenActions.tsx +++ b/src/Explorer/OpenActions/OpenActions.tsx @@ -195,5 +195,17 @@ export function handleOpenAction( return true; } + if ( + action.actionType === ActionContracts.ActionType.OpenSampleNotebook || + action.actionType === ActionContracts.ActionType[ActionContracts.ActionType.OpenSampleNotebook] + ) { + openFile(action as ActionContracts.OpenSampleNotebook, explorer); + return true; + } + return false; } + +function openFile(action: ActionContracts.OpenSampleNotebook, explorer: Explorer) { + explorer.handleOpenFileAction(decodeURIComponent(action.path)); +} diff --git a/src/Explorer/Panes/CopyNotebookPane/CopyNotebookPane.tsx b/src/Explorer/Panes/CopyNotebookPane/CopyNotebookPane.tsx new file mode 100644 index 000000000..0f7927b3b --- /dev/null +++ b/src/Explorer/Panes/CopyNotebookPane/CopyNotebookPane.tsx @@ -0,0 +1,154 @@ +import { IDropdownOption } from "@fluentui/react"; +import React, { FormEvent, FunctionComponent, useEffect, useState } from "react"; +import { HttpStatusCodes, PoolIdType } from "../../../Common/Constants"; +import { getErrorMessage, handleError } from "../../../Common/ErrorHandlingUtils"; +import { GitHubOAuthService } from "../../../GitHub/GitHubOAuthService"; +import { IPinnedRepo, JunoClient } from "../../../Juno/JunoClient"; +import * as GitHubUtils from "../../../Utils/GitHubUtils"; +import * as NotificationConsoleUtils from "../../../Utils/NotificationConsoleUtils"; +import { useSidePanel } from "../../../hooks/useSidePanel"; +import Explorer from "../../Explorer"; +import { NotebookContentItem, NotebookContentItemType } from "../../Notebook/NotebookContentItem"; +import { useNotebook } from "../../Notebook/useNotebook"; +import { RightPaneForm, RightPaneFormProps } from "../RightPaneForm/RightPaneForm"; +import { CopyNotebookPaneComponent, CopyNotebookPaneProps } from "./CopyNotebookPaneComponent"; + +interface Location { + type: "MyNotebooks" | "GitHub"; + + // GitHub + owner?: string; + repo?: string; + branch?: string; +} +export interface CopyNotebookPanelProps { + name: string; + content: string; + container: Explorer; + junoClient: JunoClient; + gitHubOAuthService: GitHubOAuthService; +} + +export const CopyNotebookPane: FunctionComponent = ({ + name, + content, + container, + junoClient, + gitHubOAuthService, +}: CopyNotebookPanelProps) => { + const closeSidePanel = useSidePanel((state) => state.closeSidePanel); + const [isExecuting, setIsExecuting] = useState(); + const [formError, setFormError] = useState(""); + const [pinnedRepos, setPinnedRepos] = useState(); + const [selectedLocation, setSelectedLocation] = useState(); + + useEffect(() => { + open(); + }, []); + + const open = async (): Promise => { + if (gitHubOAuthService.isLoggedIn()) { + const response = await junoClient.getPinnedRepos(gitHubOAuthService.getTokenObservable()()?.scope); + if (response.status !== HttpStatusCodes.OK && response.status !== HttpStatusCodes.NoContent) { + handleError(`Received HTTP ${response.status} when fetching pinned repos`, "CopyNotebookPaneAdapter/submit"); + } + + if (response.data?.length > 0) { + setPinnedRepos(response.data); + } + } + }; + + const submit = async (): Promise => { + let destination: string = selectedLocation?.type; + let clearMessage: () => void; + setIsExecuting(true); + + try { + if (!selectedLocation) { + throw new Error(`No location selected`); + } + + if (selectedLocation.type === "GitHub") { + destination = `${destination} - ${GitHubUtils.toRepoFullName( + selectedLocation.owner, + selectedLocation.repo, + )} - ${selectedLocation.branch}`; + } else if (selectedLocation.type === "MyNotebooks" && useNotebook.getState().isPhoenixNotebooks) { + destination = useNotebook.getState().notebookFolderName; + } + + clearMessage = NotificationConsoleUtils.logConsoleProgress(`Copying ${name} to ${destination}`); + + const notebookContentItem = await copyNotebook(selectedLocation); + if (!notebookContentItem) { + throw new Error(`Failed to upload ${name}`); + } + + NotificationConsoleUtils.logConsoleInfo(`Successfully copied ${name} to ${destination}`); + closeSidePanel(); + } catch (error) { + const errorMessage = getErrorMessage(error); + setFormError(`Failed to copy ${name} to ${destination}`); + handleError(errorMessage, "CopyNotebookPaneAdapter/submit", formError); + } finally { + clearMessage && clearMessage(); + setIsExecuting(false); + } + }; + + const copyNotebook = async (location: Location): Promise => { + let parent: NotebookContentItem; + let isGithubTree: boolean; + switch (location.type) { + case "MyNotebooks": + parent = { + name: useNotebook.getState().notebookFolderName, + path: useNotebook.getState().notebookBasePath, + type: NotebookContentItemType.Directory, + }; + isGithubTree = false; + if (useNotebook.getState().isPhoenixNotebooks) { + await container.allocateContainer(PoolIdType.DefaultPoolId); + } + break; + + case "GitHub": + parent = { + name: selectedLocation.branch, + path: GitHubUtils.toContentUri(selectedLocation.owner, selectedLocation.repo, selectedLocation.branch, ""), + type: NotebookContentItemType.Directory, + }; + isGithubTree = true; + break; + + default: + throw new Error(`Unsupported location type ${location.type}`); + } + + return container.uploadFile(name, content, parent, isGithubTree); + }; + + const onDropDownChange = (_: FormEvent, option?: IDropdownOption): void => { + setSelectedLocation(option?.data); + }; + + const props: RightPaneFormProps = { + formError, + isExecuting: isExecuting, + submitButtonText: "OK", + onSubmit: () => submit(), + }; + + const copyNotebookPaneProps: CopyNotebookPaneProps = { + name, + pinnedRepos, + onDropDownChange: onDropDownChange, + }; + + return ( + + + + ); +}; diff --git a/src/Explorer/Panes/CopyNotebookPane/CopyNotebookPaneComponent.tsx b/src/Explorer/Panes/CopyNotebookPane/CopyNotebookPaneComponent.tsx new file mode 100644 index 000000000..5cd0cfdc1 --- /dev/null +++ b/src/Explorer/Panes/CopyNotebookPane/CopyNotebookPaneComponent.tsx @@ -0,0 +1,120 @@ +import { + Dropdown, + IDropdownOption, + IDropdownProps, + IRenderFunction, + ISelectableOption, + Label, + SelectableOptionMenuItemType, + Stack, + Text, +} from "@fluentui/react"; +import { GitHubReposTitle } from "Explorer/Tree/ResourceTree"; +import React, { FormEvent, FunctionComponent } from "react"; +import { IPinnedRepo } from "../../../Juno/JunoClient"; +import * as GitHubUtils from "../../../Utils/GitHubUtils"; +import { useNotebook } from "../../Notebook/useNotebook"; + +interface Location { + type: "MyNotebooks" | "GitHub"; + + // GitHub + owner?: string; + repo?: string; + branch?: string; +} + +export interface CopyNotebookPaneProps { + name: string; + pinnedRepos: IPinnedRepo[]; + onDropDownChange: (_: FormEvent, option?: IDropdownOption) => void; +} + +export const CopyNotebookPaneComponent: FunctionComponent = ({ + name, + pinnedRepos, + onDropDownChange, +}: CopyNotebookPaneProps) => { + const BranchNameWhiteSpace = " "; + + const onRenderDropDownTitle: IRenderFunction = (options: IDropdownOption[]): JSX.Element => { + return {options.length && options[0].title}; + }; + + const onRenderDropDownOption: IRenderFunction = (option: ISelectableOption): JSX.Element => { + return {option.text}; + }; + + const getDropDownOptions = (): IDropdownOption[] => { + const options: IDropdownOption[] = []; + options.push({ + key: "MyNotebooks-Item", + text: useNotebook.getState().notebookFolderName, + title: useNotebook.getState().notebookFolderName, + data: { + type: "MyNotebooks", + } as Location, + }); + + if (pinnedRepos && pinnedRepos.length > 0) { + options.push({ + key: "GitHub-Header-Divider", + text: undefined, + itemType: SelectableOptionMenuItemType.Divider, + }); + + options.push({ + key: "GitHub-Header", + text: GitHubReposTitle, + itemType: SelectableOptionMenuItemType.Header, + }); + + pinnedRepos.forEach((pinnedRepo) => { + const repoFullName = GitHubUtils.toRepoFullName(pinnedRepo.owner, pinnedRepo.name); + options.push({ + key: `GitHub-Repo-${repoFullName}`, + text: repoFullName, + disabled: true, + }); + + pinnedRepo.branches.forEach((branch) => + options.push({ + key: `GitHub-Repo-${repoFullName}-${branch.name}`, + text: `${BranchNameWhiteSpace}${branch.name}`, + title: `${repoFullName} - ${branch.name}`, + data: { + type: "GitHub", + owner: pinnedRepo.owner, + repo: pinnedRepo.name, + branch: branch.name, + } as Location, + }), + ); + }); + } + + return options; + }; + const dropDownProps: IDropdownProps = { + label: "Location", + ariaLabel: "Location", + placeholder: "Select an option", + onRenderTitle: onRenderDropDownTitle, + onRenderOption: onRenderDropDownOption, + options: getDropDownOptions(), + onChange: onDropDownChange, + }; + + return ( +
+ + + + {name} + + + + +
+ ); +}; diff --git a/src/Explorer/Panes/GitHubReposPanel/__snapshots__/GitHubReposPanel.test.tsx.snap b/src/Explorer/Panes/GitHubReposPanel/__snapshots__/GitHubReposPanel.test.tsx.snap index aa2137c42..4a3a8942e 100644 --- a/src/Explorer/Panes/GitHubReposPanel/__snapshots__/GitHubReposPanel.test.tsx.snap +++ b/src/Explorer/Panes/GitHubReposPanel/__snapshots__/GitHubReposPanel.test.tsx.snap @@ -17,6 +17,8 @@ exports[`GitHub Repos Panel should render Default properly 1`] = ` addRepoProps={ Object { "container": Explorer { + "_isInitializingNotebooks": false, + "_resetNotebookWorkspace": [Function], "isFixedCollectionWithSharedThroughputSupported": [Function], "isTabsContentExpanded": [Function], "onRefreshDatabasesKeyPress": [Function], @@ -33,8 +35,10 @@ exports[`GitHub Repos Panel should render Default properly 1`] = ` "queriesClient": QueriesClient { "container": [Circular], }, + "refreshNotebookList": [Function], "resourceTree": ResourceTreeAdapter { "container": [Circular], + "copyNotebook": [Function], "parameters": [Function], }, }, diff --git a/src/Explorer/Panes/StringInputPane/__snapshots__/StringInputPane.test.tsx.snap b/src/Explorer/Panes/StringInputPane/__snapshots__/StringInputPane.test.tsx.snap index 566808f4b..8054abe19 100644 --- a/src/Explorer/Panes/StringInputPane/__snapshots__/StringInputPane.test.tsx.snap +++ b/src/Explorer/Panes/StringInputPane/__snapshots__/StringInputPane.test.tsx.snap @@ -7,6 +7,8 @@ exports[`StringInput Pane should render Create new directory properly 1`] = ` errorMessage="Could not create directory " explorer={ Explorer { + "_isInitializingNotebooks": false, + "_resetNotebookWorkspace": [Function], "isFixedCollectionWithSharedThroughputSupported": [Function], "isTabsContentExpanded": [Function], "onRefreshDatabasesKeyPress": [Function], @@ -23,8 +25,10 @@ exports[`StringInput Pane should render Create new directory properly 1`] = ` "queriesClient": QueriesClient { "container": [Circular], }, + "refreshNotebookList": [Function], "resourceTree": ResourceTreeAdapter { "container": [Circular], + "copyNotebook": [Function], "parameters": [Function], }, } diff --git a/src/Explorer/QueryCopilot/__snapshots__/QueryCopilotTab.test.tsx.snap b/src/Explorer/QueryCopilot/__snapshots__/QueryCopilotTab.test.tsx.snap index 6d875fe2e..26b52ff90 100644 --- a/src/Explorer/QueryCopilot/__snapshots__/QueryCopilotTab.test.tsx.snap +++ b/src/Explorer/QueryCopilot/__snapshots__/QueryCopilotTab.test.tsx.snap @@ -22,6 +22,8 @@ exports[`Query copilot tab snapshot test should render with initial input 1`] = databaseId="CopilotSampleDb" explorer={ Explorer { + "_isInitializingNotebooks": false, + "_resetNotebookWorkspace": [Function], "isFixedCollectionWithSharedThroughputSupported": [Function], "isTabsContentExpanded": [Function], "onRefreshDatabasesKeyPress": [Function], @@ -38,8 +40,10 @@ exports[`Query copilot tab snapshot test should render with initial input 1`] = "queriesClient": QueriesClient { "container": [Circular], }, + "refreshNotebookList": [Function], "resourceTree": ResourceTreeAdapter { "container": [Circular], + "copyNotebook": [Function], "parameters": [Function], }, } diff --git a/src/Explorer/SplashScreen/SplashScreen.tsx b/src/Explorer/SplashScreen/SplashScreen.tsx index e9477e088..f4ebb9cd0 100644 --- a/src/Explorer/SplashScreen/SplashScreen.tsx +++ b/src/Explorer/SplashScreen/SplashScreen.tsx @@ -25,9 +25,11 @@ import * as React from "react"; import ConnectIcon from "../../../images/Connect_color.svg"; import ContainersIcon from "../../../images/Containers.svg"; import LinkIcon from "../../../images/Link_blue.svg"; +import NotebookColorIcon from "../../../images/Notebooks.svg"; import PowerShellIcon from "../../../images/PowerShell.svg"; import CopilotIcon from "../../../images/QueryCopilotNewLogo.svg"; import QuickStartIcon from "../../../images/Quickstart_Lightning.svg"; +import NotebookIcon from "../../../images/notebook/Notebook-resource.svg"; import CollectionIcon from "../../../images/tree-collection.svg"; import * as Constants from "../../Common/Constants"; import { userContext } from "../../UserContext"; @@ -408,6 +410,14 @@ export class SplashScreen extends React.Component { }, }; heroes.push(launchQuickstartBtn); + } else if (useNotebook.getState().isPhoenixNotebooks) { + const newNotebookBtn = { + iconSrc: NotebookColorIcon, + title: "New notebook", + description: "Visualize your data stored in Azure Cosmos DB", + onClick: () => this.container.onNewNotebookClicked(), + }; + heroes.push(newNotebookBtn); } heroes.push(this.getShellCard()); @@ -483,12 +493,28 @@ export class SplashScreen extends React.Component { }; } + private decorateOpenNotebookActivity({ name, path }: MostRecentActivity.OpenNotebookItem) { + return { + info: path, + iconSrc: NotebookIcon, + title: name, + description: "Notebook", + onClick: () => { + const notebookItem = this.container.createNotebookContentItemFile(name, path); + notebookItem && this.container.openNotebook(notebookItem); + }, + }; + } + private createRecentItems(): SplashScreenItem[] { return MostRecentActivity.mostRecentActivity.getItems(userContext.databaseAccount?.id).map((activity) => { switch (activity.type) { default: { - throw new Error(`Unknown activity: ${activity}`); + const unknownActivity: never = activity; + throw new Error(`Unknown activity: ${unknownActivity}`); } + case MostRecentActivity.Type.OpenNotebook: + return this.decorateOpenNotebookActivity(activity); case MostRecentActivity.Type.OpenCollection: return this.decorateOpenCollectionActivity(activity); diff --git a/src/Explorer/Tabs/NotebookV2Tab.ts b/src/Explorer/Tabs/NotebookV2Tab.ts index fadb43258..92e0e6958 100644 --- a/src/Explorer/Tabs/NotebookV2Tab.ts +++ b/src/Explorer/Tabs/NotebookV2Tab.ts @@ -1,11 +1,31 @@ +import { stringifyNotebook, toJS } from "@nteract/commutable"; import * as ko from "knockout"; import * as Q from "q"; +import { userContext } from "UserContext"; +import ClearAllOutputsIcon from "../../../images/notebook/Notebook-clear-all-outputs.svg"; +import CopyIcon from "../../../images/notebook/Notebook-copy.svg"; +import CutIcon from "../../../images/notebook/Notebook-cut.svg"; +import NewCellIcon from "../../../images/notebook/Notebook-insert-cell.svg"; +import PasteIcon from "../../../images/notebook/Notebook-paste.svg"; +import RestartIcon from "../../../images/notebook/Notebook-restart.svg"; +import RunAllIcon from "../../../images/notebook/Notebook-run-all.svg"; +import RunIcon from "../../../images/notebook/Notebook-run.svg"; +import { default as InterruptKernelIcon, default as KillKernelIcon } from "../../../images/notebook/Notebook-stop.svg"; +import SaveIcon from "../../../images/save-cosmos.svg"; +import { useNotebookSnapshotStore } from "../../hooks/useNotebookSnapshotStore"; +import { Action, ActionModifiers, Source } from "../../Shared/Telemetry/TelemetryConstants"; +import * as TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor"; import * as NotebookConfigurationUtils from "../../Utils/NotebookConfigurationUtils"; import { logConsoleInfo } from "../../Utils/NotificationConsoleUtils"; import { CommandButtonComponentProps } from "../Controls/CommandButton/CommandButtonComponent"; import { useDialog } from "../Controls/Dialog"; +import * as CommandBarComponentButtonFactory from "../Menus/CommandBar/CommandBarComponentButtonFactory"; +import { KernelSpecsDisplay } from "../Notebook/NotebookClientV2"; +import * as CdbActions from "../Notebook/NotebookComponent/actions"; import { NotebookComponentAdapter } from "../Notebook/NotebookComponent/NotebookComponentAdapter"; +import { CdbAppState, SnapshotRequest } from "../Notebook/NotebookComponent/types"; import { NotebookContentItem } from "../Notebook/NotebookContentItem"; +import { NotebookUtil } from "../Notebook/NotebookUtil"; import { useNotebook } from "../Notebook/useNotebook"; import NotebookTabBase, { NotebookTabBaseOptions } from "./NotebookTabBase"; @@ -70,7 +90,275 @@ export default class NotebookTabV2 extends NotebookTabBase { } protected getTabsButtons(): CommandButtonComponentProps[] { - return []; + const availableKernels = NotebookTabV2.clientManager.getAvailableKernelSpecs(); + const isNotebookUntrusted = this.notebookComponentAdapter.isNotebookUntrusted(); + + const runBtnTooltip = isNotebookUntrusted ? NotebookUtil.UntrustedNotebookRunHint : undefined; + + const saveLabel = "Save"; + const copyToLabel = "Copy to ..."; + const publishLabel = "Publish to gallery"; + const kernelLabel = "No Kernel"; + const runLabel = "Run"; + const runActiveCellLabel = "Run Active Cell"; + const runAllLabel = "Run All"; + const interruptKernelLabel = "Interrupt Kernel"; + const killKernelLabel = "Halt Kernel"; + const restartKernelLabel = "Restart Kernel"; + const clearLabel = "Clear outputs"; + const newCellLabel = "New Cell"; + const cellTypeLabel = "Cell Type"; + const codeLabel = "Code"; + const markdownLabel = "Markdown"; + const rawLabel = "Raw"; + const copyLabel = "Copy"; + const cutLabel = "Cut"; + const pasteLabel = "Paste"; + const cellCodeType = "code"; + const cellMarkdownType = "markdown"; + const cellRawType = "raw"; + + const saveButtonChildren = []; + if (this.container.notebookManager?.gitHubOAuthService.isLoggedIn()) { + saveButtonChildren.push({ + iconName: copyToLabel, + onCommandClick: () => this.copyNotebook(), + commandButtonLabel: copyToLabel, + hasPopup: false, + disabled: false, + ariaLabel: copyToLabel, + }); + } + + if (userContext.features.publicGallery) { + saveButtonChildren.push({ + iconName: "PublishContent", + onCommandClick: async () => await this.publishToGallery(), + commandButtonLabel: publishLabel, + hasPopup: false, + disabled: false, + ariaLabel: publishLabel, + }); + } + + let buttons: CommandButtonComponentProps[] = [ + { + iconSrc: SaveIcon, + iconAlt: saveLabel, + onCommandClick: () => this.notebookComponentAdapter.notebookSave(), + commandButtonLabel: saveLabel, + hasPopup: false, + disabled: false, + ariaLabel: saveLabel, + children: saveButtonChildren.length && [ + { + iconName: "Save", + onCommandClick: () => this.notebookComponentAdapter.notebookSave(), + commandButtonLabel: saveLabel, + hasPopup: false, + disabled: false, + ariaLabel: saveLabel, + }, + ...saveButtonChildren, + ], + }, + { + iconSrc: null, + iconAlt: kernelLabel, + onCommandClick: () => {}, + commandButtonLabel: null, + hasPopup: false, + disabled: availableKernels.length < 1, + isDropdown: true, + dropdownPlaceholder: kernelLabel, + dropdownSelectedKey: this.notebookComponentAdapter.getSelectedKernelName(), //this.currentKernelName, + dropdownWidth: 100, + children: availableKernels.map( + (kernel: KernelSpecsDisplay) => + ({ + iconSrc: null, + iconAlt: kernel.displayName, + onCommandClick: () => this.notebookComponentAdapter.notebookChangeKernel(kernel.name), + commandButtonLabel: kernel.displayName, + dropdownItemKey: kernel.name, + hasPopup: false, + disabled: false, + ariaLabel: kernel.displayName, + }) as CommandButtonComponentProps, + ), + ariaLabel: kernelLabel, + }, + { + iconSrc: RunIcon, + iconAlt: runLabel, + onCommandClick: () => { + this.notebookComponentAdapter.notebookRunAndAdvance(); + this.traceTelemetry(Action.ExecuteCell); + }, + commandButtonLabel: runLabel, + tooltipText: runBtnTooltip, + ariaLabel: runLabel, + hasPopup: false, + disabled: isNotebookUntrusted, + children: [ + { + iconSrc: RunIcon, + iconAlt: runActiveCellLabel, + onCommandClick: () => { + this.notebookComponentAdapter.notebookRunAndAdvance(); + this.traceTelemetry(Action.ExecuteCell); + }, + commandButtonLabel: runActiveCellLabel, + hasPopup: false, + disabled: false, + ariaLabel: runActiveCellLabel, + }, + { + iconSrc: RunAllIcon, + iconAlt: runAllLabel, + onCommandClick: () => { + this.notebookComponentAdapter.notebookRunAll(); + this.traceTelemetry(Action.ExecuteAllCells); + }, + commandButtonLabel: runAllLabel, + hasPopup: false, + disabled: false, + ariaLabel: runAllLabel, + }, + { + iconSrc: InterruptKernelIcon, + iconAlt: interruptKernelLabel, + onCommandClick: () => this.notebookComponentAdapter.notebookInterruptKernel(), + commandButtonLabel: interruptKernelLabel, + hasPopup: false, + disabled: false, + ariaLabel: interruptKernelLabel, + }, + { + iconSrc: KillKernelIcon, + iconAlt: killKernelLabel, + onCommandClick: () => this.notebookComponentAdapter.notebookKillKernel(), + commandButtonLabel: killKernelLabel, + hasPopup: false, + disabled: false, + ariaLabel: killKernelLabel, + }, + { + iconSrc: RestartIcon, + iconAlt: restartKernelLabel, + onCommandClick: () => this.notebookComponentAdapter.notebookRestartKernel(), + commandButtonLabel: restartKernelLabel, + hasPopup: false, + disabled: false, + ariaLabel: restartKernelLabel, + }, + ], + }, + { + iconSrc: ClearAllOutputsIcon, + iconAlt: clearLabel, + onCommandClick: () => this.notebookComponentAdapter.notebookClearAllOutputs(), + commandButtonLabel: clearLabel, + hasPopup: false, + disabled: false, + ariaLabel: clearLabel, + }, + { + iconSrc: NewCellIcon, + iconAlt: newCellLabel, + onCommandClick: () => this.notebookComponentAdapter.notebookInsertBelow(), + commandButtonLabel: newCellLabel, + ariaLabel: newCellLabel, + hasPopup: false, + disabled: false, + }, + CommandBarComponentButtonFactory.createDivider(), + { + iconSrc: null, + iconAlt: null, + onCommandClick: () => {}, + commandButtonLabel: null, + ariaLabel: cellTypeLabel, + hasPopup: false, + disabled: false, + isDropdown: true, + dropdownPlaceholder: cellTypeLabel, + dropdownSelectedKey: this.notebookComponentAdapter.getActiveCellTypeStr(), + dropdownWidth: 110, + children: [ + { + iconSrc: null, + iconAlt: null, + onCommandClick: () => this.notebookComponentAdapter.notebookChangeCellType(cellCodeType), + commandButtonLabel: codeLabel, + ariaLabel: codeLabel, + dropdownItemKey: cellCodeType, + hasPopup: false, + disabled: false, + }, + { + iconSrc: null, + iconAlt: null, + onCommandClick: () => this.notebookComponentAdapter.notebookChangeCellType(cellMarkdownType), + commandButtonLabel: markdownLabel, + ariaLabel: markdownLabel, + dropdownItemKey: cellMarkdownType, + hasPopup: false, + disabled: false, + }, + { + iconSrc: null, + iconAlt: null, + onCommandClick: () => this.notebookComponentAdapter.notebookChangeCellType(cellRawType), + commandButtonLabel: rawLabel, + ariaLabel: rawLabel, + dropdownItemKey: cellRawType, + hasPopup: false, + disabled: false, + }, + ], + }, + { + iconSrc: CopyIcon, + iconAlt: copyLabel, + onCommandClick: () => this.notebookComponentAdapter.notebokCopy(), + commandButtonLabel: copyLabel, + ariaLabel: copyLabel, + hasPopup: false, + disabled: false, + children: [ + { + iconSrc: CopyIcon, + iconAlt: copyLabel, + onCommandClick: () => this.notebookComponentAdapter.notebokCopy(), + commandButtonLabel: copyLabel, + ariaLabel: copyLabel, + hasPopup: false, + disabled: false, + }, + { + iconSrc: CutIcon, + iconAlt: cutLabel, + onCommandClick: () => this.notebookComponentAdapter.notebookCut(), + commandButtonLabel: cutLabel, + ariaLabel: cutLabel, + hasPopup: false, + disabled: false, + }, + { + iconSrc: PasteIcon, + iconAlt: pasteLabel, + onCommandClick: () => this.notebookComponentAdapter.notebookPaste(), + commandButtonLabel: pasteLabel, + ariaLabel: pasteLabel, + hasPopup: false, + disabled: false, + }, + ], + }, + // TODO: Uncomment when undo/redo is reimplemented in nteract + ]; + return buttons; } protected buildCommandBarOptions(): void { @@ -94,4 +382,50 @@ export default class NotebookTabV2 extends NotebookTabBase { sparkClusterConnectionInfo, ); } + + private publishToGallery = async () => { + TelemetryProcessor.trace(Action.NotebooksGalleryClickPublishToGallery, ActionModifiers.Mark, { + source: Source.CommandBarMenu, + }); + + const notebookReduxStore = NotebookTabV2.clientManager.getStore(); + const unsubscribe = notebookReduxStore.subscribe(() => { + const cdbState = (notebookReduxStore.getState() as CdbAppState).cdb; + useNotebookSnapshotStore.setState({ + snapshot: cdbState.notebookSnapshot?.imageSrc, + error: cdbState.notebookSnapshotError, + }); + }); + + const notebookContent = this.notebookComponentAdapter.getContent(); + const notebookContentRef = this.notebookComponentAdapter.contentRef; + const onPanelClose = (): void => { + unsubscribe(); + useNotebookSnapshotStore.setState({ + snapshot: undefined, + error: undefined, + }); + notebookReduxStore.dispatch(CdbActions.takeNotebookSnapshot(undefined)); + }; + + await this.container.publishNotebook( + notebookContent.name, + notebookContent.content, + notebookContentRef, + (request: SnapshotRequest) => notebookReduxStore.dispatch(CdbActions.takeNotebookSnapshot(request)), + onPanelClose, + ); + }; + + private copyNotebook = () => { + const notebookContent = this.notebookComponentAdapter.getContent(); + let content: string; + if (typeof notebookContent.content === "string") { + content = notebookContent.content; + } else { + content = stringifyNotebook(toJS(notebookContent.content)); + } + + this.container.copyNotebook(notebookContent.name, content); + }; } diff --git a/src/Explorer/Tree/ResourceTree.tsx b/src/Explorer/Tree/ResourceTree.tsx index 0933eac7b..b5f759534 100644 --- a/src/Explorer/Tree/ResourceTree.tsx +++ b/src/Explorer/Tree/ResourceTree.tsx @@ -1,23 +1,42 @@ +import { Callout, DirectionalHint, ICalloutProps, ILinkProps, Link, Stack, Text } from "@fluentui/react"; import { SampleDataTree } from "Explorer/Tree/SampleDataTree"; import { getItemName } from "Utils/APITypeUtils"; import { useQueryCopilot } from "hooks/useQueryCopilot"; import * as React from "react"; import shallow from "zustand/shallow"; import CosmosDBIcon from "../../../images/Azure-Cosmos-DB.svg"; +import GalleryIcon from "../../../images/GalleryIcon.svg"; +import DeleteIcon from "../../../images/delete.svg"; +import CopyIcon from "../../../images/notebook/Notebook-copy.svg"; +import NewNotebookIcon from "../../../images/notebook/Notebook-new.svg"; +import NotebookIcon from "../../../images/notebook/Notebook-resource.svg"; +import FileIcon from "../../../images/notebook/file-cosmos.svg"; +import PublishIcon from "../../../images/notebook/publish_content.svg"; +import RefreshIcon from "../../../images/refresh-cosmos.svg"; import CollectionIcon from "../../../images/tree-collection.svg"; +import { Areas, ConnectionStatusType, Notebook } from "../../Common/Constants"; import { isPublicInternetAccessAllowed } from "../../Common/DatabaseAccountUtility"; import * as DataModels from "../../Contracts/DataModels"; import * as ViewModels from "../../Contracts/ViewModels"; +import { LocalStorageUtility, StorageKey } from "../../Shared/StorageUtility"; +import { Action, ActionModifiers, Source } from "../../Shared/Telemetry/TelemetryConstants"; +import * as TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor"; import { userContext } from "../../UserContext"; import { isServerlessAccount } from "../../Utils/CapabilityUtils"; +import * as GitHubUtils from "../../Utils/GitHubUtils"; +import { useSidePanel } from "../../hooks/useSidePanel"; import { useTabs } from "../../hooks/useTabs"; import * as ResourceTreeContextMenuButtonFactory from "../ContextMenuButtonFactory"; import { AccordionComponent, AccordionItemComponent } from "../Controls/Accordion/AccordionComponent"; -import { TreeComponent, TreeNode } from "../Controls/TreeComponent/TreeComponent"; +import { useDialog } from "../Controls/Dialog"; +import { TreeComponent, TreeNode, TreeNodeMenuItem } from "../Controls/TreeComponent/TreeComponent"; import Explorer from "../Explorer"; import { useCommandBar } from "../Menus/CommandBar/CommandBarComponentAdapter"; import { mostRecentActivity } from "../MostRecentActivity/MostRecentActivity"; +import { NotebookContentItem, NotebookContentItemType } from "../Notebook/NotebookContentItem"; +import { NotebookUtil } from "../Notebook/NotebookUtil"; import { useNotebook } from "../Notebook/useNotebook"; +import { GitHubReposPanel } from "../Panes/GitHubReposPanel/GitHubReposPanel"; import TabsBase from "../Tabs/TabsBase"; import { useDatabases } from "../useDatabases"; import { useSelectedNode } from "../useSelectedNode"; @@ -26,21 +45,391 @@ import StoredProcedure from "./StoredProcedure"; import Trigger from "./Trigger"; import UserDefinedFunction from "./UserDefinedFunction"; +export const MyNotebooksTitle = "My Notebooks"; +export const GitHubReposTitle = "GitHub repos"; + interface ResourceTreeProps { container: Explorer; } export const ResourceTree: React.FC = ({ container }: ResourceTreeProps): JSX.Element => { const databases = useDatabases((state) => state.databases); - const { isNotebookEnabled } = useNotebook( + const { + isNotebookEnabled, + myNotebooksContentRoot, + galleryContentRoot, + gitHubNotebooksContentRoot, + updateNotebookItem, + } = useNotebook( (state) => ({ isNotebookEnabled: state.isNotebookEnabled, + myNotebooksContentRoot: state.myNotebooksContentRoot, + galleryContentRoot: state.galleryContentRoot, + gitHubNotebooksContentRoot: state.gitHubNotebooksContentRoot, + updateNotebookItem: state.updateNotebookItem, }), shallow, ); - const { refreshActiveTab } = useTabs(); + const { activeTab, refreshActiveTab } = useTabs(); const showScriptNodes = configContext.platform !== Platform.Fabric && (userContext.apiType === "SQL" || userContext.apiType === "Gremlin"); + const pseudoDirPath = "PsuedoDir"; + + const buildGalleryCallout = (): JSX.Element => { + if ( + LocalStorageUtility.hasItem(StorageKey.GalleryCalloutDismissed) && + LocalStorageUtility.getEntryBoolean(StorageKey.GalleryCalloutDismissed) + ) { + return undefined; + } + + const calloutProps: ICalloutProps = { + calloutMaxWidth: 350, + ariaLabel: "New gallery", + role: "alertdialog", + gapSpace: 0, + target: ".galleryHeader", + directionalHint: DirectionalHint.leftTopEdge, + onDismiss: () => { + LocalStorageUtility.setEntryBoolean(StorageKey.GalleryCalloutDismissed, true); + }, + setInitialFocus: true, + }; + + const openGalleryProps: ILinkProps = { + onClick: () => { + LocalStorageUtility.setEntryBoolean(StorageKey.GalleryCalloutDismissed, true); + container.openGallery(); + }, + }; + + return ( + + + + New gallery + + + Sample notebooks are now combined in gallery. View and try out samples provided by Microsoft and other + contributors. + + Open gallery + + + ); + }; + + const buildNotebooksTree = (): TreeNode => { + const notebooksTree: TreeNode = { + label: undefined, + isExpanded: true, + children: [], + }; + + if (!useNotebook.getState().isPhoenixNotebooks) { + notebooksTree.children.push(buildNotebooksTemporarilyDownTree()); + } else { + if (galleryContentRoot) { + notebooksTree.children.push(buildGalleryNotebooksTree()); + } + + if ( + myNotebooksContentRoot && + useNotebook.getState().isPhoenixNotebooks && + useNotebook.getState().connectionInfo.status === ConnectionStatusType.Connected + ) { + notebooksTree.children.push(buildMyNotebooksTree()); + } + if (container.notebookManager?.gitHubOAuthService.isLoggedIn()) { + // collapse all other notebook nodes + notebooksTree.children.forEach((node) => (node.isExpanded = false)); + notebooksTree.children.push(buildGitHubNotebooksTree(true)); + } + } + return notebooksTree; + }; + + const buildNotebooksTemporarilyDownTree = (): TreeNode => { + return { + label: Notebook.temporarilyDownMsg, + className: "clickDisabled", + }; + }; + + const buildGalleryNotebooksTree = (): TreeNode => { + return { + label: "Gallery", + iconSrc: GalleryIcon, + className: "notebookHeader galleryHeader", + onClick: () => container.openGallery(), + isSelected: () => activeTab?.tabKind === ViewModels.CollectionTabKind.Gallery, + }; + }; + + const buildMyNotebooksTree = (): TreeNode => { + const myNotebooksTree: TreeNode = buildNotebookDirectoryNode( + myNotebooksContentRoot, + (item: NotebookContentItem) => { + container.openNotebook(item); + }, + ); + + myNotebooksTree.isExpanded = true; + myNotebooksTree.isAlphaSorted = true; + // Remove "Delete" menu item from context menu + myNotebooksTree.contextMenu = myNotebooksTree.contextMenu.filter((menuItem) => menuItem.label !== "Delete"); + return myNotebooksTree; + }; + + const buildGitHubNotebooksTree = (isConnected: boolean): TreeNode => { + const gitHubNotebooksTree: TreeNode = buildNotebookDirectoryNode( + gitHubNotebooksContentRoot, + (item: NotebookContentItem) => { + container.openNotebook(item); + }, + true, + ); + const manageGitContextMenu: TreeNodeMenuItem[] = [ + { + label: "Manage GitHub settings", + onClick: () => + useSidePanel + .getState() + .openSidePanel( + "Manage GitHub settings", + , + ), + }, + { + label: "Disconnect from GitHub", + onClick: () => { + TelemetryProcessor.trace(Action.NotebooksGitHubDisconnect, ActionModifiers.Mark, { + dataExplorerArea: Areas.Notebook, + }); + container.notebookManager?.gitHubOAuthService.logout(); + }, + }, + ]; + gitHubNotebooksTree.contextMenu = manageGitContextMenu; + gitHubNotebooksTree.isExpanded = true; + gitHubNotebooksTree.isAlphaSorted = true; + + return gitHubNotebooksTree; + }; + + const buildChildNodes = ( + item: NotebookContentItem, + onFileClick: (item: NotebookContentItem) => void, + isGithubTree?: boolean, + ): TreeNode[] => { + if (!item || !item.children) { + return []; + } else { + return item.children.map((item) => { + const result = + item.type === NotebookContentItemType.Directory + ? buildNotebookDirectoryNode(item, onFileClick, isGithubTree) + : buildNotebookFileNode(item, onFileClick, isGithubTree); + result.timestamp = item.timestamp; + return result; + }); + } + }; + + const buildNotebookFileNode = ( + item: NotebookContentItem, + onFileClick: (item: NotebookContentItem) => void, + isGithubTree?: boolean, + ): TreeNode => { + return { + label: item.name, + iconSrc: NotebookUtil.isNotebookFile(item.path) ? NotebookIcon : FileIcon, + className: "notebookHeader", + onClick: () => onFileClick(item), + isSelected: () => { + return ( + activeTab && + activeTab.tabKind === ViewModels.CollectionTabKind.NotebookV2 && + /* TODO Redesign Tab interface so that resource tree doesn't need to know about NotebookV2Tab. + NotebookV2Tab could be dynamically imported, but not worth it to just get this type right. + */ + (activeTab as any).notebookPath() === item.path + ); + }, + contextMenu: createFileContextMenu(container, item, isGithubTree), + data: item, + }; + }; + + const createFileContextMenu = ( + container: Explorer, + item: NotebookContentItem, + isGithubTree?: boolean, + ): TreeNodeMenuItem[] => { + let items: TreeNodeMenuItem[] = [ + { + label: "Rename", + iconSrc: NotebookIcon, + onClick: () => container.renameNotebook(item, isGithubTree), + }, + { + label: "Delete", + iconSrc: DeleteIcon, + onClick: () => { + useDialog + .getState() + .showOkCancelModalDialog( + "Confirm delete", + `Are you sure you want to delete "${item.name}"`, + "Delete", + () => container.deleteNotebookFile(item, isGithubTree), + "Cancel", + undefined, + ); + }, + }, + { + label: "Copy to ...", + iconSrc: CopyIcon, + onClick: () => copyNotebook(container, item), + }, + { + label: "Download", + iconSrc: NotebookIcon, + onClick: () => container.downloadFile(item), + }, + ]; + + if (item.type === NotebookContentItemType.Notebook && userContext.features.publicGallery) { + items.push({ + label: "Publish to gallery", + iconSrc: PublishIcon, + onClick: async () => { + TelemetryProcessor.trace(Action.NotebooksGalleryClickPublishToGallery, ActionModifiers.Mark, { + source: Source.ResourceTreeMenu, + }); + + const content = await container.readFile(item); + if (content) { + await container.publishNotebook(item.name, content); + } + }, + }); + } + + // "Copy to ..." isn't needed if github locations are not available + if (!container.notebookManager?.gitHubOAuthService.isLoggedIn()) { + items = items.filter((item) => item.label !== "Copy to ..."); + } + + return items; + }; + + const copyNotebook = async (container: Explorer, item: NotebookContentItem) => { + const content = await container.readFile(item); + if (content) { + container.copyNotebook(item.name, content); + } + }; + + const createDirectoryContextMenu = ( + container: Explorer, + item: NotebookContentItem, + isGithubTree?: boolean, + ): TreeNodeMenuItem[] => { + let items: TreeNodeMenuItem[] = [ + { + label: "Refresh", + iconSrc: RefreshIcon, + onClick: () => loadSubitems(item, isGithubTree), + }, + { + label: "Delete", + iconSrc: DeleteIcon, + onClick: () => { + useDialog + .getState() + .showOkCancelModalDialog( + "Confirm delete", + `Are you sure you want to delete "${item.name}?"`, + "Delete", + () => container.deleteNotebookFile(item, isGithubTree), + "Cancel", + undefined, + ); + }, + }, + { + label: "Rename", + iconSrc: NotebookIcon, + onClick: () => container.renameNotebook(item, isGithubTree), + }, + { + label: "New Directory", + iconSrc: NewNotebookIcon, + onClick: () => container.onCreateDirectory(item, isGithubTree), + }, + { + label: "Upload File", + iconSrc: NewNotebookIcon, + onClick: () => container.openUploadFilePanel(item), + }, + ]; + + //disallow renaming of temporary notebook workspace + if (item?.path === useNotebook.getState().notebookBasePath) { + items = items.filter((item) => item.label !== "Rename"); + } + + // For GitHub paths remove "Delete", "Rename", "New Directory", "Upload File" + if (GitHubUtils.fromContentUri(item.path)) { + items = items.filter( + (item) => + item.label !== "Delete" && + item.label !== "Rename" && + item.label !== "New Directory" && + item.label !== "Upload File", + ); + } + + return items; + }; + + const buildNotebookDirectoryNode = ( + item: NotebookContentItem, + onFileClick: (item: NotebookContentItem) => void, + isGithubTree?: boolean, + ): TreeNode => { + return { + label: item.name, + iconSrc: undefined, + className: "notebookHeader", + isAlphaSorted: true, + isLeavesParentsSeparate: true, + onClick: () => { + if (!item.children) { + loadSubitems(item, isGithubTree); + } + }, + isSelected: () => { + return ( + activeTab && + activeTab.tabKind === ViewModels.CollectionTabKind.NotebookV2 && + /* TODO Redesign Tab interface so that resource tree doesn't need to know about NotebookV2Tab. + NotebookV2Tab could be dynamically imported, but not worth it to just get this type right. + */ + (activeTab as any).notebookPath() === item.path + ); + }, + contextMenu: item.path !== pseudoDirPath ? createDirectoryContextMenu(container, item, isGithubTree) : undefined, + data: item, + children: buildChildNodes(item, onFileClick, isGithubTree), + }; + }; const buildDataTree = (): TreeNode => { const databaseTreeNodes: TreeNode[] = databases.map((database: ViewModels.Database) => { @@ -368,6 +757,11 @@ export const ResourceTree: React.FC = ({ container }: Resourc return traverse(schema); }; + const loadSubitems = async (item: NotebookContentItem, isGithubTree?: boolean): Promise => { + const updatedItem = await container.notebookManager?.notebookContentClient?.updateItemChildren(item); + updateNotebookItem(updatedItem, isGithubTree); + }; + const dataRootNode = buildDataTree(); const isSampleDataEnabled = useQueryCopilot().copilotEnabled && @@ -381,16 +775,46 @@ export const ResourceTree: React.FC = ({ container }: Resourc {!isNotebookEnabled && !isSampleDataEnabled && ( )} + {isNotebookEnabled && !isSampleDataEnabled && ( + <> + + + + + + + {/* {buildGalleryCallout()} */} + + )} {!isNotebookEnabled && isSampleDataEnabled && ( <> - + + + {/* {buildGalleryCallout()} */} + + )} + {isNotebookEnabled && isSampleDataEnabled && ( + <> + + + + + + + + + + + + + {/* {buildGalleryCallout()} */} )} diff --git a/src/Explorer/Tree/ResourceTreeAdapter.tsx b/src/Explorer/Tree/ResourceTreeAdapter.tsx index 67fb54773..b22f0f916 100644 --- a/src/Explorer/Tree/ResourceTreeAdapter.tsx +++ b/src/Explorer/Tree/ResourceTreeAdapter.tsx @@ -1,21 +1,42 @@ +import { Callout, DirectionalHint, ICalloutProps, ILinkProps, Link, Stack, Text } from "@fluentui/react"; import { getItemName } from "Utils/APITypeUtils"; import * as ko from "knockout"; import * as React from "react"; import CosmosDBIcon from "../../../images/Azure-Cosmos-DB.svg"; +import GalleryIcon from "../../../images/GalleryIcon.svg"; +import DeleteIcon from "../../../images/delete.svg"; +import CopyIcon from "../../../images/notebook/Notebook-copy.svg"; +import NewNotebookIcon from "../../../images/notebook/Notebook-new.svg"; +import NotebookIcon from "../../../images/notebook/Notebook-resource.svg"; +import FileIcon from "../../../images/notebook/file-cosmos.svg"; +import PublishIcon from "../../../images/notebook/publish_content.svg"; +import RefreshIcon from "../../../images/refresh-cosmos.svg"; import CollectionIcon from "../../../images/tree-collection.svg"; import { ReactAdapter } from "../../Bindings/ReactBindingHandler"; +import { Areas } from "../../Common/Constants"; import { isPublicInternetAccessAllowed } from "../../Common/DatabaseAccountUtility"; import * as DataModels from "../../Contracts/DataModels"; import * as ViewModels from "../../Contracts/ViewModels"; +import { IPinnedRepo } from "../../Juno/JunoClient"; +import { LocalStorageUtility, StorageKey } from "../../Shared/StorageUtility"; +import { Action, ActionModifiers, Source } from "../../Shared/Telemetry/TelemetryConstants"; +import * as TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor"; import { userContext } from "../../UserContext"; import { isServerlessAccount } from "../../Utils/CapabilityUtils"; +import * as GitHubUtils from "../../Utils/GitHubUtils"; +import { useSidePanel } from "../../hooks/useSidePanel"; import { useTabs } from "../../hooks/useTabs"; import * as ResourceTreeContextMenuButtonFactory from "../ContextMenuButtonFactory"; -import { TreeComponent, TreeNode } from "../Controls/TreeComponent/TreeComponent"; +import { AccordionComponent, AccordionItemComponent } from "../Controls/Accordion/AccordionComponent"; +import { useDialog } from "../Controls/Dialog"; +import { TreeComponent, TreeNode, TreeNodeMenuItem } from "../Controls/TreeComponent/TreeComponent"; import Explorer from "../Explorer"; import { useCommandBar } from "../Menus/CommandBar/CommandBarComponentAdapter"; import { mostRecentActivity } from "../MostRecentActivity/MostRecentActivity"; +import { NotebookContentItem, NotebookContentItemType } from "../Notebook/NotebookContentItem"; +import { NotebookUtil } from "../Notebook/NotebookUtil"; import { useNotebook } from "../Notebook/useNotebook"; +import { GitHubReposPanel } from "../Panes/GitHubReposPanel/GitHubReposPanel"; import TabsBase from "../Tabs/TabsBase"; import { useDatabases } from "../useDatabases"; import { useSelectedNode } from "../useSelectedNode"; @@ -25,8 +46,19 @@ import Trigger from "./Trigger"; import UserDefinedFunction from "./UserDefinedFunction"; export class ResourceTreeAdapter implements ReactAdapter { + public static readonly MyNotebooksTitle = "My Notebooks"; + public static readonly GitHubReposTitle = "GitHub repos"; + + private static readonly DataTitle = "DATA"; + private static readonly NotebooksTitle = "NOTEBOOKS"; + private static readonly PseudoDirPath = "PsuedoDir"; + public parameters: ko.Observable; + public galleryContentRoot: NotebookContentItem; + public myNotebooksContentRoot: NotebookContentItem; + public gitHubNotebooksContentRoot: NotebookContentItem; + public constructor(private container: Explorer) { this.parameters = ko.observable(Date.now()); @@ -44,9 +76,111 @@ export class ResourceTreeAdapter implements ReactAdapter { this.triggerRender(); } + private traceMyNotebookTreeInfo() { + const myNotebooksTree = this.myNotebooksContentRoot; + if (myNotebooksTree.children) { + // Count 1st generation children (tree is lazy-loaded) + const nodeCounts = { files: 0, notebooks: 0, directories: 0 }; + myNotebooksTree.children.forEach((treeNode) => { + switch ((treeNode as NotebookContentItem).type) { + case NotebookContentItemType.File: + nodeCounts.files++; + break; + case NotebookContentItemType.Directory: + nodeCounts.directories++; + break; + case NotebookContentItemType.Notebook: + nodeCounts.notebooks++; + break; + default: + break; + } + }); + TelemetryProcessor.trace(Action.RefreshResourceTreeMyNotebooks, ActionModifiers.Mark, { ...nodeCounts }); + } + } + public renderComponent(): JSX.Element { const dataRootNode = this.buildDataTree(); - return ; + const notebooksRootNode = this.buildNotebooksTrees(); + + if (useNotebook.getState().isNotebookEnabled) { + return ( + <> + + + + + + + + + + {/* {this.galleryContentRoot && this.buildGalleryCallout()} */} + + ); + } else { + return ; + } + } + + public async initialize(): Promise { + const refreshTasks: Promise[] = []; + + this.galleryContentRoot = { + name: "Gallery", + path: "Gallery", + type: NotebookContentItemType.File, + }; + this.myNotebooksContentRoot = { + name: useNotebook.getState().notebookFolderName, + path: useNotebook.getState().notebookBasePath, + type: NotebookContentItemType.Directory, + }; + + // Only if notebook server is available we can refresh + if (useNotebook.getState().notebookServerInfo?.notebookServerEndpoint) { + refreshTasks.push( + this.container.refreshContentItem(this.myNotebooksContentRoot).then(() => { + this.triggerRender(); + this.traceMyNotebookTreeInfo(); + }), + ); + } + this.gitHubNotebooksContentRoot = { + name: ResourceTreeAdapter.GitHubReposTitle, + path: ResourceTreeAdapter.PseudoDirPath, + type: NotebookContentItemType.Directory, + }; + + return Promise.all(refreshTasks); + } + + public initializeGitHubRepos(pinnedRepos: IPinnedRepo[]): void { + if (this.gitHubNotebooksContentRoot) { + this.gitHubNotebooksContentRoot.children = []; + pinnedRepos?.forEach((pinnedRepo) => { + const repoFullName = GitHubUtils.toRepoFullName(pinnedRepo.owner, pinnedRepo.name); + const repoTreeItem: NotebookContentItem = { + name: repoFullName, + path: ResourceTreeAdapter.PseudoDirPath, + type: NotebookContentItemType.Directory, + children: [], + }; + + pinnedRepo.branches.forEach((branch) => { + repoTreeItem.children.push({ + name: branch.name, + path: GitHubUtils.toContentUri(pinnedRepo.owner, pinnedRepo.name, branch.name, ""), + type: NotebookContentItemType.Directory, + }); + }); + + this.gitHubNotebooksContentRoot.children.push(repoTreeItem); + }); + + this.triggerRender(); + } } private buildDataTree(): TreeNode { @@ -370,6 +504,365 @@ export class ResourceTreeAdapter implements ReactAdapter { return traverse(schema); } + private buildNotebooksTrees(): TreeNode { + let notebooksTree: TreeNode = { + label: undefined, + isExpanded: true, + children: [], + }; + + if (this.galleryContentRoot) { + notebooksTree.children.push(this.buildGalleryNotebooksTree()); + } + + if (this.myNotebooksContentRoot) { + notebooksTree.children.push(this.buildMyNotebooksTree()); + } + + if (this.gitHubNotebooksContentRoot) { + // collapse all other notebook nodes + notebooksTree.children.forEach((node) => (node.isExpanded = false)); + notebooksTree.children.push(this.buildGitHubNotebooksTree()); + } + + return notebooksTree; + } + + private buildGalleryCallout(): JSX.Element { + if ( + LocalStorageUtility.hasItem(StorageKey.GalleryCalloutDismissed) && + LocalStorageUtility.getEntryBoolean(StorageKey.GalleryCalloutDismissed) + ) { + return undefined; + } + + const calloutProps: ICalloutProps = { + calloutMaxWidth: 350, + ariaLabel: "New gallery", + role: "alertdialog", + gapSpace: 0, + target: ".galleryHeader", + directionalHint: DirectionalHint.leftTopEdge, + onDismiss: () => { + LocalStorageUtility.setEntryBoolean(StorageKey.GalleryCalloutDismissed, true); + this.triggerRender(); + }, + setInitialFocus: true, + }; + + const openGalleryProps: ILinkProps = { + onClick: () => { + LocalStorageUtility.setEntryBoolean(StorageKey.GalleryCalloutDismissed, true); + this.container.openGallery(); + this.triggerRender(); + }, + }; + + return ( + + + + New gallery + + + Sample notebooks are now combined in gallery. View and try out samples provided by Microsoft and other + contributors. + + Open gallery + + + ); + } + + private buildGalleryNotebooksTree(): TreeNode { + return { + label: "Gallery", + iconSrc: GalleryIcon, + className: "notebookHeader galleryHeader", + onClick: () => this.container.openGallery(), + isSelected: () => { + const activeTab = useTabs.getState().activeTab; + return activeTab && activeTab.tabKind === ViewModels.CollectionTabKind.Gallery; + }, + }; + } + + private buildMyNotebooksTree(): TreeNode { + const myNotebooksTree: TreeNode = this.buildNotebookDirectoryNode( + this.myNotebooksContentRoot, + (item: NotebookContentItem) => { + this.container.openNotebook(item).then((hasOpened) => { + if (hasOpened) { + mostRecentActivity.notebookWasItemOpened(userContext.databaseAccount?.id, item); + } + }); + }, + true, + true, + ); + + myNotebooksTree.isExpanded = true; + myNotebooksTree.isAlphaSorted = true; + // Remove "Delete" menu item from context menu + myNotebooksTree.contextMenu = myNotebooksTree.contextMenu.filter((menuItem) => menuItem.label !== "Delete"); + return myNotebooksTree; + } + + private buildGitHubNotebooksTree(): TreeNode { + const gitHubNotebooksTree: TreeNode = this.buildNotebookDirectoryNode( + this.gitHubNotebooksContentRoot, + (item: NotebookContentItem) => { + this.container.openNotebook(item).then((hasOpened) => { + if (hasOpened) { + mostRecentActivity.notebookWasItemOpened(userContext.databaseAccount?.id, item); + } + }); + }, + true, + true, + ); + + gitHubNotebooksTree.contextMenu = [ + { + label: "Manage GitHub settings", + onClick: () => + useSidePanel + .getState() + .openSidePanel( + "Manage GitHub settings", + , + ), + }, + { + label: "Disconnect from GitHub", + onClick: () => { + TelemetryProcessor.trace(Action.NotebooksGitHubDisconnect, ActionModifiers.Mark, { + dataExplorerArea: Areas.Notebook, + }); + this.container.notebookManager?.gitHubOAuthService.logout(); + }, + }, + ]; + + gitHubNotebooksTree.isExpanded = true; + gitHubNotebooksTree.isAlphaSorted = true; + + return gitHubNotebooksTree; + } + + private buildChildNodes( + item: NotebookContentItem, + onFileClick: (item: NotebookContentItem) => void, + createDirectoryContextMenu: boolean, + createFileContextMenu: boolean, + ): TreeNode[] { + if (!item || !item.children) { + return []; + } else { + return item.children.map((item) => { + const result = + item.type === NotebookContentItemType.Directory + ? this.buildNotebookDirectoryNode(item, onFileClick, createDirectoryContextMenu, createFileContextMenu) + : this.buildNotebookFileNode(item, onFileClick, createFileContextMenu); + result.timestamp = item.timestamp; + return result; + }); + } + } + + private buildNotebookFileNode( + item: NotebookContentItem, + onFileClick: (item: NotebookContentItem) => void, + createFileContextMenu: boolean, + ): TreeNode { + return { + label: item.name, + iconSrc: NotebookUtil.isNotebookFile(item.path) ? NotebookIcon : FileIcon, + className: "notebookHeader", + onClick: () => onFileClick(item), + isSelected: () => { + const activeTab = useTabs.getState().activeTab; + return ( + activeTab && + activeTab.tabKind === ViewModels.CollectionTabKind.NotebookV2 && + /* TODO Redesign Tab interface so that resource tree doesn't need to know about NotebookV2Tab. + NotebookV2Tab could be dynamically imported, but not worth it to just get this type right. + */ + (activeTab as any).notebookPath() === item.path + ); + }, + contextMenu: createFileContextMenu && this.createFileContextMenu(item), + data: item, + }; + } + + private createFileContextMenu(item: NotebookContentItem): TreeNodeMenuItem[] { + let items: TreeNodeMenuItem[] = [ + { + label: "Rename", + iconSrc: NotebookIcon, + onClick: () => this.container.renameNotebook(item), + }, + { + label: "Delete", + iconSrc: DeleteIcon, + onClick: () => { + useDialog + .getState() + .showOkCancelModalDialog( + "Confirm delete", + `Are you sure you want to delete "${item.name}"`, + "Delete", + () => this.container.deleteNotebookFile(item).then(() => this.triggerRender()), + "Cancel", + undefined, + ); + }, + }, + { + label: "Copy to ...", + iconSrc: CopyIcon, + onClick: () => this.copyNotebook(item), + }, + { + label: "Download", + iconSrc: NotebookIcon, + onClick: () => this.container.downloadFile(item), + }, + ]; + + if (item.type === NotebookContentItemType.Notebook) { + items.push({ + label: "Publish to gallery", + iconSrc: PublishIcon, + onClick: async () => { + TelemetryProcessor.trace(Action.NotebooksGalleryClickPublishToGallery, ActionModifiers.Mark, { + source: Source.ResourceTreeMenu, + }); + + const content = await this.container.readFile(item); + if (content) { + await this.container.publishNotebook(item.name, content); + } + }, + }); + } + + // "Copy to ..." isn't needed if github locations are not available + if (!this.container.notebookManager?.gitHubOAuthService.isLoggedIn()) { + items = items.filter((item) => item.label !== "Copy to ..."); + } + + return items; + } + + private copyNotebook = async (item: NotebookContentItem) => { + const content = await this.container.readFile(item); + if (content) { + this.container.copyNotebook(item.name, content); + } + }; + + private createDirectoryContextMenu(item: NotebookContentItem): TreeNodeMenuItem[] { + let items: TreeNodeMenuItem[] = [ + { + label: "Refresh", + iconSrc: RefreshIcon, + onClick: () => this.container.refreshContentItem(item).then(() => this.triggerRender()), + }, + { + label: "Delete", + iconSrc: DeleteIcon, + onClick: () => { + useDialog + .getState() + .showOkCancelModalDialog( + "Confirm delete", + `Are you sure you want to delete "${item.name}?"`, + "Delete", + () => this.container.deleteNotebookFile(item).then(() => this.triggerRender()), + "Cancel", + undefined, + ); + }, + }, + { + label: "Rename", + iconSrc: NotebookIcon, + onClick: () => this.container.renameNotebook(item), + }, + { + label: "New Directory", + iconSrc: NewNotebookIcon, + onClick: () => this.container.onCreateDirectory(item), + }, + { + label: "Upload File", + iconSrc: NewNotebookIcon, + onClick: () => this.container.openUploadFilePanel(item), + }, + ]; + + //disallow renaming of temporary notebook workspace + if (item?.path === useNotebook.getState().notebookBasePath) { + items = items.filter((item) => item.label !== "Rename"); + } + + // For GitHub paths remove "Delete", "Rename", "New Directory", "Upload File" + if (GitHubUtils.fromContentUri(item.path)) { + items = items.filter( + (item) => + item.label !== "Delete" && + item.label !== "Rename" && + item.label !== "New Directory" && + item.label !== "Upload File", + ); + } + + return items; + } + + private buildNotebookDirectoryNode( + item: NotebookContentItem, + onFileClick: (item: NotebookContentItem) => void, + createDirectoryContextMenu: boolean, + createFileContextMenu: boolean, + ): TreeNode { + return { + label: item.name, + iconSrc: undefined, + className: "notebookHeader", + isAlphaSorted: true, + isLeavesParentsSeparate: true, + onClick: () => { + if (!item.children) { + this.container.refreshContentItem(item).then(() => this.triggerRender()); + } + }, + isSelected: () => { + const activeTab = useTabs.getState().activeTab; + return ( + activeTab && + activeTab.tabKind === ViewModels.CollectionTabKind.NotebookV2 && + /* TODO Redesign Tab interface so that resource tree doesn't need to know about NotebookV2Tab. + NotebookV2Tab could be dynamically imported, but not worth it to just get this type right. + */ + (activeTab as any).notebookPath() === item.path + ); + }, + contextMenu: + createDirectoryContextMenu && item.path !== ResourceTreeAdapter.PseudoDirPath + ? this.createDirectoryContextMenu(item) + : undefined, + data: item, + children: this.buildChildNodes(item, onFileClick, createDirectoryContextMenu, createFileContextMenu), + }; + } + public triggerRender() { window.requestAnimationFrame(() => this.parameters(Date.now())); } diff --git a/src/Utils/GalleryUtils.ts b/src/Utils/GalleryUtils.ts index 1314b2b3c..f11b6df5c 100644 --- a/src/Utils/GalleryUtils.ts +++ b/src/Utils/GalleryUtils.ts @@ -245,6 +245,7 @@ export function downloadItem( }, "Cancel", undefined, + container.getDownloadModalConent(name), ); } export async function downloadNotebookItem( @@ -277,6 +278,7 @@ export async function downloadNotebookItem( metadata.untrusted = true; } + await container.importAndOpenContent(data.name, JSON.stringify(notebook)); logConsoleInfo(`Successfully downloaded ${data.name} to ${useNotebook.getState().notebookFolderName}`); const increaseDownloadResponse = await junoClient.increaseNotebookDownloadCount(data.id);