mirror of
https://github.com/Azure/cosmos-explorer.git
synced 2025-12-20 01:11:25 +00:00
Initial Move from Azure DevOps to GitHub
This commit is contained in:
37
src/Explorer/Notebook/FileSystemUtil.ts
Normal file
37
src/Explorer/Notebook/FileSystemUtil.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
// Utilities for file system
|
||||
|
||||
export class FileSystemUtil {
|
||||
/**
|
||||
* file list returns path starting with ./blah
|
||||
* rename returns simply blah.
|
||||
* Both are the same. This method only handles these two cases and no other complicated paths that may contain ..
|
||||
* ./ inside the path.
|
||||
* TODO: this should go away when not using jupyter for file operations and use normalized paths.
|
||||
* @param path1
|
||||
* @param path2
|
||||
*/
|
||||
public static isPathEqual(path1: string, path2: string): boolean {
|
||||
const normalize = (path: string): string => {
|
||||
const dotSlash = "./";
|
||||
if (path.indexOf(dotSlash) === 0) {
|
||||
path = path.substring(dotSlash.length);
|
||||
}
|
||||
return path;
|
||||
};
|
||||
|
||||
return normalize(path1) === normalize(path2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove extension
|
||||
* @param path
|
||||
* @param extension Without the ".". e.g. "ipynb" (and not ".ipynb")
|
||||
*/
|
||||
public static stripExtension(path: string, extension: string): string {
|
||||
const splitted = path.split(".");
|
||||
if (splitted[splitted.length - 1] === extension) {
|
||||
splitted.pop();
|
||||
}
|
||||
return splitted.join(".");
|
||||
}
|
||||
}
|
||||
22
src/Explorer/Notebook/NTeractUtil.ts
Normal file
22
src/Explorer/Notebook/NTeractUtil.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { NotebookContentRecordProps, selectors } from "@nteract/core";
|
||||
|
||||
/**
|
||||
* A bunch of utilities to interact with nteract
|
||||
*/
|
||||
export default class NTeractUtil {
|
||||
public static getCurrentCellType(content: NotebookContentRecordProps): "markdown" | "code" | "raw" {
|
||||
if (!content) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const cellFocusedId = selectors.notebook.cellFocused(content.model);
|
||||
if (cellFocusedId) {
|
||||
const cell = selectors.notebook.cellById(content.model, { id: cellFocusedId });
|
||||
if (cell) {
|
||||
return cell.cell_type;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
264
src/Explorer/Notebook/NotebookClientV2.ts
Normal file
264
src/Explorer/Notebook/NotebookClientV2.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
// Manages all the redux logic for the notebook nteract code
|
||||
// TODO: Merge with NotebookClient?
|
||||
import { NotebookWorkspaceConnectionInfo } from "../../Contracts/DataModels";
|
||||
import * as Constants from "../../Common/Constants";
|
||||
import { CdbAppState, makeCdbRecord } from "./NotebookComponent/types";
|
||||
|
||||
// Vendor modules
|
||||
import {
|
||||
actions,
|
||||
AppState,
|
||||
createHostRef,
|
||||
createKernelspecsRef,
|
||||
makeAppRecord,
|
||||
makeCommsRecord,
|
||||
makeContentsRecord,
|
||||
makeEntitiesRecord,
|
||||
makeHostsRecord,
|
||||
makeJupyterHostRecord,
|
||||
makeStateRecord,
|
||||
makeTransformsRecord,
|
||||
ContentRecord,
|
||||
HostRecord,
|
||||
HostRef,
|
||||
KernelspecsRef,
|
||||
IContentProvider
|
||||
} from "@nteract/core";
|
||||
import { Media } from "@nteract/outputs";
|
||||
import TransformVDOM from "@nteract/transform-vdom";
|
||||
import * as Immutable from "immutable";
|
||||
import { Store, AnyAction, MiddlewareAPI, Middleware, Dispatch } from "redux";
|
||||
|
||||
import configureStore from "./NotebookComponent/store";
|
||||
|
||||
import { Notification } from "react-notification-system";
|
||||
import TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor";
|
||||
import { Action } from "../../Shared/Telemetry/TelemetryConstants";
|
||||
|
||||
export type KernelSpecsDisplay = { name: string; displayName: string };
|
||||
|
||||
export interface NotebookClientV2Parameters {
|
||||
connectionInfo: NotebookWorkspaceConnectionInfo;
|
||||
databaseAccountName: string;
|
||||
defaultExperience: string;
|
||||
isReadOnly?: boolean; // if true: do not fetch kernelspecs automatically (this is for notebook viewer)
|
||||
cellEditorType?: string; // override "codemirror" default,
|
||||
autoSaveInterval?: number; // in ms
|
||||
contentProvider: IContentProvider;
|
||||
}
|
||||
|
||||
export type ActionListener = (newValue: any) => void;
|
||||
|
||||
export class NotebookClientV2 {
|
||||
private store: Store<AppState, AnyAction>;
|
||||
private contentHostRef: HostRef;
|
||||
private kernelSpecsForDisplay: KernelSpecsDisplay[] = [];
|
||||
private kernelSpecsRef: KernelspecsRef;
|
||||
|
||||
private databaseAccountName: string;
|
||||
private defaultExperience: string;
|
||||
|
||||
constructor(params: NotebookClientV2Parameters) {
|
||||
this.databaseAccountName = params.databaseAccountName;
|
||||
this.defaultExperience = params.defaultExperience;
|
||||
|
||||
this.configureStore(params);
|
||||
|
||||
this.kernelSpecsRef = createKernelspecsRef();
|
||||
|
||||
// Fetch kernel specs when opening new tab
|
||||
if (!params.isReadOnly) {
|
||||
this.getStore().dispatch(
|
||||
actions.fetchKernelspecs({
|
||||
hostRef: this.contentHostRef,
|
||||
kernelspecsRef: this.kernelSpecsRef
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public getAvailableKernelSpecs(): KernelSpecsDisplay[] {
|
||||
return this.kernelSpecsForDisplay;
|
||||
}
|
||||
|
||||
public getStore(): Store<AppState, AnyAction> {
|
||||
return this.store;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazy init redux store as singleton.
|
||||
* Don't move store in Explorer yet as it is typed to AppState which is nteract-specific
|
||||
*/
|
||||
private configureStore(params: NotebookClientV2Parameters): void {
|
||||
const jupyterHostRecord = makeJupyterHostRecord({
|
||||
id: null,
|
||||
type: "jupyter",
|
||||
defaultKernelName: "python",
|
||||
token: params.connectionInfo.authToken,
|
||||
origin: params.connectionInfo.notebookServerEndpoint,
|
||||
basePath: "/", // Jupyter server base URL
|
||||
bookstoreEnabled: false, //!!config.bookstore.version,
|
||||
showHeaderEditor: true,
|
||||
crossDomain: true
|
||||
});
|
||||
|
||||
this.contentHostRef = createHostRef();
|
||||
const NullTransform = (): any => null;
|
||||
const kernelspecsRef = createKernelspecsRef();
|
||||
|
||||
const initialState: CdbAppState = {
|
||||
app: makeAppRecord({
|
||||
version: "dataExplorer 1.0",
|
||||
host: jupyterHostRecord
|
||||
// TODO: tamitta: notificationSystem.addNotification was removed, do we need a substitute?
|
||||
}),
|
||||
comms: makeCommsRecord(),
|
||||
config: Immutable.Map({
|
||||
theme: "light",
|
||||
editorType: params.cellEditorType || "codemirror",
|
||||
autoSaveInterval: params.autoSaveInterval || Constants.Notebook.autoSaveIntervalMs
|
||||
}),
|
||||
core: makeStateRecord({
|
||||
currentKernelspecsRef: kernelspecsRef,
|
||||
entities: makeEntitiesRecord({
|
||||
hosts: makeHostsRecord({
|
||||
byRef: Immutable.Map<string, HostRecord>().set(this.contentHostRef, jupyterHostRecord)
|
||||
}),
|
||||
contents: makeContentsRecord({
|
||||
// byRef: Immutable.Map<string, ContentRecord>().set(this.contentRef, record)
|
||||
byRef: Immutable.Map<string, ContentRecord>()
|
||||
}),
|
||||
transforms: makeTransformsRecord({
|
||||
displayOrder: Immutable.List([
|
||||
"application/vnd.jupyter.widget-view+json",
|
||||
"application/vnd.vega.v5+json",
|
||||
"application/vnd.vega.v4+json",
|
||||
"application/vnd.vega.v3+json",
|
||||
"application/vnd.vega.v2+json",
|
||||
"application/vnd.vegalite.v3+json",
|
||||
"application/vnd.vegalite.v2+json",
|
||||
"application/vnd.vegalite.v1+json",
|
||||
"application/geo+json",
|
||||
"application/vnd.plotly.v1+json",
|
||||
"text/vnd.plotly.v1+html",
|
||||
"application/x-nteract-model-debug+json",
|
||||
"application/vnd.dataresource+json",
|
||||
"application/vdom.v1+json",
|
||||
"application/json",
|
||||
"application/javascript",
|
||||
"text/html",
|
||||
"text/markdown",
|
||||
"text/latex",
|
||||
"image/svg+xml",
|
||||
"image/gif",
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"text/plain"
|
||||
]),
|
||||
byId: Immutable.Map({
|
||||
"text/vnd.plotly.v1+html": NullTransform,
|
||||
"application/vnd.plotly.v1+json": NullTransform,
|
||||
"application/geo+json": NullTransform,
|
||||
"application/x-nteract-model-debug+json": NullTransform,
|
||||
"application/vnd.dataresource+json": NullTransform,
|
||||
"application/vnd.jupyter.widget-view+json": NullTransform,
|
||||
"application/vnd.vegalite.v1+json": NullTransform,
|
||||
"application/vnd.vegalite.v2+json": NullTransform,
|
||||
"application/vnd.vegalite.v3+json": NullTransform,
|
||||
"application/vnd.vega.v2+json": NullTransform,
|
||||
"application/vnd.vega.v3+json": NullTransform,
|
||||
"application/vnd.vega.v4+json": NullTransform,
|
||||
"application/vnd.vega.v5+json": NullTransform,
|
||||
"application/vdom.v1+json": TransformVDOM,
|
||||
"application/json": Media.Json,
|
||||
"application/javascript": Media.JavaScript,
|
||||
"text/html": Media.HTML,
|
||||
"text/markdown": Media.Markdown,
|
||||
"text/latex": Media.LaTeX,
|
||||
"image/svg+xml": Media.SVG,
|
||||
"image/gif": Media.Image,
|
||||
"image/png": Media.Image,
|
||||
"image/jpeg": Media.Image,
|
||||
"text/plain": Media.Plain
|
||||
})
|
||||
})
|
||||
})
|
||||
}),
|
||||
cdb: makeCdbRecord({
|
||||
databaseAccountName: params.databaseAccountName,
|
||||
defaultExperience: params.defaultExperience
|
||||
})
|
||||
};
|
||||
|
||||
/**
|
||||
* Intercept kernelspecs updates actions rather than subscribing to the store state changes (which
|
||||
* is triggered for *any* state change).
|
||||
* TODO: Use react-redux connect() to subscribe to state changes?
|
||||
*/
|
||||
const cacheKernelSpecsMiddleware: Middleware = <D extends Dispatch<AnyAction>, S extends AppState>({
|
||||
dispatch,
|
||||
getState
|
||||
}: MiddlewareAPI<D, S>) => (next: Dispatch<AnyAction>) => <A extends AnyAction>(action: A): A => {
|
||||
switch (action.type) {
|
||||
case actions.FETCH_KERNELSPECS_FULFILLED: {
|
||||
const payload = ((action as unknown) as actions.FetchKernelspecsFulfilled).payload;
|
||||
const defaultKernelName = payload.defaultKernelName;
|
||||
this.kernelSpecsForDisplay = Object.keys(payload.kernelspecs)
|
||||
.map(name => ({
|
||||
name,
|
||||
displayName: payload.kernelspecs[name].displayName
|
||||
}))
|
||||
.sort((a: KernelSpecsDisplay, b: KernelSpecsDisplay) => {
|
||||
// Put default at the top, otherwise lexicographically compare
|
||||
if (a.displayName === defaultKernelName) {
|
||||
return -1;
|
||||
} else if (b.name === defaultKernelName) {
|
||||
return 1;
|
||||
} else {
|
||||
return a.displayName.localeCompare(b.displayName);
|
||||
}
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return next(action);
|
||||
};
|
||||
|
||||
const traceErrorFct = (title: string, message: string) => {
|
||||
TelemetryProcessor.traceFailure(Action.NotebookErrorNotification, {
|
||||
databaseAccountName: this.databaseAccountName,
|
||||
defaultExperience: this.defaultExperience,
|
||||
dataExplorerArea: Constants.Areas.Notebook,
|
||||
title,
|
||||
message,
|
||||
level: "Error"
|
||||
});
|
||||
console.error(`${title}: ${message}`);
|
||||
};
|
||||
|
||||
this.store = configureStore(initialState, params.contentProvider, traceErrorFct, [cacheKernelSpecsMiddleware]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle notification coming from nteract
|
||||
* The messages coming from nteract are not good enough to expose to user.
|
||||
* We use the notificationsToUserEpic to control the messages from action.
|
||||
* We log possible errors coming from nteract in telemetry and display in console
|
||||
*/
|
||||
private handleNotification = (msg: Notification): void => {
|
||||
if (msg.level === "error") {
|
||||
TelemetryProcessor.traceFailure(Action.NotebookErrorNotification, {
|
||||
databaseAccountName: this.databaseAccountName,
|
||||
defaultExperience: this.defaultExperience,
|
||||
dataExplorerArea: Constants.Areas.Notebook,
|
||||
title: msg.title,
|
||||
message: msg.message,
|
||||
level: msg.level
|
||||
});
|
||||
console.error(`${msg.title}: ${msg.message}`);
|
||||
} else {
|
||||
console.log(`${msg.title}: ${msg.message}`);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
.notebookComponentContainer {
|
||||
text-transform:none;
|
||||
line-height:1.28581;
|
||||
letter-spacing:0;
|
||||
font-size:14px;
|
||||
font-weight:400;
|
||||
color:#182026;
|
||||
height: 100%;
|
||||
|
||||
.hotKeys {
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { ContentRef } from "@nteract/core";
|
||||
import * as React from "react";
|
||||
import NotificationSystem, { System as ReactNotificationSystem } from "react-notification-system";
|
||||
import { default as Contents } from "./contents";
|
||||
|
||||
export class NotebookComponent extends React.Component<{ contentRef: ContentRef }> {
|
||||
notificationSystem!: ReactNotificationSystem;
|
||||
|
||||
shouldComponentUpdate(nextProps: { contentRef: ContentRef }): boolean {
|
||||
return nextProps.contentRef !== this.props.contentRef;
|
||||
}
|
||||
|
||||
public render(): JSX.Element {
|
||||
return (
|
||||
<div className="notebookComponentContainer">
|
||||
<Contents contentRef={this.props.contentRef} />
|
||||
<NotificationSystem
|
||||
ref={(notificationSystem: ReactNotificationSystem) => {
|
||||
this.notificationSystem = notificationSystem;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { ReactAdapter } from "../../../Bindings/ReactBindingHandler";
|
||||
import { NotebookClientV2 } from "../NotebookClientV2";
|
||||
|
||||
// Vendor modules
|
||||
import { actions, createContentRef, createKernelRef, selectors } from "@nteract/core";
|
||||
import VirtualCommandBarComponent from "./VirtualCommandBarComponent";
|
||||
import { NotebookContentItem } from "../NotebookContentItem";
|
||||
import { NotebookComponentBootstrapper } from "./NotebookComponentBootstrapper";
|
||||
|
||||
export interface NotebookComponentAdapterOptions {
|
||||
contentItem: NotebookContentItem;
|
||||
notebooksBasePath: string;
|
||||
notebookClient: NotebookClientV2;
|
||||
onUpdateKernelInfo: () => void;
|
||||
}
|
||||
|
||||
export class NotebookComponentAdapter extends NotebookComponentBootstrapper implements ReactAdapter {
|
||||
private onUpdateKernelInfo: () => void;
|
||||
public parameters: any;
|
||||
|
||||
constructor(options: NotebookComponentAdapterOptions) {
|
||||
super({
|
||||
contentRef: selectors.contentRefByFilepath(options.notebookClient.getStore().getState(), {
|
||||
filepath: options.contentItem.path
|
||||
}),
|
||||
notebookClient: options.notebookClient
|
||||
});
|
||||
|
||||
this.onUpdateKernelInfo = options.onUpdateKernelInfo;
|
||||
|
||||
if (!this.contentRef) {
|
||||
this.contentRef = createContentRef();
|
||||
const kernelRef = createKernelRef();
|
||||
|
||||
// Request fetching notebook content
|
||||
this.getStore().dispatch(
|
||||
actions.fetchContent({
|
||||
filepath: options.contentItem.path,
|
||||
params: {},
|
||||
kernelRef,
|
||||
contentRef: this.contentRef
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
protected renderExtraComponent = (): JSX.Element => {
|
||||
return <VirtualCommandBarComponent contentRef={this.contentRef} onRender={this.onUpdateKernelInfo} />;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { NotebookComponent } from "./NotebookComponent";
|
||||
import { NotebookClientV2 } from "../NotebookClientV2";
|
||||
import { NotebookUtil } from "../NotebookUtil";
|
||||
|
||||
// Vendor modules
|
||||
import {
|
||||
actions,
|
||||
AppState,
|
||||
createKernelRef,
|
||||
DocumentRecordProps,
|
||||
ContentRef,
|
||||
KernelRef,
|
||||
NotebookContentRecord,
|
||||
selectors
|
||||
} from "@nteract/core";
|
||||
import * as Immutable from "immutable";
|
||||
import { Provider } from "react-redux";
|
||||
import { CellType, CellId } from "@nteract/commutable";
|
||||
import { Store, AnyAction } from "redux";
|
||||
|
||||
import "./NotebookComponent.less";
|
||||
|
||||
import "codemirror/addon/hint/show-hint.css";
|
||||
import "codemirror/lib/codemirror.css";
|
||||
import "@nteract/styles/editor-overrides.css";
|
||||
import "@nteract/styles/global-variables.css";
|
||||
import "react-table/react-table.css";
|
||||
|
||||
import * as CdbActions from "./actions";
|
||||
import NteractUtil from "../NTeractUtil";
|
||||
|
||||
export interface NotebookComponentBootstrapperOptions {
|
||||
notebookClient: NotebookClientV2;
|
||||
contentRef: ContentRef;
|
||||
}
|
||||
|
||||
export class NotebookComponentBootstrapper {
|
||||
protected contentRef: ContentRef;
|
||||
protected renderExtraComponent: () => JSX.Element;
|
||||
|
||||
private notebookClient: NotebookClientV2;
|
||||
|
||||
constructor(options: NotebookComponentBootstrapperOptions) {
|
||||
this.notebookClient = options.notebookClient;
|
||||
this.contentRef = options.contentRef;
|
||||
}
|
||||
|
||||
protected static wrapModelIntoContent(name: string, path: string, content: any) {
|
||||
return {
|
||||
name,
|
||||
path,
|
||||
last_modified: new Date(),
|
||||
created: "",
|
||||
content,
|
||||
format: "json",
|
||||
mimetype: null as any,
|
||||
size: 0,
|
||||
writeable: false,
|
||||
type: "notebook"
|
||||
};
|
||||
}
|
||||
|
||||
private renderDefaultNotebookComponent(props: any): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
{this.renderExtraComponent && this.renderExtraComponent()}
|
||||
{React.createElement<{ contentRef: ContentRef }>(NotebookComponent, { contentRef: this.contentRef, ...props })}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
public setContent(name: string, content: any): void {
|
||||
this.getStore().dispatch(
|
||||
actions.fetchContentFulfilled({
|
||||
filepath: undefined,
|
||||
model: NotebookComponentBootstrapper.wrapModelIntoContent(name, undefined, content),
|
||||
kernelRef: createKernelRef(),
|
||||
contentRef: this.contentRef
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* We can overload the notebook renderer here
|
||||
* @param renderer
|
||||
* @props additional props
|
||||
*/
|
||||
public renderComponent(
|
||||
renderer?: any, // TODO FIX THIS React.ComponentClass<{ contentRef: ContentRef; isReadOnly?: boolean }>,
|
||||
props?: any
|
||||
): JSX.Element {
|
||||
return (
|
||||
<Provider store={this.getStore()}>
|
||||
{renderer
|
||||
? React.createElement<{ contentRef: ContentRef }>(renderer, { contentRef: this.contentRef, ...props })
|
||||
: this.renderDefaultNotebookComponent(props)}
|
||||
</Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/* Notebook operations. See nteract/packages/connected-components/src/notebook-menu/index.tsx */
|
||||
public notebookSave(): void {
|
||||
this.getStore().dispatch(
|
||||
actions.save({
|
||||
contentRef: this.contentRef
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
public notebookChangeKernel(kernelSpecName: string): void {
|
||||
this.getStore().dispatch(
|
||||
actions.changeKernelByName({
|
||||
contentRef: this.contentRef,
|
||||
kernelSpecName,
|
||||
oldKernelRef: this.getCurrentKernelRef()
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
public notebookRunAndAdvance(): void {
|
||||
this.getStore().dispatch(
|
||||
CdbActions.executeFocusedCellAndFocusNext({
|
||||
contentRef: this.contentRef
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
public notebookRunAll(): void {
|
||||
this.getStore().dispatch(
|
||||
actions.executeAllCells({
|
||||
contentRef: this.contentRef
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
public notebookInterruptKernel(): void {
|
||||
this.getStore().dispatch(
|
||||
actions.interruptKernel({
|
||||
kernelRef: this.getCurrentKernelRef()
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
public notebookKillKernel(): void {
|
||||
this.getStore().dispatch(
|
||||
actions.killKernel({
|
||||
restarting: false,
|
||||
kernelRef: this.getCurrentKernelRef()
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
public notebookRestartKernel(): void {
|
||||
this.getStore().dispatch(
|
||||
actions.restartKernel({
|
||||
kernelRef: this.getCurrentKernelRef(),
|
||||
contentRef: this.contentRef,
|
||||
outputHandling: "None"
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
public notebookClearAllOutputs(): void {
|
||||
this.getStore().dispatch(
|
||||
actions.clearAllOutputs({
|
||||
contentRef: this.contentRef
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
public notebookInsertBelow(): void {
|
||||
this.getStore().dispatch(
|
||||
actions.createCellBelow({
|
||||
cellType: "code",
|
||||
source: "",
|
||||
contentRef: this.contentRef
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
public notebookChangeCellType(type: CellType): void {
|
||||
const focusedCellId = this.getFocusedCellId();
|
||||
if (!focusedCellId) {
|
||||
console.error("No focused cell");
|
||||
return;
|
||||
}
|
||||
|
||||
this.getStore().dispatch(
|
||||
actions.changeCellType({
|
||||
id: focusedCellId,
|
||||
contentRef: this.contentRef,
|
||||
to: type
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
public notebokCopy(): void {
|
||||
const focusedCellId = this.getFocusedCellId();
|
||||
if (!focusedCellId) {
|
||||
console.error("No focused cell");
|
||||
return;
|
||||
}
|
||||
|
||||
this.getStore().dispatch(
|
||||
actions.copyCell({
|
||||
id: focusedCellId,
|
||||
contentRef: this.contentRef
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
public notebookCut(): void {
|
||||
const focusedCellId = this.getFocusedCellId();
|
||||
if (!focusedCellId) {
|
||||
console.error("No focused cell");
|
||||
return;
|
||||
}
|
||||
|
||||
this.getStore().dispatch(
|
||||
actions.cutCell({
|
||||
id: focusedCellId,
|
||||
contentRef: this.contentRef
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
public notebookPaste(): void {
|
||||
this.getStore().dispatch(
|
||||
actions.pasteCell({
|
||||
contentRef: this.contentRef
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
public notebookShutdown(): void {
|
||||
const store = this.getStore();
|
||||
const kernelRef = this.getCurrentKernelRef();
|
||||
|
||||
if (kernelRef) {
|
||||
store.dispatch(
|
||||
actions.killKernel({
|
||||
restarting: false,
|
||||
kernelRef
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
store.dispatch(
|
||||
CdbActions.closeNotebook({
|
||||
contentRef: this.contentRef
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
public isContentDirty(): boolean {
|
||||
const content = selectors.content(this.getStore().getState(), { contentRef: this.contentRef });
|
||||
if (!content) {
|
||||
console.log("No error");
|
||||
return false;
|
||||
}
|
||||
|
||||
return selectors.notebook.isDirty(content.model as Immutable.RecordOf<DocumentRecordProps>);
|
||||
}
|
||||
|
||||
/**
|
||||
* For display purposes, only return non-killed kernels
|
||||
*/
|
||||
public getCurrentKernelName(): string {
|
||||
const currentKernel = selectors.kernel(this.getStore().getState(), { kernelRef: this.getCurrentKernelRef() });
|
||||
return (currentKernel && currentKernel.status !== "killed" && currentKernel.kernelSpecName) || undefined;
|
||||
}
|
||||
|
||||
// Returns the kernel name to select in the kernels dropdown
|
||||
public getSelectedKernelName(): string {
|
||||
const currentKernelName = this.getCurrentKernelName();
|
||||
if (!currentKernelName) {
|
||||
// if there's no live kernel, try to get the kernel name from the notebook metadata
|
||||
const content = selectors.content(this.getStore().getState(), { contentRef: this.contentRef });
|
||||
const notebook = content && (content as NotebookContentRecord).model.notebook;
|
||||
if (!notebook) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const { kernelSpecName } = NotebookUtil.extractNewKernel("", notebook);
|
||||
return kernelSpecName || undefined;
|
||||
}
|
||||
|
||||
return currentKernelName;
|
||||
}
|
||||
|
||||
public getActiveCellTypeStr(): string {
|
||||
const content = selectors.content(this.getStore().getState(), { contentRef: this.contentRef });
|
||||
return NteractUtil.getCurrentCellType(content as NotebookContentRecord);
|
||||
}
|
||||
|
||||
private getCurrentKernelRef(): KernelRef {
|
||||
return selectors.kernelRefByContentRef(this.getStore().getState(), { contentRef: this.contentRef });
|
||||
}
|
||||
|
||||
private getFocusedCellId(): CellId {
|
||||
const content = selectors.content(this.getStore().getState(), { contentRef: this.contentRef });
|
||||
if (!content) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return selectors.notebook.cellFocused((content as NotebookContentRecord).model);
|
||||
}
|
||||
|
||||
protected getStore(): Store<AppState, AnyAction> {
|
||||
return this.notebookClient.getStore();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { ServerConfig, IContentProvider, FileType, IContent, IGetParams } from "@nteract/core";
|
||||
import { Observable } from "rxjs";
|
||||
import { AjaxResponse } from "rxjs/ajax";
|
||||
import { GitHubContentProvider } from "../../../GitHub/GitHubContentProvider";
|
||||
import { GitHubUtils } from "../../../Utils/GitHubUtils";
|
||||
|
||||
export class NotebookContentProvider implements IContentProvider {
|
||||
constructor(private gitHubContentProvider: GitHubContentProvider, private jupyterContentProvider: IContentProvider) {}
|
||||
|
||||
public remove(serverConfig: ServerConfig, path: string): Observable<AjaxResponse> {
|
||||
return this.getContentProvider(path).remove(serverConfig, path);
|
||||
}
|
||||
|
||||
public get(serverConfig: ServerConfig, path: string, params: Partial<IGetParams>): Observable<AjaxResponse> {
|
||||
return this.getContentProvider(path).get(serverConfig, path, params);
|
||||
}
|
||||
|
||||
public update<FT extends FileType>(
|
||||
serverConfig: ServerConfig,
|
||||
path: string,
|
||||
model: Partial<IContent<FT>>
|
||||
): Observable<AjaxResponse> {
|
||||
return this.getContentProvider(path).update(serverConfig, path, model);
|
||||
}
|
||||
|
||||
public create<FT extends FileType>(
|
||||
serverConfig: ServerConfig,
|
||||
path: string,
|
||||
model: Partial<IContent<FT>> & { type: FT }
|
||||
): Observable<AjaxResponse> {
|
||||
return this.getContentProvider(path).create(serverConfig, path, model);
|
||||
}
|
||||
|
||||
public save<FT extends FileType>(
|
||||
serverConfig: ServerConfig,
|
||||
path: string,
|
||||
model: Partial<IContent<FT>>
|
||||
): Observable<AjaxResponse> {
|
||||
return this.getContentProvider(path).save(serverConfig, path, model);
|
||||
}
|
||||
|
||||
public listCheckpoints(serverConfig: ServerConfig, path: string): Observable<AjaxResponse> {
|
||||
return this.getContentProvider(path).listCheckpoints(serverConfig, path);
|
||||
}
|
||||
|
||||
public createCheckpoint(serverConfig: ServerConfig, path: string): Observable<AjaxResponse> {
|
||||
return this.getContentProvider(path).createCheckpoint(serverConfig, path);
|
||||
}
|
||||
|
||||
public deleteCheckpoint(serverConfig: ServerConfig, path: string, checkpointID: string): Observable<AjaxResponse> {
|
||||
return this.getContentProvider(path).deleteCheckpoint(serverConfig, path, checkpointID);
|
||||
}
|
||||
|
||||
public restoreFromCheckpoint(
|
||||
serverConfig: ServerConfig,
|
||||
path: string,
|
||||
checkpointID: string
|
||||
): Observable<AjaxResponse> {
|
||||
return this.getContentProvider(path).restoreFromCheckpoint(serverConfig, path, checkpointID);
|
||||
}
|
||||
|
||||
private getContentProvider(path: string): IContentProvider {
|
||||
if (GitHubUtils.fromGitHubUri(path)) {
|
||||
return this.gitHubContentProvider;
|
||||
}
|
||||
|
||||
return this.jupyterContentProvider;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import * as React from "react";
|
||||
import { AppState, ContentRef, selectors } from "@nteract/core";
|
||||
import { connect } from "react-redux";
|
||||
import NteractUtil from "../NTeractUtil";
|
||||
|
||||
interface VirtualCommandBarComponentProps {
|
||||
kernelSpecName: string;
|
||||
kernelStatus: string;
|
||||
currentCellType: string;
|
||||
onRender: () => void;
|
||||
}
|
||||
|
||||
class VirtualCommandBarComponent extends React.Component<VirtualCommandBarComponentProps> {
|
||||
constructor(props: VirtualCommandBarComponentProps) {
|
||||
super(props);
|
||||
this.state = {};
|
||||
}
|
||||
|
||||
shouldComponentUpdate(nextProps: VirtualCommandBarComponentProps): boolean {
|
||||
return (
|
||||
this.props.kernelStatus !== nextProps.kernelStatus ||
|
||||
this.props.kernelSpecName !== nextProps.kernelSpecName ||
|
||||
this.props.currentCellType !== nextProps.currentCellType
|
||||
);
|
||||
}
|
||||
|
||||
public render(): JSX.Element {
|
||||
this.props.onRender && this.props.onRender();
|
||||
return <></>;
|
||||
}
|
||||
}
|
||||
|
||||
interface InitialProps {
|
||||
contentRef: ContentRef;
|
||||
onRender: () => void;
|
||||
}
|
||||
|
||||
// Redux
|
||||
const makeMapStateToProps = (
|
||||
initialState: AppState,
|
||||
initialProps: InitialProps
|
||||
): ((state: AppState) => VirtualCommandBarComponentProps) => {
|
||||
const { contentRef } = initialProps;
|
||||
const mapStateToProps = (state: AppState) => {
|
||||
const content = selectors.content(state, { contentRef });
|
||||
let kernelStatus, kernelSpecName, currentCellType;
|
||||
|
||||
if (!content || content.type !== "notebook") {
|
||||
return {
|
||||
kernelStatus,
|
||||
kernelSpecName,
|
||||
currentCellType
|
||||
} as VirtualCommandBarComponentProps;
|
||||
}
|
||||
|
||||
const kernelRef = content.model.kernelRef;
|
||||
let kernel;
|
||||
if (kernelRef) {
|
||||
kernel = selectors.kernel(state, { kernelRef });
|
||||
}
|
||||
|
||||
if (kernel) {
|
||||
kernelStatus = kernel.status;
|
||||
kernelSpecName = kernel.kernelSpecName;
|
||||
}
|
||||
|
||||
currentCellType = NteractUtil.getCurrentCellType(content);
|
||||
return {
|
||||
kernelStatus,
|
||||
kernelSpecName,
|
||||
currentCellType,
|
||||
onRender: initialProps.onRender
|
||||
};
|
||||
};
|
||||
|
||||
return mapStateToProps;
|
||||
};
|
||||
|
||||
export default connect(makeMapStateToProps)(VirtualCommandBarComponent);
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Observable, of } from "rxjs";
|
||||
import { AjaxResponse } from "rxjs/ajax";
|
||||
import { ServerConfig } from "rx-jupyter";
|
||||
|
||||
let fakeAjaxResponse: AjaxResponse = {
|
||||
originalEvent: undefined,
|
||||
xhr: new XMLHttpRequest(),
|
||||
request: null,
|
||||
status: 200,
|
||||
response: {},
|
||||
responseText: null,
|
||||
responseType: "json"
|
||||
};
|
||||
export const sessions = {
|
||||
create: (serverConfig: ServerConfig, body: object): Observable<AjaxResponse> => of(fakeAjaxResponse),
|
||||
__setResponse: (response: AjaxResponse) => {
|
||||
fakeAjaxResponse = response;
|
||||
},
|
||||
createSpy: undefined as any
|
||||
};
|
||||
83
src/Explorer/Notebook/NotebookComponent/actions.ts
Normal file
83
src/Explorer/Notebook/NotebookComponent/actions.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { ContentRef } from "@nteract/core";
|
||||
import { CellId } from "@nteract/commutable";
|
||||
|
||||
export const CLOSE_NOTEBOOK = "CLOSE_NOTEBOOK";
|
||||
export interface CloseNotebookAction {
|
||||
type: "CLOSE_NOTEBOOK";
|
||||
payload: {
|
||||
contentRef: ContentRef;
|
||||
};
|
||||
}
|
||||
|
||||
export const closeNotebook = (payload: { contentRef: ContentRef }): CloseNotebookAction => {
|
||||
return {
|
||||
type: CLOSE_NOTEBOOK,
|
||||
payload
|
||||
};
|
||||
};
|
||||
|
||||
export const UPDATE_LAST_MODIFIED = "UPDATE_LAST_MODIFIED";
|
||||
export interface UpdateLastModifiedAction {
|
||||
type: "UPDATE_LAST_MODIFIED";
|
||||
payload: {
|
||||
contentRef: ContentRef;
|
||||
lastModified: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const updateLastModified = (payload: {
|
||||
contentRef: ContentRef;
|
||||
lastModified: string;
|
||||
}): UpdateLastModifiedAction => {
|
||||
return {
|
||||
type: UPDATE_LAST_MODIFIED,
|
||||
payload
|
||||
};
|
||||
};
|
||||
|
||||
export const EXECUTE_FOCUSED_CELL_AND_FOCUS_NEXT = "EXECUTE_FOCUSED_CELL_AND_FOCUS_NEXT";
|
||||
export interface ExecuteFocusedCellAndFocusNextAction {
|
||||
type: "EXECUTE_FOCUSED_CELL_AND_FOCUS_NEXT";
|
||||
payload: {
|
||||
contentRef: ContentRef;
|
||||
};
|
||||
}
|
||||
|
||||
export const executeFocusedCellAndFocusNext = (payload: {
|
||||
contentRef: ContentRef;
|
||||
}): ExecuteFocusedCellAndFocusNextAction => {
|
||||
return {
|
||||
type: EXECUTE_FOCUSED_CELL_AND_FOCUS_NEXT,
|
||||
payload
|
||||
};
|
||||
};
|
||||
|
||||
export const UPDATE_KERNEL_RESTART_DELAY = "UPDATE_KERNEL_RESTART_DELAY";
|
||||
export interface UpdateKernelRestartDelayAction {
|
||||
type: "UPDATE_KERNEL_RESTART_DELAY";
|
||||
payload: {
|
||||
delayMs: number;
|
||||
};
|
||||
}
|
||||
|
||||
export const UpdateKernelRestartDelay = (payload: { delayMs: number }): UpdateKernelRestartDelayAction => {
|
||||
return {
|
||||
type: UPDATE_KERNEL_RESTART_DELAY,
|
||||
payload
|
||||
};
|
||||
};
|
||||
|
||||
export const SET_HOVERED_CELL = "SET_HOVERED_CELL";
|
||||
export interface SetHoveredCellAction {
|
||||
type: "SET_HOVERED_CELL";
|
||||
payload: {
|
||||
cellId: CellId;
|
||||
};
|
||||
}
|
||||
|
||||
export const setHoveredCell = (payload: { cellId: CellId }): SetHoveredCellAction => {
|
||||
return {
|
||||
type: SET_HOVERED_CELL,
|
||||
payload
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,97 @@
|
||||
import { AppState, ContentRef, selectors } from "@nteract/core";
|
||||
import * as React from "react";
|
||||
import { connect } from "react-redux";
|
||||
import styled from "styled-components";
|
||||
|
||||
import NotebookRenderer from "../../../NotebookRenderer/NotebookRenderer";
|
||||
import * as TextFile from "./text-file";
|
||||
|
||||
const PaddedContainer = styled.div`
|
||||
padding-left: var(--nt-spacing-l, 10px);
|
||||
padding-top: var(--nt-spacing-m, 10px);
|
||||
padding-right: var(--nt-spacing-m, 10px);
|
||||
`;
|
||||
|
||||
const JupyterExtensionContainer = styled.div`
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
align-items: stretch;
|
||||
height: 100%;
|
||||
`;
|
||||
|
||||
const JupyterExtensionChoiceContainer = styled.div`
|
||||
flex: 1 1 auto;
|
||||
overflow: auto;
|
||||
`;
|
||||
|
||||
interface FileProps {
|
||||
type: "notebook" | "file" | "dummy";
|
||||
contentRef: ContentRef;
|
||||
mimetype?: string | null;
|
||||
}
|
||||
|
||||
export class File extends React.PureComponent<FileProps> {
|
||||
getChoice = () => {
|
||||
let choice = null;
|
||||
|
||||
// notebooks don't report a mimetype so we'll use the content.type
|
||||
if (this.props.type === "notebook") {
|
||||
choice = <NotebookRenderer contentRef={this.props.contentRef} />;
|
||||
} else if (this.props.type === "dummy") {
|
||||
choice = null;
|
||||
} else if (this.props.mimetype == null || !TextFile.handles(this.props.mimetype)) {
|
||||
// This should not happen as we intercept mimetype upstream, but just in case
|
||||
choice = (
|
||||
<PaddedContainer>
|
||||
<pre>
|
||||
This file type cannot be rendered. Please download the file, in order to view it outside of Data Explorer.
|
||||
</pre>
|
||||
</PaddedContainer>
|
||||
);
|
||||
} else {
|
||||
choice = <TextFile.default contentRef={this.props.contentRef} />;
|
||||
}
|
||||
|
||||
return choice;
|
||||
};
|
||||
|
||||
render(): JSX.Element {
|
||||
const choice = this.getChoice();
|
||||
|
||||
// Right now we only handle one kind of editor
|
||||
// If/when we support more modes, we would case them off here
|
||||
return (
|
||||
<React.Fragment>
|
||||
<JupyterExtensionContainer>
|
||||
<JupyterExtensionChoiceContainer>{choice}</JupyterExtensionChoiceContainer>
|
||||
</JupyterExtensionContainer>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
interface InitialProps {
|
||||
contentRef: ContentRef;
|
||||
}
|
||||
|
||||
// Since the contentRef stays unique for the duration of this file,
|
||||
// we use the makeMapStateToProps pattern to optimize re-render
|
||||
const makeMapStateToProps = (initialState: AppState, initialProps: InitialProps) => {
|
||||
const { contentRef } = initialProps;
|
||||
|
||||
const mapStateToProps = (state: AppState) => {
|
||||
const content = selectors.content(state, initialProps);
|
||||
|
||||
return {
|
||||
contentRef,
|
||||
mimetype: content.mimetype,
|
||||
type: content.type
|
||||
};
|
||||
};
|
||||
|
||||
return mapStateToProps;
|
||||
};
|
||||
|
||||
export const ConnectedFile = connect(makeMapStateToProps)(File);
|
||||
|
||||
export default ConnectedFile;
|
||||
@@ -0,0 +1,143 @@
|
||||
import { StringUtils } from "../../../../../Utils/StringUtils";
|
||||
import { actions, AppState, ContentRef, selectors } from "@nteract/core";
|
||||
import { MonacoEditorProps } from "@nteract/monaco-editor";
|
||||
import * as React from "react";
|
||||
import { connect } from "react-redux";
|
||||
import { Dispatch } from "redux";
|
||||
import styled from "styled-components";
|
||||
|
||||
const EditorContainer = styled.div`
|
||||
position: absolute;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
|
||||
.monaco {
|
||||
height: 100%;
|
||||
}
|
||||
`;
|
||||
|
||||
interface MappedStateProps {
|
||||
mimetype: string;
|
||||
text: string;
|
||||
contentRef: ContentRef;
|
||||
theme: string; // "light" | "dark";
|
||||
}
|
||||
|
||||
interface MappedDispatchProps {
|
||||
handleChange: (value: string) => void;
|
||||
}
|
||||
|
||||
type TextFileProps = MappedStateProps & MappedDispatchProps;
|
||||
|
||||
interface TextFileState {
|
||||
Editor: React.ComponentType<MonacoEditorProps>;
|
||||
}
|
||||
|
||||
class EditorPlaceholder extends React.PureComponent<MonacoEditorProps> {
|
||||
render(): JSX.Element {
|
||||
// TODO: Show a little blocky placeholder
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export class TextFile extends React.PureComponent<TextFileProps, TextFileState> {
|
||||
constructor(props: TextFileProps) {
|
||||
super(props);
|
||||
this.state = {
|
||||
Editor: EditorPlaceholder
|
||||
};
|
||||
}
|
||||
|
||||
handleChange = (source: string) => {
|
||||
this.props.handleChange(source);
|
||||
};
|
||||
|
||||
componentDidMount(): void {
|
||||
import(/* webpackChunkName: "monaco-editor" */ "@nteract/monaco-editor").then(module => {
|
||||
this.setState({ Editor: module.default });
|
||||
});
|
||||
}
|
||||
|
||||
render(): JSX.Element {
|
||||
const Editor = this.state.Editor;
|
||||
|
||||
return (
|
||||
<EditorContainer className="nteract-editor" style={{ position: "static" }}>
|
||||
<Editor
|
||||
theme={this.props.theme === "dark" ? "vs-dark" : "vs"}
|
||||
mode={this.props.mimetype}
|
||||
editorFocused
|
||||
value={this.props.text}
|
||||
onChange={this.handleChange.bind(this)}
|
||||
/>
|
||||
</EditorContainer>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
interface InitialProps {
|
||||
contentRef: ContentRef;
|
||||
}
|
||||
|
||||
function makeMapStateToTextFileProps(
|
||||
initialState: AppState,
|
||||
initialProps: InitialProps
|
||||
): (state: AppState) => MappedStateProps {
|
||||
const { contentRef } = initialProps;
|
||||
|
||||
const mapStateToTextFileProps = (state: AppState) => {
|
||||
const content = selectors.content(state, { contentRef });
|
||||
if (!content || content.type !== "file") {
|
||||
throw new Error("The text file component must have content");
|
||||
}
|
||||
|
||||
const text = content.model ? content.model.text : "";
|
||||
|
||||
return {
|
||||
contentRef,
|
||||
mimetype: content.mimetype != null ? content.mimetype : "text/plain",
|
||||
text,
|
||||
theme: selectors.currentTheme(state)
|
||||
};
|
||||
};
|
||||
return mapStateToTextFileProps;
|
||||
}
|
||||
|
||||
const makeMapDispatchToTextFileProps = (
|
||||
initialDispatch: Dispatch,
|
||||
initialProps: InitialProps
|
||||
): ((dispatch: Dispatch) => MappedDispatchProps) => {
|
||||
const { contentRef } = initialProps;
|
||||
|
||||
const mapDispatchToTextFileProps = (dispatch: Dispatch) => {
|
||||
return {
|
||||
handleChange: (source: string) => {
|
||||
dispatch(
|
||||
actions.updateFileText({
|
||||
contentRef,
|
||||
text: source
|
||||
})
|
||||
);
|
||||
}
|
||||
};
|
||||
};
|
||||
return mapDispatchToTextFileProps;
|
||||
};
|
||||
|
||||
const ConnectedTextFile = connect<MappedStateProps, MappedDispatchProps, InitialProps, AppState>(
|
||||
makeMapStateToTextFileProps,
|
||||
makeMapDispatchToTextFileProps
|
||||
)(TextFile);
|
||||
|
||||
export function handles(mimetype: string) {
|
||||
return (
|
||||
!mimetype ||
|
||||
StringUtils.startsWith(mimetype, "text/") ||
|
||||
StringUtils.startsWith(mimetype, "application/javascript") ||
|
||||
StringUtils.startsWith(mimetype, "application/json") ||
|
||||
StringUtils.startsWith(mimetype, "application/x-ipynb+json")
|
||||
);
|
||||
}
|
||||
|
||||
export default ConnectedTextFile;
|
||||
173
src/Explorer/Notebook/NotebookComponent/contents/index.tsx
Normal file
173
src/Explorer/Notebook/NotebookComponent/contents/index.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
// Vendor modules
|
||||
import { CellType, ImmutableNotebook } from "@nteract/commutable";
|
||||
import { HeaderDataProps } from "@nteract/connected-components/lib/header-editor";
|
||||
import {
|
||||
AppState,
|
||||
ContentRef,
|
||||
HostRecord,
|
||||
selectors,
|
||||
actions,
|
||||
DirectoryContentRecordProps,
|
||||
DummyContentRecordProps,
|
||||
FileContentRecordProps,
|
||||
NotebookContentRecordProps
|
||||
} from "@nteract/core";
|
||||
import { RecordOf } from "immutable";
|
||||
import * as React from "react";
|
||||
import { HotKeys, KeyMap } from "react-hotkeys";
|
||||
import { connect } from "react-redux";
|
||||
import { Dispatch } from "redux";
|
||||
|
||||
// Local modules
|
||||
import { default as File } from "./file";
|
||||
|
||||
interface IContentsBaseProps {
|
||||
contentRef: ContentRef;
|
||||
error?: object | null;
|
||||
}
|
||||
|
||||
interface IStateToProps {
|
||||
headerData?: HeaderDataProps;
|
||||
}
|
||||
|
||||
interface IDispatchFromProps {
|
||||
handlers?: any;
|
||||
onHeaderEditorChange?: (props: HeaderDataProps) => void;
|
||||
}
|
||||
|
||||
type ContentsProps = IContentsBaseProps & IStateToProps & IDispatchFromProps;
|
||||
|
||||
class Contents extends React.PureComponent<ContentsProps> {
|
||||
private keyMap: KeyMap = {
|
||||
CHANGE_CELL_TYPE: ["ctrl+shift+y", "ctrl+shift+m", "meta+shift+y", "meta+shift+m"],
|
||||
COPY_CELL: ["ctrl+shift+c", "meta+shift+c"],
|
||||
CREATE_CELL_ABOVE: ["ctrl+shift+a", "meta+shift+a"],
|
||||
CREATE_CELL_BELOW: ["ctrl+shift+b", "meta+shift+b"],
|
||||
CUT_CELL: ["ctrl+shift+x", "meta+shift+x"],
|
||||
DELETE_CELL: ["ctrl+shift+d", "meta+shift+d"],
|
||||
EXECUTE_ALL_CELLS: ["alt+r a"],
|
||||
INTERRUPT_KERNEL: ["alt+r i"],
|
||||
KILL_KERNEL: ["alt+r k"],
|
||||
OPEN: ["ctrl+o", "meta+o"],
|
||||
PASTE_CELL: ["ctrl+shift+v"],
|
||||
RESTART_KERNEL: ["alt+r r", "alt+r c", "alt+r a"],
|
||||
SAVE: ["ctrl+s", "ctrl+shift+s", "meta+s", "meta+shift+s"]
|
||||
};
|
||||
|
||||
render(): JSX.Element {
|
||||
const { contentRef, handlers } = this.props;
|
||||
|
||||
if (!contentRef) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<HotKeys keyMap={this.keyMap} handlers={handlers} className="hotKeys">
|
||||
<File contentRef={contentRef} />
|
||||
</HotKeys>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const makeMapStateToProps: any = (initialState: AppState, initialProps: { contentRef: ContentRef }) => {
|
||||
const host: HostRecord = initialState.app.host;
|
||||
|
||||
if (host.type !== "jupyter") {
|
||||
throw new Error("this component only works with jupyter apps");
|
||||
}
|
||||
|
||||
const mapStateToProps = (state: AppState): Partial<ContentsProps> => {
|
||||
const contentRef: ContentRef = initialProps.contentRef;
|
||||
|
||||
if (!contentRef) {
|
||||
throw new Error("cant display without a contentRef");
|
||||
}
|
||||
|
||||
const content:
|
||||
| RecordOf<NotebookContentRecordProps>
|
||||
| RecordOf<DummyContentRecordProps>
|
||||
| RecordOf<FileContentRecordProps>
|
||||
| RecordOf<DirectoryContentRecordProps>
|
||||
| undefined = selectors.content(state, { contentRef });
|
||||
|
||||
if (!content) {
|
||||
return {
|
||||
contentRef: undefined,
|
||||
error: undefined,
|
||||
headerData: undefined
|
||||
};
|
||||
}
|
||||
|
||||
let headerData: HeaderDataProps = {
|
||||
authors: [],
|
||||
description: "",
|
||||
tags: [],
|
||||
title: ""
|
||||
};
|
||||
|
||||
// If a notebook, we need to read in the headerData if available
|
||||
if (content.type === "notebook") {
|
||||
const notebook: ImmutableNotebook = content.model.get("notebook");
|
||||
const metadata: any = notebook.metadata.toJS();
|
||||
const { authors = [], description = "", tags = [], title = "" } = metadata;
|
||||
|
||||
// Updates
|
||||
headerData = Object.assign({}, headerData, {
|
||||
authors,
|
||||
description,
|
||||
tags,
|
||||
title
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
contentRef,
|
||||
error: content.error,
|
||||
headerData
|
||||
};
|
||||
};
|
||||
|
||||
return mapStateToProps;
|
||||
};
|
||||
|
||||
const mapDispatchToProps = (dispatch: Dispatch, ownProps: ContentsProps): object => {
|
||||
const { contentRef } = ownProps;
|
||||
|
||||
return {
|
||||
onHeaderEditorChange: (props: HeaderDataProps) => {
|
||||
return dispatch(
|
||||
actions.overwriteMetadataFields({
|
||||
...props,
|
||||
contentRef: ownProps.contentRef
|
||||
})
|
||||
);
|
||||
},
|
||||
// `HotKeys` handlers object
|
||||
// see: https://github.com/greena13/react-hotkeys#defining-handlers
|
||||
handlers: {
|
||||
CHANGE_CELL_TYPE: (event: KeyboardEvent) => {
|
||||
const type: CellType = event.key === "Y" ? "code" : "markdown";
|
||||
return dispatch(actions.changeCellType({ to: type, contentRef }));
|
||||
},
|
||||
COPY_CELL: () => dispatch(actions.copyCell({ contentRef })),
|
||||
CREATE_CELL_ABOVE: () => dispatch(actions.createCellAbove({ cellType: "code", contentRef })),
|
||||
CREATE_CELL_BELOW: () => dispatch(actions.createCellBelow({ cellType: "code", source: "", contentRef })),
|
||||
CUT_CELL: () => dispatch(actions.cutCell({ contentRef })),
|
||||
DELETE_CELL: () => dispatch(actions.deleteCell({ contentRef })),
|
||||
EXECUTE_ALL_CELLS: () => dispatch(actions.executeAllCells({ contentRef })),
|
||||
INTERRUPT_KERNEL: () => dispatch(actions.interruptKernel({})),
|
||||
KILL_KERNEL: () => dispatch(actions.killKernel({ restarting: false })),
|
||||
PASTE_CELL: () => dispatch(actions.pasteCell({ contentRef })),
|
||||
RESTART_KERNEL: (event: KeyboardEvent) => {
|
||||
const outputHandling: "None" | "Clear All" | "Run All" =
|
||||
event.key === "r" ? "None" : event.key === "a" ? "Run All" : "Clear All";
|
||||
return dispatch(actions.restartKernel({ outputHandling, contentRef }));
|
||||
},
|
||||
SAVE: () => dispatch(actions.save({ contentRef }))
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export default connect(makeMapStateToProps, mapDispatchToProps)(Contents);
|
||||
492
src/Explorer/Notebook/NotebookComponent/epics.test.ts
Normal file
492
src/Explorer/Notebook/NotebookComponent/epics.test.ts
Normal file
@@ -0,0 +1,492 @@
|
||||
import * as Immutable from "immutable";
|
||||
import { ActionsObservable, StateObservable } from "redux-observable";
|
||||
import { Subject } from "rxjs";
|
||||
import { toArray } from "rxjs/operators";
|
||||
import { makeNotebookRecord } from "@nteract/commutable";
|
||||
import { actions, state } from "@nteract/core";
|
||||
import * as sinon from "sinon";
|
||||
|
||||
import { CdbAppState, makeCdbRecord } from "./types";
|
||||
import { launchWebSocketKernelEpic } from "./epics";
|
||||
import { NotebookUtil } from "../NotebookUtil";
|
||||
|
||||
import { sessions } from "rx-jupyter";
|
||||
|
||||
describe("Extract kernel from notebook", () => {
|
||||
it("Reads metadata kernelspec first", () => {
|
||||
const fakeNotebook = makeNotebookRecord({
|
||||
metadata: Immutable.Map({
|
||||
kernelspec: {
|
||||
display_name: "Python 3",
|
||||
language: "python",
|
||||
name: "python3"
|
||||
},
|
||||
language_info: {
|
||||
name: "python",
|
||||
version: "3.7.3",
|
||||
mimetype: "text/x-python",
|
||||
codemirror_mode: {
|
||||
name: "ipython",
|
||||
version: 3
|
||||
},
|
||||
pygments_lexer: "ipython3",
|
||||
nbconvert_exporter: "python",
|
||||
file_extension: ".py"
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
const result = NotebookUtil.extractNewKernel("blah", fakeNotebook);
|
||||
expect(result.kernelSpecName).toEqual("python3");
|
||||
});
|
||||
|
||||
it("Reads language info in metadata if kernelspec not present", () => {
|
||||
const fakeNotebook = makeNotebookRecord({
|
||||
metadata: Immutable.Map({
|
||||
language_info: {
|
||||
name: "python",
|
||||
version: "3.7.3",
|
||||
mimetype: "text/x-python",
|
||||
codemirror_mode: {
|
||||
name: "ipython",
|
||||
version: 3
|
||||
},
|
||||
pygments_lexer: "ipython3",
|
||||
nbconvert_exporter: "python",
|
||||
file_extension: ".py"
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
const result = NotebookUtil.extractNewKernel("blah", fakeNotebook);
|
||||
expect(result.kernelSpecName).toEqual("python");
|
||||
});
|
||||
|
||||
it("Returns nothing if no kernelspec nor language info is found in metadata", () => {
|
||||
const fakeNotebook = makeNotebookRecord({
|
||||
metadata: Immutable.Map({
|
||||
blah: "this should be ignored"
|
||||
})
|
||||
});
|
||||
|
||||
const result = NotebookUtil.extractNewKernel("blah", fakeNotebook);
|
||||
expect(result.kernelSpecName).toEqual(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe("launchWebSocketKernelEpic", () => {
|
||||
const createSpy = sinon.spy(sessions, "create");
|
||||
|
||||
const contentRef = "fakeContentRef";
|
||||
const kernelRef = "fake";
|
||||
const initialState = {
|
||||
app: state.makeAppRecord({
|
||||
host: state.makeJupyterHostRecord({
|
||||
type: "jupyter",
|
||||
token: "eh",
|
||||
basePath: "/"
|
||||
})
|
||||
}),
|
||||
comms: state.makeCommsRecord(),
|
||||
config: Immutable.Map({}),
|
||||
core: state.makeStateRecord({
|
||||
kernelRef: "fake",
|
||||
entities: state.makeEntitiesRecord({
|
||||
contents: state.makeContentsRecord({
|
||||
byRef: Immutable.Map({
|
||||
fakeContentRef: state.makeNotebookContentRecord()
|
||||
})
|
||||
}),
|
||||
kernels: state.makeKernelsRecord({
|
||||
byRef: Immutable.Map({
|
||||
fake: state.makeRemoteKernelRecord({
|
||||
type: "websocket",
|
||||
channels: new Subject<any>(),
|
||||
kernelSpecName: "fancy",
|
||||
id: "0"
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
}),
|
||||
cdb: makeCdbRecord({
|
||||
databaseAccountName: "dbAccountName",
|
||||
defaultExperience: "defaultExperience"
|
||||
})
|
||||
};
|
||||
|
||||
it("launches remote kernels", async () => {
|
||||
const state$ = new StateObservable(new Subject<CdbAppState>(), initialState);
|
||||
|
||||
const cwd = "/";
|
||||
const kernelId = "123";
|
||||
const kernelSpecName = "kernelspecname";
|
||||
const sessionId = "sessionId";
|
||||
|
||||
const action$ = ActionsObservable.of(
|
||||
actions.launchKernelByName({
|
||||
contentRef,
|
||||
kernelRef,
|
||||
kernelSpecName,
|
||||
cwd,
|
||||
selectNextKernel: true
|
||||
})
|
||||
);
|
||||
|
||||
(sessions as any).__setResponse({
|
||||
originalEvent: undefined,
|
||||
xhr: new XMLHttpRequest(),
|
||||
request: null,
|
||||
status: 200,
|
||||
response: {
|
||||
id: sessionId,
|
||||
path: "notebooks/Untitled7.ipynb",
|
||||
name: "",
|
||||
type: "notebook",
|
||||
kernel: {
|
||||
id: kernelId,
|
||||
name: "kernel_launched",
|
||||
last_activity: "2019-11-07T14:29:54.432454Z",
|
||||
execution_state: "starting",
|
||||
connections: 0
|
||||
},
|
||||
notebook: {
|
||||
path: "notebooks/Untitled7.ipynb",
|
||||
name: ""
|
||||
}
|
||||
},
|
||||
responseText: null,
|
||||
responseType: "json"
|
||||
});
|
||||
|
||||
const responseActions = await launchWebSocketKernelEpic(action$, state$)
|
||||
.pipe(toArray())
|
||||
.toPromise();
|
||||
|
||||
expect(responseActions).toMatchObject([
|
||||
{
|
||||
type: actions.LAUNCH_KERNEL_SUCCESSFUL,
|
||||
payload: {
|
||||
contentRef,
|
||||
kernelRef,
|
||||
selectNextKernel: true,
|
||||
kernel: {
|
||||
info: null,
|
||||
sessionId: sessionId,
|
||||
type: "websocket",
|
||||
kernelSpecName,
|
||||
cwd,
|
||||
id: kernelId
|
||||
}
|
||||
}
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it("launches any kernel with no kernelspecs in the state", async () => {
|
||||
const state$ = new StateObservable(new Subject<CdbAppState>(), initialState);
|
||||
|
||||
const cwd = "/";
|
||||
const kernelId = "123";
|
||||
const kernelSpecName = "kernelspecname";
|
||||
const sessionId = "sessionId";
|
||||
|
||||
const action$ = ActionsObservable.of(
|
||||
actions.launchKernelByName({
|
||||
contentRef,
|
||||
kernelRef,
|
||||
kernelSpecName,
|
||||
cwd,
|
||||
selectNextKernel: true
|
||||
})
|
||||
);
|
||||
|
||||
(sessions as any).__setResponse({
|
||||
originalEvent: undefined,
|
||||
xhr: new XMLHttpRequest(),
|
||||
request: null,
|
||||
status: 200,
|
||||
response: {
|
||||
id: sessionId,
|
||||
path: "notebooks/Untitled7.ipynb",
|
||||
name: "",
|
||||
type: "notebook",
|
||||
kernel: {
|
||||
id: kernelId,
|
||||
name: "kernel_launched",
|
||||
last_activity: "2019-11-07T14:29:54.432454Z",
|
||||
execution_state: "starting",
|
||||
connections: 0
|
||||
},
|
||||
notebook: {
|
||||
path: "notebooks/Untitled7.ipynb",
|
||||
name: ""
|
||||
}
|
||||
},
|
||||
responseText: null,
|
||||
responseType: "json"
|
||||
});
|
||||
|
||||
await launchWebSocketKernelEpic(action$, state$)
|
||||
.pipe(toArray())
|
||||
.toPromise();
|
||||
|
||||
expect(createSpy.lastCall.args[1]).toMatchObject({
|
||||
kernel: {
|
||||
name: kernelSpecName
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("launches no kernel if no kernel is specified and state has no kernelspecs", async () => {
|
||||
const state$ = new StateObservable(new Subject<CdbAppState>(), initialState);
|
||||
|
||||
const cwd = "/";
|
||||
const kernelId = "123";
|
||||
const kernelSpecName = "kernelspecname";
|
||||
const sessionId = "sessionId";
|
||||
|
||||
const action$ = ActionsObservable.of(
|
||||
actions.launchKernelByName({
|
||||
contentRef,
|
||||
kernelRef,
|
||||
kernelSpecName: undefined,
|
||||
cwd,
|
||||
selectNextKernel: true
|
||||
})
|
||||
);
|
||||
|
||||
(sessions as any).__setResponse({
|
||||
originalEvent: undefined,
|
||||
xhr: new XMLHttpRequest(),
|
||||
request: null,
|
||||
status: 200,
|
||||
response: {
|
||||
id: sessionId,
|
||||
path: "notebooks/Untitled7.ipynb",
|
||||
name: "",
|
||||
type: "notebook",
|
||||
kernel: {
|
||||
id: kernelId,
|
||||
name: "kernel_launched",
|
||||
last_activity: "2019-11-07T14:29:54.432454Z",
|
||||
execution_state: "starting",
|
||||
connections: 0
|
||||
},
|
||||
notebook: {
|
||||
path: "notebooks/Untitled7.ipynb",
|
||||
name: ""
|
||||
}
|
||||
},
|
||||
responseText: null,
|
||||
responseType: "json"
|
||||
});
|
||||
|
||||
const responseActions = await launchWebSocketKernelEpic(action$, state$)
|
||||
.pipe(toArray())
|
||||
.toPromise();
|
||||
|
||||
expect(responseActions).toMatchObject([
|
||||
{
|
||||
type: actions.LAUNCH_KERNEL_FAILED
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it("emits an error if backend returns an error", async () => {
|
||||
const state$ = new StateObservable(new Subject<CdbAppState>(), initialState);
|
||||
|
||||
const cwd = "/";
|
||||
const action$ = ActionsObservable.of(
|
||||
actions.launchKernelByName({
|
||||
contentRef,
|
||||
kernelRef,
|
||||
kernelSpecName: undefined,
|
||||
cwd,
|
||||
selectNextKernel: true
|
||||
})
|
||||
);
|
||||
|
||||
(sessions as any).__setResponse({
|
||||
originalEvent: undefined,
|
||||
xhr: new XMLHttpRequest(),
|
||||
request: null,
|
||||
status: 500,
|
||||
response: null,
|
||||
responseText: null,
|
||||
responseType: "json"
|
||||
});
|
||||
|
||||
const responseActions = await launchWebSocketKernelEpic(action$, state$)
|
||||
.pipe(toArray())
|
||||
.toPromise();
|
||||
|
||||
expect(responseActions).toMatchObject([
|
||||
{
|
||||
type: actions.LAUNCH_KERNEL_FAILED
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
describe("Choose correct kernelspecs to launch", () => {
|
||||
beforeAll(() => {
|
||||
// Initialize kernelspecs with 2 supported kernels
|
||||
const createKernelSpecsRecord = (): Immutable.RecordOf<state.KernelspecsRecordProps> =>
|
||||
state.makeKernelspecsRecord({
|
||||
byRef: Immutable.Map({
|
||||
kernelspecsref: state.makeKernelspecsByRefRecord({
|
||||
defaultKernelName: "kernel2",
|
||||
byName: Immutable.Map({
|
||||
kernel1: state.makeKernelspec({
|
||||
name: "kernel1",
|
||||
argv: Immutable.List([]),
|
||||
env: Immutable.Map(),
|
||||
interruptMode: "interruptMode1",
|
||||
language: "language1",
|
||||
displayName: "Kernel One",
|
||||
metadata: Immutable.Map(),
|
||||
resources: Immutable.Map()
|
||||
}),
|
||||
kernel2: state.makeKernelspec({
|
||||
name: "kernel2",
|
||||
argv: Immutable.List([]),
|
||||
env: Immutable.Map(),
|
||||
interruptMode: "interruptMode2",
|
||||
language: "language2",
|
||||
displayName: "Kernel Two",
|
||||
metadata: Immutable.Map(),
|
||||
resources: Immutable.Map()
|
||||
})
|
||||
})
|
||||
})
|
||||
}),
|
||||
refs: Immutable.List(["kernelspecsref"])
|
||||
});
|
||||
initialState.core = initialState.core
|
||||
.setIn(["entities", "kernelspecs"], createKernelSpecsRecord())
|
||||
.set("currentKernelspecsRef", "kernelspecsref");
|
||||
|
||||
// some fake response we don't care about
|
||||
(sessions as any).__setResponse({
|
||||
originalEvent: undefined,
|
||||
xhr: new XMLHttpRequest(),
|
||||
request: null,
|
||||
status: 200,
|
||||
response: {
|
||||
id: "sessionId",
|
||||
path: "notebooks/Untitled7.ipynb",
|
||||
name: "",
|
||||
type: "notebook",
|
||||
kernel: {
|
||||
id: "kernelId",
|
||||
name: "kernel_launched",
|
||||
last_activity: "2019-11-07T14:29:54.432454Z",
|
||||
execution_state: "starting",
|
||||
connections: 0
|
||||
},
|
||||
notebook: {
|
||||
path: "notebooks/Untitled7.ipynb",
|
||||
name: ""
|
||||
}
|
||||
},
|
||||
responseText: null,
|
||||
responseType: "json"
|
||||
});
|
||||
});
|
||||
|
||||
it("launches supported kernel in kernelspecs", async () => {
|
||||
const state$ = new StateObservable(new Subject<CdbAppState>(), initialState);
|
||||
|
||||
const action$ = ActionsObservable.of(
|
||||
actions.launchKernelByName({
|
||||
contentRef,
|
||||
kernelRef,
|
||||
kernelSpecName: "kernel2",
|
||||
cwd: "cwd",
|
||||
selectNextKernel: true
|
||||
})
|
||||
);
|
||||
|
||||
await launchWebSocketKernelEpic(action$, state$)
|
||||
.pipe(toArray())
|
||||
.toPromise();
|
||||
expect(createSpy.lastCall.args[1]).toMatchObject({
|
||||
kernel: {
|
||||
name: "kernel2"
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("launches undefined kernel uses default kernel from kernelspecs", async () => {
|
||||
const state$ = new StateObservable(new Subject<CdbAppState>(), initialState);
|
||||
|
||||
const action$ = ActionsObservable.of(
|
||||
actions.launchKernelByName({
|
||||
contentRef,
|
||||
kernelRef,
|
||||
kernelSpecName: undefined,
|
||||
cwd: "cwd",
|
||||
selectNextKernel: true
|
||||
})
|
||||
);
|
||||
|
||||
await launchWebSocketKernelEpic(action$, state$)
|
||||
.pipe(toArray())
|
||||
.toPromise();
|
||||
|
||||
expect(createSpy.lastCall.args[1]).toMatchObject({
|
||||
kernel: {
|
||||
name: "kernel2"
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("launches unsupported kernel uses default kernel from kernelspecs", async () => {
|
||||
const state$ = new StateObservable(new Subject<CdbAppState>(), initialState);
|
||||
|
||||
const action$ = ActionsObservable.of(
|
||||
actions.launchKernelByName({
|
||||
contentRef,
|
||||
kernelRef,
|
||||
kernelSpecName: "This is an unknown kernelspec",
|
||||
cwd: "cwd",
|
||||
selectNextKernel: true
|
||||
})
|
||||
);
|
||||
|
||||
await launchWebSocketKernelEpic(action$, state$)
|
||||
.pipe(toArray())
|
||||
.toPromise();
|
||||
|
||||
expect(createSpy.lastCall.args[1]).toMatchObject({
|
||||
kernel: {
|
||||
name: "kernel2"
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("launches unsupported kernel uses kernelspecs with similar name", async () => {
|
||||
const state$ = new StateObservable(new Subject<CdbAppState>(), initialState);
|
||||
|
||||
const action$ = ActionsObservable.of(
|
||||
actions.launchKernelByName({
|
||||
contentRef,
|
||||
kernelRef,
|
||||
kernelSpecName: "ernel1",
|
||||
cwd: "cwd",
|
||||
selectNextKernel: true
|
||||
})
|
||||
);
|
||||
|
||||
await launchWebSocketKernelEpic(action$, state$)
|
||||
.pipe(toArray())
|
||||
.toPromise();
|
||||
|
||||
expect(createSpy.lastCall.args[1]).toMatchObject({
|
||||
kernel: {
|
||||
name: "kernel1"
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
925
src/Explorer/Notebook/NotebookComponent/epics.ts
Normal file
925
src/Explorer/Notebook/NotebookComponent/epics.ts
Normal file
@@ -0,0 +1,925 @@
|
||||
import { empty, merge, of, timer, interval, concat, Subject, Subscriber, Observable, Observer } from "rxjs";
|
||||
import { webSocket } from "rxjs/webSocket";
|
||||
import { ActionsObservable, StateObservable } from "redux-observable";
|
||||
import { ofType } from "redux-observable";
|
||||
import {
|
||||
mergeMap,
|
||||
tap,
|
||||
retryWhen,
|
||||
delayWhen,
|
||||
map,
|
||||
switchMap,
|
||||
take,
|
||||
distinctUntilChanged,
|
||||
filter,
|
||||
catchError,
|
||||
first,
|
||||
concatMap,
|
||||
timeout
|
||||
} from "rxjs/operators";
|
||||
import {
|
||||
AppState,
|
||||
ServerConfig as JupyterServerConfig,
|
||||
JupyterHostRecordProps,
|
||||
JupyterHostRecord,
|
||||
RemoteKernelProps,
|
||||
castToSessionId,
|
||||
createKernelRef,
|
||||
KernelRef,
|
||||
ContentRef,
|
||||
KernelInfo,
|
||||
actions,
|
||||
selectors,
|
||||
IContentProvider
|
||||
} from "@nteract/core";
|
||||
import { message, JupyterMessage, Channels, createMessage, childOf, ofMessageType } from "@nteract/messaging";
|
||||
import { sessions, kernels } from "rx-jupyter";
|
||||
import { RecordOf } from "immutable";
|
||||
|
||||
import * as Constants from "../../../Common/Constants";
|
||||
import { NotificationConsoleUtils } from "../../../Utils/NotificationConsoleUtils";
|
||||
import { ConsoleDataType } from "../../Menus/NotificationConsole/NotificationConsoleComponent";
|
||||
import * as CdbActions from "./actions";
|
||||
import TelemetryProcessor from "../../../Shared/Telemetry/TelemetryProcessor";
|
||||
import { Action as TelemetryAction } from "../../../Shared/Telemetry/TelemetryConstants";
|
||||
import { CdbAppState } from "./types";
|
||||
import { decryptJWTToken } from "../../../Utils/AuthorizationUtils";
|
||||
import * as TextFile from "./contents/file/text-file";
|
||||
import { NotebookUtil } from "../NotebookUtil";
|
||||
|
||||
interface NotebookServiceConfig extends JupyterServerConfig {
|
||||
userPuid?: string;
|
||||
}
|
||||
|
||||
const logToTelemetry = (state: CdbAppState, title: string, error?: string) => {
|
||||
TelemetryProcessor.traceFailure(TelemetryAction.NotebookErrorNotification, {
|
||||
databaseAccountName: state.cdb.databaseAccountName,
|
||||
defaultExperience: state.cdb.defaultExperience,
|
||||
dataExplorerArea: Constants.Areas.Notebook,
|
||||
title,
|
||||
error
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Automatically add a new cell if notebook is empty
|
||||
* @param action$
|
||||
* @param state$
|
||||
*/
|
||||
const addInitialCodeCellEpic = (
|
||||
action$: ActionsObservable<actions.FetchContentFulfilled>,
|
||||
state$: StateObservable<AppState>
|
||||
): Observable<{} | actions.CreateCellBelow> => {
|
||||
return action$.pipe(
|
||||
ofType(actions.FETCH_CONTENT_FULFILLED),
|
||||
mergeMap(action => {
|
||||
const state = state$.value;
|
||||
const contentRef = action.payload.contentRef;
|
||||
const model = selectors.model(state, { contentRef });
|
||||
|
||||
// If it's not a notebook, we shouldn't be here
|
||||
if (!model || model.type !== "notebook") {
|
||||
return empty();
|
||||
}
|
||||
|
||||
const cellOrder = selectors.notebook.cellOrder(model);
|
||||
if (cellOrder.size === 0) {
|
||||
return of(
|
||||
actions.createCellAppend({
|
||||
cellType: "code",
|
||||
contentRef
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return empty();
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Updated kernels.formWebSocketURL so we pass the userId as a query param
|
||||
*/
|
||||
const formWebSocketURL = (serverConfig: NotebookServiceConfig, kernelId: string, sessionId?: string): string => {
|
||||
const params = new URLSearchParams();
|
||||
if (serverConfig.token) {
|
||||
params.append("token", serverConfig.token);
|
||||
}
|
||||
if (sessionId) {
|
||||
params.append("session_id", sessionId);
|
||||
}
|
||||
|
||||
const userId = getUserPuid();
|
||||
if (userId) {
|
||||
params.append("user_id", userId);
|
||||
}
|
||||
|
||||
const q = params.toString();
|
||||
const suffix = q !== "" ? `?${q}` : "";
|
||||
|
||||
const url = (serverConfig.endpoint || "") + `api/kernels/${kernelId}/channels${suffix}`;
|
||||
|
||||
return url.replace(/^http(s)?/, "ws$1");
|
||||
};
|
||||
|
||||
/**
|
||||
* Override from kernel-lifecycle to improve code mirror language intellisense
|
||||
* @param action$
|
||||
*/
|
||||
export const acquireKernelInfoEpic = (action$: ActionsObservable<actions.NewKernelAction>) => {
|
||||
return action$.pipe(
|
||||
ofType(actions.LAUNCH_KERNEL_SUCCESSFUL),
|
||||
switchMap((action: actions.NewKernelAction) => {
|
||||
const {
|
||||
payload: {
|
||||
kernel: { channels },
|
||||
kernelRef,
|
||||
contentRef
|
||||
}
|
||||
} = action;
|
||||
return acquireKernelInfo(channels, kernelRef, contentRef);
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Send a kernel_info_request to the kernel and derive code mirror mode based on the language name.
|
||||
*/
|
||||
function acquireKernelInfo(channels: Channels, kernelRef: KernelRef, contentRef: ContentRef) {
|
||||
const message = createMessage("kernel_info_request");
|
||||
|
||||
const obs = channels.pipe(
|
||||
childOf(message),
|
||||
ofMessageType("kernel_info_reply"),
|
||||
first(),
|
||||
mergeMap(msg => {
|
||||
const content = msg.content;
|
||||
const languageInfo = (content && content.language_info) || {
|
||||
name: "",
|
||||
version: "",
|
||||
mimetype: "",
|
||||
file_extension: "",
|
||||
pygments_lexer: "",
|
||||
codemirror_mode: "",
|
||||
nbconvert_exporter: ""
|
||||
};
|
||||
|
||||
switch (languageInfo.name) {
|
||||
case "csharp":
|
||||
languageInfo.codemirror_mode = "text/x-csharp";
|
||||
break;
|
||||
case "scala":
|
||||
languageInfo.codemirror_mode = "text/x-scala";
|
||||
break;
|
||||
}
|
||||
|
||||
const info: KernelInfo = {
|
||||
protocolVersion: content.protocol_version,
|
||||
implementation: content.implementation,
|
||||
implementationVersion: content.implementation_version,
|
||||
banner: content.banner,
|
||||
helpLinks: content.help_links,
|
||||
languageName: languageInfo.name,
|
||||
languageVersion: languageInfo.version,
|
||||
mimetype: languageInfo.mimetype,
|
||||
fileExtension: languageInfo.file_extension,
|
||||
pygmentsLexer: languageInfo.pygments_lexer,
|
||||
codemirrorMode: languageInfo.codemirror_mode,
|
||||
nbconvertExporter: languageInfo.nbconvert_exporter
|
||||
};
|
||||
|
||||
let result;
|
||||
if (!content.protocol_version.startsWith("5")) {
|
||||
result = [
|
||||
actions.launchKernelFailed({
|
||||
kernelRef,
|
||||
contentRef,
|
||||
error: new Error(
|
||||
"The kernel that you are attempting to launch does not support the latest version (v5) of the messaging protocol."
|
||||
)
|
||||
})
|
||||
];
|
||||
} else {
|
||||
result = [
|
||||
// The original action we were using
|
||||
actions.setLanguageInfo({
|
||||
langInfo: msg.content.language_info,
|
||||
kernelRef,
|
||||
contentRef
|
||||
}),
|
||||
actions.setKernelInfo({
|
||||
kernelRef,
|
||||
info
|
||||
})
|
||||
];
|
||||
}
|
||||
|
||||
return of(...result);
|
||||
})
|
||||
);
|
||||
|
||||
return Observable.create((observer: Observer<any>) => {
|
||||
const subscription = obs.subscribe(observer);
|
||||
channels.next(message);
|
||||
return subscription;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Updated kernels.connect so we use the updated formWebSocketURL to pass
|
||||
* the userId as a query param
|
||||
* @param serverConfig
|
||||
* @param kernelID
|
||||
* @param sessionID
|
||||
*/
|
||||
const connect = (serverConfig: NotebookServiceConfig, kernelID: string, sessionID?: string): Subject<any> => {
|
||||
const wsSubject = webSocket<JupyterMessage>({
|
||||
url: formWebSocketURL(serverConfig, kernelID, sessionID),
|
||||
protocol: serverConfig.wsProtocol
|
||||
});
|
||||
|
||||
// Create a subject that does some of the handling inline for the session
|
||||
// and ensuring it's serialized
|
||||
return Subject.create(
|
||||
Subscriber.create(
|
||||
(message?: JupyterMessage) => {
|
||||
if (typeof message === "object") {
|
||||
const sessionizedMessage = {
|
||||
...message,
|
||||
header: {
|
||||
session: sessionID,
|
||||
...message.header
|
||||
}
|
||||
};
|
||||
|
||||
wsSubject.next(sessionizedMessage);
|
||||
} else {
|
||||
console.error("Message must be an object, the app sent", message);
|
||||
}
|
||||
},
|
||||
(e: Error) => wsSubject.error(e),
|
||||
() => wsSubject.complete()
|
||||
), // Subscriber
|
||||
// Subject.create takes a subscriber and an observable. We're only
|
||||
// overriding the subscriber here so we pass the subject on as an
|
||||
// observable as the second argument to Subject.create (since it's
|
||||
// _also_ an observable)
|
||||
wsSubject
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Override launch websocket kernel epic:
|
||||
* - pass the userId
|
||||
* - if kernelspecs are present in the state:
|
||||
* * verify that the kernel name matches one of the kernelspecs
|
||||
* * else attempt to pick a kernel that matches the name from the kernelspecs list
|
||||
* * else pick the default kernel specs
|
||||
* @param action$
|
||||
* @param state$
|
||||
*/
|
||||
export const launchWebSocketKernelEpic = (
|
||||
action$: ActionsObservable<actions.LaunchKernelByNameAction>,
|
||||
state$: StateObservable<CdbAppState>
|
||||
) => {
|
||||
return action$.pipe(
|
||||
ofType(actions.LAUNCH_KERNEL_BY_NAME),
|
||||
// Only accept jupyter servers for the host with this epic
|
||||
filter(() => selectors.isCurrentHostJupyter(state$.value)),
|
||||
switchMap((action: actions.LaunchKernelByNameAction) => {
|
||||
const state = state$.value;
|
||||
const host = selectors.currentHost(state);
|
||||
if (host.type !== "jupyter") {
|
||||
return empty();
|
||||
}
|
||||
const serverConfig: NotebookServiceConfig = selectors.serverConfig(host);
|
||||
serverConfig.userPuid = getUserPuid();
|
||||
|
||||
const {
|
||||
payload: { kernelSpecName, cwd, kernelRef, contentRef }
|
||||
} = action;
|
||||
|
||||
const content = selectors.content(state, { contentRef });
|
||||
if (!content || content.type !== "notebook") {
|
||||
return empty();
|
||||
}
|
||||
|
||||
let kernelSpecToLaunch = kernelSpecName;
|
||||
|
||||
const currentKernelspecs = selectors.currentKernelspecs(state$.value);
|
||||
|
||||
if (!kernelSpecToLaunch) {
|
||||
if (currentKernelspecs) {
|
||||
kernelSpecToLaunch = currentKernelspecs.defaultKernelName;
|
||||
const msg = `No kernelspec name specified to launch, using default kernel: ${kernelSpecToLaunch}`;
|
||||
NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Info, msg);
|
||||
logToTelemetry(state$.value, "Launching alternate kernel", msg);
|
||||
} else {
|
||||
return of(
|
||||
actions.launchKernelFailed({
|
||||
error: new Error(
|
||||
"Unable to launch kernel: no kernelspec name specified to launch and no default kernelspecs"
|
||||
),
|
||||
contentRef
|
||||
})
|
||||
);
|
||||
}
|
||||
} else if (currentKernelspecs && !currentKernelspecs.byName.get(kernelSpecToLaunch)) {
|
||||
let msg = `Cannot launch kernelspec: "${kernelSpecToLaunch}" is not supported by the notebook server.`;
|
||||
|
||||
// Find a kernel that best matches the kernel name
|
||||
const match = currentKernelspecs.byName.find(
|
||||
value => value.name.toLowerCase().indexOf(kernelSpecName.toLowerCase()) !== -1
|
||||
);
|
||||
if (match) {
|
||||
kernelSpecToLaunch = match.name;
|
||||
msg += ` Found kernel with similar name: ${kernelSpecToLaunch}`;
|
||||
} else {
|
||||
kernelSpecToLaunch = currentKernelspecs.defaultKernelName;
|
||||
msg += ` Using default kernel: ${kernelSpecToLaunch}`;
|
||||
}
|
||||
NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Info, msg);
|
||||
logToTelemetry(state$.value, "Launching alternate kernel", msg);
|
||||
}
|
||||
|
||||
const sessionPayload = {
|
||||
kernel: {
|
||||
id: null,
|
||||
name: kernelSpecToLaunch
|
||||
} as any,
|
||||
name: "",
|
||||
path: content.filepath.replace(/^\/+/g, ""),
|
||||
type: "notebook"
|
||||
};
|
||||
|
||||
return sessions.create(serverConfig, sessionPayload).pipe(
|
||||
mergeMap(data => {
|
||||
const session = data.response;
|
||||
|
||||
const sessionId = castToSessionId(session.id);
|
||||
|
||||
const kernel: RemoteKernelProps = Object.assign({}, session.kernel, {
|
||||
type: "websocket",
|
||||
info: null,
|
||||
sessionId,
|
||||
cwd,
|
||||
channels: connect(serverConfig, session.kernel.id, sessionId),
|
||||
kernelSpecName: kernelSpecToLaunch
|
||||
});
|
||||
|
||||
kernel.channels.next(message({ msg_type: "kernel_info_request" }));
|
||||
|
||||
return of(
|
||||
actions.launchKernelSuccessful({
|
||||
kernel,
|
||||
kernelRef,
|
||||
contentRef: action.payload.contentRef,
|
||||
selectNextKernel: true
|
||||
})
|
||||
);
|
||||
}),
|
||||
catchError(error => {
|
||||
return of(actions.launchKernelFailed({ error }));
|
||||
})
|
||||
);
|
||||
})
|
||||
);
|
||||
};
|
||||
/**
|
||||
* Override the restartWebSocketKernelEpic from nteract since the /restart endpoint of our kernels has not
|
||||
* been implmemented;
|
||||
* TODO: Remove this epic once the /restart endpoint is implemented.
|
||||
*/
|
||||
export const restartWebSocketKernelEpic = (
|
||||
action$: ActionsObservable<actions.RestartKernel | actions.NewKernelAction>,
|
||||
state$: StateObservable<AppState>
|
||||
) =>
|
||||
action$.pipe(
|
||||
ofType(actions.RESTART_KERNEL),
|
||||
concatMap((action: actions.RestartKernel) => {
|
||||
const state = state$.value;
|
||||
|
||||
const contentRef = action.payload.contentRef;
|
||||
const kernelRef = selectors.kernelRefByContentRef(state, { contentRef }) || action.payload.kernelRef;
|
||||
|
||||
/**
|
||||
* If there is still no KernelRef, then throw an error.
|
||||
*/
|
||||
if (!kernelRef) {
|
||||
return of(
|
||||
actions.restartKernelFailed({
|
||||
error: new Error("Can't execute restart without kernel ref."),
|
||||
kernelRef: "none provided",
|
||||
contentRef
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const host = selectors.currentHost(state);
|
||||
if (host.type !== "jupyter") {
|
||||
return of(
|
||||
actions.restartKernelFailed({
|
||||
error: new Error("Can't restart a kernel with no Jupyter host."),
|
||||
kernelRef,
|
||||
contentRef
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const kernel = selectors.kernel(state, { kernelRef });
|
||||
if (!kernel) {
|
||||
return of(
|
||||
actions.restartKernelFailed({
|
||||
error: new Error("Can't restart a kernel that does not exist."),
|
||||
kernelRef,
|
||||
contentRef
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (kernel.type !== "websocket" || !kernel.id) {
|
||||
return of(
|
||||
actions.restartKernelFailed({
|
||||
error: new Error("Can only restart Websocket kernels via API."),
|
||||
kernelRef,
|
||||
contentRef
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const newKernelRef = createKernelRef();
|
||||
const kill = actions.killKernel({
|
||||
restarting: true,
|
||||
kernelRef
|
||||
});
|
||||
|
||||
const relaunch = actions.launchKernelByName({
|
||||
kernelSpecName: kernel.kernelSpecName ?? undefined,
|
||||
cwd: kernel.cwd,
|
||||
kernelRef: newKernelRef,
|
||||
selectNextKernel: true,
|
||||
contentRef: contentRef
|
||||
});
|
||||
|
||||
const awaitKernelReady = action$.pipe(
|
||||
ofType(actions.LAUNCH_KERNEL_SUCCESSFUL),
|
||||
filter((action: actions.NewKernelAction | actions.RestartKernel) => action.payload.kernelRef === newKernelRef),
|
||||
take(1),
|
||||
timeout(60000), // If kernel doesn't come up within this interval we will abort follow-on actions.
|
||||
concatMap(() => {
|
||||
const restartSuccess = actions.restartKernelSuccessful({
|
||||
kernelRef: newKernelRef,
|
||||
contentRef
|
||||
});
|
||||
|
||||
if ((action as actions.RestartKernel).payload.outputHandling === "Run All") {
|
||||
return of(restartSuccess, actions.executeAllCells({ contentRef }));
|
||||
} else {
|
||||
return of(restartSuccess);
|
||||
}
|
||||
}),
|
||||
catchError(error => {
|
||||
return of(
|
||||
actions.restartKernelFailed({
|
||||
error,
|
||||
kernelRef: newKernelRef,
|
||||
contentRef
|
||||
})
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
return merge(of(kill, relaunch), awaitKernelReady);
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* Override changeWebSocketKernelEpic:
|
||||
* - to pass the userId when connecting to the kernel.
|
||||
* - to override extractNewKernel()
|
||||
* @param action$
|
||||
* @param state$
|
||||
*/
|
||||
const changeWebSocketKernelEpic = (
|
||||
action$: ActionsObservable<actions.ChangeKernelByName>,
|
||||
state$: StateObservable<AppState>
|
||||
) => {
|
||||
return action$.pipe(
|
||||
ofType(actions.CHANGE_KERNEL_BY_NAME),
|
||||
// Only accept jupyter servers for the host with this epic
|
||||
filter(() => selectors.isCurrentHostJupyter(state$.value)),
|
||||
switchMap((action: actions.ChangeKernelByName) => {
|
||||
const {
|
||||
payload: { contentRef, oldKernelRef, kernelSpecName }
|
||||
} = action;
|
||||
const state = state$.value;
|
||||
const host = selectors.currentHost(state);
|
||||
if (host.type !== "jupyter") {
|
||||
return empty();
|
||||
}
|
||||
|
||||
const serverConfig: NotebookServiceConfig = selectors.serverConfig(host);
|
||||
if (!oldKernelRef) {
|
||||
return empty();
|
||||
}
|
||||
|
||||
const oldKernel = selectors.kernel(state, { kernelRef: oldKernelRef });
|
||||
if (!oldKernel || oldKernel.type !== "websocket") {
|
||||
return empty();
|
||||
}
|
||||
const { sessionId } = oldKernel;
|
||||
if (!sessionId) {
|
||||
return empty();
|
||||
}
|
||||
|
||||
const content = selectors.content(state, { contentRef });
|
||||
if (!content || content.type !== "notebook") {
|
||||
return empty();
|
||||
}
|
||||
const {
|
||||
filepath,
|
||||
model: { notebook }
|
||||
} = content;
|
||||
const { cwd } = NotebookUtil.extractNewKernel(filepath, notebook);
|
||||
|
||||
const kernelRef = createKernelRef();
|
||||
return kernels.start(serverConfig, kernelSpecName, cwd).pipe(
|
||||
mergeMap(({ response }) => {
|
||||
const { id: kernelId } = response;
|
||||
const sessionPayload = {
|
||||
kernel: { id: kernelId, name: kernelSpecName }
|
||||
};
|
||||
// The sessions API will close down the old kernel for us if it is on this session
|
||||
return sessions.update(serverConfig, sessionId, sessionPayload).pipe(
|
||||
mergeMap(({ response: session }) => {
|
||||
const kernel: RemoteKernelProps = Object.assign({}, session.kernel, {
|
||||
type: "websocket",
|
||||
sessionId,
|
||||
cwd,
|
||||
channels: connect(serverConfig, session.kernel.id, sessionId),
|
||||
kernelSpecName
|
||||
});
|
||||
return of(
|
||||
actions.launchKernelSuccessful({
|
||||
kernel,
|
||||
kernelRef,
|
||||
contentRef: action.payload.contentRef,
|
||||
selectNextKernel: true
|
||||
})
|
||||
);
|
||||
}),
|
||||
catchError(error => of(actions.launchKernelFailed({ error, kernelRef, contentRef })))
|
||||
);
|
||||
}),
|
||||
catchError(error => of(actions.launchKernelFailed({ error, kernelRef, contentRef })))
|
||||
);
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Automatically focus on cell if only one cell
|
||||
* @param action$
|
||||
* @param state$
|
||||
*/
|
||||
const focusInitialCodeCellEpic = (
|
||||
action$: ActionsObservable<actions.CreateCellAppend>,
|
||||
state$: StateObservable<AppState>
|
||||
): Observable<{} | actions.FocusCell> => {
|
||||
return action$.pipe(
|
||||
ofType(actions.CREATE_CELL_APPEND),
|
||||
mergeMap(action => {
|
||||
const state = state$.value;
|
||||
const contentRef = action.payload.contentRef;
|
||||
const model = selectors.model(state, { contentRef });
|
||||
|
||||
// If it's not a notebook, we shouldn't be here
|
||||
if (!model || model.type !== "notebook") {
|
||||
return empty();
|
||||
}
|
||||
|
||||
const cellOrder = selectors.notebook.cellOrder(model);
|
||||
if (cellOrder.size === 1) {
|
||||
const id = cellOrder.get(0);
|
||||
// Focus on the cell
|
||||
return of(
|
||||
actions.focusCell({
|
||||
id,
|
||||
contentRef
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return empty();
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Capture some actions to display to notification console
|
||||
* TODO: Log these (or everything) in telemetry?
|
||||
* @param action$
|
||||
* @param state$
|
||||
*/
|
||||
const notificationsToUserEpic = (
|
||||
action$: ActionsObservable<any>,
|
||||
state$: StateObservable<CdbAppState>
|
||||
): Observable<{}> => {
|
||||
return action$.pipe(
|
||||
ofType(
|
||||
actions.RESTART_KERNEL_SUCCESSFUL,
|
||||
actions.RESTART_KERNEL_FAILED,
|
||||
actions.SAVE_FULFILLED,
|
||||
actions.SAVE_FAILED,
|
||||
actions.FETCH_CONTENT_FAILED
|
||||
),
|
||||
mergeMap(action => {
|
||||
switch (action.type) {
|
||||
case actions.RESTART_KERNEL_SUCCESSFUL: {
|
||||
const title = "Kernel restart";
|
||||
const msg = "Kernel successfully restarted";
|
||||
NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Info, msg);
|
||||
logToTelemetry(state$.value, title, msg);
|
||||
break;
|
||||
}
|
||||
case actions.RESTART_KERNEL_FAILED:
|
||||
// TODO: enable once incorrect kernel restart failure signals are fixed
|
||||
// NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Error, "Failed to restart kernel");
|
||||
break;
|
||||
case actions.SAVE_FAILED: {
|
||||
const title = "Save failure";
|
||||
const msg = `Failed to save notebook: ${(action as actions.SaveFailed).payload.error}`;
|
||||
NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Error, msg);
|
||||
logToTelemetry(state$.value, title, msg);
|
||||
break;
|
||||
}
|
||||
case actions.FETCH_CONTENT_FAILED: {
|
||||
const typedAction: actions.FetchContentFailed = action;
|
||||
const filepath = selectors.filepath(state$.value, { contentRef: typedAction.payload.contentRef });
|
||||
const title = "Fetching content failure";
|
||||
const msg = `Failed to fetch notebook content: ${filepath}, error: ${typedAction.payload.error}`;
|
||||
NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Error, msg);
|
||||
logToTelemetry(state$.value, title, msg);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return empty();
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Connection lost: ping server until back up and restart kernel
|
||||
* @param action$
|
||||
* @param state$
|
||||
*/
|
||||
const handleKernelConnectionLostEpic = (
|
||||
action$: ActionsObservable<actions.UpdateDisplayFailed>,
|
||||
state$: StateObservable<CdbAppState>
|
||||
): Observable<CdbActions.UpdateKernelRestartDelayAction | actions.RestartKernel | {}> => {
|
||||
return action$.pipe(
|
||||
ofType(actions.UPDATE_DISPLAY_FAILED),
|
||||
mergeMap(action => {
|
||||
const state = state$.value;
|
||||
|
||||
const msg = "Notebook was disconnected from kernel";
|
||||
NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Error, msg);
|
||||
logToTelemetry(state, "Error", "Kernel connection error");
|
||||
|
||||
const host = selectors.currentHost(state);
|
||||
const serverConfig: NotebookServiceConfig = selectors.serverConfig(host as RecordOf<JupyterHostRecordProps>);
|
||||
|
||||
const contentRef = action.payload.contentRef;
|
||||
const kernelRef = selectors.kernelRefByContentRef(state$.value, { contentRef });
|
||||
|
||||
const delayMs = state.cdb.kernelRestartDelayMs;
|
||||
if (delayMs > Constants.Notebook.kernelRestartMaxDelayMs) {
|
||||
const msg =
|
||||
"Restarted kernel too many times. Please reload the page to enable Data Explorer to restart the kernel automatically.";
|
||||
NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Error, msg);
|
||||
logToTelemetry(state, "Kernel restart error", msg);
|
||||
|
||||
const explorer = window.dataExplorer;
|
||||
if (explorer) {
|
||||
explorer.showOkModalDialog("kernel restarts", msg);
|
||||
}
|
||||
return of(empty());
|
||||
}
|
||||
|
||||
return concat(
|
||||
of(CdbActions.UpdateKernelRestartDelay({ delayMs: delayMs * 1.5 })),
|
||||
sessions.list(serverConfig).pipe(
|
||||
delayWhen(() => timer(delayMs)),
|
||||
map(xhr => {
|
||||
return actions.restartKernel({
|
||||
outputHandling: "None",
|
||||
kernelRef,
|
||||
contentRef
|
||||
});
|
||||
}),
|
||||
retryWhen(errors => {
|
||||
return errors.pipe(
|
||||
delayWhen(() => timer(Constants.Notebook.heartbeatDelayMs)),
|
||||
tap(() => console.log("retrying...")) // TODO: Send new action?
|
||||
);
|
||||
})
|
||||
)
|
||||
);
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Connection lost: clean up kernel ref
|
||||
* @param action$
|
||||
* @param state$
|
||||
*/
|
||||
export const cleanKernelOnConnectionLostEpic = (
|
||||
action$: ActionsObservable<actions.UpdateDisplayFailed>,
|
||||
state$: StateObservable<AppState>
|
||||
): Observable<actions.KillKernelSuccessful> => {
|
||||
return action$.pipe(
|
||||
ofType(actions.UPDATE_DISPLAY_FAILED),
|
||||
switchMap(action => {
|
||||
const contentRef = action.payload.contentRef;
|
||||
const kernelRef = selectors.kernelRefByContentRef(state$.value, { contentRef });
|
||||
return of(
|
||||
actions.killKernelSuccessful({
|
||||
kernelRef
|
||||
})
|
||||
);
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Workaround for issue: https://github.com/nteract/nteract/issues/4583
|
||||
* We reajust the property
|
||||
* @param action$
|
||||
* @param state$
|
||||
*/
|
||||
const adjustLastModifiedOnSaveEpic = (
|
||||
action$: ActionsObservable<actions.SaveFulfilled>,
|
||||
state$: StateObservable<AppState>,
|
||||
dependencies: { contentProvider: IContentProvider }
|
||||
): Observable<{} | CdbActions.UpdateLastModifiedAction> => {
|
||||
return action$.pipe(
|
||||
ofType(actions.SAVE_FULFILLED),
|
||||
mergeMap(action => {
|
||||
const pollDelayMs = 500;
|
||||
const nbAttempts = 4;
|
||||
|
||||
// Retry updating last modified
|
||||
const currentHost = selectors.currentHost(state$.value);
|
||||
const serverConfig = selectors.serverConfig(currentHost as JupyterHostRecord);
|
||||
const filepath = selectors.filepath(state$.value, { contentRef: action.payload.contentRef });
|
||||
const content = selectors.content(state$.value, { contentRef: action.payload.contentRef });
|
||||
const lastSaved = (content.lastSaved as any) as string;
|
||||
const contentProvider = dependencies.contentProvider;
|
||||
|
||||
// Query until value is stable
|
||||
return interval(pollDelayMs)
|
||||
.pipe(take(nbAttempts))
|
||||
.pipe(
|
||||
mergeMap(x =>
|
||||
contentProvider.get(serverConfig, filepath, { content: 0 }).pipe(
|
||||
map(xhr => {
|
||||
if (xhr.status !== 200 || typeof xhr.response === "string") {
|
||||
return undefined;
|
||||
}
|
||||
const model = xhr.response;
|
||||
const lastModified = model.last_modified;
|
||||
if (lastModified === lastSaved) {
|
||||
return undefined;
|
||||
}
|
||||
// Return last modified
|
||||
return lastModified;
|
||||
})
|
||||
)
|
||||
),
|
||||
distinctUntilChanged(),
|
||||
mergeMap(lastModified => {
|
||||
if (!lastModified) {
|
||||
return empty();
|
||||
}
|
||||
|
||||
return of(
|
||||
CdbActions.updateLastModified({
|
||||
contentRef: action.payload.contentRef,
|
||||
lastModified
|
||||
})
|
||||
);
|
||||
})
|
||||
);
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Execute focused cell and focus next cell
|
||||
* @param action$
|
||||
* @param state$
|
||||
*/
|
||||
const executeFocusedCellAndFocusNextEpic = (
|
||||
action$: ActionsObservable<CdbActions.ExecuteFocusedCellAndFocusNextAction>,
|
||||
state$: StateObservable<AppState>
|
||||
): Observable<{} | actions.FocusNextCellEditor> => {
|
||||
return action$.pipe(
|
||||
ofType(CdbActions.EXECUTE_FOCUSED_CELL_AND_FOCUS_NEXT),
|
||||
mergeMap(action => {
|
||||
const contentRef = action.payload.contentRef;
|
||||
return concat(
|
||||
of(actions.executeFocusedCell({ contentRef })),
|
||||
of(actions.focusNextCell({ contentRef, createCellIfUndefined: false }))
|
||||
);
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
function getUserPuid(): string {
|
||||
const arcadiaToken = window.dataExplorer && window.dataExplorer.arcadiaToken();
|
||||
if (!arcadiaToken) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let userPuid;
|
||||
try {
|
||||
const tokenPayload = decryptJWTToken(arcadiaToken);
|
||||
if (tokenPayload && tokenPayload.hasOwnProperty("puid")) {
|
||||
userPuid = tokenPayload.puid;
|
||||
}
|
||||
} catch (error) {
|
||||
// ignore
|
||||
}
|
||||
|
||||
return userPuid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Close tab if mimetype not supported
|
||||
* @param action$
|
||||
* @param state$
|
||||
*/
|
||||
const closeUnsupportedMimetypesEpic = (
|
||||
action$: ActionsObservable<actions.FetchContentFulfilled>,
|
||||
state$: StateObservable<AppState>
|
||||
): Observable<{}> => {
|
||||
return action$.pipe(
|
||||
ofType(actions.FETCH_CONTENT_FULFILLED),
|
||||
mergeMap(action => {
|
||||
const mimetype = action.payload.model.mimetype;
|
||||
const explorer = window.dataExplorer;
|
||||
if (explorer && !TextFile.handles(mimetype)) {
|
||||
const filepath = action.payload.filepath;
|
||||
// Close tab and show error message
|
||||
explorer.closeNotebookTab(filepath);
|
||||
const msg = `${filepath} cannot be rendered. Please download the file, in order to view it outside of Data Explorer.`;
|
||||
explorer.showOkModalDialog("File cannot be rendered", msg);
|
||||
NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Error, msg);
|
||||
}
|
||||
return empty();
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Close tab if file content fails to fetch not supported
|
||||
* @param action$
|
||||
* @param state$
|
||||
*/
|
||||
const closeContentFailedToFetchEpic = (
|
||||
action$: ActionsObservable<actions.FetchContentFailed>,
|
||||
state$: StateObservable<AppState>
|
||||
): Observable<{}> => {
|
||||
return action$.pipe(
|
||||
ofType(actions.FETCH_CONTENT_FAILED),
|
||||
mergeMap(action => {
|
||||
const explorer = window.dataExplorer;
|
||||
if (explorer) {
|
||||
const filepath = action.payload.filepath;
|
||||
// Close tab and show error message
|
||||
explorer.closeNotebookTab(filepath);
|
||||
const msg = `Failed to load file: ${filepath}.`;
|
||||
explorer.showOkModalDialog("Failure to load", msg);
|
||||
NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Error, msg);
|
||||
}
|
||||
return empty();
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
export const allEpics = [
|
||||
addInitialCodeCellEpic,
|
||||
focusInitialCodeCellEpic,
|
||||
notificationsToUserEpic,
|
||||
launchWebSocketKernelEpic,
|
||||
changeWebSocketKernelEpic,
|
||||
acquireKernelInfoEpic,
|
||||
handleKernelConnectionLostEpic,
|
||||
cleanKernelOnConnectionLostEpic,
|
||||
adjustLastModifiedOnSaveEpic,
|
||||
executeFocusedCellAndFocusNextEpic,
|
||||
closeUnsupportedMimetypesEpic,
|
||||
closeContentFailedToFetchEpic,
|
||||
restartWebSocketKernelEpic
|
||||
];
|
||||
35
src/Explorer/Notebook/NotebookComponent/loadTransform.ts
Normal file
35
src/Explorer/Notebook/NotebookComponent/loadTransform.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
// This replicates transform loading from:
|
||||
// https://github.com/nteract/nteract/blob/master/applications/jupyter-extension/nteract_on_jupyter/app/contents/notebook.tsx
|
||||
|
||||
export default (props: { addTransform: (component: any) => void }) => {
|
||||
import(/* webpackChunkName: "plotly" */ "@nteract/transform-plotly").then(module => {
|
||||
props.addTransform(module.default);
|
||||
props.addTransform(module.PlotlyNullTransform);
|
||||
});
|
||||
|
||||
import(/* webpackChunkName: "tabular-dataresource" */ "@nteract/data-explorer").then(module => {
|
||||
props.addTransform(module.default);
|
||||
});
|
||||
|
||||
import(/* webpackChunkName: "jupyter-widgets" */ "@nteract/jupyter-widgets").then(module => {
|
||||
props.addTransform(module.WidgetDisplay);
|
||||
});
|
||||
|
||||
import("@nteract/transform-model-debug").then(module => {
|
||||
props.addTransform(module.default);
|
||||
});
|
||||
|
||||
import(/* webpackChunkName: "vega-transform" */ "@nteract/transform-vega").then(module => {
|
||||
props.addTransform(module.VegaLite1);
|
||||
props.addTransform(module.VegaLite2);
|
||||
props.addTransform(module.VegaLite3);
|
||||
props.addTransform(module.VegaLite4);
|
||||
props.addTransform(module.Vega2);
|
||||
props.addTransform(module.Vega3);
|
||||
props.addTransform(module.Vega4);
|
||||
props.addTransform(module.Vega5);
|
||||
});
|
||||
|
||||
// TODO: The geojson transform will likely need some work because of the basemap URL(s)
|
||||
// import GeoJSONTransform from "@nteract/transform-geojson";
|
||||
};
|
||||
80
src/Explorer/Notebook/NotebookComponent/reducers.ts
Normal file
80
src/Explorer/Notebook/NotebookComponent/reducers.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import * as cdbActions from "./actions";
|
||||
import { CdbRecord } from "./types";
|
||||
|
||||
import { Action } from "redux";
|
||||
import { actions, CoreRecord, reducers as nteractReducers } from "@nteract/core";
|
||||
|
||||
export const coreReducer = (state: CoreRecord, action: Action) => {
|
||||
let typedAction;
|
||||
switch (action.type) {
|
||||
case cdbActions.CLOSE_NOTEBOOK: {
|
||||
typedAction = action as cdbActions.CloseNotebookAction;
|
||||
return state.setIn(
|
||||
["entities", "contents", "byRef"],
|
||||
state.entities.contents.byRef.delete(typedAction.payload.contentRef)
|
||||
);
|
||||
}
|
||||
case actions.CHANGE_KERNEL_BY_NAME: {
|
||||
// Update content metadata
|
||||
typedAction = action as actions.ChangeKernelByName;
|
||||
const kernelSpecName = typedAction.payload.kernelSpecName;
|
||||
|
||||
if (!state.currentKernelspecsRef) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const currentKernelspecs = state.entities.kernelspecs.byRef.get(state.currentKernelspecsRef);
|
||||
if (!currentKernelspecs) {
|
||||
return state;
|
||||
}
|
||||
|
||||
// Find kernelspecs by name
|
||||
const kernelspecs = currentKernelspecs.byName.get(kernelSpecName);
|
||||
if (!kernelspecs) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const path = [
|
||||
"entities",
|
||||
"contents",
|
||||
"byRef",
|
||||
typedAction.payload.contentRef,
|
||||
"model",
|
||||
"notebook",
|
||||
"metadata",
|
||||
"kernelspec"
|
||||
];
|
||||
// Update metadata
|
||||
return state
|
||||
.setIn(path.concat("name"), kernelspecs.name)
|
||||
.setIn(path.concat("displayName"), kernelspecs.displayName)
|
||||
.setIn(path.concat("language"), kernelspecs.language);
|
||||
}
|
||||
case cdbActions.UPDATE_LAST_MODIFIED: {
|
||||
typedAction = action as cdbActions.UpdateLastModifiedAction;
|
||||
const path = ["entities", "contents", "byRef", typedAction.payload.contentRef, "lastSaved"];
|
||||
return state.setIn(path, typedAction.payload.lastModified);
|
||||
}
|
||||
default:
|
||||
return nteractReducers.core(state as any, action as any);
|
||||
}
|
||||
};
|
||||
|
||||
export const cdbReducer = (state: CdbRecord, action: Action) => {
|
||||
if (!state) {
|
||||
return null;
|
||||
}
|
||||
|
||||
switch (action.type) {
|
||||
case cdbActions.UPDATE_KERNEL_RESTART_DELAY: {
|
||||
const typedAction = action as cdbActions.UpdateKernelRestartDelayAction;
|
||||
return state.set("kernelRestartDelayMs", typedAction.payload.delayMs);
|
||||
}
|
||||
|
||||
case cdbActions.SET_HOVERED_CELL: {
|
||||
const typedAction = action as cdbActions.SetHoveredCellAction;
|
||||
return state.set("hoveredCellId", typedAction.payload.cellId);
|
||||
}
|
||||
}
|
||||
return state;
|
||||
};
|
||||
112
src/Explorer/Notebook/NotebookComponent/store.ts
Normal file
112
src/Explorer/Notebook/NotebookComponent/store.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { AppState, epics as coreEpics, reducers, IContentProvider } from "@nteract/core";
|
||||
import {
|
||||
applyMiddleware,
|
||||
combineReducers,
|
||||
compose,
|
||||
createStore,
|
||||
Store,
|
||||
AnyAction,
|
||||
Middleware,
|
||||
Dispatch,
|
||||
MiddlewareAPI
|
||||
} from "redux";
|
||||
import { combineEpics, createEpicMiddleware, Epic, ActionsObservable } from "redux-observable";
|
||||
import { allEpics } from "./epics";
|
||||
import { coreReducer, cdbReducer } from "./reducers";
|
||||
import { catchError } from "rxjs/operators";
|
||||
|
||||
const composeEnhancers = (window as any).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
|
||||
|
||||
export default function configureStore(
|
||||
initialState: Partial<AppState>,
|
||||
contentProvider: IContentProvider,
|
||||
onTraceFailure: (title: string, message: string) => void,
|
||||
customMiddlewares?: Middleware<{}, any, Dispatch<AnyAction>>[]
|
||||
): Store<AppState, AnyAction> {
|
||||
const rootReducer = combineReducers({
|
||||
app: reducers.app,
|
||||
comms: reducers.comms,
|
||||
config: reducers.config,
|
||||
core: coreReducer,
|
||||
cdb: cdbReducer
|
||||
});
|
||||
|
||||
/**
|
||||
* Catches errors in reducers
|
||||
*/
|
||||
const catchErrorMiddleware: Middleware = <D extends Dispatch<AnyAction>, S extends AppState>({
|
||||
dispatch,
|
||||
getState
|
||||
}: MiddlewareAPI<D, S>) => (next: Dispatch<AnyAction>) => <A extends AnyAction>(action: A): any => {
|
||||
try {
|
||||
next(action);
|
||||
} catch (error) {
|
||||
traceFailure("Reducer failure", error);
|
||||
}
|
||||
};
|
||||
|
||||
const protect = (epic: Epic) => {
|
||||
return (action$: ActionsObservable<any>, state$: any, dependencies: any) =>
|
||||
epic(action$, state$, dependencies).pipe(
|
||||
catchError((error, caught) => {
|
||||
traceFailure("Epic failure", error);
|
||||
return caught;
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const traceFailure = (title: string, error: any) => {
|
||||
if (error instanceof Error) {
|
||||
onTraceFailure(title, `${error.message} ${JSON.stringify(error.stack)}`);
|
||||
console.error(error);
|
||||
} else {
|
||||
onTraceFailure(title, JSON.stringify(error));
|
||||
}
|
||||
};
|
||||
|
||||
const combineAndProtectEpics = (epics: Epic[]): Epic => {
|
||||
const protectedEpics = epics.map(epic => protect(epic));
|
||||
return combineEpics<Epic>(...protectedEpics);
|
||||
};
|
||||
|
||||
// This list needs to be consistent and in sync with core.allEpics until we figure
|
||||
// out how to safely filter out the ones we are overriding here.
|
||||
const filteredCoreEpics = [
|
||||
coreEpics.autoSaveCurrentContentEpic,
|
||||
coreEpics.executeCellEpic,
|
||||
coreEpics.executeFocusedCellEpic,
|
||||
coreEpics.executeCellAfterKernelLaunchEpic,
|
||||
coreEpics.sendExecuteRequestEpic,
|
||||
coreEpics.updateDisplayEpic,
|
||||
coreEpics.executeAllCellsEpic,
|
||||
coreEpics.commListenEpic,
|
||||
coreEpics.interruptKernelEpic,
|
||||
coreEpics.lazyLaunchKernelEpic,
|
||||
coreEpics.killKernelEpic,
|
||||
coreEpics.watchExecutionStateEpic,
|
||||
coreEpics.restartKernelEpic,
|
||||
coreEpics.fetchKernelspecsEpic,
|
||||
coreEpics.fetchContentEpic,
|
||||
coreEpics.updateContentEpic,
|
||||
coreEpics.saveContentEpic,
|
||||
coreEpics.publishToBookstore,
|
||||
coreEpics.publishToBookstoreAfterSave,
|
||||
coreEpics.sendInputReplyEpic
|
||||
];
|
||||
const rootEpic = combineAndProtectEpics([...filteredCoreEpics, ...allEpics]);
|
||||
const epicMiddleware = createEpicMiddleware({ dependencies: { contentProvider } });
|
||||
let middlewares: Middleware[] = [epicMiddleware];
|
||||
// TODO: tamitta: errorMiddleware was removed, do we need a substitute?
|
||||
|
||||
if (customMiddlewares) {
|
||||
middlewares = middlewares.concat(customMiddlewares);
|
||||
}
|
||||
middlewares.push(catchErrorMiddleware);
|
||||
|
||||
const store = createStore(rootReducer, initialState, composeEnhancers(applyMiddleware(...middlewares)));
|
||||
|
||||
epicMiddleware.run(rootEpic);
|
||||
|
||||
// TODO Fix typing issue here: createStore() output type doesn't quite match AppState
|
||||
return store as Store<AppState, AnyAction>;
|
||||
}
|
||||
25
src/Explorer/Notebook/NotebookComponent/types.ts
Normal file
25
src/Explorer/Notebook/NotebookComponent/types.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import * as Immutable from "immutable";
|
||||
import { AppState } from "@nteract/core";
|
||||
|
||||
import { Notebook } from "../../../Common/Constants";
|
||||
import { CellId } from "@nteract/commutable";
|
||||
|
||||
export interface CdbRecordProps {
|
||||
databaseAccountName: string;
|
||||
defaultExperience: string;
|
||||
kernelRestartDelayMs: number;
|
||||
hoveredCellId: CellId;
|
||||
}
|
||||
|
||||
export type CdbRecord = Immutable.RecordOf<CdbRecordProps>;
|
||||
|
||||
export interface CdbAppState extends AppState {
|
||||
cdb: CdbRecord;
|
||||
}
|
||||
|
||||
export const makeCdbRecord = Immutable.Record<CdbRecordProps>({
|
||||
databaseAccountName: undefined,
|
||||
defaultExperience: undefined,
|
||||
kernelRestartDelayMs: Notebook.kernelRestartInitialDelayMs,
|
||||
hoveredCellId: undefined
|
||||
});
|
||||
148
src/Explorer/Notebook/NotebookContainerClient.ts
Normal file
148
src/Explorer/Notebook/NotebookContainerClient.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* Notebook container related stuff
|
||||
*/
|
||||
import * as DataModels from "../../Contracts/DataModels";
|
||||
import * as ViewModels from "../../Contracts/ViewModels";
|
||||
import * as Constants from "../../Common/Constants";
|
||||
import { ConsoleDataType } from "../Menus/NotificationConsole/NotificationConsoleComponent";
|
||||
import { NotificationConsoleUtils } from "../../Utils/NotificationConsoleUtils";
|
||||
import { Logger } from "../../Common/Logger";
|
||||
|
||||
export class NotebookContainerClient implements ViewModels.INotebookContainerClient {
|
||||
private reconnectingNotificationId: string;
|
||||
private isResettingWorkspace: boolean;
|
||||
|
||||
constructor(
|
||||
private notebookServerInfo: ko.Observable<DataModels.NotebookWorkspaceConnectionInfo>,
|
||||
private onConnectionLost: () => void,
|
||||
private onMemoryUsageInfoUpdate: (update: DataModels.MemoryUsageInfo) => void
|
||||
) {
|
||||
if (notebookServerInfo() && notebookServerInfo().notebookServerEndpoint) {
|
||||
this.scheduleHeartbeat(Constants.Notebook.heartbeatDelayMs);
|
||||
} else {
|
||||
const subscription = notebookServerInfo.subscribe((newServerInfo: DataModels.NotebookWorkspaceConnectionInfo) => {
|
||||
if (newServerInfo && newServerInfo.notebookServerEndpoint) {
|
||||
this.scheduleHeartbeat(Constants.Notebook.heartbeatDelayMs);
|
||||
}
|
||||
subscription.dispose();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Heartbeat: each ping schedules another ping
|
||||
*/
|
||||
private scheduleHeartbeat(delayMs: number): void {
|
||||
setTimeout(() => {
|
||||
this.getMemoryUsage()
|
||||
.then(memoryUsageInfo => this.onMemoryUsageInfoUpdate(memoryUsageInfo))
|
||||
.finally(() => this.scheduleHeartbeat(Constants.Notebook.heartbeatDelayMs));
|
||||
}, delayMs);
|
||||
}
|
||||
|
||||
private async getMemoryUsage(): Promise<DataModels.MemoryUsageInfo> {
|
||||
if (!this.notebookServerInfo() || !this.notebookServerInfo().notebookServerEndpoint) {
|
||||
const error = "No server endpoint detected";
|
||||
Logger.logError(error, "NotebookContainerClient/getMemoryUsage");
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
if (this.isResettingWorkspace) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const { notebookServerEndpoint, authToken } = this.getNotebookServerConfig();
|
||||
try {
|
||||
const response = await fetch(`${notebookServerEndpoint}/api/metrics/memory`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: authToken,
|
||||
"content-type": "application/json"
|
||||
}
|
||||
});
|
||||
if (response.ok) {
|
||||
if (this.reconnectingNotificationId) {
|
||||
NotificationConsoleUtils.clearInProgressMessageWithId(this.reconnectingNotificationId);
|
||||
this.reconnectingNotificationId = "";
|
||||
}
|
||||
const memoryUsageInfo = await response.json();
|
||||
if (memoryUsageInfo) {
|
||||
return {
|
||||
totalKB: memoryUsageInfo.total,
|
||||
freeKB: memoryUsageInfo.free
|
||||
};
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
} catch (error) {
|
||||
Logger.logError(error, "NotebookContainerClient/getMemoryUsage");
|
||||
if (!this.reconnectingNotificationId) {
|
||||
this.reconnectingNotificationId = NotificationConsoleUtils.logConsoleMessage(
|
||||
ConsoleDataType.InProgress,
|
||||
"Connection lost with Notebook server. Attempting to reconnect..."
|
||||
);
|
||||
}
|
||||
this.onConnectionLost();
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
public async resetWorkspace(): Promise<void> {
|
||||
this.isResettingWorkspace = true;
|
||||
try {
|
||||
await this._resetWorkspace();
|
||||
} catch (error) {
|
||||
Promise.reject(error);
|
||||
}
|
||||
this.isResettingWorkspace = false;
|
||||
}
|
||||
|
||||
private async _resetWorkspace(): Promise<void> {
|
||||
if (!this.notebookServerInfo() || !this.notebookServerInfo().notebookServerEndpoint) {
|
||||
const error = "No server endpoint detected";
|
||||
Logger.logError(error, "NotebookContainerClient/resetWorkspace");
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
const { notebookServerEndpoint, authToken } = this.getNotebookServerConfig();
|
||||
try {
|
||||
await fetch(`${notebookServerEndpoint}/api/shutdown`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: authToken }
|
||||
});
|
||||
} catch (error) {
|
||||
Logger.logError(error, "NotebookContainerClient/resetWorkspace");
|
||||
await this.recreateNotebookWorkspaceAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private getNotebookServerConfig(): { notebookServerEndpoint: string; authToken: string } {
|
||||
let authToken: string,
|
||||
notebookServerEndpoint = this.notebookServerInfo().notebookServerEndpoint,
|
||||
token = this.notebookServerInfo().authToken;
|
||||
if (token) {
|
||||
authToken = `Token ${token}`;
|
||||
}
|
||||
|
||||
return {
|
||||
notebookServerEndpoint,
|
||||
authToken
|
||||
};
|
||||
}
|
||||
|
||||
private async recreateNotebookWorkspaceAsync(): Promise<void> {
|
||||
const explorer = window.dataExplorer as ViewModels.Explorer;
|
||||
if (!explorer || !explorer.databaseAccount() || !explorer.databaseAccount().id) {
|
||||
throw new Error("DataExplorer not initialized");
|
||||
}
|
||||
|
||||
const notebookWorkspaceManager = explorer.notebookWorkspaceManager;
|
||||
try {
|
||||
await notebookWorkspaceManager.deleteNotebookWorkspaceAsync(explorer.databaseAccount().id, "default");
|
||||
await notebookWorkspaceManager.createNotebookWorkspaceAsync(explorer.databaseAccount().id, "default");
|
||||
} catch (error) {
|
||||
Logger.logError(error, "NotebookContainerClient/recreateNotebookWorkspaceAsync");
|
||||
return Promise.reject(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
293
src/Explorer/Notebook/NotebookContentClient.ts
Normal file
293
src/Explorer/Notebook/NotebookContentClient.ts
Normal file
@@ -0,0 +1,293 @@
|
||||
import * as DataModels from "../../Contracts/DataModels";
|
||||
import * as ViewModels from "../../Contracts/ViewModels";
|
||||
import { NotebookContentItem, NotebookContentItemType } from "./NotebookContentItem";
|
||||
import { StringUtils } from "../../Utils/StringUtils";
|
||||
import { FileSystemUtil } from "./FileSystemUtil";
|
||||
import { NotebookUtil } from "./NotebookUtil";
|
||||
|
||||
import { ServerConfig, IContent, IContentProvider, FileType, IEmptyContent } from "@nteract/core";
|
||||
import { AjaxResponse } from "rxjs/ajax";
|
||||
import { stringifyNotebook } from "@nteract/commutable";
|
||||
|
||||
export class NotebookContentClient implements ViewModels.INotebookContentClient {
|
||||
constructor(
|
||||
private notebookServerInfo: ko.Observable<DataModels.NotebookWorkspaceConnectionInfo>,
|
||||
private notebookBasePath: ko.Observable<string>,
|
||||
private contentProvider: IContentProvider
|
||||
) {}
|
||||
|
||||
/**
|
||||
* This updates the item and points all the children's parent to this item
|
||||
* @param item
|
||||
*/
|
||||
public updateItemChildren(item: NotebookContentItem): Promise<void> {
|
||||
return this.fetchNotebookFiles(item.path).then(subItems => {
|
||||
item.children = subItems;
|
||||
subItems.forEach(subItem => (subItem.parent = item));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param parent parent folder
|
||||
*/
|
||||
public createNewNotebookFile(parent: NotebookContentItem): Promise<NotebookContentItem> {
|
||||
if (!parent || parent.type !== NotebookContentItemType.Directory) {
|
||||
throw new Error(`Parent must be a directory: ${parent}`);
|
||||
}
|
||||
|
||||
const type = "notebook";
|
||||
return this.contentProvider
|
||||
.create<"notebook">(this.getServerConfig(), parent.path, { type })
|
||||
.toPromise()
|
||||
.then((xhr: AjaxResponse) => {
|
||||
if (typeof xhr.response === "string") {
|
||||
throw new Error(`jupyter server response invalid: ${xhr.response}`);
|
||||
}
|
||||
|
||||
if (xhr.response.type !== type) {
|
||||
throw new Error(`jupyter server response not for notebook: ${xhr.response}`);
|
||||
}
|
||||
|
||||
const notebookFile = xhr.response;
|
||||
|
||||
const item = NotebookUtil.createNotebookContentItem(notebookFile.name, notebookFile.path, notebookFile.type);
|
||||
if (parent.children) {
|
||||
item.parent = parent;
|
||||
parent.children.push(item);
|
||||
}
|
||||
|
||||
return item;
|
||||
});
|
||||
}
|
||||
|
||||
public deleteContentItem(item: NotebookContentItem): Promise<void> {
|
||||
return this.deleteNotebookFile(item.path).then((path: string) => {
|
||||
if (!path || path !== item.path) {
|
||||
throw new Error("No path provided");
|
||||
}
|
||||
|
||||
if (item.parent && item.parent.children) {
|
||||
// Remove deleted child
|
||||
const newChildren = item.parent.children.filter(child => child.path !== path);
|
||||
item.parent.children = newChildren;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param name file name
|
||||
* @param content file content string
|
||||
* @param parent parent folder
|
||||
*/
|
||||
public async uploadFileAsync(
|
||||
name: string,
|
||||
content: string,
|
||||
parent: NotebookContentItem
|
||||
): Promise<NotebookContentItem> {
|
||||
if (!parent || parent.type !== NotebookContentItemType.Directory) {
|
||||
throw new Error(`Parent must be a directory: ${parent}`);
|
||||
}
|
||||
|
||||
const filepath = `${parent.path}/${name}`;
|
||||
if (await this.checkIfFilepathExists(filepath)) {
|
||||
throw new Error(`File already exists: ${filepath}`);
|
||||
}
|
||||
|
||||
const model: Partial<IContent<"file">> = {
|
||||
content,
|
||||
format: "text",
|
||||
name,
|
||||
type: "file"
|
||||
};
|
||||
|
||||
return this.contentProvider
|
||||
.save(this.getServerConfig(), filepath, model)
|
||||
.toPromise()
|
||||
.then((xhr: AjaxResponse) => {
|
||||
const notebookFile = xhr.response;
|
||||
const item = NotebookUtil.createNotebookContentItem(notebookFile.name, notebookFile.path, notebookFile.type);
|
||||
if (parent.children) {
|
||||
item.parent = parent;
|
||||
parent.children.push(item);
|
||||
}
|
||||
return item;
|
||||
});
|
||||
}
|
||||
|
||||
private async checkIfFilepathExists(filepath: string): Promise<boolean> {
|
||||
const basename = filepath.split("/").pop();
|
||||
let parentDirPath = filepath
|
||||
.split(basename)
|
||||
.shift()
|
||||
.replace(/\/$/, ""); // no trailling slash
|
||||
|
||||
const items = await this.fetchNotebookFiles(parentDirPath);
|
||||
return items.some(value => FileSystemUtil.isPathEqual(value.path, filepath));
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param sourcePath
|
||||
* @param targetName is not prefixed with path
|
||||
*/
|
||||
public renameNotebook(item: NotebookContentItem, targetName: string): Promise<NotebookContentItem> {
|
||||
const sourcePath = item.path;
|
||||
// Match extension
|
||||
if (sourcePath.indexOf(".") !== -1) {
|
||||
const extension = `.${sourcePath.split(".").pop()}`;
|
||||
if (!StringUtils.endsWith(targetName, extension)) {
|
||||
targetName += extension;
|
||||
}
|
||||
}
|
||||
const parsedPath = NotebookContentClient.parsePath(sourcePath);
|
||||
const targetPath = `${parsedPath.dirpath}${targetName}`;
|
||||
return this.contentProvider
|
||||
.update<"file" | "notebook" | "directory">(this.getServerConfig(), sourcePath, { path: targetPath })
|
||||
.toPromise()
|
||||
.then(xhr => {
|
||||
if (typeof xhr.response === "string") {
|
||||
throw new Error(`jupyter server response invalid: ${xhr.response}`);
|
||||
}
|
||||
|
||||
if (xhr.response.type !== "file" && xhr.response.type !== "notebook" && xhr.response.type !== "directory") {
|
||||
throw new Error(`jupyter server response not for notebook/file/directory: ${xhr.response}`);
|
||||
}
|
||||
|
||||
const notebookFile = xhr.response;
|
||||
item.name = notebookFile.name;
|
||||
item.path = notebookFile.path;
|
||||
item.timestamp = NotebookUtil.getCurrentTimestamp();
|
||||
return item;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param parent
|
||||
* @param newDirectoryName basename of the new directory
|
||||
*/
|
||||
public async createDirectory(parent: NotebookContentItem, newDirectoryName: string): Promise<NotebookContentItem> {
|
||||
if (parent.type !== NotebookContentItemType.Directory) {
|
||||
throw new Error(`Parent is not a directory: ${parent.path}`);
|
||||
}
|
||||
|
||||
const targetPath = `${parent.path}/${newDirectoryName}`;
|
||||
|
||||
// Reject if already exists
|
||||
if (await this.checkIfFilepathExists(targetPath)) {
|
||||
throw new Error(`Directory already exists: ${targetPath}`);
|
||||
}
|
||||
|
||||
const type = "directory";
|
||||
return this.contentProvider
|
||||
.save<"directory">(this.getServerConfig(), targetPath, { type, path: targetPath })
|
||||
.toPromise()
|
||||
.then((xhr: AjaxResponse) => {
|
||||
if (typeof xhr.response === "string") {
|
||||
throw new Error(`jupyter server response invalid: ${xhr.response}`);
|
||||
}
|
||||
|
||||
if (xhr.response.type !== type) {
|
||||
throw new Error(`jupyter server response not for creating directory: ${xhr.response}`);
|
||||
}
|
||||
|
||||
const dir = xhr.response;
|
||||
const item = NotebookUtil.createNotebookContentItem(dir.name, dir.path, dir.type);
|
||||
item.parent = parent;
|
||||
parent.children.push(item);
|
||||
return item;
|
||||
});
|
||||
}
|
||||
|
||||
public readFileContent(filePath: string): Promise<string> {
|
||||
return this.contentProvider
|
||||
.get(this.getServerConfig(), filePath, { type: "notebook", format: "text", content: 1 })
|
||||
.toPromise()
|
||||
.then(xhr => {
|
||||
const content = (xhr.response as any).content;
|
||||
if (!content) {
|
||||
throw new Error("No content read");
|
||||
}
|
||||
return stringifyNotebook(content);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param path
|
||||
* @returns basename and dirpath. Note: dirpath has trailing / already
|
||||
*/
|
||||
private static parsePath(path: string): { dirpath: string; basename: string } {
|
||||
const basename = path.split("/").pop();
|
||||
const dirpath = path.split(basename).shift();
|
||||
|
||||
return {
|
||||
dirpath,
|
||||
basename
|
||||
};
|
||||
}
|
||||
|
||||
private deleteNotebookFile(path: string): Promise<string> {
|
||||
return this.contentProvider
|
||||
.remove(this.getServerConfig(), path)
|
||||
.toPromise()
|
||||
.then((xhr: AjaxResponse) => path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert rx-jupyter type to our type
|
||||
* @param type
|
||||
*/
|
||||
private static getType(type: FileType): NotebookContentItemType {
|
||||
switch (type) {
|
||||
case "directory":
|
||||
return NotebookContentItemType.Directory;
|
||||
case "notebook":
|
||||
return NotebookContentItemType.Notebook;
|
||||
case "file":
|
||||
return NotebookContentItemType.File;
|
||||
default:
|
||||
throw new Error(`Unknown file type: ${type}`);
|
||||
}
|
||||
}
|
||||
|
||||
private fetchNotebookFiles(path: string): Promise<NotebookContentItem[]> {
|
||||
return this.contentProvider
|
||||
.get(this.getServerConfig(), path, {
|
||||
type: "directory"
|
||||
})
|
||||
.toPromise()
|
||||
.then(xhr => {
|
||||
if (xhr.status !== 200) {
|
||||
throw new Error(JSON.stringify(xhr.response));
|
||||
}
|
||||
|
||||
if (typeof xhr.response === "string") {
|
||||
throw new Error(`jupyter server response invalid: ${xhr.response}`);
|
||||
}
|
||||
|
||||
if (xhr.response.type !== "directory") {
|
||||
throw new Error(`jupyter server response not for directory: ${xhr.response}`);
|
||||
}
|
||||
|
||||
const list = xhr.response.content as IEmptyContent<FileType>[];
|
||||
return list.map(
|
||||
(item: IEmptyContent<FileType>): NotebookContentItem => ({
|
||||
name: item.name,
|
||||
path: item.path,
|
||||
type: NotebookUtil.getType(item.type)
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private getServerConfig(): ServerConfig {
|
||||
return {
|
||||
endpoint: this.notebookServerInfo().notebookServerEndpoint,
|
||||
token: this.notebookServerInfo().authToken,
|
||||
crossDomain: true
|
||||
};
|
||||
}
|
||||
}
|
||||
14
src/Explorer/Notebook/NotebookContentItem.ts
Normal file
14
src/Explorer/Notebook/NotebookContentItem.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export interface NotebookContentItem {
|
||||
name: string;
|
||||
path: string;
|
||||
type: NotebookContentItemType;
|
||||
children?: NotebookContentItem[];
|
||||
parent?: NotebookContentItem;
|
||||
timestamp?: number;
|
||||
}
|
||||
|
||||
export enum NotebookContentItemType {
|
||||
Notebook,
|
||||
File,
|
||||
Directory
|
||||
}
|
||||
115
src/Explorer/Notebook/NotebookRenderer/AzureTheme.tsx
Normal file
115
src/Explorer/Notebook/NotebookRenderer/AzureTheme.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import { createGlobalStyle } from "styled-components";
|
||||
|
||||
const AzureTheme = createGlobalStyle`
|
||||
:root {
|
||||
/* --theme-primary-bg-hover: #0078d4;
|
||||
--theme-primary-bg-focus: #0078d4;
|
||||
--theme-primary-shadow-hover: #0078d4; */
|
||||
|
||||
--theme-app-bg: white;
|
||||
--theme-app-fg: var(--nt-color-midnight);
|
||||
--theme-app-border: var(--nt-color-grey-light);
|
||||
|
||||
--theme-primary-bg: var(--nt-color-grey-lightest);
|
||||
--theme-primary-bg-hover: var(--nt-color-grey-lighter);
|
||||
--theme-primary-bg-focus: var(--nt-color-grey-light);
|
||||
|
||||
--theme-primary-fg: var(--nt-color-midnight-light);
|
||||
--theme-primary-fg-hover: var(--nt-color-midnight);
|
||||
--theme-primary-fg-focus: var(--theme-app-fg);
|
||||
|
||||
--theme-secondary-bg: var(--theme-primary-bg);
|
||||
--theme-secondary-bg-hover: var(--theme-primary-bg-hover);
|
||||
--theme-secondary-bg-focus: var(--theme-primary-bg-focus);
|
||||
|
||||
--theme-secondary-fg: var(--nt-color-midnight-lighter);
|
||||
--theme-secondary-fg-hover: var(--nt-color-midnight-light);
|
||||
--theme-secondary-fg-focus: var(--theme-primary-fg);
|
||||
|
||||
/* --theme-primary-shadow-hover: 0px 2px 4px rgba(0, 0, 0, 0.1);
|
||||
--theme-primary-shadow-focus: 0px 2px 4px rgba(0, 0, 0, 0.1); */
|
||||
|
||||
--theme-title-bar-bg: var(--theme-primary-bg-hover);
|
||||
|
||||
--theme-menu-bg: var(--theme-primary-bg);
|
||||
--theme-menu-bg-hover: var(--theme-primary-bg-hover);
|
||||
--theme-menu-bg-focus: var(--theme-primary-bg-focus);
|
||||
/* --theme-menu-shadow: var(--theme-primary-shadow-hover); */
|
||||
|
||||
--theme-menu-fg: var(--theme-app-fg);
|
||||
--theme-menu-fg-hover: var(--theme-app-fg);
|
||||
--theme-menu-fg-focus: var(--theme-app-fg);
|
||||
|
||||
--theme-cell-bg: var(--theme-app-bg);
|
||||
/* --theme-cell-shadow-hover: var(--theme-primary-shadow-hover); */
|
||||
/* --theme-cell-shadow-focus: var(--theme-primary-shadow-focus); */
|
||||
|
||||
--theme-cell-prompt-bg: var(--theme-primary-bg);
|
||||
--theme-cell-prompt-bg-hover: var(--theme-primary-bg-hover);
|
||||
--theme-cell-prompt-bg-focus: var(--theme-primary-bg-focus);
|
||||
|
||||
--theme-cell-prompt-fg: var(--theme-secondary-fg);
|
||||
--theme-cell-prompt-fg-hover: var(--theme-secondary-fg-hover);
|
||||
--theme-cell-prompt-fg-focus: var(--theme-secondary-fg-focus);
|
||||
|
||||
--theme-cell-toolbar-bg: var(--theme-primary-bg);
|
||||
--theme-cell-toolbar-bg-hover: var(--theme-primary-bg-hover);
|
||||
--theme-cell-toolbar-bg-focus: var(--theme-primary-bg-focus);
|
||||
|
||||
--theme-cell-toolbar-fg: var(--theme-secondary-fg);
|
||||
--theme-cell-toolbar-fg-hover: var(--theme-secondary-fg-hover);
|
||||
--theme-cell-toolbar-fg-focus: var(--theme-secondary-fg-focus);
|
||||
|
||||
--theme-cell-menu-bg: var(--theme-primary-bg);
|
||||
--theme-cell-menu-bg-hover: var(--theme-primary-bg-hover);
|
||||
--theme-cell-menu-bg-focus: var(--theme-primary-bg-focus);
|
||||
|
||||
--theme-cell-menu-fg: var(--theme-primary-fg);
|
||||
--theme-cell-menu-fg-hover: var(--theme-primary-fg-hover);
|
||||
--theme-cell-menu-fg-focus: var(--theme-primary-fg-focus);
|
||||
|
||||
--theme-cell-input-bg: var(--theme-secondary-bg);
|
||||
--theme-cell-input-fg: var(--theme-app-fg);
|
||||
|
||||
--theme-cell-output-bg: var(--theme-app-bg);
|
||||
--theme-cell-output-fg: var(--theme-primary-fg);
|
||||
|
||||
--theme-cell-creator-bg: var(--theme-app-bg);
|
||||
|
||||
--theme-cell-creator-fg: var(--theme-secondary-fg);
|
||||
--theme-cell-creator-fg-hover: var(--theme-secondary-fg-hover);
|
||||
--theme-cell-creator-fg-focus: var(--theme-secondary-fg-focus);
|
||||
|
||||
--theme-pager-bg: #fafafa;
|
||||
|
||||
--cm-background: #fafafa;
|
||||
--cm-color: black;
|
||||
|
||||
--cm-gutter-bg: white;
|
||||
|
||||
--cm-comment: #a86;
|
||||
--cm-keyword: blue;
|
||||
--cm-string: #a22;
|
||||
--cm-builtin: #077;
|
||||
--cm-special: #0aa;
|
||||
--cm-variable: black;
|
||||
--cm-number: #3a3;
|
||||
--cm-meta: #555;
|
||||
--cm-link: #3a3;
|
||||
--cm-operator: black;
|
||||
--cm-def: black;
|
||||
|
||||
--cm-activeline-bg: #e8f2ff;
|
||||
--cm-matchingbracket-outline: grey;
|
||||
--cm-matchingbracket-color: black;
|
||||
|
||||
--cm-hint-color: var(--cm-color);
|
||||
--cm-hint-color-active: var(--cm-color);
|
||||
--cm-hint-bg: var(--theme-app-bg);
|
||||
--cm-hint-bg-active: #abd1ff;
|
||||
|
||||
--status-bar: #eeedee;
|
||||
}
|
||||
`;
|
||||
|
||||
export { AzureTheme };
|
||||
@@ -0,0 +1,56 @@
|
||||
.NotebookReadOnlyRender {
|
||||
.nteract-cell-container {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.nteract-cell {
|
||||
padding: 0.5px;
|
||||
border: 1px solid #ffffff;
|
||||
border-left: 3px solid #ffffff;
|
||||
}
|
||||
|
||||
.CodeMirror-scroll {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.CodeMirror-lines {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.nteract-cell:hover {
|
||||
border: 1px solid #0078d4;
|
||||
border-left: 3px solid #0078d4;
|
||||
|
||||
.CodeMirror-scroll {
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
.nteract-cell-outputs {
|
||||
border-top: 1px solid #d7d7d7;
|
||||
}
|
||||
|
||||
.nteract-md-cell {
|
||||
background-color: #ffffff;
|
||||
}
|
||||
}
|
||||
|
||||
.nteract-cell-outputs {
|
||||
padding: 10px;
|
||||
border-top: 1px solid #ffffff;
|
||||
|
||||
pre {
|
||||
background-color: #ffffff;
|
||||
border: none;
|
||||
padding: 0px;
|
||||
margin: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
.nteract-md-cell {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.nteract-cell:hover.nteract-md-cell {
|
||||
background-color: #ffffff;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import * as React from "react";
|
||||
import "./base.css";
|
||||
import "./default.css";
|
||||
|
||||
import { CodeCell, RawCell, Cells, MarkdownCell } from "@nteract/stateful-components";
|
||||
import { AzureTheme } from "./AzureTheme";
|
||||
|
||||
import { connect } from "react-redux";
|
||||
import { Dispatch } from "redux";
|
||||
import { actions, ContentRef } from "@nteract/core";
|
||||
import loadTransform from "../NotebookComponent/loadTransform";
|
||||
import CodeMirrorEditor from "@nteract/editor";
|
||||
import "./NotebookReadOnlyRenderer.less";
|
||||
|
||||
export interface NotebookRendererProps {
|
||||
contentRef: any;
|
||||
}
|
||||
|
||||
interface PassedEditorProps {
|
||||
id: string;
|
||||
contentRef: ContentRef;
|
||||
editorFocused: boolean;
|
||||
value: string;
|
||||
channels: any;
|
||||
kernelStatus: string;
|
||||
theme: string;
|
||||
onChange: (text: string) => void;
|
||||
onFocusChange: (focused: boolean) => void;
|
||||
className: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* This is the class that uses nteract to render a read-only notebook.
|
||||
*/
|
||||
class NotebookReadOnlyRenderer extends React.Component<NotebookRendererProps> {
|
||||
componentDidMount() {
|
||||
loadTransform(this.props as any);
|
||||
}
|
||||
|
||||
render(): JSX.Element {
|
||||
return (
|
||||
<div className="NotebookReadOnlyRender">
|
||||
<Cells contentRef={this.props.contentRef}>
|
||||
{{
|
||||
code: ({ id, contentRef }: { id: any; contentRef: ContentRef }) => (
|
||||
<CodeCell id={id} contentRef={contentRef}>
|
||||
{{
|
||||
editor: {
|
||||
codemirror: (props: PassedEditorProps) => <CodeMirrorEditor {...props} readOnly={"nocursor"} />
|
||||
},
|
||||
prompt: ({ id, contentRef }) => <></>
|
||||
}}
|
||||
</CodeCell>
|
||||
),
|
||||
markdown: ({ id, contentRef }: { id: any; contentRef: ContentRef }) => (
|
||||
<MarkdownCell id={id} contentRef={contentRef} cell_type="markdown">
|
||||
{{
|
||||
editor: {}
|
||||
}}
|
||||
</MarkdownCell>
|
||||
),
|
||||
raw: ({ id, contentRef }: { id: any; contentRef: ContentRef }) => (
|
||||
<RawCell id={id} contentRef={contentRef} cell_type="raw">
|
||||
{{
|
||||
editor: {
|
||||
codemirror: (props: PassedEditorProps) => <CodeMirrorEditor {...props} readOnly={"nocursor"} />
|
||||
}
|
||||
}}
|
||||
</RawCell>
|
||||
)
|
||||
}}
|
||||
</Cells>
|
||||
<AzureTheme />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const makeMapDispatchToProps = (initialDispatch: Dispatch, initialProps: NotebookRendererProps) => {
|
||||
const mapDispatchToProps = (dispatch: Dispatch) => {
|
||||
return {
|
||||
addTransform: (transform: React.ComponentType & { MIMETYPE: string }) => {
|
||||
return dispatch(
|
||||
actions.addTransform({
|
||||
mediaType: transform.MIMETYPE,
|
||||
component: transform
|
||||
})
|
||||
);
|
||||
}
|
||||
};
|
||||
};
|
||||
return mapDispatchToProps;
|
||||
};
|
||||
|
||||
export default connect(null, makeMapDispatchToProps)(NotebookReadOnlyRenderer);
|
||||
108
src/Explorer/Notebook/NotebookRenderer/NotebookRenderer.less
Normal file
108
src/Explorer/Notebook/NotebookRenderer/NotebookRenderer.less
Normal file
@@ -0,0 +1,108 @@
|
||||
// CommandBar
|
||||
@HoverColor: #d7d7d7;
|
||||
@HighlightColor: #0078d4;
|
||||
|
||||
.NotebookRendererContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.NotebookRenderer {
|
||||
overflow: auto;
|
||||
flex-grow: 1;
|
||||
|
||||
.nteract-cells {
|
||||
padding-top: 0px;
|
||||
}
|
||||
|
||||
.nteract-cell-container {
|
||||
margin-bottom: 10px;
|
||||
|
||||
.nteract-cell {
|
||||
padding: 0.5px;
|
||||
border: 1px solid #ffffff;
|
||||
border-left: 3px solid #ffffff;
|
||||
|
||||
.CellContextMenuButton {
|
||||
position: sticky;
|
||||
z-index: 1;
|
||||
top: 0px;
|
||||
right: 0px;
|
||||
margin: 0px 0px 0px -100%;
|
||||
float: right;
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.CodeMirror-scroll, .CodeMirror-linenumber, .CodeMirror-gutters {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.nteract-cell:hover {
|
||||
border: 1px solid @HoverColor;
|
||||
border-left: 3px solid @HoverColor;
|
||||
|
||||
.CellContextMenuButton {
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.nteract-cell-container.selected {
|
||||
.nteract-cell {
|
||||
border: 1px solid @HighlightColor;
|
||||
border-left: 3px solid @HighlightColor;
|
||||
}
|
||||
}
|
||||
|
||||
// White background when hovered or selected
|
||||
.nteract-cell:hover, .nteract-cell-container.selected .nteract-cell {
|
||||
.CodeMirror-scroll, .CodeMirror-linenumber, .CodeMirror-gutters {
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
.CodeMirror-linenumber {
|
||||
color: #015CDA;
|
||||
}
|
||||
|
||||
.nteract-cell-outputs {
|
||||
border-top: 1px solid @HoverColor;
|
||||
}
|
||||
|
||||
.nteract-md-cell {
|
||||
background-color: #ffffff;
|
||||
}
|
||||
}
|
||||
|
||||
.nteract-cell-outputs {
|
||||
padding: 10px;
|
||||
border-top: 1px solid #ffffff;
|
||||
|
||||
pre {
|
||||
background-color: #ffffff;
|
||||
border: none;
|
||||
padding: 0px;
|
||||
margin: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
.nteract-md-cell {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.nteract-cell:hover.nteract-md-cell {
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
.nteract-md-cell .ntreact-cell-source {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Undo tree.less
|
||||
.expanded::before {
|
||||
content: '';
|
||||
}
|
||||
165
src/Explorer/Notebook/NotebookRenderer/NotebookRenderer.tsx
Normal file
165
src/Explorer/Notebook/NotebookRenderer/NotebookRenderer.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
import * as React from "react";
|
||||
import "./base.css";
|
||||
import "./default.css";
|
||||
|
||||
import { RawCell, Cells, CodeCell, MarkdownCell } from "@nteract/stateful-components";
|
||||
import CodeMirrorEditor from "@nteract/stateful-components/lib/inputs/connected-editors/codemirror";
|
||||
|
||||
import Prompt from "./Prompt";
|
||||
import { promptContent } from "./PromptContent";
|
||||
|
||||
import { AzureTheme } from "./AzureTheme";
|
||||
import { DndProvider } from "react-dnd";
|
||||
import HTML5Backend from "react-dnd-html5-backend";
|
||||
|
||||
import { connect } from "react-redux";
|
||||
import { Dispatch } from "redux";
|
||||
import { actions, ContentRef } from "@nteract/core";
|
||||
import { CellId } from "@nteract/commutable";
|
||||
import loadTransform from "../NotebookComponent/loadTransform";
|
||||
import DraggableCell from "./decorators/draggable";
|
||||
import CellCreator from "./decorators/CellCreator";
|
||||
import KeyboardShortcuts from "./decorators/kbd-shortcuts";
|
||||
|
||||
import CellToolbar from "./Toolbar";
|
||||
import StatusBar from "./StatusBar";
|
||||
|
||||
import HijackScroll from "./decorators/hijack-scroll";
|
||||
import { CellType } from "@nteract/commutable/src";
|
||||
|
||||
import "./NotebookRenderer.less";
|
||||
import HoverableCell from "./decorators/HoverableCell";
|
||||
import CellLabeler from "./decorators/CellLabeler";
|
||||
|
||||
export interface NotebookRendererProps {
|
||||
contentRef: any;
|
||||
}
|
||||
|
||||
interface PassedEditorProps {
|
||||
id: string;
|
||||
contentRef: ContentRef;
|
||||
editorFocused: boolean;
|
||||
value: string;
|
||||
channels: any;
|
||||
kernelStatus: string;
|
||||
theme: string;
|
||||
onChange: (text: string) => void;
|
||||
onFocusChange: (focused: boolean) => void;
|
||||
className: string;
|
||||
}
|
||||
|
||||
const decorate = (id: string, contentRef: ContentRef, cell_type: CellType, children: React.ReactNode) => {
|
||||
const Cell = () => (
|
||||
<DraggableCell id={id} contentRef={contentRef}>
|
||||
<HijackScroll id={id} contentRef={contentRef}>
|
||||
<CellCreator id={id} contentRef={contentRef}>
|
||||
<CellLabeler id={id} contentRef={contentRef}>
|
||||
<HoverableCell id={id} contentRef={contentRef}>
|
||||
{children}
|
||||
</HoverableCell>
|
||||
</CellLabeler>
|
||||
</CellCreator>
|
||||
</HijackScroll>
|
||||
</DraggableCell>
|
||||
);
|
||||
|
||||
Cell.defaultProps = { cell_type };
|
||||
return <Cell />;
|
||||
};
|
||||
|
||||
class BaseNotebookRenderer extends React.Component<NotebookRendererProps> {
|
||||
constructor(props: NotebookRendererProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
hoveredCellId: undefined
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
loadTransform(this.props as any);
|
||||
}
|
||||
|
||||
render(): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<div className="NotebookRendererContainer">
|
||||
<div className="NotebookRenderer">
|
||||
<DndProvider backend={HTML5Backend}>
|
||||
<KeyboardShortcuts contentRef={this.props.contentRef}>
|
||||
<Cells contentRef={this.props.contentRef}>
|
||||
{{
|
||||
code: ({ id, contentRef }: { id: CellId; contentRef: ContentRef }) =>
|
||||
decorate(
|
||||
id,
|
||||
contentRef,
|
||||
"code",
|
||||
<CodeCell id={id} contentRef={contentRef} cell_type="code">
|
||||
{{
|
||||
editor: {
|
||||
codemirror: (props: PassedEditorProps) => (
|
||||
<CodeMirrorEditor {...props} lineNumbers={true} />
|
||||
)
|
||||
},
|
||||
prompt: ({ id, contentRef }: { id: CellId; contentRef: ContentRef }) => (
|
||||
<Prompt id={id} contentRef={contentRef} isHovered={false}>
|
||||
{promptContent}
|
||||
</Prompt>
|
||||
),
|
||||
toolbar: () => <CellToolbar id={id} contentRef={contentRef} />
|
||||
}}
|
||||
</CodeCell>
|
||||
),
|
||||
markdown: ({ id, contentRef }: { id: any; contentRef: ContentRef }) =>
|
||||
decorate(
|
||||
id,
|
||||
contentRef,
|
||||
"markdown",
|
||||
<MarkdownCell id={id} contentRef={contentRef} cell_type="markdown">
|
||||
{{
|
||||
toolbar: () => <CellToolbar id={id} contentRef={contentRef} />
|
||||
}}
|
||||
</MarkdownCell>
|
||||
),
|
||||
|
||||
raw: ({ id, contentRef }: { id: any; contentRef: ContentRef }) =>
|
||||
decorate(
|
||||
id,
|
||||
contentRef,
|
||||
"raw",
|
||||
<RawCell id={id} contentRef={contentRef} cell_type="raw">
|
||||
{{
|
||||
toolbar: () => <CellToolbar id={id} contentRef={contentRef} />
|
||||
}}
|
||||
</RawCell>
|
||||
)
|
||||
}}
|
||||
</Cells>
|
||||
</KeyboardShortcuts>
|
||||
<AzureTheme />
|
||||
</DndProvider>
|
||||
</div>
|
||||
<StatusBar contentRef={this.props.contentRef} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const makeMapDispatchToProps = (initialDispatch: Dispatch, initialProps: NotebookRendererProps) => {
|
||||
const mapDispatchToProps = (dispatch: Dispatch) => {
|
||||
return {
|
||||
addTransform: (transform: React.ComponentType & { MIMETYPE: string }) => {
|
||||
return dispatch(
|
||||
actions.addTransform({
|
||||
mediaType: transform.MIMETYPE,
|
||||
component: transform
|
||||
})
|
||||
);
|
||||
}
|
||||
};
|
||||
};
|
||||
return mapDispatchToProps;
|
||||
};
|
||||
|
||||
export default connect(null, makeMapDispatchToProps)(BaseNotebookRenderer);
|
||||
30
src/Explorer/Notebook/NotebookRenderer/Prompt.less
Normal file
30
src/Explorer/Notebook/NotebookRenderer/Prompt.less
Normal file
@@ -0,0 +1,30 @@
|
||||
@import "../../../../less/Common/Constants";
|
||||
|
||||
.runCellButton {
|
||||
max-height: 100%;
|
||||
width: 100%;
|
||||
position: sticky;
|
||||
z-index: 300;
|
||||
left: 0;
|
||||
top: 0;
|
||||
|
||||
.ms-Button-flexContainer {
|
||||
align-items: start;
|
||||
padding-top: 11px;
|
||||
|
||||
.ms-Button-icon {
|
||||
color: #0078D4;
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.greyStopButton {
|
||||
.runCellButton .ms-Button-flexContainer .ms-Button-icon {
|
||||
color: @BaseMediumHigh;
|
||||
}
|
||||
|
||||
.ms-Spinner .ms-Spinner-circle {
|
||||
border-top-color: @BaseMediumHigh;
|
||||
}
|
||||
}
|
||||
90
src/Explorer/Notebook/NotebookRenderer/Prompt.tsx
Normal file
90
src/Explorer/Notebook/NotebookRenderer/Prompt.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import React from "react";
|
||||
import { connect } from "react-redux";
|
||||
import { Dispatch } from "redux";
|
||||
|
||||
import { ContentRef, selectors, actions } from "@nteract/core";
|
||||
import { CdbAppState } from "../NotebookComponent/types";
|
||||
|
||||
export interface PassedPromptProps {
|
||||
id: string;
|
||||
contentRef: ContentRef;
|
||||
status?: string;
|
||||
executionCount?: number;
|
||||
isHovered?: boolean;
|
||||
runCell?: () => void;
|
||||
stopCell?: () => void;
|
||||
}
|
||||
|
||||
interface ComponentProps {
|
||||
id: string;
|
||||
contentRef: ContentRef;
|
||||
isHovered?: boolean;
|
||||
children: (props: PassedPromptProps) => React.ReactNode;
|
||||
}
|
||||
|
||||
interface StateProps {
|
||||
status?: string;
|
||||
executionCount?: number;
|
||||
}
|
||||
|
||||
interface DispatchProps {
|
||||
executeCell: () => void;
|
||||
stopExecution: () => void;
|
||||
}
|
||||
|
||||
type Props = StateProps & DispatchProps & ComponentProps;
|
||||
|
||||
export class PromptPure extends React.Component<Props> {
|
||||
render() {
|
||||
return (
|
||||
<div className="nteract-cell-prompt">
|
||||
{this.props.children({
|
||||
id: this.props.id,
|
||||
contentRef: this.props.contentRef,
|
||||
status: this.props.status,
|
||||
executionCount: this.props.executionCount,
|
||||
runCell: this.props.executeCell,
|
||||
stopCell: this.props.stopExecution,
|
||||
isHovered: this.props.isHovered
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const makeMapStateToProps = (state: CdbAppState, ownProps: ComponentProps): ((state: CdbAppState) => StateProps) => {
|
||||
const mapStateToProps = (state: CdbAppState) => {
|
||||
const { contentRef, id } = ownProps;
|
||||
const model = selectors.model(state, { contentRef });
|
||||
|
||||
let status;
|
||||
let executionCount;
|
||||
|
||||
if (model && model.type === "notebook") {
|
||||
status = model.transient.getIn(["cellMap", id, "status"]);
|
||||
const cell = selectors.notebook.cellById(model, { id });
|
||||
if (cell) {
|
||||
executionCount = cell.get("execution_count", undefined);
|
||||
}
|
||||
}
|
||||
|
||||
const isHovered = state.cdb.hoveredCellId === id;
|
||||
|
||||
return {
|
||||
status,
|
||||
executionCount,
|
||||
isHovered
|
||||
};
|
||||
};
|
||||
return mapStateToProps;
|
||||
};
|
||||
|
||||
const mapDispatchToProps = (
|
||||
dispatch: Dispatch,
|
||||
{ id, contentRef }: { id: string; contentRef: ContentRef }
|
||||
): DispatchProps => ({
|
||||
executeCell: () => dispatch(actions.executeCell({ id, contentRef })),
|
||||
stopExecution: () => dispatch(actions.interruptKernel({}))
|
||||
});
|
||||
|
||||
export default connect(makeMapStateToProps, mapDispatchToProps)(PromptPure);
|
||||
56
src/Explorer/Notebook/NotebookRenderer/PromptContent.tsx
Normal file
56
src/Explorer/Notebook/NotebookRenderer/PromptContent.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import * as React from "react";
|
||||
import { IconButton } from "office-ui-fabric-react/lib/Button";
|
||||
import { Spinner, SpinnerSize } from "office-ui-fabric-react/lib/Spinner";
|
||||
import "./Prompt.less";
|
||||
import { PassedPromptProps } from "./Prompt";
|
||||
|
||||
export const promptContent = (props: PassedPromptProps): JSX.Element => {
|
||||
if (props.status === "busy") {
|
||||
const stopButtonText: string = "Stop cell execution";
|
||||
return (
|
||||
<div
|
||||
style={{ position: "sticky", width: "100%", maxHeight: "100%", left: 0, top: 0, zIndex: 300 }}
|
||||
className={props.isHovered ? "" : "greyStopButton"}
|
||||
>
|
||||
<IconButton
|
||||
className="runCellButton"
|
||||
iconProps={{ iconName: "CircleStopSolid" }}
|
||||
title={stopButtonText}
|
||||
ariaLabel={stopButtonText}
|
||||
onClick={props.stopCell}
|
||||
style={{ position: "absolute" }}
|
||||
/>
|
||||
<Spinner size={SpinnerSize.large} style={{ position: "absolute", width: "100%", paddingTop: 5 }} />
|
||||
</div>
|
||||
);
|
||||
} else if (props.isHovered) {
|
||||
const playButtonText: string = "Run cell";
|
||||
return (
|
||||
<IconButton
|
||||
className="runCellButton"
|
||||
iconProps={{ iconName: "MSNVideosSolid" }}
|
||||
title={playButtonText}
|
||||
ariaLabel={playButtonText}
|
||||
onClick={props.runCell}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return <div style={{ paddingTop: 7 }}>{promptText(props)}</div>;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate what text goes inside the prompt based on the props to the prompt
|
||||
*/
|
||||
const promptText = (props: PassedPromptProps): string => {
|
||||
if (props.status === "busy") {
|
||||
return "[*]";
|
||||
}
|
||||
if (props.status === "queued") {
|
||||
return "[…]";
|
||||
}
|
||||
if (typeof props.executionCount === "number") {
|
||||
return `[${props.executionCount}]`;
|
||||
}
|
||||
return "[ ]";
|
||||
};
|
||||
55
src/Explorer/Notebook/NotebookRenderer/StatusBar.test.tsx
Normal file
55
src/Explorer/Notebook/NotebookRenderer/StatusBar.test.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { shallow } from "enzyme";
|
||||
import React from "react";
|
||||
|
||||
import { StatusBar } from "./StatusBar";
|
||||
|
||||
describe("StatusBar", () => {
|
||||
test("can render on a dummyNotebook", () => {
|
||||
const lastSaved = new Date();
|
||||
const kernelSpecDisplayName = "python3";
|
||||
|
||||
const component = shallow(
|
||||
<StatusBar kernelStatus="kernel status" lastSaved={lastSaved} kernelSpecDisplayName={kernelSpecDisplayName} />
|
||||
);
|
||||
|
||||
expect(component).not.toBeNull();
|
||||
});
|
||||
test("Update if kernelSpecDisplayName has changed", () => {
|
||||
const lastSaved = new Date();
|
||||
const kernelSpecDisplayName = "python3";
|
||||
|
||||
const component = shallow(
|
||||
<StatusBar kernelStatus="kernel status" lastSaved={lastSaved} kernelSpecDisplayName={kernelSpecDisplayName} />
|
||||
);
|
||||
|
||||
const shouldUpdate = component.instance().shouldComponentUpdate(
|
||||
{
|
||||
lastSaved,
|
||||
kernelSpecDisplayName: "javascript",
|
||||
kernelStatus: "kernelStatus"
|
||||
},
|
||||
null,
|
||||
null
|
||||
);
|
||||
expect(shouldUpdate).toBe(true);
|
||||
});
|
||||
test("update if kernelStatus has changed", () => {
|
||||
const lastSaved = new Date();
|
||||
const kernelSpecDisplayName = "python3";
|
||||
|
||||
const component = shallow(
|
||||
<StatusBar kernelStatus="kernel status" lastSaved={lastSaved} kernelSpecDisplayName={kernelSpecDisplayName} />
|
||||
);
|
||||
|
||||
const shouldUpdate = component.instance().shouldComponentUpdate(
|
||||
{
|
||||
lastSaved: new Date(),
|
||||
kernelSpecDisplayName: "python3",
|
||||
kernelStatus: "kernelStatus"
|
||||
},
|
||||
null,
|
||||
null
|
||||
);
|
||||
expect(shouldUpdate).toBe(true);
|
||||
});
|
||||
});
|
||||
128
src/Explorer/Notebook/NotebookRenderer/StatusBar.tsx
Normal file
128
src/Explorer/Notebook/NotebookRenderer/StatusBar.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import { AppState, ContentRef, selectors } from "@nteract/core";
|
||||
import distanceInWordsToNow from "date-fns/distance_in_words_to_now";
|
||||
import React from "react";
|
||||
import { connect } from "react-redux";
|
||||
import { StyleConstants } from "../../../Common/Constants";
|
||||
|
||||
interface Props {
|
||||
lastSaved?: Date | null;
|
||||
kernelSpecDisplayName: string;
|
||||
kernelStatus: string;
|
||||
}
|
||||
|
||||
const NOT_CONNECTED = "not connected";
|
||||
|
||||
import styled from "styled-components";
|
||||
|
||||
export const LeftStatus = styled.div`
|
||||
float: left;
|
||||
display: block;
|
||||
padding-left: 10px;
|
||||
`;
|
||||
export const RightStatus = styled.div`
|
||||
float: right;
|
||||
padding-right: 10px;
|
||||
display: block;
|
||||
`;
|
||||
|
||||
export const Bar = styled.div`
|
||||
padding: 8px 0px 2px;
|
||||
border-top: 1px solid ${StyleConstants.BaseMedium};
|
||||
border-left: 1px solid ${StyleConstants.BaseMedium};
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
font-size: 12px;
|
||||
line-height: 0.5em;
|
||||
background: var(--status-bar);
|
||||
z-index: 99;
|
||||
@media print {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
const BarContainer = styled.div`
|
||||
padding-left: 4px;
|
||||
`;
|
||||
|
||||
export class StatusBar extends React.Component<Props> {
|
||||
shouldComponentUpdate(nextProps: Props): boolean {
|
||||
if (this.props.lastSaved !== nextProps.lastSaved || this.props.kernelStatus !== nextProps.kernelStatus) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
render() {
|
||||
const name = this.props.kernelSpecDisplayName || "Loading...";
|
||||
|
||||
return (
|
||||
<BarContainer>
|
||||
<Bar data-test="notebookStatusBar">
|
||||
<RightStatus>
|
||||
{this.props.lastSaved ? (
|
||||
<p data-test="saveStatus"> Last saved {distanceInWordsToNow(this.props.lastSaved)} </p>
|
||||
) : (
|
||||
<p> Not saved yet </p>
|
||||
)}
|
||||
</RightStatus>
|
||||
<LeftStatus>
|
||||
<p data-test="kernelStatus">
|
||||
{name} | {this.props.kernelStatus}
|
||||
</p>
|
||||
</LeftStatus>
|
||||
</Bar>
|
||||
</BarContainer>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
interface InitialProps {
|
||||
contentRef: ContentRef;
|
||||
}
|
||||
|
||||
const makeMapStateToProps = (initialState: AppState, initialProps: InitialProps): ((state: AppState) => Props) => {
|
||||
const { contentRef } = initialProps;
|
||||
|
||||
const mapStateToProps = (state: AppState) => {
|
||||
const content = selectors.content(state, { contentRef });
|
||||
|
||||
if (!content || content.type !== "notebook") {
|
||||
return {
|
||||
kernelStatus: NOT_CONNECTED,
|
||||
kernelSpecDisplayName: "no kernel",
|
||||
lastSaved: null
|
||||
};
|
||||
}
|
||||
|
||||
const kernelRef = content.model.kernelRef;
|
||||
let kernel = null;
|
||||
if (kernelRef) {
|
||||
kernel = selectors.kernel(state, { kernelRef });
|
||||
}
|
||||
|
||||
const lastSaved = content && content.lastSaved ? content.lastSaved : null;
|
||||
|
||||
const kernelStatus = kernel != null && kernel.status != null ? kernel.status : NOT_CONNECTED;
|
||||
|
||||
// TODO: We need kernels associated to the kernelspec they came from
|
||||
// so we can pluck off the display_name and provide it here
|
||||
let kernelSpecDisplayName = " ";
|
||||
if (kernelStatus === NOT_CONNECTED) {
|
||||
kernelSpecDisplayName = "no kernel";
|
||||
} else if (kernel != null && kernel.kernelSpecName != null) {
|
||||
kernelSpecDisplayName = kernel.kernelSpecName;
|
||||
} else if (content && content.type === "notebook") {
|
||||
kernelSpecDisplayName = selectors.notebook.displayName(content.model) || " ";
|
||||
}
|
||||
|
||||
return {
|
||||
kernelSpecDisplayName,
|
||||
kernelStatus,
|
||||
lastSaved
|
||||
};
|
||||
};
|
||||
|
||||
return mapStateToProps;
|
||||
};
|
||||
|
||||
export default connect(makeMapStateToProps)(StatusBar);
|
||||
181
src/Explorer/Notebook/NotebookRenderer/Toolbar.tsx
Normal file
181
src/Explorer/Notebook/NotebookRenderer/Toolbar.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
import { ContentRef } from "@nteract/types";
|
||||
import * as React from "react";
|
||||
import { connect } from "react-redux";
|
||||
import { Dispatch } from "redux";
|
||||
|
||||
import { IconButton } from "office-ui-fabric-react/lib/Button";
|
||||
import {
|
||||
DirectionalHint,
|
||||
IContextualMenuItem,
|
||||
ContextualMenuItemType
|
||||
} from "office-ui-fabric-react/lib/ContextualMenu";
|
||||
import { actions, AppState, DocumentRecordProps } from "@nteract/core";
|
||||
import { CellToolbarContext } from "@nteract/stateful-components";
|
||||
import { CellType, CellId } from "@nteract/commutable";
|
||||
import * as selectors from "@nteract/selectors";
|
||||
import { RecordOf } from "immutable";
|
||||
|
||||
export interface ComponentProps {
|
||||
contentRef: ContentRef;
|
||||
id: CellId;
|
||||
}
|
||||
|
||||
interface DispatchProps {
|
||||
executeCell: () => void;
|
||||
insertCodeCellAbove: () => void;
|
||||
insertCodeCellBelow: () => void;
|
||||
insertTextCellAbove: () => void;
|
||||
insertTextCellBelow: () => void;
|
||||
moveCell: (destinationId: CellId, above: boolean) => void;
|
||||
clearOutputs: () => void;
|
||||
deleteCell: () => void;
|
||||
}
|
||||
|
||||
interface StateProps {
|
||||
cellType: CellType;
|
||||
cellIdAbove: CellId;
|
||||
cellIdBelow: CellId;
|
||||
}
|
||||
|
||||
class BaseToolbar extends React.PureComponent<ComponentProps & DispatchProps & StateProps> {
|
||||
static contextType = CellToolbarContext;
|
||||
|
||||
render(): JSX.Element {
|
||||
let items: IContextualMenuItem[] = [];
|
||||
|
||||
if (this.props.cellType === "code") {
|
||||
items = items.concat([
|
||||
{
|
||||
key: "Run",
|
||||
text: "Run",
|
||||
onClick: this.props.executeCell
|
||||
},
|
||||
{
|
||||
key: "Clear Outputs",
|
||||
text: "Clear Outputs",
|
||||
onClick: this.props.clearOutputs
|
||||
},
|
||||
{
|
||||
key: "Divider",
|
||||
itemType: ContextualMenuItemType.Divider
|
||||
}
|
||||
]);
|
||||
}
|
||||
|
||||
items = items.concat([
|
||||
{
|
||||
key: "Divider",
|
||||
itemType: ContextualMenuItemType.Divider
|
||||
},
|
||||
{
|
||||
key: "Insert Code Cell Above",
|
||||
text: "Insert Code Cell Above",
|
||||
onClick: this.props.insertCodeCellAbove
|
||||
},
|
||||
{
|
||||
key: "Insert Code Cell Below",
|
||||
text: "Insert Code Cell Below",
|
||||
onClick: this.props.insertCodeCellBelow
|
||||
},
|
||||
{
|
||||
key: "Insert Text Cell Above",
|
||||
text: "Insert Text Cell Above",
|
||||
onClick: this.props.insertTextCellAbove
|
||||
},
|
||||
{
|
||||
key: "Insert Text Cell Below",
|
||||
text: "Insert Text Cell Below",
|
||||
onClick: this.props.insertTextCellBelow
|
||||
},
|
||||
{
|
||||
key: "Divider",
|
||||
itemType: ContextualMenuItemType.Divider
|
||||
}
|
||||
]);
|
||||
|
||||
const moveItems: IContextualMenuItem[] = [];
|
||||
if (this.props.cellIdAbove !== undefined) {
|
||||
moveItems.push({
|
||||
key: "Move Cell Up",
|
||||
text: "Move Cell Up",
|
||||
onClick: () => this.props.moveCell(this.props.cellIdAbove, true)
|
||||
});
|
||||
}
|
||||
|
||||
if (this.props.cellIdBelow !== undefined) {
|
||||
moveItems.push({
|
||||
key: "Move Cell Down",
|
||||
text: "Move Cell Down",
|
||||
onClick: () => this.props.moveCell(this.props.cellIdBelow, false)
|
||||
});
|
||||
}
|
||||
|
||||
if (moveItems.length > 0) {
|
||||
moveItems.push({
|
||||
key: "Divider",
|
||||
itemType: ContextualMenuItemType.Divider
|
||||
});
|
||||
items = items.concat(moveItems);
|
||||
}
|
||||
|
||||
items.push({
|
||||
key: "Delete Cell",
|
||||
text: "Delete Cell",
|
||||
onClick: this.props.deleteCell
|
||||
});
|
||||
|
||||
const menuItemLabel = "More";
|
||||
return (
|
||||
<IconButton
|
||||
name="More"
|
||||
className="CellContextMenuButton"
|
||||
ariaLabel={menuItemLabel}
|
||||
menuIconProps={{
|
||||
iconName: menuItemLabel,
|
||||
styles: { root: { fontSize: "18px", fontWeight: "bold" } }
|
||||
}}
|
||||
menuProps={{
|
||||
isBeakVisible: false,
|
||||
directionalHint: DirectionalHint.bottomRightEdge,
|
||||
items
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const mapDispatchToProps = (
|
||||
dispatch: Dispatch,
|
||||
{ id, contentRef }: { id: CellId; contentRef: ContentRef }
|
||||
): DispatchProps => ({
|
||||
executeCell: () => dispatch(actions.executeCell({ id, contentRef })),
|
||||
insertCodeCellAbove: () => dispatch(actions.createCellAbove({ id, contentRef, cellType: "code" })),
|
||||
insertCodeCellBelow: () => dispatch(actions.createCellBelow({ id, contentRef, cellType: "code", source: "" })),
|
||||
insertTextCellAbove: () => dispatch(actions.createCellAbove({ id, contentRef, cellType: "markdown" })),
|
||||
insertTextCellBelow: () => dispatch(actions.createCellBelow({ id, contentRef, cellType: "markdown", source: "" })),
|
||||
moveCell: (destinationId: CellId, above: boolean) =>
|
||||
dispatch(actions.moveCell({ id, contentRef, destinationId, above })),
|
||||
clearOutputs: () => dispatch(actions.clearOutputs({ id, contentRef })),
|
||||
deleteCell: () => dispatch(actions.deleteCell({ id, contentRef }))
|
||||
});
|
||||
|
||||
const makeMapStateToProps = (state: AppState, ownProps: ComponentProps): ((state: AppState) => StateProps) => {
|
||||
const mapStateToProps = (state: AppState) => {
|
||||
const cellType = selectors.cell.cellFromState(state, { id: ownProps.id, contentRef: ownProps.contentRef })
|
||||
.cell_type;
|
||||
const model = selectors.model(state, { contentRef: ownProps.contentRef });
|
||||
const cellOrder = selectors.notebook.cellOrder(model as RecordOf<DocumentRecordProps>);
|
||||
const cellIndex = cellOrder.indexOf(ownProps.id);
|
||||
const cellIdAbove = cellIndex ? cellOrder.get(cellIndex - 1, undefined) : undefined;
|
||||
const cellIdBelow = cellIndex !== undefined ? cellOrder.get(cellIndex + 1, undefined) : undefined;
|
||||
|
||||
return {
|
||||
cellType,
|
||||
cellIdAbove,
|
||||
cellIdBelow
|
||||
};
|
||||
};
|
||||
return mapStateToProps;
|
||||
};
|
||||
|
||||
export default connect(makeMapStateToProps, mapDispatchToProps)(BaseToolbar);
|
||||
143
src/Explorer/Notebook/NotebookRenderer/base.css
Normal file
143
src/Explorer/Notebook/NotebookRenderer/base.css
Normal file
@@ -0,0 +1,143 @@
|
||||
.nteract-cell-prompt {
|
||||
font-size: 12px;
|
||||
line-height: 22px;
|
||||
/* For creating a buffer area for <Prompt blank /> */
|
||||
min-height: 22px;
|
||||
width: var(--prompt-width, 50px);
|
||||
padding: 2px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.nteract-cell-outputs {
|
||||
padding: 10px 10px 10px calc(var(--prompt-width, 50px) + 10px);
|
||||
word-wrap: break-word;
|
||||
overflow-y: hidden;
|
||||
outline: none;
|
||||
/* When expanded, this is overtaken to 100% */
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.nteract-cell-outputs:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nteract-cell-outputs code {
|
||||
white-space: pre-wrap;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.nteract-cell-outputs pre {
|
||||
white-space: pre-wrap;
|
||||
font-size: 14px;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.nteract-cell-outputs img {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.nteract-cell {
|
||||
position: relative;
|
||||
transition: all 0.1s ease-in-out;
|
||||
}
|
||||
|
||||
.nteract-cells {
|
||||
padding-bottom: 10px;
|
||||
padding: var(--nt-spacing-m, 10px);
|
||||
}
|
||||
|
||||
.nteract-cell-input .nteract-cell-source {
|
||||
flex: 1 1 auto;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
/** Adaptation for the R kernel's inline lists **/
|
||||
.nteract-cell-outputs .list-inline li {
|
||||
display: inline;
|
||||
padding-right: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.nteract-cell-input {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
.nteract-cell-input.invisible {
|
||||
height: 34px;
|
||||
}
|
||||
.nteract-cell-input .nteract-cell-prompt {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
/* for nested paragraphs in block quotes */
|
||||
.nteract-cell-outputs blockquote p {
|
||||
display: inline;
|
||||
}
|
||||
.nteract-cell-outputs dd {
|
||||
display: block;
|
||||
-webkit-margin-start: 40px;
|
||||
}
|
||||
.nteract-cell-outputs dl {
|
||||
display: block;
|
||||
-webkit-margin-before: 1__qem;
|
||||
-webkit-margin-after: 1em;
|
||||
-webkit-margin-start: 0;
|
||||
-webkit-margin-end: 0;
|
||||
}
|
||||
.nteract-cell-outputs dt {
|
||||
display: block;
|
||||
}
|
||||
.nteract-cell-outputs dl {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
.nteract-cell-outputs dt {
|
||||
font-weight: bold;
|
||||
float: left;
|
||||
width: 20%;
|
||||
/* adjust the width; make sure the total of both is 100% */
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
.nteract-cell-outputs dd {
|
||||
float: left;
|
||||
width: 80%;
|
||||
/* adjust the width; make sure the total of both is 100% */
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.nteract-cell-outputs kbd {
|
||||
display: inline-block;
|
||||
padding: 0.1em 0.5em;
|
||||
margin: 0 0.2em;
|
||||
}
|
||||
|
||||
.nteract-cell-outputs table {
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.nteract-cell-outputs th {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.nteract-cell-outputs th,
|
||||
.nteract-cell-outputs td,
|
||||
/* for legacy output handling */
|
||||
.nteract-cell-outputs .th,
|
||||
.nteract-cell-outputs .td {
|
||||
padding: 0.5em 1em;
|
||||
}
|
||||
|
||||
.nteract-cell-outputs blockquote {
|
||||
padding: 0.75em 0.5em 0.75em 1em;
|
||||
}
|
||||
|
||||
.nteract-cell-outputs blockquote::before {
|
||||
display: block;
|
||||
height: 0;
|
||||
margin-left: -0.95em;
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
import { actions, selectors, ContentRef, AppState } from "@nteract/core";
|
||||
import { CellType } from "@nteract/commutable";
|
||||
import * as React from "react";
|
||||
import { connect } from "react-redux";
|
||||
import { Dispatch } from "redux";
|
||||
|
||||
import styled from "styled-components";
|
||||
import AddCodeCellIcon from "../../../../../images/notebook/add-code-cell.svg";
|
||||
import AddTextCellIcon from "../../../../../images/notebook/add-text-cell.svg";
|
||||
|
||||
interface ComponentProps {
|
||||
id: string;
|
||||
contentRef: ContentRef;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
interface StateProps {
|
||||
isFirstCell: boolean;
|
||||
}
|
||||
|
||||
interface DispatchProps {
|
||||
createCellAppend: (payload: { cellType: CellType; contentRef: ContentRef }) => void;
|
||||
createCellAbove: (payload: { cellType: CellType; id?: string; contentRef: ContentRef }) => void;
|
||||
createCellBelow: (payload: { cellType: CellType; id?: string; source: string; contentRef: ContentRef }) => void;
|
||||
}
|
||||
|
||||
export const CellCreatorMenu = styled.div`
|
||||
display: none;
|
||||
pointer-events: all;
|
||||
position: relative;
|
||||
top: 0px;
|
||||
/**
|
||||
* Now that the cell-creator is added as a decorator we need
|
||||
* this x-index to ensure that it is always shown on the top
|
||||
* of other cells.
|
||||
*/
|
||||
z-index: 50;
|
||||
|
||||
button:first-child {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
button {
|
||||
display: inline-block;
|
||||
|
||||
width: 109px;
|
||||
height: 24px;
|
||||
padding: 0px 4px;
|
||||
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
|
||||
border: 1px solid #0078d4;
|
||||
outline: none;
|
||||
background: var(--theme-cell-creator-bg);
|
||||
color: #0078d4;
|
||||
}
|
||||
|
||||
button span {
|
||||
color: var(--theme-cell-creator-fg);
|
||||
}
|
||||
|
||||
button span:hover {
|
||||
color: var(--theme-cell-creator-fg-hover);
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background-color: #f0f0f0;
|
||||
}
|
||||
|
||||
.octicon {
|
||||
transition: color 0.5s;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
img {
|
||||
height: 12px;
|
||||
}
|
||||
`;
|
||||
|
||||
export const Divider = styled.div`
|
||||
display: none;
|
||||
position: relative;
|
||||
top: 12px;
|
||||
height: 1px;
|
||||
width: 100%;
|
||||
border-top: 1px solid rgba(204, 204, 204, 0.8);
|
||||
`;
|
||||
|
||||
const CreatorHoverMask = styled.div`
|
||||
display: block;
|
||||
position: relative;
|
||||
overflow: visible;
|
||||
height: 0px;
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
const CreatorHoverRegion = styled.div`
|
||||
position: relative;
|
||||
overflow: visible;
|
||||
top: 5px;
|
||||
height: 30px;
|
||||
text-align: center;
|
||||
|
||||
&:hover ${CellCreatorMenu} {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
&:hover ${Divider} {
|
||||
display: inherit;
|
||||
}
|
||||
`;
|
||||
|
||||
const FirstCreatorContainer = styled.div`
|
||||
height: 20px;
|
||||
`;
|
||||
|
||||
interface CellCreatorProps {
|
||||
above: boolean;
|
||||
createCell: (type: "markdown" | "code", above: boolean) => void;
|
||||
}
|
||||
|
||||
export class PureCellCreator extends React.PureComponent<CellCreatorProps> {
|
||||
createMarkdownCell = () => {
|
||||
this.props.createCell("markdown", this.props.above);
|
||||
};
|
||||
|
||||
createCodeCell = () => {
|
||||
this.props.createCell("code", this.props.above);
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<CreatorHoverMask>
|
||||
<CreatorHoverRegion>
|
||||
<Divider />
|
||||
<CellCreatorMenu>
|
||||
<button onClick={this.createCodeCell} className="add-code-cell">
|
||||
<span className="octicon">
|
||||
<img src={AddCodeCellIcon} alt="Add code cell" />
|
||||
</span>
|
||||
Add code
|
||||
</button>
|
||||
<button onClick={this.createMarkdownCell} className="add-text-cell">
|
||||
<span className="octicon">
|
||||
<img src={AddTextCellIcon} alt="Add text cell" />
|
||||
</span>
|
||||
Add text
|
||||
</button>
|
||||
</CellCreatorMenu>
|
||||
</CreatorHoverRegion>
|
||||
</CreatorHoverMask>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class CellCreator extends React.PureComponent<ComponentProps & DispatchProps & StateProps> {
|
||||
createCell = (type: "code" | "markdown", above: boolean): void => {
|
||||
const { createCellBelow, createCellAppend, createCellAbove, id, contentRef } = this.props;
|
||||
|
||||
if (id === undefined || typeof id !== "string") {
|
||||
createCellAppend({ cellType: type, contentRef });
|
||||
return;
|
||||
}
|
||||
|
||||
above
|
||||
? createCellAbove({ cellType: type, id, contentRef })
|
||||
: createCellBelow({ cellType: type, id, source: "", contentRef });
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<React.Fragment>
|
||||
{this.props.isFirstCell && (
|
||||
<FirstCreatorContainer>
|
||||
<PureCellCreator above={true} createCell={this.createCell} />
|
||||
</FirstCreatorContainer>
|
||||
)}
|
||||
{this.props.children}
|
||||
<PureCellCreator above={false} createCell={this.createCell} />
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const mapStateToProps = (state: AppState, ownProps: ComponentProps) => {
|
||||
const { id, contentRef } = ownProps;
|
||||
const model = selectors.model(state, { contentRef });
|
||||
let isFirstCell = false;
|
||||
|
||||
if (model && model.type === "notebook") {
|
||||
const cellOrder = selectors.notebook.cellOrder(model);
|
||||
const cellIndex = cellOrder.findIndex(cellId => cellId === id);
|
||||
isFirstCell = cellIndex === 0;
|
||||
}
|
||||
|
||||
return {
|
||||
isFirstCell
|
||||
};
|
||||
};
|
||||
|
||||
const mapDispatchToProps = (dispatch: Dispatch) => ({
|
||||
createCellAbove: (payload: { cellType: CellType; id?: string; contentRef: ContentRef }) =>
|
||||
dispatch(actions.createCellAbove(payload)),
|
||||
createCellAppend: (payload: { cellType: CellType; contentRef: ContentRef }) =>
|
||||
dispatch(actions.createCellAppend(payload)),
|
||||
createCellBelow: (payload: { cellType: CellType; id?: string; source: string; contentRef: ContentRef }) =>
|
||||
dispatch(actions.createCellBelow(payload))
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(CellCreator);
|
||||
@@ -0,0 +1,8 @@
|
||||
@import "../../../../../less/Common/Constants.less";
|
||||
|
||||
.CellLabeler .CellLabel {
|
||||
margin-left: 5px;
|
||||
font-family: @DataExplorerFont;
|
||||
font-size: 12px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import React from "react";
|
||||
import { connect } from "react-redux";
|
||||
import "./CellLabeler.less";
|
||||
|
||||
import { AppState, ContentRef, selectors, DocumentRecordProps } from "@nteract/core";
|
||||
import { RecordOf } from "immutable";
|
||||
|
||||
interface ComponentProps {
|
||||
id: string;
|
||||
contentRef: ContentRef; // TODO: Make this per contentRef?
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
interface StateProps {
|
||||
cellIndex: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays "Cell <index>"
|
||||
*/
|
||||
class CellLabeler extends React.Component<ComponentProps & StateProps> {
|
||||
render() {
|
||||
return (
|
||||
<div className="CellLabeler">
|
||||
<div className="CellLabel">Cell {this.props.cellIndex + 1}</div>
|
||||
{this.props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const makeMapStateToProps = (state: AppState, ownProps: ComponentProps): ((state: AppState) => StateProps) => {
|
||||
const mapStateToProps = (state: AppState) => {
|
||||
const model = selectors.model(state, { contentRef: ownProps.contentRef });
|
||||
const cellOrder = selectors.notebook.cellOrder(model as RecordOf<DocumentRecordProps>);
|
||||
const cellIndex = cellOrder.indexOf(ownProps.id);
|
||||
|
||||
return {
|
||||
cellIndex
|
||||
};
|
||||
};
|
||||
return mapStateToProps;
|
||||
};
|
||||
|
||||
export default connect(makeMapStateToProps, undefined)(CellLabeler);
|
||||
@@ -0,0 +1,40 @@
|
||||
import React from "react";
|
||||
import { connect } from "react-redux";
|
||||
import { Dispatch } from "redux";
|
||||
|
||||
import { ContentRef } from "@nteract/core";
|
||||
import * as actions from "../../NotebookComponent/actions";
|
||||
|
||||
interface ComponentProps {
|
||||
id: string;
|
||||
contentRef: ContentRef; // TODO: Make this per contentRef?
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
interface DispatchProps {
|
||||
hover: () => void;
|
||||
unHover: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* HoverableCell sets the hovered cell
|
||||
*/
|
||||
class HoverableCell extends React.Component<ComponentProps & DispatchProps> {
|
||||
render() {
|
||||
return (
|
||||
<div className="HoverableCell" onMouseEnter={this.props.hover} onMouseLeave={this.props.unHover}>
|
||||
{this.props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const mapDispatchToProps = (
|
||||
dispatch: Dispatch,
|
||||
{ id, contentRef }: { id: string; contentRef: ContentRef }
|
||||
): DispatchProps => ({
|
||||
hover: () => dispatch(actions.setHoveredCell({ cellId: id })),
|
||||
unHover: () => dispatch(actions.setHoveredCell({ cellId: undefined }))
|
||||
});
|
||||
|
||||
export default connect(undefined, mapDispatchToProps)(HoverableCell);
|
||||
@@ -0,0 +1,234 @@
|
||||
import { actions, ContentRef } from "@nteract/core";
|
||||
import React from "react";
|
||||
import {
|
||||
ConnectDragPreview,
|
||||
ConnectDragSource,
|
||||
ConnectDropTarget,
|
||||
DragSource,
|
||||
DragSourceConnector,
|
||||
DragSourceMonitor,
|
||||
DropTarget,
|
||||
DropTargetConnector,
|
||||
DropTargetMonitor
|
||||
} from "react-dnd";
|
||||
|
||||
import { connect } from "react-redux";
|
||||
import { Dispatch } from "redux";
|
||||
import styled, { StyledComponent } from "styled-components";
|
||||
|
||||
/**
|
||||
The cell drag preview image is just a little stylized version of
|
||||
|
||||
[ ]
|
||||
|
||||
It matches nteract's default light theme
|
||||
|
||||
*/
|
||||
const cellDragPreviewImage = [
|
||||
"data:image/png;base64,",
|
||||
"iVBORw0KGgoAAAANSUhEUgAAADsAAAAzCAYAAAApdnDeAAAAAXNSR0IArs4c6QAA",
|
||||
"AwNJREFUaAXtmlFL3EAUhe9MZptuoha3rLWgYC0W+lj/T3+26INvXbrI2oBdE9km",
|
||||
"O9Nzxu1S0LI70AQScyFmDDfkfvdMZpNwlCCccwq7f21MaVM4FPtkU0o59RdoJBMx",
|
||||
"WZINBg+DQWGKCAk+2kIKFh9JlSzLYVmOilEpR1Kh/iUbQFiNQTSbzWJrbYJximOJ",
|
||||
"cSaulpVRoqh4K8JhjprIVJWqFlCpQNG51roYj8cLjJcGf5RMZWC1TYw1o2LxcEmy",
|
||||
"0jeEo3ZFWVHIx0ji4eeKHFOx8l4sVVVZnBE6tWLHq7xO7FY86YpPeVjeo5y61tlR",
|
||||
"JyhXEOQhF/lw6BGWixHvUWXVTpdgyUMu8q1h/ZJbqQhdiLsESx4FLvL9gcV6q3Cs",
|
||||
"0liq2IHuBHjItYIV3rMvJnrYrkrdK9sr24EO9NO4AyI+i/CilOXbTi1xeXXFTyAS",
|
||||
"GSOfzs42XmM+v5fJ5JvP29/fl8PDw43nhCbUpuzFxYXs7OxKmqZb1WQGkc/P80K+",
|
||||
"T6dbnROaVJuyfPY+Pj7aup7h66HP/1Uu5O7u59bnhSTWpmxIEU3l9rBNdbrp6/TK",
|
||||
"Nt3xpq7XK9tUp5u+Tm2/s/jYJdfX12LwBHVycrKRK89zmeJhYnZ7K3Fcz3e/2mDP",
|
||||
"z7/waZEf8zaC+gSkKa3l4OBA3uztbXdOYFZtsKcfToNKSZNUPp6GnRN0AST3C1Ro",
|
||||
"x9qS3yvbFqVC6+yVDe1YW/J7ZduiVGidvbKhHWtLfq9sW5QKrdMri9cxB6OFhQmO",
|
||||
"TrDuBHjIRT5CEZZj0i7xOkYnWGeCPOQiHqC8lc/R60cLnNPuvjOkns7dk4t8/Jfv",
|
||||
"s46mRlWqQiudxebVV3gAj7C9hXsmgZeztnfe/91YODEr3IoF/JY/sE2gbGaVLci3",
|
||||
"hh0tRtWNvsm16JmNcOs6N9dW72LP7yOtWbEhjAUkZ+icoJ5HbE6+NSxMjKWe6cKb",
|
||||
"GkUWgMwiFbXSlRpFkXelUlF4F70rVd7Bd4oZ/LL8xiDmtPV2Nwyf2zOlTfHERY7i",
|
||||
"Haa1+w2+iFqx0aIgvgAAAABJRU5ErkJggg=="
|
||||
].join("");
|
||||
|
||||
interface Props {
|
||||
focusCell: (payload: any) => void;
|
||||
id: string;
|
||||
moveCell: (payload: any) => void;
|
||||
children: React.ReactNode;
|
||||
contentRef: ContentRef;
|
||||
}
|
||||
|
||||
interface DnDSourceProps {
|
||||
connectDragPreview: ConnectDragPreview;
|
||||
connectDragSource: ConnectDragSource;
|
||||
isDragging: boolean;
|
||||
}
|
||||
|
||||
interface DnDTargetProps {
|
||||
connectDropTarget: ConnectDropTarget;
|
||||
isOver: boolean;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hoverUpperHalf: boolean;
|
||||
}
|
||||
|
||||
const cellSource = {
|
||||
beginDrag(props: Props) {
|
||||
return {
|
||||
id: props.id
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const DragHandle = styled.div.attrs({
|
||||
role: "presentation"
|
||||
})`
|
||||
position: absolute;
|
||||
z-index: 200;
|
||||
width: var(--prompt-width, 50px);
|
||||
height: 20px;
|
||||
cursor: move;
|
||||
`;
|
||||
|
||||
interface DragAreaProps {
|
||||
isDragging: boolean;
|
||||
isOver: boolean;
|
||||
hoverUpperHalf: boolean;
|
||||
}
|
||||
|
||||
const DragArea = styled.div.attrs<DragAreaProps>(props => ({
|
||||
style: {
|
||||
opacity: props.isDragging ? 0.25 : 1,
|
||||
borderTop: props.isOver && props.hoverUpperHalf ? "3px lightgray solid" : "3px transparent solid",
|
||||
borderBottom: props.isOver && !props.hoverUpperHalf ? "3px lightgray solid" : "3px transparent solid"
|
||||
}
|
||||
}))`
|
||||
padding: 10px;
|
||||
margin-top: -15px;
|
||||
` as StyledComponent<"div", any, DragAreaProps, never>; // Somehow setting the type on `attrs` isn't propagating properly;
|
||||
|
||||
// This is the div that DragHandle's absolute position will anchor
|
||||
const DragHandleAnchor = styled.div`
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
export function isDragUpper(props: Props, monitor: DropTargetMonitor, el: HTMLElement): boolean {
|
||||
const hoverBoundingRect = el.getBoundingClientRect();
|
||||
const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;
|
||||
|
||||
const clientOffset = monitor.getClientOffset();
|
||||
const hoverClientY = clientOffset!.y - hoverBoundingRect.top;
|
||||
|
||||
return hoverClientY < hoverMiddleY;
|
||||
}
|
||||
|
||||
export const cellTarget = {
|
||||
drop(props: Props, monitor: DropTargetMonitor, component: any): void {
|
||||
if (monitor) {
|
||||
const hoverUpperHalf = isDragUpper(props, monitor, component.el);
|
||||
// DropTargetSpec monitor definition could be undefined. we'll need a check for monitor in order to pass validation.
|
||||
props.moveCell({
|
||||
id: monitor.getItem().id,
|
||||
destinationId: props.id,
|
||||
above: hoverUpperHalf,
|
||||
contentRef: props.contentRef
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
hover(props: Props, monitor: DropTargetMonitor, component: any): void {
|
||||
if (monitor) {
|
||||
component.setState({
|
||||
hoverUpperHalf: isDragUpper(props, monitor, component.el)
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function collectSource(
|
||||
connect: DragSourceConnector,
|
||||
monitor: DragSourceMonitor
|
||||
): {
|
||||
connectDragSource: ConnectDragSource;
|
||||
isDragging: boolean;
|
||||
connectDragPreview: ConnectDragPreview;
|
||||
} {
|
||||
return {
|
||||
connectDragSource: connect.dragSource(),
|
||||
isDragging: monitor.isDragging(),
|
||||
connectDragPreview: connect.dragPreview()
|
||||
};
|
||||
}
|
||||
|
||||
function collectTarget(
|
||||
connect: DropTargetConnector,
|
||||
monitor: DropTargetMonitor
|
||||
): {
|
||||
connectDropTarget: ConnectDropTarget;
|
||||
isOver: boolean;
|
||||
} {
|
||||
return {
|
||||
connectDropTarget: connect.dropTarget(),
|
||||
isOver: monitor.isOver()
|
||||
};
|
||||
}
|
||||
|
||||
export class DraggableCellView extends React.Component<Props & DnDSourceProps & DnDTargetProps, State> {
|
||||
el?: HTMLDivElement | null;
|
||||
|
||||
state = {
|
||||
hoverUpperHalf: true
|
||||
};
|
||||
|
||||
componentDidMount(): void {
|
||||
const connectDragPreview = this.props.connectDragPreview;
|
||||
const img = new (window as any).Image();
|
||||
|
||||
img.src = cellDragPreviewImage;
|
||||
|
||||
img.onload = /*dragImageLoaded*/ () => {
|
||||
connectDragPreview(img);
|
||||
};
|
||||
}
|
||||
|
||||
selectCell = () => {
|
||||
const { focusCell, id, contentRef } = this.props;
|
||||
focusCell({ id, contentRef });
|
||||
};
|
||||
|
||||
render() {
|
||||
return this.props.connectDropTarget(
|
||||
// Sadly connectDropTarget _has_ to take a React element for a DOM element (no styled-divs)
|
||||
<div>
|
||||
<DragArea
|
||||
isDragging={this.props.isDragging}
|
||||
hoverUpperHalf={this.state.hoverUpperHalf}
|
||||
isOver={this.props.isOver}
|
||||
ref={el => {
|
||||
this.el = el;
|
||||
}}
|
||||
>
|
||||
<DragHandleAnchor>
|
||||
{this.props.connectDragSource(
|
||||
// Same thing with connectDragSource... It also needs a React Element that matches a DOM element
|
||||
<div>
|
||||
<DragHandle onClick={this.selectCell} />
|
||||
</div>
|
||||
)}
|
||||
{this.props.children}
|
||||
</DragHandleAnchor>
|
||||
</DragArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const source = DragSource<Props, DnDSourceProps>("CELL", cellSource, collectSource);
|
||||
const target = DropTarget<Props, DnDTargetProps>("CELL", cellTarget, collectTarget);
|
||||
|
||||
export const makeMapDispatchToProps = (initialDispatch: Dispatch) => {
|
||||
const mapDispatchToProps = (dispatch: Dispatch) => ({
|
||||
moveCell: (payload: actions.MoveCell["payload"]) => dispatch(actions.moveCell(payload)),
|
||||
focusCell: (payload: actions.FocusCell["payload"]) => dispatch(actions.focusCell(payload))
|
||||
});
|
||||
return mapDispatchToProps;
|
||||
};
|
||||
|
||||
export default connect(null, makeMapDispatchToProps)(source(target(DraggableCellView)));
|
||||
@@ -0,0 +1,97 @@
|
||||
/* eslint jsx-a11y/no-static-element-interactions: 0 */
|
||||
/* eslint jsx-a11y/click-events-have-key-events: 0 */
|
||||
|
||||
import React from "react";
|
||||
import { connect } from "react-redux";
|
||||
import { Dispatch } from "redux";
|
||||
|
||||
import { actions, selectors, ContentRef, AppState } from "@nteract/core";
|
||||
|
||||
interface ComponentProps {
|
||||
id: string;
|
||||
contentRef: ContentRef;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
interface StateProps {
|
||||
focused: boolean;
|
||||
}
|
||||
|
||||
interface DispatchProps {
|
||||
selectCell: () => void;
|
||||
}
|
||||
|
||||
type Props = ComponentProps & DispatchProps & StateProps;
|
||||
|
||||
export class HijackScroll extends React.Component<Props> {
|
||||
el: HTMLDivElement | null = null;
|
||||
|
||||
scrollIntoViewIfNeeded(prevFocused?: boolean): void {
|
||||
// Check if the element is being hovered over.
|
||||
const hovered = this.el && this.el.parentElement && this.el.parentElement.querySelector(":hover") === this.el;
|
||||
|
||||
if (
|
||||
this.props.focused &&
|
||||
prevFocused !== this.props.focused &&
|
||||
// Don't scroll into view if already hovered over, this prevents
|
||||
// accidentally selecting text within the codemirror area
|
||||
!hovered
|
||||
) {
|
||||
if (this.el && "scrollIntoViewIfNeeded" in this.el) {
|
||||
// This is only valid in Chrome, WebKit
|
||||
(this.el as any).scrollIntoViewIfNeeded();
|
||||
} else if (this.el) {
|
||||
// Make a best guess effort for older platforms
|
||||
this.el.scrollIntoView();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: Props) {
|
||||
this.scrollIntoViewIfNeeded(prevProps.focused);
|
||||
}
|
||||
|
||||
componentDidMount(): void {
|
||||
this.scrollIntoViewIfNeeded();
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div
|
||||
onClick={this.props.selectCell}
|
||||
role="presentation"
|
||||
ref={el => {
|
||||
this.el = el;
|
||||
}}
|
||||
>
|
||||
{this.props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const makeMapStateToProps = (initialState: AppState, ownProps: ComponentProps) => {
|
||||
const mapStateToProps = (state: AppState) => {
|
||||
const { id, contentRef } = ownProps;
|
||||
const model = selectors.model(state, { contentRef });
|
||||
let focused = false;
|
||||
|
||||
if (model && model.type === "notebook") {
|
||||
focused = model.cellFocused === id;
|
||||
}
|
||||
|
||||
return {
|
||||
focused
|
||||
};
|
||||
};
|
||||
return mapStateToProps;
|
||||
};
|
||||
|
||||
const makeMapDispatchToProps = (initialDispatch: Dispatch, ownProps: ComponentProps) => {
|
||||
const mapDispatchToProps = (dispatch: Dispatch) => ({
|
||||
selectCell: () => dispatch(actions.focusCell({ id: ownProps.id, contentRef: ownProps.contentRef }))
|
||||
});
|
||||
return mapDispatchToProps;
|
||||
};
|
||||
|
||||
export default connect(makeMapStateToProps, makeMapDispatchToProps)(HijackScroll);
|
||||
@@ -0,0 +1,142 @@
|
||||
import Immutable from "immutable";
|
||||
import React from "react";
|
||||
import { connect } from "react-redux";
|
||||
import { Dispatch } from "redux";
|
||||
|
||||
import { CellId } from "@nteract/commutable";
|
||||
import { actions, AppState, ContentRef, selectors } from "@nteract/core";
|
||||
|
||||
interface ComponentProps {
|
||||
contentRef: ContentRef;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
interface StateProps {
|
||||
cellMap: Immutable.Map<string, any>;
|
||||
cellOrder: Immutable.List<string>;
|
||||
focusedCell?: string | null;
|
||||
}
|
||||
|
||||
interface DispatchProps {
|
||||
executeFocusedCell: (payload: { contentRef: ContentRef }) => void;
|
||||
focusNextCell: (payload: { id?: CellId; createCellIfUndefined: boolean; contentRef: ContentRef }) => void;
|
||||
focusNextCellEditor: (payload: { id?: CellId; contentRef: ContentRef }) => void;
|
||||
}
|
||||
|
||||
type Props = ComponentProps & StateProps & DispatchProps;
|
||||
|
||||
export class KeyboardShortcuts extends React.Component<Props> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.keyDown = this.keyDown.bind(this);
|
||||
}
|
||||
|
||||
shouldComponentUpdate(nextProps: Props) {
|
||||
const newContentRef = this.props.contentRef !== nextProps.contentRef;
|
||||
const newFocusedCell = this.props.focusedCell !== nextProps.focusedCell;
|
||||
const newCellOrder = this.props.cellOrder && this.props.cellOrder.size !== nextProps.cellOrder.size;
|
||||
return newContentRef || newFocusedCell || newCellOrder;
|
||||
}
|
||||
|
||||
componentDidMount(): void {
|
||||
document.addEventListener("keydown", this.keyDown);
|
||||
}
|
||||
|
||||
componentWillUnmount(): void {
|
||||
document.removeEventListener("keydown", this.keyDown);
|
||||
}
|
||||
|
||||
keyDown(e: KeyboardEvent): void {
|
||||
// If enter is not pressed, do nothing
|
||||
if (e.key !== "Enter") {
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
executeFocusedCell,
|
||||
focusNextCell,
|
||||
focusNextCellEditor,
|
||||
contentRef,
|
||||
cellOrder,
|
||||
focusedCell,
|
||||
cellMap
|
||||
} = this.props;
|
||||
|
||||
let ctrlKeyPressed = e.ctrlKey;
|
||||
// Allow cmd + enter (macOS) to operate like ctrl + enter
|
||||
if (process.platform === "darwin") {
|
||||
ctrlKeyPressed = (e.metaKey || e.ctrlKey) && !(e.metaKey && e.ctrlKey);
|
||||
}
|
||||
|
||||
const shiftXORctrl = (e.shiftKey || ctrlKeyPressed) && !(e.shiftKey && ctrlKeyPressed);
|
||||
if (!shiftXORctrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
if (focusedCell) {
|
||||
// NOTE: Order matters here because we need it to execute _before_ we
|
||||
// focus the next cell
|
||||
executeFocusedCell({ contentRef });
|
||||
|
||||
if (e.shiftKey) {
|
||||
/** Get the next cell and check if it is a markdown cell. */
|
||||
const focusedCellIndex = cellOrder.indexOf(focusedCell);
|
||||
const nextCellId = cellOrder.get(focusedCellIndex + 1);
|
||||
const nextCell = nextCellId ? cellMap.get(nextCellId) : undefined;
|
||||
|
||||
/** Always focus the next cell. */
|
||||
focusNextCell({
|
||||
id: undefined,
|
||||
createCellIfUndefined: true,
|
||||
contentRef
|
||||
});
|
||||
|
||||
/** Only focus the next editor if it is a code cell or a cell
|
||||
* created at the bottom of the notebook. */
|
||||
if (nextCell === undefined || (nextCell && nextCell.get("cell_type") === "code")) {
|
||||
focusNextCellEditor({ id: focusedCell, contentRef });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return <React.Fragment>{this.props.children}</React.Fragment>;
|
||||
}
|
||||
}
|
||||
|
||||
export const makeMapStateToProps = (state: AppState, ownProps: ComponentProps) => {
|
||||
const { contentRef } = ownProps;
|
||||
const mapStateToProps = (state: AppState) => {
|
||||
const model = selectors.model(state, { contentRef });
|
||||
|
||||
let cellOrder = Immutable.List();
|
||||
let cellMap = Immutable.Map<string, any>();
|
||||
let focusedCell;
|
||||
|
||||
if (model && model.type === "notebook") {
|
||||
cellOrder = model.notebook.cellOrder;
|
||||
cellMap = selectors.notebook.cellMap(model);
|
||||
focusedCell = selectors.notebook.cellFocused(model);
|
||||
}
|
||||
|
||||
return {
|
||||
cellOrder,
|
||||
cellMap,
|
||||
focusedCell
|
||||
};
|
||||
};
|
||||
return mapStateToProps;
|
||||
};
|
||||
|
||||
export const mapDispatchToProps = (dispatch: Dispatch) => ({
|
||||
executeFocusedCell: (payload: { contentRef: ContentRef }) => dispatch(actions.executeFocusedCell(payload)),
|
||||
focusNextCell: (payload: { id?: CellId; createCellIfUndefined: boolean; contentRef: ContentRef }) =>
|
||||
dispatch(actions.focusNextCell(payload)),
|
||||
focusNextCellEditor: (payload: { id?: CellId; contentRef: ContentRef }) =>
|
||||
dispatch(actions.focusNextCellEditor(payload))
|
||||
});
|
||||
|
||||
export default connect(makeMapStateToProps, mapDispatchToProps)(KeyboardShortcuts);
|
||||
181
src/Explorer/Notebook/NotebookRenderer/default.css
Normal file
181
src/Explorer/Notebook/NotebookRenderer/default.css
Normal file
@@ -0,0 +1,181 @@
|
||||
.nteract-cell-prompt {
|
||||
font-family: monospace;
|
||||
color: var(--theme-cell-prompt-fg, black);
|
||||
background-color: var(--theme-cell-prompt-bg, #fafafa);
|
||||
}
|
||||
|
||||
.nteract-cell-pagers {
|
||||
background-color: var(--theme-pager-bg, #fafafa);
|
||||
}
|
||||
|
||||
.nteract-cell-outputs a {
|
||||
color: var(--link-color-unvisited, blue);
|
||||
}
|
||||
|
||||
.nteract-cell-outputs a:visited {
|
||||
color: var(--link-color-visited, blue);
|
||||
}
|
||||
|
||||
.nteract-cell-outputs code {
|
||||
font-family: "Source Code Pro", monospace;
|
||||
}
|
||||
|
||||
.nteract-cell-outputs kbd {
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 1px 0px rgba(0, 0, 0, 0.2), 0 0 0 2px #fff inset;
|
||||
background-color: #f7f7f7;
|
||||
}
|
||||
|
||||
.nteract-cell-outputs th,
|
||||
.nteract-cell-outputs td,
|
||||
/* for legacy output handling */
|
||||
.nteract-cell-outputs .th,
|
||||
.nteract-cell-outputs .td {
|
||||
border: 1px solid var(--theme-app-border, #cbcbcb);
|
||||
}
|
||||
|
||||
.nteract-cell-outputs blockquote {
|
||||
padding: 0.75em 0.5em 0.75em 1em;
|
||||
}
|
||||
|
||||
.nteract-cell-outputs blockquote::before {
|
||||
display: block;
|
||||
height: 0;
|
||||
margin-left: -0.95em;
|
||||
}
|
||||
|
||||
.nteract-cell-input .nteract-cell-source {
|
||||
background-color: var(--theme-cell-input-bg, #fafafa);
|
||||
}
|
||||
|
||||
.nteract-cells {
|
||||
font-family: "Source Sans Pro", Helvetica Neue, Helvetica, sans-serif;
|
||||
font-size: 16px;
|
||||
background-color: var(--theme-app-bg);
|
||||
color: var(--theme-app-fg);
|
||||
}
|
||||
|
||||
.nteract-cell {
|
||||
background: var(--theme-cell-bg, white);
|
||||
}
|
||||
|
||||
.nteract-cell-container.selected .nteract-cell-prompt {
|
||||
background-color: var(--theme-cell-prompt-bg-focus, hsl(0, 0%, 90%));
|
||||
color: var(--theme-cell-prompt-fg-focus, hsl(0, 0%, 51%));
|
||||
}
|
||||
|
||||
.nteract-cell-container:hover:not(.selected) .nteract-cell-prompt,
|
||||
.nteract-cell-container:active:not(.selected) .nteract-cell-prompt {
|
||||
background-color: var(--theme-cell-prompt-bg-hover, hsl(0, 0%, 94%));
|
||||
color: var(--theme-cell-prompt-fg-hover, hsl(0, 0%, 15%));
|
||||
}
|
||||
|
||||
.nteract-cell-outputs {
|
||||
background-color: var(--theme-cell-output-bg);
|
||||
}
|
||||
|
||||
.nteract-cell-container.selected .nteract-cell-outputs {
|
||||
background-color: var(--theme-cell-output-bg-focus);
|
||||
}
|
||||
|
||||
.nteract-cell-container:hover:not(.selected) .nteract-cell-outputs,
|
||||
.nteract-cell-container:active:not(.selected) .nteract-cell-outputs {
|
||||
background-color: var(--theme-cell-output-bg-hover);
|
||||
}
|
||||
|
||||
.nteract-cell:focus .nteract-cell-prompt {
|
||||
background-color: var(--theme-cell-prompt-bg-focus, hsl(0, 0%, 90%));
|
||||
color: var(--theme-cell-prompt-fg-focus, hsl(0, 0%, 51%));
|
||||
}
|
||||
|
||||
@media print {
|
||||
/* make sure all cells look the same in print regarless of focus */
|
||||
.nteract-cell-container .nteract-cell-prompt,
|
||||
.nteract-cell-container.selected .nteract-cell-prompt,
|
||||
.nteract-cell-container:focus .nteract-cell-prompt,
|
||||
.nteract-cell-container:hover:not(.selected) .nteract-cell-prompt {
|
||||
background-color: var(--theme-cell-prompt-bg, white);
|
||||
color: var(--theme-cell-prompt-fg, black);
|
||||
}
|
||||
}
|
||||
|
||||
.nteract-cell-toolbar {
|
||||
opacity: 0.4;
|
||||
transition: opacity 0.4s;
|
||||
}
|
||||
|
||||
.nteract-cell-container:not(.selected) .nteract-cell-toolbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nteract-cell-container:hover:not(.selected) .nteract-cell-toolbar,
|
||||
.nteract-cell-container.selected .nteract-cell-toolbar {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.nteract-cell-toolbar > div {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.nteract-cell-toolbar:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media print {
|
||||
.nteract-cell-toolbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.nteract-cell-toolbar button {
|
||||
display: inline-block;
|
||||
|
||||
width: 22px;
|
||||
height: 20px;
|
||||
padding: 0px 4px;
|
||||
|
||||
text-align: center;
|
||||
|
||||
border: none;
|
||||
outline: none;
|
||||
background: none;
|
||||
}
|
||||
|
||||
.nteract-cell-toolbar span {
|
||||
font-size: 15px;
|
||||
line-height: 1;
|
||||
color: var(--theme-cell-toolbar-fg);
|
||||
}
|
||||
|
||||
.nteract-cell-toolbar button span:hover {
|
||||
color: var(--theme-cell-toolbar-fg-hover);
|
||||
}
|
||||
|
||||
.nteract-cell-toolbar .octicon {
|
||||
transition: color 0.5s;
|
||||
}
|
||||
|
||||
.nteract-cell-toolbar span.spacer {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
margin: 1px 5px 3px 5px;
|
||||
height: 11px;
|
||||
}
|
||||
|
||||
.nteract-cell-toolbar {
|
||||
z-index: 9;
|
||||
position: sticky; /* keep visible with large code cells that need scrolling */
|
||||
float: right;
|
||||
top: 0;
|
||||
right: 0;
|
||||
height: 34px;
|
||||
margin: 0 0 0 -100%; /* allow code cell to completely overlap (underlap?) */
|
||||
padding: 0 0 0 50px; /* give users extra room to move their mouse to the
|
||||
toolbar without causing the cell to go out of
|
||||
focus/hide the toolbar before they get there */
|
||||
}
|
||||
|
||||
.nteract-cell.hidden .nteract-cell-toolbar {
|
||||
display: none;
|
||||
}
|
||||
75
src/Explorer/Notebook/NotebookUtil.ts
Normal file
75
src/Explorer/Notebook/NotebookUtil.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import path from "path";
|
||||
import { ImmutableNotebook } from "@nteract/commutable";
|
||||
import { NotebookContentItem, NotebookContentItemType } from "./NotebookContentItem";
|
||||
import { StringUtils } from "../../Utils/StringUtils";
|
||||
|
||||
// Must match rx-jupyter' FileType
|
||||
export type FileType = "directory" | "file" | "notebook";
|
||||
// Utilities for notebooks
|
||||
export class NotebookUtil {
|
||||
/**
|
||||
* It's a notebook file if the filename ends with .ipynb.
|
||||
*/
|
||||
public static isNotebookFile(notebookPath: string): boolean {
|
||||
return StringUtils.endsWith(notebookPath, ".ipynb");
|
||||
}
|
||||
|
||||
/**
|
||||
* Note: this does not connect the item to a parent in a tree.
|
||||
* @param name
|
||||
* @param path
|
||||
*/
|
||||
public static createNotebookContentItem(name: string, path: string, type: FileType): NotebookContentItem {
|
||||
return {
|
||||
name,
|
||||
path,
|
||||
type: NotebookUtil.getType(type),
|
||||
timestamp: NotebookUtil.getCurrentTimestamp()
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert rx-jupyter type to our type
|
||||
* @param type
|
||||
*/
|
||||
public static getType(type: FileType): NotebookContentItemType {
|
||||
switch (type) {
|
||||
case "directory":
|
||||
return NotebookContentItemType.Directory;
|
||||
case "notebook":
|
||||
return NotebookContentItemType.Notebook;
|
||||
case "file":
|
||||
return NotebookContentItemType.File;
|
||||
default:
|
||||
throw new Error(`Unknown file type: ${type}`);
|
||||
}
|
||||
}
|
||||
|
||||
public static getCurrentTimestamp(): number {
|
||||
return new Date().getTime();
|
||||
}
|
||||
|
||||
/**
|
||||
* Override from kernel-lifecycle.ts to improve kernel selection:
|
||||
* Only return the kernel name persisted in the notebook
|
||||
*
|
||||
* @param filepath
|
||||
* @param notebook
|
||||
*/
|
||||
public static extractNewKernel(filepath: string | null, notebook: ImmutableNotebook) {
|
||||
const cwd = (filepath && path.dirname(filepath)) || "/";
|
||||
|
||||
const kernelSpecName =
|
||||
notebook.getIn(["metadata", "kernelspec", "name"]) || notebook.getIn(["metadata", "language_info", "name"]);
|
||||
|
||||
return {
|
||||
cwd,
|
||||
kernelSpecName
|
||||
};
|
||||
}
|
||||
|
||||
public static getContentName(uri: string): string | undefined {
|
||||
const parts = uri.split("/");
|
||||
return parts.pop() || parts.pop(); // handle potential trailing slash
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user