From 5886db81e96d174ab55455c324852ea40442effb Mon Sep 17 00:00:00 2001 From: Tanuj Mittal Date: Tue, 11 Aug 2020 09:27:57 -0700 Subject: [PATCH] Copy To functionality for notebooks (#141) * Add Copy To functionality for notebooks * Fix formatting * Fix linting errors * Fixes * Fix build failure * Rebase and address feedback * Increase test coverage --- .../GalleryViewerComponent.tsx | 11 +- src/Explorer/Explorer.ts | 14 +- .../Notebook/NotebookContentClient.ts | 9 +- src/Explorer/Notebook/NotebookManager.ts | 16 + src/Explorer/Notebook/NotebookUtil.test.ts | 26 +- src/Explorer/Notebook/NotebookUtil.ts | 40 +++ src/Explorer/Panes/CopyNotebookPane.tsx | 283 ++++++++++++++++++ .../Panes/PublishNotebookPaneAdapter.tsx | 1 + .../PublishNotebookPaneComponent.test.tsx | 1 + .../Panes/PublishNotebookPaneComponent.tsx | 19 ++ ...PublishNotebookPaneComponent.test.tsx.snap | 9 + src/Explorer/Tabs/NotebookV2Tab.ts | 22 ++ src/Explorer/Tree/ResourceTreeAdapter.tsx | 20 +- src/GitHub/GitHubClient.ts | 7 + src/GitHub/GitHubContentProvider.ts | 66 ++-- src/Utils/Base64Utils.test.ts | 11 + src/Utils/Base64Utils.ts | 7 + src/explorer.html | 4 + 18 files changed, 530 insertions(+), 36 deletions(-) create mode 100644 src/Explorer/Panes/CopyNotebookPane.tsx create mode 100644 src/Utils/Base64Utils.test.ts create mode 100644 src/Utils/Base64Utils.ts diff --git a/src/Explorer/Controls/NotebookGallery/GalleryViewerComponent.tsx b/src/Explorer/Controls/NotebookGallery/GalleryViewerComponent.tsx index 0241f76eb..5922aa017 100644 --- a/src/Explorer/Controls/NotebookGallery/GalleryViewerComponent.tsx +++ b/src/Explorer/Controls/NotebookGallery/GalleryViewerComponent.tsx @@ -385,12 +385,11 @@ export class GalleryViewerComponent extends React.Component tag.toUpperCase()) - ]; + const searchData: string[] = [item.author.toUpperCase(), item.description.toUpperCase(), item.name.toUpperCase()]; + + if (item.tags) { + searchData.push(...item.tags.map(tag => tag.toUpperCase())); + } for (const data of searchData) { if (data?.indexOf(toSearch) !== -1) { diff --git a/src/Explorer/Explorer.ts b/src/Explorer/Explorer.ts index 1761cfa7b..15be7c0c9 100644 --- a/src/Explorer/Explorer.ts +++ b/src/Explorer/Explorer.ts @@ -203,6 +203,7 @@ export default class Explorer { public setupNotebooksPane: SetupNotebooksPane; public gitHubReposPane: ContextualPaneBase; public publishNotebookPaneAdapter: ReactAdapter; + public copyNotebookPaneAdapter: ReactAdapter; // features public isGalleryPublishEnabled: ko.Computed; @@ -210,6 +211,7 @@ export default class Explorer { public isLinkInjectionEnabled: ko.Computed; public isGitHubPaneEnabled: ko.Observable; public isPublishNotebookPaneEnabled: ko.Observable; + public isCopyNotebookPaneEnabled: ko.Observable; public isHostedDataExplorerEnabled: ko.Computed; public isRightPanelV2Enabled: ko.Computed; public canExceedMaximumValue: ko.Computed; @@ -418,6 +420,7 @@ export default class Explorer { ); this.isGitHubPaneEnabled = ko.observable(false); this.isPublishNotebookPaneEnabled = ko.observable(false); + this.isCopyNotebookPaneEnabled = ko.observable(false); this.canExceedMaximumValue = ko.computed(() => this.isFeatureEnabled(Constants.Features.canExceedMaximumValue) @@ -2305,7 +2308,7 @@ export default class Explorer { return _.find(offers, (offer: DataModels.Offer) => offer.resource === resourceId); } - private uploadFile(name: string, content: string, parent: NotebookContentItem): Promise { + public uploadFile(name: string, content: string, parent: NotebookContentItem): Promise { if (!this.isNotebookEnabled() || !this.notebookManager?.notebookContentClient) { const error = "Attempt to upload notebook, but notebook is not enabled"; Logger.logError(error, "Explorer/uploadFile"); @@ -2374,6 +2377,14 @@ export default class Explorer { } } + public copyNotebook(name: string, content: string): void { + if (this.notebookManager) { + this.notebookManager.openCopyNotebookPane(name, content); + this.copyNotebookPaneAdapter = this.notebookManager.copyNotebookPaneAdapter; + this.isCopyNotebookPaneEnabled(true); + } + } + public showOkModalDialog(title: string, msg: string): void { this._dialogProps({ isModal: true, @@ -2730,6 +2741,7 @@ export default class Explorer { } await this.resourceTree.initialize(); + this.notebookManager?.refreshPinnedRepos(); if (this.notebookToImport) { this.importAndOpenContent(this.notebookToImport.name, this.notebookToImport.content); } diff --git a/src/Explorer/Notebook/NotebookContentClient.ts b/src/Explorer/Notebook/NotebookContentClient.ts index 4fe92b435..b4036fadf 100644 --- a/src/Explorer/Notebook/NotebookContentClient.ts +++ b/src/Explorer/Notebook/NotebookContentClient.ts @@ -89,7 +89,7 @@ export class NotebookContentClient { throw new Error(`Parent must be a directory: ${parent}`); } - const filepath = `${parent.path}/${name}`; + const filepath = NotebookUtil.getFilePath(parent.path, name); if (await this.checkIfFilepathExists(filepath)) { throw new Error(`File already exists: ${filepath}`); } @@ -116,12 +116,7 @@ export class NotebookContentClient { } private async checkIfFilepathExists(filepath: string): Promise { - const basename = filepath.split("/").pop(); - let parentDirPath = filepath - .split(basename) - .shift() - .replace(/\/$/, ""); // no trailling slash - + const parentDirPath = NotebookUtil.getParentPath(filepath); const items = await this.fetchNotebookFiles(parentDirPath); return items.some(value => FileSystemUtil.isPathEqual(value.path, filepath)); } diff --git a/src/Explorer/Notebook/NotebookManager.ts b/src/Explorer/Notebook/NotebookManager.ts index 582b4fda8..50830ee8a 100644 --- a/src/Explorer/Notebook/NotebookManager.ts +++ b/src/Explorer/Notebook/NotebookManager.ts @@ -25,6 +25,7 @@ import { getFullName } from "../../Utils/UserUtils"; import { ImmutableNotebook } from "@nteract/commutable"; import Explorer from "../Explorer"; import { ContextualPaneBase } from "../Panes/ContextualPaneBase"; +import { CopyNotebookPaneAdapter } from "../Panes/CopyNotebookPane"; export interface NotebookManagerOptions { container: Explorer; @@ -49,6 +50,7 @@ export default class NotebookManager { public gitHubReposPane: ContextualPaneBase; public publishNotebookPaneAdapter: PublishNotebookPaneAdapter; + public copyNotebookPaneAdapter: CopyNotebookPaneAdapter; public initialize(params: NotebookManagerOptions): void { this.params = params; @@ -90,6 +92,12 @@ export default class NotebookManager { this.publishNotebookPaneAdapter = new PublishNotebookPaneAdapter(this.params.container, this.junoClient); } + this.copyNotebookPaneAdapter = new CopyNotebookPaneAdapter( + this.params.container, + this.junoClient, + this.gitHubOAuthService + ); + this.gitHubOAuthService.getTokenObservable().subscribe(token => { this.gitHubClient.setToken(token?.access_token); @@ -108,6 +116,10 @@ export default class NotebookManager { this.junoClient.getPinnedRepos(this.gitHubOAuthService.getTokenObservable()()?.scope); } + public refreshPinnedRepos(): void { + this.junoClient.getPinnedRepos(this.gitHubOAuthService.getTokenObservable()()?.scope); + } + public async openPublishNotebookPane( name: string, content: string | ImmutableNotebook, @@ -125,6 +137,10 @@ export default class NotebookManager { ); } + public openCopyNotebookPane(name: string, content: string): void { + this.copyNotebookPaneAdapter.open(name, content); + } + // 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/Notebook/NotebookUtil.test.ts b/src/Explorer/Notebook/NotebookUtil.test.ts index 2f6c1136f..2cfa7eadc 100644 --- a/src/Explorer/Notebook/NotebookUtil.test.ts +++ b/src/Explorer/Notebook/NotebookUtil.test.ts @@ -13,8 +13,10 @@ import { List, Map } from "immutable"; const fileName = "file"; const notebookName = "file.ipynb"; -const filePath = `folder/${fileName}`; -const notebookPath = `folder/${notebookName}`; +const folderPath = "folder"; +const filePath = `${folderPath}/${fileName}`; +const notebookPath = `${folderPath}/${notebookName}`; +const gitHubFolderUri = GitHubUtils.toContentUri("owner", "repo", "branch", folderPath); const gitHubFileUri = GitHubUtils.toContentUri("owner", "repo", "branch", filePath); const gitHubNotebookUri = GitHubUtils.toContentUri("owner", "repo", "branch", notebookPath); const notebookRecord = makeNotebookRecord({ @@ -80,6 +82,26 @@ describe("NotebookUtil", () => { }); }); + describe("getFilePath", () => { + it("works for jupyter file paths", () => { + expect(NotebookUtil.getFilePath(folderPath, fileName)).toEqual(filePath); + }); + + it("works for github file uris", () => { + expect(NotebookUtil.getFilePath(gitHubFolderUri, fileName)).toEqual(gitHubFileUri); + }); + }); + + describe("getParentPath", () => { + it("works for jupyter file paths", () => { + expect(NotebookUtil.getParentPath(filePath)).toEqual(folderPath); + }); + + it("works for github file uris", () => { + expect(NotebookUtil.getParentPath(gitHubFileUri)).toEqual(gitHubFolderUri); + }); + }); + describe("getName", () => { it("works for jupyter file paths", () => { expect(NotebookUtil.getName(filePath)).toEqual(fileName); diff --git a/src/Explorer/Notebook/NotebookUtil.ts b/src/Explorer/Notebook/NotebookUtil.ts index 27ad34a53..3b076c55b 100644 --- a/src/Explorer/Notebook/NotebookUtil.ts +++ b/src/Explorer/Notebook/NotebookUtil.ts @@ -70,6 +70,46 @@ export class NotebookUtil { }; } + public static getFilePath(path: string, fileName: string): string { + const contentInfo = GitHubUtils.fromContentUri(path); + if (contentInfo) { + let path = fileName; + if (contentInfo.path) { + path = `${contentInfo.path}/${path}`; + } + return GitHubUtils.toContentUri(contentInfo.owner, contentInfo.repo, contentInfo.branch, path); + } + + return `${path}/${fileName}`; + } + + public static getParentPath(filepath: string): undefined | string { + const basename = NotebookUtil.getName(filepath); + if (basename) { + const contentInfo = GitHubUtils.fromContentUri(filepath); + if (contentInfo) { + const parentPath = contentInfo.path.split(basename).shift(); + if (parentPath === undefined) { + return undefined; + } + + return GitHubUtils.toContentUri( + contentInfo.owner, + contentInfo.repo, + contentInfo.branch, + parentPath.replace(/\/$/, "") // no trailling slash + ); + } + + const parentPath = filepath.split(basename).shift(); + if (parentPath) { + return parentPath.replace(/\/$/, ""); // no trailling slash + } + } + + return undefined; + } + public static getName(path: string): undefined | string { let relativePath: string = path; const contentInfo = GitHubUtils.fromContentUri(path); diff --git a/src/Explorer/Panes/CopyNotebookPane.tsx b/src/Explorer/Panes/CopyNotebookPane.tsx new file mode 100644 index 000000000..62ad1c6c7 --- /dev/null +++ b/src/Explorer/Panes/CopyNotebookPane.tsx @@ -0,0 +1,283 @@ +import ko from "knockout"; +import * as React from "react"; +import { ReactAdapter } from "../../Bindings/ReactBindingHandler"; +import * as Logger from "../../Common/Logger"; +import { JunoClient, IPinnedRepo } from "../../Juno/JunoClient"; +import * as NotificationConsoleUtils from "../../Utils/NotificationConsoleUtils"; +import Explorer from "../Explorer"; +import { GenericRightPaneComponent, GenericRightPaneProps } from "./GenericRightPaneComponent"; +import { + Stack, + Label, + Text, + Dropdown, + IDropdownProps, + IDropdownOption, + SelectableOptionMenuItemType, + IRenderFunction, + ISelectableOption +} from "office-ui-fabric-react"; +import { GitHubOAuthService } from "../../GitHub/GitHubOAuthService"; +import { HttpStatusCodes } from "../../Common/Constants"; +import * as GitHubUtils from "../../Utils/GitHubUtils"; +import { NotebookContentItemType, NotebookContentItem } from "../Notebook/NotebookContentItem"; +import { ResourceTreeAdapter } from "../Tree/ResourceTreeAdapter"; + +interface Location { + type: "MyNotebooks" | "GitHub"; + + // GitHub + owner?: string; + repo?: string; + branch?: string; +} + +export class CopyNotebookPaneAdapter implements ReactAdapter { + private static readonly BranchNameWhiteSpace = " "; + + parameters: ko.Observable; + private isOpened: boolean; + private isExecuting: boolean; + private formError: string; + private formErrorDetail: string; + private name: string; + private content: string; + private pinnedRepos: IPinnedRepo[]; + private selectedLocation: Location; + + constructor( + private container: Explorer, + private junoClient: JunoClient, + private gitHubOAuthService: GitHubOAuthService + ) { + this.parameters = ko.observable(Date.now()); + this.reset(); + this.triggerRender(); + } + + public renderComponent(): JSX.Element { + if (!this.isOpened) { + return undefined; + } + + const props: GenericRightPaneProps = { + container: this.container, + content: this.createContent(), + formError: this.formError, + formErrorDetail: this.formErrorDetail, + id: "copynotebookpane", + isExecuting: this.isExecuting, + title: "Copy notebook", + submitButtonText: "OK", + onClose: () => this.close(), + onSubmit: () => this.submit() + }; + + return ; + } + + public triggerRender(): void { + window.requestAnimationFrame(() => this.parameters(Date.now())); + } + + public async open(name: string, content: string): Promise { + this.name = name; + this.content = content; + + this.isOpened = true; + this.triggerRender(); + + if (this.gitHubOAuthService.isLoggedIn()) { + const response = await this.junoClient.getPinnedRepos(this.gitHubOAuthService.getTokenObservable()()?.scope); + if (response.status !== HttpStatusCodes.OK && response.status !== HttpStatusCodes.NoContent) { + const message = `Received HTTP ${response.status} when fetching pinned repos`; + Logger.logError(message, "CopyNotebookPaneAdapter/submit"); + NotificationConsoleUtils.logConsoleError(message); + } + + if (response.data?.length > 0) { + this.pinnedRepos = response.data; + this.triggerRender(); + } + } + } + + public close(): void { + this.reset(); + this.triggerRender(); + } + + public async submit(): Promise { + let destination: string = this.selectedLocation?.type; + let clearMessage: () => void; + this.isExecuting = true; + this.triggerRender(); + + try { + if (!this.selectedLocation) { + throw new Error(`No location selected`); + } + + if (this.selectedLocation.type === "GitHub") { + destination = `${destination} - ${GitHubUtils.toRepoFullName( + this.selectedLocation.owner, + this.selectedLocation.repo + )} - ${this.selectedLocation.branch}`; + } + + clearMessage = NotificationConsoleUtils.logConsoleProgress(`Copying ${this.name} to ${destination}`); + + const notebookContentItem = await this.copyNotebook(this.selectedLocation); + if (!notebookContentItem) { + throw new Error(`Failed to upload ${this.name}`); + } + + NotificationConsoleUtils.logConsoleInfo(`Successfully copied ${this.name} to ${destination}`); + } catch (error) { + this.formError = `Failed to copy ${this.name} to ${destination}`; + this.formErrorDetail = `${error}`; + + const message = `${this.formError}: ${this.formErrorDetail}`; + Logger.logError(message, "CopyNotebookPaneAdapter/submit"); + NotificationConsoleUtils.logConsoleError(message); + return; + } finally { + clearMessage && clearMessage(); + this.isExecuting = false; + this.triggerRender(); + } + + this.close(); + } + + private copyNotebook = async (location: Location): Promise => { + let parent: NotebookContentItem; + switch (location.type) { + case "MyNotebooks": + parent = { + name: ResourceTreeAdapter.MyNotebooksTitle, + path: this.container.getNotebookBasePath(), + type: NotebookContentItemType.Directory + }; + break; + + case "GitHub": + parent = { + name: ResourceTreeAdapter.GitHubReposTitle, + path: GitHubUtils.toContentUri( + this.selectedLocation.owner, + this.selectedLocation.repo, + this.selectedLocation.branch, + "" + ), + type: NotebookContentItemType.Directory + }; + break; + + default: + throw new Error(`Unsupported location type ${location.type}`); + } + + return this.container.uploadFile(this.name, this.content, parent); + }; + + private createContent = (): JSX.Element => { + const dropDownProps: IDropdownProps = { + label: "Location", + ariaLabel: "Location", + placeholder: "Select an option", + onRenderTitle: this.onRenderDropDownTitle, + onRenderOption: this.onRenderDropDownOption, + options: this.getDropDownOptions(), + onChange: this.onDropDownChange + }; + + return ( +
+ + + + {this.name} + + + + +
+ ); + }; + + private onRenderDropDownTitle: IRenderFunction = (options: IDropdownOption[]): JSX.Element => { + return {options.length && options[0].title}; + }; + + private onRenderDropDownOption: IRenderFunction = (option: ISelectableOption): JSX.Element => { + return {option.text}; + }; + + private getDropDownOptions = (): IDropdownOption[] => { + const options: IDropdownOption[] = []; + + options.push({ + key: "MyNotebooks-Item", + text: ResourceTreeAdapter.MyNotebooksTitle, + title: ResourceTreeAdapter.MyNotebooksTitle, + data: { + type: "MyNotebooks" + } as Location + }); + + if (this.pinnedRepos && this.pinnedRepos.length > 0) { + options.push({ + key: "GitHub-Header-Divider", + text: undefined, + itemType: SelectableOptionMenuItemType.Divider + }); + + options.push({ + key: "GitHub-Header", + text: ResourceTreeAdapter.GitHubReposTitle, + itemType: SelectableOptionMenuItemType.Header + }); + + this.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: `${CopyNotebookPaneAdapter.BranchNameWhiteSpace}${branch.name}`, + title: `${repoFullName} - ${branch.name}`, + data: { + type: "GitHub", + owner: pinnedRepo.owner, + repo: pinnedRepo.name, + branch: branch.name + } as Location + }) + ); + }); + } + + return options; + }; + + private onDropDownChange = (_: React.FormEvent, option?: IDropdownOption): void => { + this.selectedLocation = option?.data; + }; + + private reset = (): void => { + this.isOpened = false; + this.isExecuting = false; + this.formError = undefined; + this.formErrorDetail = undefined; + this.name = undefined; + this.content = undefined; + this.pinnedRepos = undefined; + this.selectedLocation = undefined; + }; +} diff --git a/src/Explorer/Panes/PublishNotebookPaneAdapter.tsx b/src/Explorer/Panes/PublishNotebookPaneAdapter.tsx index 2848db120..3af78a4c2 100644 --- a/src/Explorer/Panes/PublishNotebookPaneAdapter.tsx +++ b/src/Explorer/Panes/PublishNotebookPaneAdapter.tsx @@ -175,6 +175,7 @@ export class PublishNotebookPaneAdapter implements ReactAdapter { notebookCreatedDate: new Date().toISOString(), notebookObject: this.notebookObject, notebookParentDomElement: this.parentDomElement, + onChangeName: (newValue: string) => (this.name = newValue), onChangeDescription: (newValue: string) => (this.description = newValue), onChangeTags: (newValue: string) => (this.tags = newValue), onChangeImageSrc: (newValue: string) => (this.imageSrc = newValue), diff --git a/src/Explorer/Panes/PublishNotebookPaneComponent.test.tsx b/src/Explorer/Panes/PublishNotebookPaneComponent.test.tsx index 8f2aae7f7..59762d955 100644 --- a/src/Explorer/Panes/PublishNotebookPaneComponent.test.tsx +++ b/src/Explorer/Panes/PublishNotebookPaneComponent.test.tsx @@ -12,6 +12,7 @@ describe("PublishNotebookPaneComponent", () => { notebookCreatedDate: "2020-07-17T00:00:00Z", notebookObject: undefined, notebookParentDomElement: undefined, + onChangeName: undefined, onChangeDescription: undefined, onChangeTags: undefined, onChangeImageSrc: undefined, diff --git a/src/Explorer/Panes/PublishNotebookPaneComponent.tsx b/src/Explorer/Panes/PublishNotebookPaneComponent.tsx index 29aab162c..986b42e2e 100644 --- a/src/Explorer/Panes/PublishNotebookPaneComponent.tsx +++ b/src/Explorer/Panes/PublishNotebookPaneComponent.tsx @@ -15,6 +15,7 @@ export interface PublishNotebookPaneProps { notebookCreatedDate: string; notebookObject: ImmutableNotebook; notebookParentDomElement: HTMLElement; + onChangeName: (newValue: string) => void; onChangeDescription: (newValue: string) => void; onChangeTags: (newValue: string) => void; onChangeImageSrc: (newValue: string) => void; @@ -24,6 +25,7 @@ export interface PublishNotebookPaneProps { interface PublishNotebookPaneState { type: string; + notebookName: string; notebookDescription: string; notebookTags: string; imageSrc: string; @@ -40,6 +42,7 @@ export class PublishNotebookPaneComponent extends React.Component { + this.props.onChangeName(newValue); + this.setState({ notebookName: newValue }); + } + }; + this.descriptionProps = { label: "Description", ariaLabel: "Description", @@ -245,6 +260,10 @@ export class PublishNotebookPaneComponent extends React.Component{this.descriptionPara2} + + + + diff --git a/src/Explorer/Panes/__snapshots__/PublishNotebookPaneComponent.test.tsx.snap b/src/Explorer/Panes/__snapshots__/PublishNotebookPaneComponent.test.tsx.snap index 744060f85..f7499f6af 100644 --- a/src/Explorer/Panes/__snapshots__/PublishNotebookPaneComponent.test.tsx.snap +++ b/src/Explorer/Panes/__snapshots__/PublishNotebookPaneComponent.test.tsx.snap @@ -22,6 +22,15 @@ exports[`PublishNotebookPaneComponent renders 1`] = ` Would you like to publish and share "SampleNotebook" to the gallery? + + + this.copyNotebook(), + commandButtonLabel: copyToLabel, + hasPopup: false, + disabled: false, + ariaLabel: copyToLabel + }, { iconName: "PublishContent", onCommandClick: async () => await this.publishToGallery(), @@ -465,6 +475,18 @@ export default class NotebookTabV2 extends TabsBase { ); }; + 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); + }; + private traceTelemetry(actionType: number) { TelemetryProcessor.trace(actionType, ActionModifiers.Mark, { databaseAccountName: this.container.databaseAccount() && this.container.databaseAccount().name, diff --git a/src/Explorer/Tree/ResourceTreeAdapter.tsx b/src/Explorer/Tree/ResourceTreeAdapter.tsx index 15f477103..2564a4930 100644 --- a/src/Explorer/Tree/ResourceTreeAdapter.tsx +++ b/src/Explorer/Tree/ResourceTreeAdapter.tsx @@ -7,6 +7,7 @@ import * as ViewModels from "../../Contracts/ViewModels"; import { NotebookContentItem, NotebookContentItemType } from "../Notebook/NotebookContentItem"; import { ResourceTreeContextMenuButtonFactory } from "../ContextMenuButtonFactory"; import * as MostRecentActivity from "../MostRecentActivity/MostRecentActivity"; +import CopyIcon from "../../../images/notebook/Notebook-copy.svg"; import CosmosDBIcon from "../../../images/Azure-Cosmos-DB.svg"; import CollectionIcon from "../../../images/tree-collection.svg"; import DeleteIcon from "../../../images/delete.svg"; @@ -33,6 +34,9 @@ import TabsBase from "../Tabs/TabsBase"; import { userContext } from "../../UserContext"; 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"; @@ -104,7 +108,7 @@ export class ResourceTreeAdapter implements ReactAdapter { }; this.myNotebooksContentRoot = { - name: "My Notebooks", + name: ResourceTreeAdapter.MyNotebooksTitle, path: this.container.getNotebookBasePath(), type: NotebookContentItemType.Directory }; @@ -118,7 +122,7 @@ export class ResourceTreeAdapter implements ReactAdapter { if (this.container.notebookManager?.gitHubOAuthService.isLoggedIn()) { this.gitHubNotebooksContentRoot = { - name: "GitHub repos", + name: ResourceTreeAdapter.GitHubReposTitle, path: ResourceTreeAdapter.PseudoDirPath, type: NotebookContentItemType.Directory }; @@ -563,6 +567,11 @@ export class ResourceTreeAdapter implements ReactAdapter { ); } }, + { + label: "Copy to ...", + iconSrc: CopyIcon, + onClick: () => this.copyNotebook(item) + }, { label: "Download", iconSrc: NotebookIcon, @@ -574,6 +583,13 @@ export class ResourceTreeAdapter implements ReactAdapter { }; } + 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[] = [ { diff --git a/src/GitHub/GitHubClient.ts b/src/GitHub/GitHubClient.ts index f6d761405..64ed9e2d1 100644 --- a/src/GitHub/GitHubClient.ts +++ b/src/GitHub/GitHubClient.ts @@ -317,6 +317,13 @@ export class GitHubClient { objectExpression: `refs/heads/${branch}:${path || ""}` } as ContentsQueryParams)) as ContentsQueryResponse; + if (!response.repository.object) { + return { + status: HttpStatusCodes.NotFound, + data: undefined + }; + } + let data: IGitHubFile | IGitHubFile[]; const entries = response.repository.object.entries; const gitHubRepo = GitHubClient.toGitHubRepo(response.repository); diff --git a/src/GitHub/GitHubContentProvider.ts b/src/GitHub/GitHubContentProvider.ts index e4be2a11e..899268704 100644 --- a/src/GitHub/GitHubContentProvider.ts +++ b/src/GitHub/GitHubContentProvider.ts @@ -2,10 +2,11 @@ import { Notebook, stringifyNotebook, makeNotebookRecord, toJS } from "@nteract/ import { FileType, IContent, IContentProvider, IEmptyContent, IGetParams, ServerConfig } from "@nteract/core"; import { from, Observable, of } from "rxjs"; import { AjaxResponse } from "rxjs/ajax"; +import * as Base64Utils from "../Utils/Base64Utils"; import { HttpStatusCodes } from "../Common/Constants"; import * as Logger from "../Common/Logger"; import { NotebookUtil } from "../Explorer/Notebook/NotebookUtil"; -import { GitHubClient, IGitHubFile, IGitHubResponse, IGitHubCommit, IGitHubBranch } from "./GitHubClient"; +import { GitHubClient, IGitHubFile, IGitHubResponse } from "./GitHubClient"; import * as GitHubUtils from "../Utils/GitHubUtils"; import UrlUtility from "../Common/UrlUtility"; @@ -131,7 +132,7 @@ export class GitHubContentProvider implements IContentProvider { throw new GitHubContentProviderError(`Failed to parse ${uri}`); } - const content = btoa(stringifyNotebook(toJS(makeNotebookRecord()))); + const content = Base64Utils.utf8ToB64(stringifyNotebook(toJS(makeNotebookRecord()))); const options: Intl.DateTimeFormatOptions = { year: "numeric", month: "short", @@ -195,34 +196,63 @@ export class GitHubContentProvider implements IContentProvider { return from( this.getContent(uri).then(async (content: IGitHubResponse) => { try { - const commitMsg = await this.validateContentAndGetCommitMsg(content, "Save", "Save"); + let commitMsg: string; + if (content.status === HttpStatusCodes.NotFound) { + // We'll create a new file since it doesn't exist + commitMsg = await this.params.promptForCommitMsg("Save", "Save"); + if (!commitMsg) { + throw new GitHubContentProviderError("Couldn't get a commit message"); + } + } else { + commitMsg = await this.validateContentAndGetCommitMsg(content, "Save", "Save"); + } + let updatedContent: string; if (model.type === "notebook") { - updatedContent = btoa(stringifyNotebook(model.content as Notebook)); + updatedContent = Base64Utils.utf8ToB64(stringifyNotebook(model.content as Notebook)); } else if (model.type === "file") { updatedContent = model.content as string; if (model.format !== "base64") { - updatedContent = btoa(updatedContent); + updatedContent = Base64Utils.utf8ToB64(updatedContent); } } else { throw new GitHubContentProviderError("Unsupported content type"); } - const gitHubFile = content.data as IGitHubFile; - const response = await this.params.gitHubClient.createOrUpdateFileAsync( - gitHubFile.repo.owner, - gitHubFile.repo.name, - gitHubFile.branch.name, - gitHubFile.path, - commitMsg, - updatedContent, - gitHubFile.sha - ); - if (response.status !== HttpStatusCodes.OK) { - throw new GitHubContentProviderError("Failed to update", response.status); + const contentInfo = GitHubUtils.fromContentUri(uri); + let gitHubFile: IGitHubFile; + if (content.data) { + gitHubFile = content.data as IGitHubFile; } - gitHubFile.commit = response.data; + const response = await this.params.gitHubClient.createOrUpdateFileAsync( + contentInfo.owner, + contentInfo.repo, + contentInfo.branch, + contentInfo.path, + commitMsg, + updatedContent, + gitHubFile?.sha + ); + if (response.status !== HttpStatusCodes.OK && response.status !== HttpStatusCodes.Created) { + throw new GitHubContentProviderError("Failed to create or update", response.status); + } + + if (gitHubFile) { + gitHubFile.commit = response.data; + } else { + const contentResponse = await this.params.gitHubClient.getContentsAsync( + contentInfo.owner, + contentInfo.repo, + contentInfo.branch, + contentInfo.path + ); + if (contentResponse.status !== HttpStatusCodes.OK) { + throw new GitHubContentProviderError("Failed to get content", response.status); + } + + gitHubFile = contentResponse.data as IGitHubFile; + } return this.createSuccessAjaxResponse( HttpStatusCodes.OK, diff --git a/src/Utils/Base64Utils.test.ts b/src/Utils/Base64Utils.test.ts new file mode 100644 index 000000000..938fe3f46 --- /dev/null +++ b/src/Utils/Base64Utils.test.ts @@ -0,0 +1,11 @@ +import * as Base64Utils from "./Base64Utils"; + +describe("Base64Utils", () => { + describe("utf8ToB64", () => { + it("should convert utf8 to base64", () => { + expect(Base64Utils.utf8ToB64("abcd")).toEqual(btoa("abcd")); + expect(Base64Utils.utf8ToB64("小飼弾")).toEqual("5bCP6aO85by+"); + expect(Base64Utils.utf8ToB64("à mon hôpital préféré")).toEqual("w6AgbW9uIGjDtHBpdGFsIHByw6lmw6lyw6k="); + }); + }); +}); diff --git a/src/Utils/Base64Utils.ts b/src/Utils/Base64Utils.ts new file mode 100644 index 000000000..83396f0b9 --- /dev/null +++ b/src/Utils/Base64Utils.ts @@ -0,0 +1,7 @@ +export const utf8ToB64 = (utf8Str: string): string => { + return btoa( + encodeURIComponent(utf8Str).replace(/%([0-9A-F]{2})/g, (_, args) => { + return String.fromCharCode(parseInt(args, 16)); + }) + ); +}; diff --git a/src/explorer.html b/src/explorer.html index fd6ae83c4..263e6ab98 100644 --- a/src/explorer.html +++ b/src/explorer.html @@ -296,6 +296,10 @@
+ +
+ +