From d8beabe6275f67d8205edafb88c90047192fe99e Mon Sep 17 00:00:00 2001 From: Michiel Dral Date: Tue, 24 Nov 2020 05:14:56 +0100 Subject: [PATCH 01/98] "What if I start small" --- frontend/common/Bond.js | 6 +- frontend/common/PlutoConnection.js | 20 +- frontend/common/types.js | 0 frontend/components/Cell.js | 158 ++--- frontend/components/CellInput.js | 23 +- frontend/components/DropRuler.js | 10 +- frontend/components/Editor.js | 889 ++++++++++++--------------- frontend/components/Notebook.js | 56 +- frontend/components/SelectionArea.js | 5 +- frontend/editor.js | 25 +- frontend/imports/Preact.js | 1 + frontend/imports/immer.d.ts | 15 + frontend/imports/immer.js | 6 +- frontend/imports/uuid.d.ts | 1 + frontend/imports/uuid.js | 4 + src/evaluation/Run.jl | 14 +- src/notebook/Cell.jl | 14 +- src/notebook/Notebook.jl | 38 +- src/webserver/Dynamic.jl | 320 ++++++---- src/webserver/FirebaseSimple.jl | 642 +++++++++++++++++++ src/webserver/Session.jl | 59 -- 21 files changed, 1477 insertions(+), 829 deletions(-) create mode 100644 frontend/common/types.js create mode 100644 frontend/imports/uuid.d.ts create mode 100644 frontend/imports/uuid.js create mode 100644 src/webserver/FirebaseSimple.jl diff --git a/frontend/common/Bond.js b/frontend/common/Bond.js index 6777b89261..7a6179c5f5 100644 --- a/frontend/common/Bond.js +++ b/frontend/common/Bond.js @@ -17,7 +17,10 @@ export const connect_bonds = (node, all_completed_promise, cell_invalidated_prom while (!node_is_invalidated) { // wait for all (other) cells to complete - if we don't, the Julia code would get overloaded with new values - await all_completed_promise.current + + // TODO Something with all_completed_promise + // await all_completed_promise.current + // wait for a new input value. If a value is ready, then this promise resolves immediately const val = await inputs.next().value if (!node_is_invalidated) { @@ -38,6 +41,7 @@ const transformed_val = async (val) => { } else if (val instanceof File) { return await new Promise((res) => { const reader = new FileReader() + // @ts-ignore reader.onload = () => res({ name: val.name, type: val.type, data: new Uint8Array(reader.result) }) reader.onerror = () => res({ name: val.name, type: val.type, data: null }) reader.readAsArrayBuffer(val) diff --git a/frontend/common/PlutoConnection.js b/frontend/common/PlutoConnection.js index e13ead527b..1f33128e06 100644 --- a/frontend/common/PlutoConnection.js +++ b/frontend/common/PlutoConnection.js @@ -131,7 +131,9 @@ const create_ws_connection = (address, { on_message, on_socket_close }, timeout_ console.log(event) alert( - `Something went wrong!\n\nPlease open an issue on https://github.com/fonsp/Pluto.jl with this info:\n\nFailed to process update\n${ex}\n\n${JSON.stringify(event)}` + `Something went wrong!\n\nPlease open an issue on https://github.com/fonsp/Pluto.jl with this info:\n\nFailed to process update\n${ex}\n\n${JSON.stringify( + event + )}` ) } }) @@ -208,7 +210,7 @@ export const create_pluto_connection = async ({ on_unrequested_update, on_reconn const sent_requests = {} const handle_update = (update) => { - const by_me = "initiator_id" in update && update.initiator_id == client_id + const by_me = update.initiator_id == client_id const request_id = update.request_id if (by_me && request_id) { @@ -241,17 +243,17 @@ export const create_pluto_connection = async ({ on_unrequested_update, on_reconn ...metadata, } - var p = undefined + var p = resolvable_promise() - if (create_promise) { - const rp = resolvable_promise() - p = rp.current - - sent_requests[request_id] = rp.resolve + sent_requests[request_id] = (message) => { + if (create_promise === false) { + on_unrequested_update(message, true) + } + p.resolve(message) } ws_connection.send(message) - return p + return p.current } client.send = send diff --git a/frontend/common/types.js b/frontend/common/types.js new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frontend/components/Cell.js b/frontend/components/Cell.js index 508ae9c77d..7e7ac8b2a5 100644 --- a/frontend/components/Cell.js +++ b/frontend/components/Cell.js @@ -5,78 +5,86 @@ import { CellInput } from "./CellInput.js" import { RunArea, useMillisSinceTruthy } from "./RunArea.js" import { cl } from "../common/ClassTable.js" -/** - * @typedef {Object} CodeState - * @property {string} body - * @property {number} [timestamp] - * @property {boolean} [submitted_by_me] - */ +// /** +// * @typedef {Object} CodeState +// * @property {string} body +// * @property {number} [timestamp] +// * @property {boolean} [submitted_by_me] +// */ -/** - * A cell! - * @typedef {Object} CellState - * @property {string} cell_id - * @property {CodeState} remote_code - * @property {CodeState} local_code - * @property {boolean} code_folded - * @property {boolean} queued - * @property {boolean} running - * @property {?number} runtime - * @property {boolean} errored - * @property {{body: string, timestamp: number, mime: string, rootassignee: ?string}} output - * @property {boolean} selected - * @property {boolean} pasted - */ +// /** +// * Cell as it could be loaded from the .jl file, +// * owned by the user, thus the Pluto frontend +// * @typedef CellData +// * @type {{ +// * id: string, +// * code: string, +// * folded: boolean, +// * }} +// */ -/** - * - * @param {string} cell_id - * @returns {CellState} - */ -export const empty_cell_data = (cell_id) => { - return { - cell_id: cell_id, - remote_code: { - body: "", - timestamp: 0, // don't use Pluto before 1970! - submitted_by_me: false, - }, - local_code: { - body: "", - }, - code_folded: false, - queued: true, - running: false, - runtime: null, - errored: false, - output: { - body: null, - timestamp: 0, // proof that Apollo 11 used Jupyter! - mime: "text/plain", - rootassignee: null, - }, - selected: false, - pasted: false, - } -} +// /** +// * Running state of a cell, +// * owned by the worker +// * @typedef CellStateData +// * @type {{ +// * queued: boolean, +// * running: boolean, +// * errored: boolean, +// * runtime?: number, +// * output: { +// * body: string, +// * timestamp: number, +// * mime: string, +// * rootassignee: ?string, +// * } +// * }} +// */ -/** - * - * @param {CellState} cell - * @return {boolean} - */ +// /** +// * A cell! +// * @typedef {Object} CellState +// * @property {string} cell_id +// * @property {CodeState} remote_code +// * @property {CodeState} local_code +// * @property {boolean} code_folded +// * @property {boolean} queued +// * @property {boolean} running +// * @property {?number} runtime +// * @property {boolean} errored +// * @property {{body: string, timestamp: number, mime: string, rootassignee: ?string}} output +// * @property {boolean} selected +// * @property {boolean} pasted +// */ + +// /** +// * +// * @param {CellState} cell +// * @return {boolean} +// */ export const code_differs = (cell) => cell.remote_code.body !== cell.local_code.body +/** + * @param {{ + * cell: import("./Editor.js").CellData, + * cell_state: import("./Editor.js").CellState, + * cell_local: import("./Editor.js").CellData, + * selected: boolean, + * [key: string]: any, + * }} props + * */ export const Cell = ({ - cell_id, - remote_code, - local_code, - code_folded, - queued, - running, - runtime, - errored, - output, + cell: { cell_id, code, code_folded }, + cell_state: { queued, running, runtime, errored, output }, + cell_local, + // remote_code, + // local_code, + // code_folded, + // queued, + // running, + // runtime, + // errored, + // output, selected, on_change, on_update_doc_query, @@ -113,7 +121,7 @@ export const Cell = ({ } }, []) - const class_code_differs = remote_code.body !== local_code.body + const class_code_differs = code !== (cell_local?.code ?? code) const class_code_folded = code_folded && cm_forced_focus == null let show_input = errored || class_code_differs || !class_code_folded @@ -133,8 +141,8 @@ export const Cell = ({ - <${CellOutput} ...${output} all_completed_promise=${all_completed_promise} requests=${requests} cell_id=${cell_id} /> + <${CellOutput} ...${output} requests=${requests} cell_id=${cell_id} /> ${show_input && html`<${CellInput} local_code=${cell_local?.code ?? code} diff --git a/frontend/components/CellOutput.js b/frontend/components/CellOutput.js index af566f1a8a..afd76d84a0 100644 --- a/frontend/components/CellOutput.js +++ b/frontend/components/CellOutput.js @@ -12,6 +12,7 @@ export class CellOutput extends Component { constructor() { super() this.old_height = 0 + // @ts-ignore Is there a way to use the latest DOM spec? this.resize_observer = new ResizeObserver((entries) => { const new_height = this.base.offsetHeight @@ -84,7 +85,7 @@ export let PlutoImage = ({ body, mime }) => { return html`` } -export const OutputBody = ({ mime, body, cell_id, all_completed_promise, requests, persist_js_state }) => { +export const OutputBody = ({ mime, body, cell_id, requests, persist_js_state }) => { switch (mime) { case "image/png": case "image/jpg": @@ -103,34 +104,16 @@ export const OutputBody = ({ mime, body, cell_id, all_completed_promise, request if (body.startsWith("` } else { - return html`<${RawHTMLContainer} - cell_id=${cell_id} - body=${body} - all_completed_promise=${all_completed_promise} - requests=${requests} - persist_js_state=${persist_js_state} - />` + return html`<${RawHTMLContainer} cell_id=${cell_id} body=${body} requests=${requests} persist_js_state=${persist_js_state} />` } break case "application/vnd.pluto.tree+object": return html`
- <${TreeView} - cell_id=${cell_id} - body=${body} - all_completed_promise=${all_completed_promise} - requests=${requests} - persist_js_state=${persist_js_state} - /> + <${TreeView} cell_id=${cell_id} body=${body} requests=${requests} persist_js_state=${persist_js_state} />
` break case "application/vnd.pluto.table+object": - return html` <${TableView} - cell_id=${cell_id} - body=${body} - all_completed_promise=${all_completed_promise} - requests=${requests} - persist_js_state=${persist_js_state} - />` + return html` <${TableView} cell_id=${cell_id} body=${body} requests=${requests} persist_js_state=${persist_js_state} />` break case "application/vnd.pluto.stacktrace+object": return html`
<${ErrorMessage} cell_id=${cell_id} requests=${requests} ...${body} />
` @@ -168,6 +151,7 @@ let IframeContainer = ({ body }) => { // Apply iframe resizer from the host side new Promise((resolve) => x.addEventListener("load", () => resolve())) + // @ts-ignore window.iFrameResize({ checkOrigin: false }, iframeref.current) }) @@ -193,6 +177,11 @@ let execute_dynamic_function = async ({ environment, code }) => { return result } +/** + * @typedef PlutoScript + * @type {HTMLScriptElement | { pluto_is_loading_me?: boolean }} + */ + const execute_scripttags = async ({ root_node, script_nodes, previous_results_map, invalidation }) => { let results_map = new Map() @@ -206,8 +195,10 @@ const execute_scripttags = async ({ root_node, script_nodes, previous_results_ma script_el = document.createElement("script") script_el.src = node.src script_el.type = node.type === "module" ? "module" : "text/javascript" + // @ts-ignore script_el.pluto_is_loading_me = true } + // @ts-ignore const need_to_await = script_el.pluto_is_loading_me != null if (need_to_await) { await new Promise((resolve) => { @@ -215,6 +206,7 @@ const execute_scripttags = async ({ root_node, script_nodes, previous_results_ma script_el.addEventListener("error", resolve) document.head.appendChild(script_el) }) + // @ts-ignore script_el.pluto_is_loading_me = undefined } } else { @@ -251,7 +243,7 @@ const execute_scripttags = async ({ root_node, script_nodes, previous_results_ma let run = (f) => f() -export let RawHTMLContainer = ({ body, all_completed_promise, requests, persist_js_state = false }) => { +export let RawHTMLContainer = ({ body, requests, persist_js_state = false }) => { let previous_results_map = useRef(new Map()) let invalidate_scripts = useRef(() => {}) @@ -277,12 +269,15 @@ export let RawHTMLContainer = ({ body, all_completed_promise, requests, persist_ previous_results_map: persist_js_state ? previous_results_map.current : new Map(), }) - if (all_completed_promise != null && requests != null) { - connect_bonds(container.current, all_completed_promise, invalidation, requests) + if (requests != null) { + connect_bonds(container.current, invalidation, requests) + } else { + console.log(`Bonds couldn't connect, because no requests`) } // convert LaTeX to svg try { + // @ts-ignore window.MathJax.typeset([container.current]) } catch (err) { console.info("Failed to typeset TeX:") @@ -308,6 +303,7 @@ export let RawHTMLContainer = ({ body, all_completed_promise, requests, persist_ /** @param {HTMLElement} code_element */ export let highlight_julia = (code_element) => { if (code_element.children.length === 0) { + // @ts-ignore window.CodeMirror.runMode(code_element.innerText, "julia", code_element) code_element.classList.add("cm-s-default") } diff --git a/frontend/components/Editor.js b/frontend/components/Editor.js index 6f91bfa9a1..c8584ac282 100644 --- a/frontend/components/Editor.js +++ b/frontend/components/Editor.js @@ -230,10 +230,6 @@ export class Editor extends Component { selected_cells: [], } - // bonds only send their latest value to the back-end when all cells have completed - this is triggered using a promise - this.all_completed = true - this.all_completed_promise = resolvable_promise() - // statistics that are accumulated over time this.counter_statistics = create_counter_statistics() @@ -318,25 +314,22 @@ export class Editor extends Component { // break case "notebook_diff": this.setState((state) => { - // console.group("Update!") - // for (let patch of message) { - // console.group(`Patch :${patch.op}`) - // console.log(`patch.path:`, patch.path) - // console.log(`patch.value:`, patch.value) - // console.groupEnd() - // } + console.group("Update!") + for (let patch of message) { + console.group(`Patch :${patch.op}`) + console.log(`patch.path:`, patch.path) + console.log(`patch.value:`, patch.value) + console.groupEnd() + } let new_notebook = applyPatches(state.notebook, message) - // console.log(`message:`, message) - // console.log(`new_notebook:`, new_notebook) - // console.groupEnd() + console.log(`message:`, message) + console.log(`new_notebook:`, new_notebook) + console.groupEnd() return { notebook: new_notebook, } }) break - // case "bond_update": - // // by someone else - // break case "log": handle_log(message, this.state.notebook.path) break @@ -361,8 +354,7 @@ export class Editor extends Component { // on socket success this.client.send("get_all_notebooks", {}, {}).then(on_remote_notebooks) - await update_notebook(() => {}).then((x) => { - // console.log(`Hmmmm x:`, x) + this.client.send("update_notebook", { updates: [] }, { notebook_id: this.state.notebook.notebook_id }, false).then(() => { this.setState({ loading: false }) }) @@ -397,7 +389,11 @@ export class Editor extends Component { let [new_notebook, changes, inverseChanges] = produceWithPatches(this.state.notebook, (notebook) => { mutate_fn(notebook) }) - console.trace(`changes:`, changes) + if (changes.length === 0) { + return + } + + console.trace(`Changes to send to server:`, changes) for (let change of changes) { if (change.path.some((x) => typeof x === "number")) { @@ -435,6 +431,7 @@ export class Editor extends Component { delete state.cells_local[cell_id] }) ) + console.log("RUN MULTIPLE CELLS", cell_id) await this.client.send("run_multiple_cells", { cells: [cell_id] }, { notebook_id: this.state.notebook.notebook_id }) }, wrap_remote_cell: (cell_id, block = "begin") => { @@ -549,13 +546,13 @@ export class Editor extends Component { }, fold_remote_cell: (cell_id, newFolded) => { update_notebook((notebook) => { - let cell = notebook.cell_dict[cell_id] - cell.code_folded = newFolded + notebook.cell_dict[cell_id].code_folded = newFolded }) }, set_and_run_all_changed_remote_cells: () => { const changed = this.state.notebook.cell_order.filter( - (cell_id) => this.state.notebook.cell_dict[cell_id].code !== this.state.cells_local[cell_id]?.code + (cell_id) => + this.state.cells_local[cell_id] != null && this.state.notebook.cell_dict[cell_id].code !== this.state.cells_local[cell_id]?.code ) this.requests.set_and_run_multiple(changed) return changed.length > 0 @@ -568,23 +565,15 @@ export class Editor extends Component { } } }) + console.log(`run_multiple_cells cells_ids:`, cells_ids) await this.client.send("run_multiple_cells", { cells: cells_ids }, { notebook_id: this.state.notebook.notebook_id }) }, set_bond: async (symbol, value, is_first_value) => { this.counter_statistics.numBondSets++ - if (this.all_completed) { - // instead of waiting for this component to update, we reset the promise right now - // this prevents very fast bonds from sending multiple values within the ping interval - this.all_completed = false - Object.assign(this.all_completed_promise, resolvable_promise()) - } - await update_notebook((notebook) => { notebook.bonds[symbol] = value }) - this.all_completed = true - this.all_completed_promise.resolve() // TODO Something with all_completed true ? // // the back-end tells us whether any cells depend on the bound value @@ -634,33 +623,32 @@ export class Editor extends Component { notebook.in_temp_dir = false notebook.path = new_path }) - false && - this.client - .send( - "move_notebook_file", - { - path: new_path, - }, - { notebook_id: this.state.notebook.notebook_id } - ) - .then((u) => { - this.setState({ - loading: false, - }) - if (u.message.success) { - this.setState({ - path: new_path, - }) - // @ts-ignore - document.activeElement.blur() - } else { - this.setState({ - path: old_path, - }) - reset_cm_value() - alert("Failed to move file:\n\n" + u.message.reason) - } - }) + // this.client + // .send( + // "move_notebook_file", + // { + // path: new_path, + // }, + // { notebook_id: this.state.notebook.notebook_id } + // ) + // .then((u) => { + // this.setState({ + // loading: false, + // }) + // if (u.message.success) { + // this.setState({ + // path: new_path, + // }) + // // @ts-ignore + // document.activeElement.blur() + // } else { + // this.setState({ + // path: old_path, + // }) + // reset_cm_value() + // alert("Failed to move file:\n\n" + u.message.reason) + // } + // }) } else { this.setState({ path: old_path, @@ -824,15 +812,15 @@ export class Editor extends Component { document.body.classList.add("disconnected") } - const all_completed_now = !Object.values(this.state.notebook.cells_running).some((cell) => cell && (cell.running || cell.queued)) - if (all_completed_now && !this.all_completed) { - this.all_completed = true - this.all_completed_promise.resolve() - } - if (!all_completed_now && this.all_completed) { - this.all_completed = false - Object.assign(this.all_completed_promise, resolvable_promise()) - } + // const all_completed_now = !Object.values(this.state.notebook.cells_running).some((cell) => cell && (cell.running || cell.queued)) + // if (all_completed_now && !this.all_completed) { + // this.all_completed = true + // this.all_completed_promise.resolve() + // } + // if (!all_completed_now && this.all_completed) { + // this.all_completed = false + // Object.assign(this.all_completed_promise, resolvable_promise()) + // } } render() { @@ -906,7 +894,6 @@ export class Editor extends Component { }} disable_input=${!this.state.connected} focus_after_creation=${!this.state.loading} - all_completed_promise=${this.all_completed_promise} selected_friends=${this.selected_friends} requests=${this.requests} client=${this.client} diff --git a/frontend/components/Notebook.js b/frontend/components/Notebook.js index d489add8d9..c6be9a9972 100644 --- a/frontend/components/Notebook.js +++ b/frontend/components/Notebook.js @@ -13,7 +13,6 @@ import { Cell } from "./Cell.js" * on_focus_neighbor: any, * disable_input: any, * focus_after_creation: any, - * all_completed_promise: any, * selected_friends: any, * requests: any, * client: any, @@ -29,7 +28,6 @@ export const Notebook = ({ on_focus_neighbor, disable_input, focus_after_creation, - all_completed_promise, selected_friends, requests, client, @@ -65,7 +63,6 @@ export const Notebook = ({ disable_input=${disable_input} focus_after_creation=${false /* focus_after_creation && !d.pasted */} scroll_into_view_after_creation=${false /* d.pasted */} - all_completed_promise=${all_completed_promise} selected_friends=${selected_friends} requests=${requests} client=${client} diff --git a/frontend/components/TreeView.js b/frontend/components/TreeView.js index 6c3a094f79..c65307c607 100644 --- a/frontend/components/TreeView.js +++ b/frontend/components/TreeView.js @@ -8,7 +8,7 @@ import { PlutoImage, RawHTMLContainer } from "./CellOutput.js" // whatever // // TODO: remove this, use OutputBody instead, and fix the CSS classes so that i all looks nice again -const SimpleOutputBody = ({ mime, body, cell_id, all_completed_promise, requests, persist_js_state }) => { +const SimpleOutputBody = ({ mime, body, cell_id, requests, persist_js_state }) => { switch (mime) { case "image/png": case "image/jpg": @@ -19,21 +19,10 @@ const SimpleOutputBody = ({ mime, body, cell_id, all_completed_promise, requests return html`<${PlutoImage} mime=${mime} body=${body} />` break case "text/html": - return html`<${RawHTMLContainer} - body=${body} - all_completed_promise=${all_completed_promise} - requests=${requests} - persist_js_state=${persist_js_state} - />` + return html`<${RawHTMLContainer} body=${body} requests=${requests} persist_js_state=${persist_js_state} />` break case "application/vnd.pluto.tree+object": - return html`<${TreeView} - cell_id=${cell_id} - body=${body} - all_completed_promise=${all_completed_promise} - requests=${requests} - persist_js_state=${persist_js_state} - />` + return html`<${TreeView} cell_id=${cell_id} body=${body} requests=${requests} persist_js_state=${persist_js_state} />` break case "text/plain": default: @@ -58,7 +47,7 @@ const More = ({ on_click_more }) => { >` } -export const TreeView = ({ mime, body, cell_id, all_completed_promise, requests, persist_js_state }) => { +export const TreeView = ({ mime, body, cell_id, requests, persist_js_state }) => { const node_ref = useRef(null) const onclick = (e) => { // TODO: this could be reactified but no rush @@ -87,7 +76,6 @@ export const TreeView = ({ mime, body, cell_id, all_completed_promise, requests, cell_id=${cell_id} mime=${pair[1]} body=${pair[0]} - all_completed_promise=${all_completed_promise} requests=${requests} persist_js_state=${persist_js_state} />` @@ -126,14 +114,13 @@ export const TreeView = ({ mime, body, cell_id, all_completed_promise, requests, return html`` } -export const TableView = ({ mime, body, cell_id, all_completed_promise, requests, persist_js_state }) => { +export const TableView = ({ mime, body, cell_id, requests, persist_js_state }) => { const node_ref = useRef(null) const mimepair_output = (pair) => html`<${SimpleOutputBody} cell_id=${cell_id} mime=${pair[1]} body=${pair[0]} - all_completed_promise=${all_completed_promise} requests=${requests} persist_js_state=${persist_js_state} />` diff --git a/src/webserver/Dynamic.jl b/src/webserver/Dynamic.jl index 701d10e534..cb56612aef 100644 --- a/src/webserver/Dynamic.jl +++ b/src/webserver/Dynamic.jl @@ -25,6 +25,7 @@ responses[:run_multiple_cells] = (session::ServerSession, body, notebook::Notebo # cells = [notebook.cells[i] for i in indices if i !== nothing] # @info "Run multiple cells" cells indices uuids = UUID.(body["cells"]) + @info "Cells to run" uuids cells = map(uuids) do uuid notebook.cell_dict[uuid] end @@ -68,52 +69,52 @@ module Firebase include("./FirebaseSimple.jl") end function notebook_to_js(notebook::Notebook) return Dict( - :notebook_id => notebook.notebook_id, - :path => notebook.path, - :in_temp_dir => startswith(notebook.path, new_notebooks_directory()), - :shortpath => basename(notebook.path), - :cell_dict => Dict(map(collect(notebook.cell_dict)) do (id, cell) + "notebook_id" => notebook.notebook_id, + "path" => notebook.path, + "in_temp_dir" => startswith(notebook.path, new_notebooks_directory()), + "shortpath" => basename(notebook.path), + "cell_dict" => Dict(map(collect(notebook.cell_dict)) do (id, cell) id => Dict( - :cell_id => cell.cell_id, - :code => cell.code, - :code_folded => cell.code_folded, + "cell_id" => cell.cell_id, + "code" => cell.code, + "code_folded" => cell.code_folded, ) end), - :cells_running => Dict(map(collect(notebook.cell_dict)) do (id, cell) + "cells_running" => Dict(map(collect(notebook.cell_dict)) do (id, cell) id => Dict( - :cell_id => cell.cell_id, - :queued => cell.queued, - :running => cell.running, - :errored => cell.errored, - :runtime => ismissing(cell.runtime) ? nothing : cell.runtime, - :output => Dict( - :last_run_timestamp => cell.last_run_timestamp, - :persist_js_state => cell.persist_js_state, - :mime => cell.repr_mime, - :body => cell.output_repr, - :rootassignee => cell.rootassignee, + "cell_id" => cell.cell_id, + "queued" => cell.queued, + "running" => cell.running, + "errored" => cell.errored, + "runtime" => ismissing(cell.runtime) ? nothing : cell.runtime, + "output" => Dict( + "last_run_timestamp" => cell.last_run_timestamp, + "persist_js_state" => cell.persist_js_state, + "mime" => cell.repr_mime, + "body" => cell.output_repr, + "rootassignee" => cell.rootassignee, ), ) end), - :cell_order => notebook.cell_order, - :bonds => Dict(notebook.bonds), + "cell_order" => notebook.cell_order, + "bonds" => Dict(notebook.bonds), ) end global current_state_for_clients = WeakKeyDict{ClientSession,Any}() function send_notebook_changes!(request::NotebookRequest) + notebook_dict = notebook_to_js(request.notebook) for (_, client) in request.session.connected_clients if client.connected_notebook !== nothing && client.connected_notebook.notebook_id == request.notebook.notebook_id - notebook_dict = notebook_to_js(request.notebook) current_dict = get(current_state_for_clients, client, :empty) - @info "current_dict:" current_dict patches = Firebase.diff(current_dict, notebook_dict) local patches_as_dict::Array{Dict} = patches - - @info "Patches:" patches current_state_for_clients[client] = notebook_dict - if length(patches) != 0 + # Make sure we do send a confirmation to the client who made the request, even without changes + send_anyway = request.initiator !== nothing && client == request.initiator.client + + if length(patches) != 0 || send_anyway initiator = isnothing(request.initiator) ? missing : request.initiator putclientupdates!(client, UpdateMessage(:notebook_diff, patches_as_dict, request.notebook, nothing, initiator)) end @@ -121,73 +122,127 @@ function send_notebook_changes!(request::NotebookRequest) end end +function convert_jsonpatch(::Type{Firebase.JSONPatch}, patch_dict::Dict) + if patch_dict["op"] == "add" + Firebase.AddPatch(patch_dict["path"], patch_dict["value"]) + elseif patch_dict["op"] == "remove" + Firebase.RemovePatch(patch_dict["path"]) + elseif patch_dict["op"] == "replace" + Firebase.ReplacePatch(patch_dict["path"], patch_dict["value"]) + else + throw("Unknown operation :$(patch_dict["op"]) in Dict to JSONPatch conversion") + end +end + +struct Wildcard end +function trigger_resolver(anything, path, values=[]) + (value=anything, matches=values, rest=path) +end +function trigger_resolver(resolvers::Dict, path, values=[]) + if length(path) == 0 + throw("No key matched") + end + + segment = path[begin] + rest = path[begin+1:end] + for (key, resolver) in resolvers + if key isa Wildcard + continue + end + if key == segment + return trigger_resolver(resolver, rest, values) + end + end + + if haskey(resolvers, Wildcard()) + return trigger_resolver(resolvers[Wildcard()], rest, (values..., segment)) + else + throw("No key matched") + end +end + +abstract type Changed end +struct CodeChanged <: Changed end +struct FileChanged <: Changed end + +const mutators = Dict( + "path" => function(; request::NotebookRequest, patch::Firebase.ReplacePatch) + newpath = tamepath(patch.value) + @info "Newpath:" newpath + request.notebook.path = newpath + SessionActions.move(request.session, request.notebook, newpath) + nothing + end, + "in_temp_dir" => function(; _...) nothing end, + "cell_dict" => Dict( + Wildcard() => function(cell_id, rest; request::NotebookRequest, patch::Firebase.JSONPatch) + Firebase.update!(request.notebook, patch) + @info "Updating cell" patch + + if length(rest) == 0 + [CodeChanged(), FileChanged()] + elseif length(rest) == 1 && Symbol(rest[1]) == :code + request.notebook.cell_dict[UUID(cell_id)].parsedcode = nothing + [CodeChanged(), FileChanged()] + else + [FileChanged()] + end + end, + ), + "cell_order" => function(; request::NotebookRequest, patch::Firebase.ReplacePatch) + request.notebook.cell_order = patch.value + return [FileChanged()] + end, + "bonds" => Dict( + Wildcard() => function(name; request::NotebookRequest, patch::Firebase.JSONPatch) + # @assert patch isa Firebase.ReplacePatch || patch isa Firebase.AddPatch + name = Symbol(name) + # request.notebook.bonds[name] = patch.value + Firebase.update!(request.notebook, patch) + @info "Bond" name patch.value + refresh_bond(session=request.session, notebook=request.notebook, name=name) + nothing + end, + ) +) function update_notebook(request::NotebookRequest) notebook = request.notebook - - # if !haskey(current_state_for_clients, request.client) - # current_state_for_clients[request.client] = nothing - # end - code_changed = false - file_changed = false + if length(request.message["updates"]) == 0 + return send_notebook_changes!(request) + end + + if !haskey(current_state_for_clients, request.initiator.client) + throw("Updating without having a first version of the notebook??") + end + # TODO Immutable ?? for update in request.message["updates"] - local operation = Symbol(update["op"]) - local path = Tuple(update["path"]) - local value = haskey(update, "value") ? update["value"] : nothing - - @assert operation in [:add, :replace, :remove] "Operation $(operation) unknown" - - if length(path) == 1 && path[1] == "path" - @assert operation == :replace - newpath = tamepath(value) - notebook.path = newpath - @info "Newpath:" newpath - SessionActions.move(request.session, notebook, newpath) - elseif length(path) == 1 && path[1] == "in_temp_dir" - nothing - elseif length(path) == 3 && path[1] == "cell_dict" - file_changed = true - code_changed = true - - @assert operation == :replace "You can only :replace on cells, you tried :$(operation)" - property = Symbol(path[3]) - cell_id = UUID(path[2]) - # cell_index = c(notebook, cell_id) - cell = notebook.cell_dict[cell_id] - setproperty!(cell, property, value) - - if property == :code - cell.parsedcode = nothing - code_changed = true - end - elseif length(path) == 2 && path[1] == "cell_dict" - file_changed = true - code_changed = true - - if operation == :add - cell_id = UUID(path[2]) - notebook.cell_dict[cell_id] = value - elseif operation == :remove - cell_id = UUID(path[2]) - delete!(notebook.cell_dict, cell_id) + patch = convert_jsonpatch(Firebase.JSONPatch, update) + Firebase.update!(current_state_for_clients[request.initiator.client], patch) + end + + changes = Set{Changed}() + + for update in request.message["updates"] + patch = convert_jsonpatch(Firebase.JSONPatch, update) + + match = trigger_resolver(mutators, patch.path) + if match !== nothing + (mutator, matches, rest) = match + + current_changes = if length(rest) == 0 && hasmethod(mutator, Tuple{fill(Any, length(matches))...}) + mutator(matches...; request=request, patch=patch) else - throw("Tried :$(operation) on a whole cell, but you can only :add or :remove") + mutator(matches..., rest; request=request, patch=patch) + end + + if current_changes !== nothing && current_changes isa AbstractVector{Changed} + push!(changes, current_changes...) end - elseif length(path) == 1 && path[1] == "cell_order" - file_changed = true - - @assert operation == :replace - notebook.cell_order = value - elseif length(path) == 2 && path[1] == "bonds" - name = Symbol(path[2]) - notebook.bonds[name] = value - @info "Bond" name value - refresh_bond(session=request.session, notebook=notebook, name=name) - @info "Refreshed bond..." else - throw("Path :$(path[1]) not yet implemented") + @warn "Not matching" patch end end @@ -195,7 +250,8 @@ function update_notebook(request::NotebookRequest) # end - if file_changed + if FileChanged ∈ changes + @info "SAVE NOTEBOOK" save_notebook(notebook) end diff --git a/src/webserver/FirebaseSimple.jl b/src/webserver/FirebaseSimple.jl index 62be03cc61..b9a302060f 100644 --- a/src/webserver/FirebaseSimple.jl +++ b/src/webserver/FirebaseSimple.jl @@ -17,7 +17,9 @@ md"### ==" md"### convert(::Type{Dict}, ::JSONPatch)" # ╔═╡ 921a130e-b028-4f91-b077-3bd79dcb6c6d - +function force_convert_key(::Dict{T,<:Any}, value) where T + T(value) +end # ╔═╡ daf9ec12-2de1-11eb-3a8d-59d9c2753134 md"## Diff" @@ -232,9 +234,10 @@ function getpath(value, path) current = path[begin] rest = path[begin+1:end] if value isa AbstractDict - getpath(getindex(value[current]), rest) + key = force_convert_key(value, current) + getpath(getindex(value, key), rest) else - getpath(getproperty(value[current]), rest) + getpath(getproperty(value, Symbol(current)), rest) end end @@ -261,15 +264,17 @@ function update!(value, patch::AddPatch) rest = patch.path[begin:end-1] subvalue = getpath(value, rest) if subvalue isa AbstractDict + key = force_convert_key(subvalue, last) if STRICT - @assert get(subvalue, last, nothing) === nothing + @assert get(subvalue, key, nothing) === nothing end - subvalue[last] = patch.value + subvalue[key] = patch.value else + key = Symbol(last) if STRICT - @assert getproperty(subvalue, last) === nothing + @assert getproperty(subvalue, key) === nothing end - setproperty!(subvalue, last, patch.value) + setproperty!(subvalue, key, patch.value) end end return value @@ -287,15 +292,17 @@ function update!(value, patch::ReplacePatch) rest = patch.path[begin:end-1] subvalue = getpath(value, rest) if subvalue isa AbstractDict + key = force_convert_key(subvalue, last) if STRICT - @assert get(subvalue, last, nothing) !== nothing + @assert get(subvalue, key, nothing) !== nothing end - subvalue[last] = patch.value + subvalue[key] = patch.value else + key = Symbol(last) if STRICT - @assert getproperty(subvalue, last) !== nothing + @assert getproperty(subvalue, key) !== nothing end - setproperty!(subvalue, last, patch.value) + setproperty!(subvalue, key, patch.value) end end return value @@ -313,15 +320,17 @@ function update!(value, patch::RemovePatch) rest = patch.path[begin:end-1] subvalue = getpath(value, rest) if subvalue isa AbstractDict + key = force_convert_key(subvalue, last) if STRICT - @assert get(subvalue, last, nothing) !== nothing + @assert get(subvalue, key, nothing) !== nothing end - delete!(subvalue, last) + delete!(subvalue, key) else + key = Symbol(last) if STRICT - @assert getproperty(subvalue, last) !== nothing + @assert getproperty(subvalue, key) !== nothing end - setproperty!(subvalue, last, nothing) + setproperty!(subvalue, key, nothing) end end return value diff --git a/src/webserver/Session.jl b/src/webserver/Session.jl index d546412bad..6eb50d3e9a 100644 --- a/src/webserver/Session.jl +++ b/src/webserver/Session.jl @@ -18,9 +18,18 @@ end "A combination of _client ID_ and a _request ID_. The front-end generates a unqique ID for every request that it sends. The back-end (the stuff you are currently reading) can respond to a specific request. In that case, the response does not go through the normal message handlers in the front-end, but it flies directly to the place where the message was sent. (It resolves the promise returned by `send(...)`.)" struct Initiator - client_id::Symbol + client::ClientSession request_id::Symbol end +function Base.getproperty(initiator::Initiator, property::Symbol) + if property == :client_id + getfield(initiator, :client).id + # elseif property == :request_id + # getfield(initiator, :request).id + else + getfield(initiator, property) + end +end ### # SERVER diff --git a/src/webserver/WebServer.jl b/src/webserver/WebServer.jl index ebc68e85e0..709a206658 100644 --- a/src/webserver/WebServer.jl +++ b/src/webserver/WebServer.jl @@ -344,7 +344,7 @@ function process_ws_message(session::ServerSession, parentbody::Dict, clientstre if haskey(responses, messagetype) responsefunc = responses[messagetype] try - responsefunc(session, body, args..., initiator=Initiator(client.id, request_id)) + responsefunc(session, body, args..., initiator=Initiator(client, request_id)) catch ex @warn "Response function to message of type $(messagetype) failed" rethrow(ex) From 90d022f2e4855215738b95e87c7cc4b7909f4f39 Mon Sep 17 00:00:00 2001 From: Michiel Dral Date: Wed, 25 Nov 2020 20:19:54 +0100 Subject: [PATCH 03/98] Take this --- src/webserver/Dynamic.jl | 3 ++- src/webserver/FirebaseSimple.jl | 16 ++++++++-------- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/webserver/Dynamic.jl b/src/webserver/Dynamic.jl index cb56612aef..5b8c6dffa8 100644 --- a/src/webserver/Dynamic.jl +++ b/src/webserver/Dynamic.jl @@ -190,7 +190,8 @@ const mutators = Dict( end, ), "cell_order" => function(; request::NotebookRequest, patch::Firebase.ReplacePatch) - request.notebook.cell_order = patch.value + Firebase.update!(request.notebook, patch) + # request.notebook.cell_order = patch.value return [FileChanged()] end, "bonds" => Dict( diff --git a/src/webserver/FirebaseSimple.jl b/src/webserver/FirebaseSimple.jl index b9a302060f..e367d05462 100644 --- a/src/webserver/FirebaseSimple.jl +++ b/src/webserver/FirebaseSimple.jl @@ -231,8 +231,8 @@ function getpath(value, path) return value end - current = path[begin] - rest = path[begin+1:end] + current = path[firstindex(path)] + rest = path[firstindex(path)+1:lastindex(path)] if value isa AbstractDict key = force_convert_key(value, current) getpath(getindex(value, key), rest) @@ -260,8 +260,8 @@ function update!(value, patch::AddPatch) if length(patch.path) == 0 throw("Impossible") else - last = patch.path[end] - rest = patch.path[begin:end-1] + last = patch.path[lastindex(patch.path)] + rest = patch.path[firstindex(patch.path):lastindex(patch.path)-1] subvalue = getpath(value, rest) if subvalue isa AbstractDict key = force_convert_key(subvalue, last) @@ -288,8 +288,8 @@ function update!(value, patch::ReplacePatch) if length(patch.path) == 0 throw("Impossible") else - last = patch.path[end] - rest = patch.path[begin:end-1] + last = patch.path[lastindex(patch.path)] + rest = patch.path[firstindex(patch.path):lastindex(patch.path)-1] subvalue = getpath(value, rest) if subvalue isa AbstractDict key = force_convert_key(subvalue, last) @@ -316,8 +316,8 @@ function update!(value, patch::RemovePatch) if length(patch.path) == 0 throw("Impossible") else - last = patch.path[end] - rest = patch.path[begin:end-1] + last = patch.path[lastindex(patch.path)] + rest = patch.path[firstindex(patch.path):lastindex(patch.path)-1] subvalue = getpath(value, rest) if subvalue isa AbstractDict key = force_convert_key(subvalue, last) From 4bea727dea5b1ee7799a3ceb5c14c955362510ab Mon Sep 17 00:00:00 2001 From: Michiel Dral Date: Wed, 25 Nov 2020 20:25:46 +0100 Subject: [PATCH 04/98] Okay this then --- src/notebook/Notebook.jl | 7 ------- src/webserver/Dynamic.jl | 8 ++------ src/webserver/WebServer.jl | 18 +++++++++--------- 3 files changed, 11 insertions(+), 22 deletions(-) diff --git a/src/notebook/Notebook.jl b/src/notebook/Notebook.jl index 398641f3b7..42d8452fd6 100644 --- a/src/notebook/Notebook.jl +++ b/src/notebook/Notebook.jl @@ -62,13 +62,6 @@ function Base.getproperty(notebook::Notebook, property::Symbol) end end -function cell_index_from_id(notebook::Notebook, cell_id::UUID)::Union{Int,Nothing} - findfirst(c -> c.cell_id == cell_id, notebook.cells) -end - - - - const _notebook_header = "### A Pluto.jl notebook ###" # We use a creative delimiter to avoid accidental use in code # so don't get inspired to suddenly use these in your code! diff --git a/src/webserver/Dynamic.jl b/src/webserver/Dynamic.jl index 5b8c6dffa8..cab9e1d0a6 100644 --- a/src/webserver/Dynamic.jl +++ b/src/webserver/Dynamic.jl @@ -21,11 +21,7 @@ responses[:ping] = (session::ServerSession, body, notebook = nothing; initiator: end responses[:run_multiple_cells] = (session::ServerSession, body, notebook::Notebook; initiator::Union{Initiator,Missing}=missing) -> let - # indices = cell_index_from_id.([notebook], UUID.(body["cells"])) - # cells = [notebook.cells[i] for i in indices if i !== nothing] - # @info "Run multiple cells" cells indices uuids = UUID.(body["cells"]) - @info "Cells to run" uuids cells = map(uuids) do uuid notebook.cell_dict[uuid] end @@ -143,8 +139,8 @@ function trigger_resolver(resolvers::Dict, path, values=[]) throw("No key matched") end - segment = path[begin] - rest = path[begin+1:end] + segment = path[firstindex(path)] + rest = path[firstindex(path)+1:lastindex(path)] for (key, resolver) in resolvers if key isa Wildcard continue diff --git a/src/webserver/WebServer.jl b/src/webserver/WebServer.jl index 709a206658..6d16897907 100644 --- a/src/webserver/WebServer.jl +++ b/src/webserver/WebServer.jl @@ -328,15 +328,15 @@ function process_ws_message(session::ServerSession, parentbody::Dict, clientstre push!(args, notebook) - if haskey(parentbody, "cell_id") - cell_id = UUID(parentbody["cell_id"]) - index = cell_index_from_id(notebook, cell_id) - if index === nothing - @warn "Remote cell not found locally!" - else - push!(args, notebook.cells[index]) - end - end + # if haskey(parentbody, "cell_id") + # cell_id = UUID(parentbody["cell_id"]) + # index = cell_index_from_id(notebook, cell_id) + # if index === nothing + # @warn "Remote cell not found locally!" + # else + # push!(args, notebook.cells[index]) + # end + # end end body = parentbody["body"] From 1cf3bed805147642f8d9211ffb01dc06bb5bb4ba Mon Sep 17 00:00:00 2001 From: Michiel Dral Date: Wed, 25 Nov 2020 21:11:09 +0100 Subject: [PATCH 05/98] Bonds now triggering less often --- frontend/components/Editor.js | 73 ++++++++++++++++++++++------------- 1 file changed, 46 insertions(+), 27 deletions(-) diff --git a/frontend/components/Editor.js b/frontend/components/Editor.js index c8584ac282..d13c4656e4 100644 --- a/frontend/components/Editor.js +++ b/frontend/components/Editor.js @@ -228,6 +228,8 @@ export class Editor extends Component { export_menu_open: false, selected_cells: [], + + update_is_ongoing: false, } // statistics that are accumulated over time @@ -302,33 +304,28 @@ export class Editor extends Component { on_remote_notebooks(update) break } - } else { - if (this.state.notebook.notebook_id !== update.notebook_id) { - return - } + } else if (this.state.notebook.notebook_id === update.notebook_id) { const message = update.message - const cell = this.state.notebook.cell_dict[update.cell_id] switch (update.type) { - // case "notebook": - // this.setState({ notebook: message }) - // break case "notebook_diff": - this.setState((state) => { - console.group("Update!") - for (let patch of message) { - console.group(`Patch :${patch.op}`) - console.log(`patch.path:`, patch.path) - console.log(`patch.value:`, patch.value) + if (message.length !== 0) { + this.setState((state) => { + console.group("Update!") + for (let patch of message) { + console.group(`Patch :${patch.op}`) + console.log(`patch.path:`, patch.path) + console.log(`patch.value:`, patch.value) + console.groupEnd() + } + let new_notebook = applyPatches(state.notebook, message) + console.log(`message:`, message) + console.log(`new_notebook:`, new_notebook) console.groupEnd() - } - let new_notebook = applyPatches(state.notebook, message) - console.log(`message:`, message) - console.log(`new_notebook:`, new_notebook) - console.groupEnd() - return { - notebook: new_notebook, - } - }) + return { + notebook: new_notebook, + } + }) + } break case "log": handle_log(message, this.state.notebook.path) @@ -339,9 +336,10 @@ export class Editor extends Component { // alert("Something went wrong 🙈\n Try clearing your browser cache and refreshing the page") break } + } else { + // Update for a different notebook, TODO maybe log this as it shouldn't happen } } - this.on_update = on_update const on_establish_connection = async (client) => { // nasty @@ -379,6 +377,11 @@ export class Editor extends Component { connect_metadata: { notebook_id: this.state.notebook.notebook_id }, }).then(on_establish_connection) + // Not completely happy with this yet, but it will do for now - DRAL + this.bonds_changes_to_apply_when_done = [] + this.notebook_is_idle = () => + !Object.values(this.state.notebook.cells_running).some((cell) => cell.running || cell.queued) && !this.state.update_is_ongoing + /** @param {(notebook: NotebookData) => void} mutate_fn */ let update_notebook = async (mutate_fn) => { // if (this.state.loading) { @@ -389,11 +392,21 @@ export class Editor extends Component { let [new_notebook, changes, inverseChanges] = produceWithPatches(this.state.notebook, (notebook) => { mutate_fn(notebook) }) + + if (!this.notebook_is_idle()) { + let changes_involving_bonds = changes.filter((x) => x.path[0] === "bonds") + this.bonds_changes_to_apply_when_done = [...this.bonds_changes_to_apply_when_done, ...changes_involving_bonds] + changes = changes.filter((x) => x.path[0] !== "bonds") + } + if (changes.length === 0) { return } - console.trace(`Changes to send to server:`, changes) + try { + let previous_function_name = new Error().stack.split("\n")[2].trim().split(" ")[1] + console.log(`Changes to send to server from "${previous_function_name}":`, changes) + } catch (error) {} for (let change of changes) { if (change.path.some((x) => typeof x === "number")) { @@ -401,6 +414,7 @@ export class Editor extends Component { } } + this.setState({ update_is_ongoing: true }) await Promise.all([ this.client.send("update_notebook", { updates: changes }, { notebook_id: this.state.notebook.notebook_id }, false), new Promise((resolve) => { @@ -412,6 +426,7 @@ export class Editor extends Component { ) }), ]) + this.setState({ update_is_ongoing: false }) } this.update_notebook = update_notebook @@ -419,13 +434,13 @@ export class Editor extends Component { this.requests = { change_remote_cell: async (cell_id, new_code, create_promise = false) => { this.counter_statistics.numEvals++ - // set_cell_state(cell_id, { running: true }) // TODO Need to do the update locally too, not doing that right now await update_notebook((notebook) => { notebook.cell_dict[cell_id].code = new_code }) // Just making sure there is no local state to overwrite left + // TODO I'm not sure if this is useful actually this.setState( immer((state) => { delete state.cells_local[cell_id] @@ -437,7 +452,6 @@ export class Editor extends Component { wrap_remote_cell: (cell_id, block = "begin") => { const cell = this.state.notebook.cell_dict[cell_id] const new_code = block + "\n\t" + cell.code.replace(/\n/g, "\n\t") + "\n" + "end" - // this.actions.update_local_cell_input(cell, false, new_code, cell.code_folded) this.requests.change_remote_cell(cell_id, new_code) }, split_remote_cell: async (cell_id, boundaries, submit = false) => { @@ -812,6 +826,11 @@ export class Editor extends Component { document.body.classList.add("disconnected") } + if (this.notebook_is_idle() && this.bonds_changes_to_apply_when_done) { + this.update_notebook((notebook) => { + applyPatches(notebook, this.bonds_changes_to_apply_when_done) + }) + } // const all_completed_now = !Object.values(this.state.notebook.cells_running).some((cell) => cell && (cell.running || cell.queued)) // if (all_completed_now && !this.all_completed) { // this.all_completed = true From a52e779e5bed2825d5eab3ea45831c022ba733e3 Mon Sep 17 00:00:00 2001 From: Michiel Dral Date: Thu, 26 Nov 2020 12:51:52 +0100 Subject: [PATCH 06/98] Cool cool --- frontend/common/Feedback.js | 29 ++++++++++++++--- frontend/components/Editor.js | 60 ++++++++++++----------------------- frontend/imports/uuid.js | 2 +- 3 files changed, 46 insertions(+), 45 deletions(-) diff --git a/frontend/common/Feedback.js b/frontend/common/Feedback.js index 8a5d723632..9a6d53bcff 100644 --- a/frontend/common/Feedback.js +++ b/frontend/common/Feedback.js @@ -18,13 +18,26 @@ const value_counts = (values) => }, {}) const sum = (values) => values.reduce((a, b) => a + b, 0) +/** + * @param {{ + * notebook: import("../components/Editor.js").NotebookData + * cells_local: { [id: string]: import("../components/Editor.js").CellData } + * }} state + * */ export const finalize_statistics = async (state, client, counter_statistics) => { - const cells = state.notebook.cells + const cells_running = state.notebook.cell_order.map((cell_id) => state.notebook.cells_running[cell_id]) + const cells = state.notebook.cell_order.map((cell_id) => state.notebook.cell_dict[cell_id]) + const cells_local = state.notebook.cell_order.map((cell_id) => { + return { + ...state.notebook.cell_dict[cell_id], + ...state.cells_local[cell_id], + } + }) const statistics = { numCells: cells.length, // integer - numErrored: cells.filter((c) => c.errored).length, + numErrored: cells_running.filter((c) => c.errored).length, // integer numFolded: cells.filter((c) => c.code_folded).length, // integer @@ -32,16 +45,16 @@ export const finalize_statistics = async (state, client, counter_statistics) => // integer numMarkdowns: cells.filter((c) => first_line(c).startsWith('md"')).length, // integer - numBinds: sum(cells.map((c) => count_matches(/\@bind/g, c.local_code.body))), + numBinds: sum(cells_local.map((c) => count_matches(/\@bind/g, c.code))), // integer numBegins: cells.filter((c) => first_line(c).endsWith("begin")).length, // integer numLets: cells.filter((c) => first_line(c).endsWith("let")).length, // integer - cellSizes: value_counts(cells.map((c) => count_matches(/\n/g, c.local_code.body) + 1)), + cellSizes: value_counts(cells_local.map((c) => count_matches(/\n/g, c.code) + 1)), // {numLines: numCells, ...} // e.g. {1: 28, 3: 14, 5: 7, 7: 1, 12: 1, 14: 1} - runtimes: value_counts(cells.map((c) => Math.floor(Math.log10(c.runtime + 1)))), + runtimes: value_counts(cells_running.map((c) => Math.floor(Math.log10(c.runtime + 1)))), // {runtime: numCells, ...} // where `runtime` is log10, rounded // e.g. {1: 28, 3: 14, 5: 7, 7: 1, 12: 1, 14: 1} @@ -50,6 +63,7 @@ export const finalize_statistics = async (state, client, counter_statistics) => // string, e.g. "v0.7.10" // versionJulia: client.julia_version, // // string, e.g. "v1.0.5" + // @ts-ignore timestamp: firebase.firestore.Timestamp.now(), // timestamp (ms) screenWidthApprox: 100 * Math.round(document.body.clientWidth / 100), @@ -94,11 +108,13 @@ const feedbackdb = { instance: null, } const init_firebase = () => { + // @ts-ignore firebase.initializeApp({ apiKey: "AIzaSyC0DqEcaM8AZ6cvApXuNcNU2RgZZOj7F68", authDomain: "localhost", projectId: "pluto-feedback", }) + // @ts-ignore feedbackdb.instance = firebase.firestore() } @@ -111,7 +127,9 @@ export const init_feedback = () => { timeout_promise( feedbackdb.instance.collection("feedback").add({ + // @ts-ignore feedback: new FormData(e.target).get("opinion"), + // @ts-ignore timestamp: firebase.firestore.Timestamp.now(), email: email ? email : "", }), @@ -121,6 +139,7 @@ export const init_feedback = () => { let message = "Submitted. Thank you for your feedback! 💕" console.log(message) alert(message) + // @ts-ignore feedbackform.querySelector("#opinion").value = "" }) .catch((error) => { diff --git a/frontend/components/Editor.js b/frontend/components/Editor.js index d13c4656e4..c6caeedfa3 100644 --- a/frontend/components/Editor.js +++ b/frontend/components/Editor.js @@ -310,17 +310,19 @@ export class Editor extends Component { case "notebook_diff": if (message.length !== 0) { this.setState((state) => { - console.group("Update!") - for (let patch of message) { - console.group(`Patch :${patch.op}`) - console.log(`patch.path:`, patch.path) - console.log(`patch.value:`, patch.value) - console.groupEnd() - } let new_notebook = applyPatches(state.notebook, message) - console.log(`message:`, message) - console.log(`new_notebook:`, new_notebook) - console.groupEnd() + + // console.group("Update!") + // for (let patch of message) { + // console.group(`Patch :${patch.op}`) + // console.log(`patch.path:`, patch.path) + // console.log(`patch.value:`, patch.value) + // console.groupEnd() + // } + // console.log(`message:`, message) + // console.log(`new_notebook:`, new_notebook) + // console.groupEnd() + return { notebook: new_notebook, } @@ -393,6 +395,10 @@ export class Editor extends Component { mutate_fn(notebook) }) + // If "notebook is not idle" I seperate and store the bonds updates, + // to send when the notebook is idle. This delays the updating of the bond for performance, + // but when the server can discard bond updates itself (now it executes them one by one, even if there is a newer update ready) + // this will no longer be necessary if (!this.notebook_is_idle()) { let changes_involving_bonds = changes.filter((x) => x.path[0] === "bonds") this.bonds_changes_to_apply_when_done = [...this.bonds_changes_to_apply_when_done, ...changes_involving_bonds] @@ -435,12 +441,11 @@ export class Editor extends Component { change_remote_cell: async (cell_id, new_code, create_promise = false) => { this.counter_statistics.numEvals++ - // TODO Need to do the update locally too, not doing that right now await update_notebook((notebook) => { notebook.cell_dict[cell_id].code = new_code }) // Just making sure there is no local state to overwrite left - // TODO I'm not sure if this is useful actually + // TODO I'm not sure if this is useful/necessary/required actually this.setState( immer((state) => { delete state.cells_local[cell_id] @@ -485,7 +490,6 @@ export class Editor extends Component { }) if (submit) { - // const cells = new_ids.map((id) => this.state.notebook.cells.find((c) => c.cell_id == id)) await this.requests.set_and_run_multiple(cells_to_add.map((x) => x.cell_id)) } }, @@ -554,7 +558,6 @@ export class Editor extends Component { } notebook.cell_order = notebook.cell_order.filter((cell_id) => !cell_ids.includes(cell_id)) }) - // cells.forEach((f) => this.requests.delete_cell(f.cell_id)) } } }, @@ -588,19 +591,6 @@ export class Editor extends Component { await update_notebook((notebook) => { notebook.bonds[symbol] = value }) - - // TODO Something with all_completed true ? - // // the back-end tells us whether any cells depend on the bound value - // if (message.triggered_other_cells) { - // // there are dependent cells, those cells will start running and returning output soon - // // when the last running cell returns its output, the all_completed_promise is resolved, and a new bond value can be sent - // } else { - // // there are no dependent cells, so we resolve the promise right now - // if (!this.all_completed) { - // this.all_completed = true - // this.all_completed_promise.resolve() - // } - // } }, reshow_cell: (cell_id, objectid, dim) => { this.client.send( @@ -777,7 +767,10 @@ export class Editor extends Component { }) window.addEventListener("beforeunload", (event) => { - const unsaved_cells = this.state.notebook.cell_order.filter((id) => this.state.notebook.cell_dict[id].code !== this.state.cells_local[id].code) + const unsaved_cells = this.state.notebook.cell_order.filter( + (id) => this.state.cells_local[id] && this.state.notebook.cell_dict[id].code !== this.state.cells_local[id].code + ) + console.log(`unsaved_cells:`, unsaved_cells) const first_unsaved = unsaved_cells[0] if (first_unsaved != null) { window.dispatchEvent(new CustomEvent("cell_focus", { detail: { cell_id: first_unsaved } })) @@ -831,20 +824,9 @@ export class Editor extends Component { applyPatches(notebook, this.bonds_changes_to_apply_when_done) }) } - // const all_completed_now = !Object.values(this.state.notebook.cells_running).some((cell) => cell && (cell.running || cell.queued)) - // if (all_completed_now && !this.all_completed) { - // this.all_completed = true - // this.all_completed_promise.resolve() - // } - // if (!all_completed_now && this.all_completed) { - // this.all_completed = false - // Object.assign(this.all_completed_promise, resolvable_promise()) - // } } render() { - // console.log(`this.state.notebook:`, this.state.notebook) - let { export_menu_open } = this.state return html` <${Scroller} active=${this.state.scroller} /> diff --git a/frontend/imports/uuid.js b/frontend/imports/uuid.js index 6cd9314447..557f57b986 100644 --- a/frontend/imports/uuid.js +++ b/frontend/imports/uuid.js @@ -1,4 +1,4 @@ // @ts-nocheck -import { v4 } from "https://jspm.dev/uuid" +import { v4 } from "https://cdn.jsdelivr.net/npm/uuid@8.3.1/dist/esm-browser/index.js" export { v4 } From e8811c917e7e47da59e70fa131bda0ee4e23be53 Mon Sep 17 00:00:00 2001 From: Michiel Dral Date: Thu, 26 Nov 2020 18:00:39 +0100 Subject: [PATCH 07/98] Remove cells!!! --- frontend/common/Feedback.js | 3 +- .../common/NodejsCompatibilityPolyfill.js | 1 + frontend/common/PlutoConnection.js | 67 +++++++------------ frontend/components/Cell.js | 15 ----- frontend/components/Editor.js | 8 +-- src/evaluation/Run.jl | 22 ++++-- src/evaluation/Update.jl | 6 +- src/webserver/Dynamic.jl | 1 + 8 files changed, 53 insertions(+), 70 deletions(-) diff --git a/frontend/common/Feedback.js b/frontend/common/Feedback.js index 9a6d53bcff..3ab95e48a0 100644 --- a/frontend/common/Feedback.js +++ b/frontend/common/Feedback.js @@ -1,4 +1,3 @@ -import { code_differs } from "../components/Cell.js" import { timeout_promise } from "./PlutoConnection.js" export const create_counter_statistics = () => { @@ -41,7 +40,7 @@ export const finalize_statistics = async (state, client, counter_statistics) => // integer numFolded: cells.filter((c) => c.code_folded).length, // integer - numCodeDiffers: cells.filter(code_differs).length, + numCodeDiffers: state.notebook.cell_order.filter((cell_id) => state.notebook.cell_dict[cell_id].code === cells_local[cell_id].code).length, // integer numMarkdowns: cells.filter((c) => first_line(c).startsWith('md"')).length, // integer diff --git a/frontend/common/NodejsCompatibilityPolyfill.js b/frontend/common/NodejsCompatibilityPolyfill.js index e6ed11dec4..60c53087a9 100644 --- a/frontend/common/NodejsCompatibilityPolyfill.js +++ b/frontend/common/NodejsCompatibilityPolyfill.js @@ -1,3 +1,4 @@ +// @ts-ignore window.process = { env: { NODE_ENV: "production", diff --git a/frontend/common/PlutoConnection.js b/frontend/common/PlutoConnection.js index 15f9791586..84e275a74c 100644 --- a/frontend/common/PlutoConnection.js +++ b/frontend/common/PlutoConnection.js @@ -20,9 +20,9 @@ const RECONNECT_DELAY = 500 export const timeout_promise = (promise, time_ms) => Promise.race([ promise, - new Promise((res, rej) => { + new Promise((resolve, reject) => { setTimeout(() => { - rej(new Error("Promise timed out.")) + reject(new Error("Promise timed out.")) }, time_ms) }), ]) @@ -53,15 +53,6 @@ export const resolvable_promise = () => { } } -const do_all = async (queue) => { - const next = queue[0] - await next() - queue.shift() - if (queue.length > 0) { - await do_all(queue) - } -} - /** * @returns {string} */ @@ -92,7 +83,7 @@ const try_close_socket_connection = (socket) => { /** * Open a 'raw' websocket connection to an API with MessagePack serialization. The method is asynchonous, and resolves to a @see WebsocketConnection when the connection is established. - * @typedef {{socket: WebSocket, send: Function, kill: Function}} WebsocketConnection + * @typedef {{socket: WebSocket, send: Function}} WebsocketConnection * @param {string} address The WebSocket URL * @param {{on_message: Function, on_socket_close:Function}} callbacks * @return {Promise} @@ -100,7 +91,6 @@ const try_close_socket_connection = (socket) => { const create_ws_connection = (address, { on_message, on_socket_close }, timeout_ms = 30 * 1000) => { return new Promise((resolve, reject) => { const socket = new WebSocket(address) - const task_queue = [] var has_been_open = false @@ -114,13 +104,15 @@ const create_ws_connection = (address, { on_message, on_socket_close }, timeout_ const encoded = pack(message) socket.send(encoded) } + + let last_task = Promise.resolve() socket.onmessage = (event) => { // we read and deserialize the incoming messages asynchronously // they arrive in order (WS guarantees this), i.e. this socket.onmessage event gets fired with the message events in the right order // but some message are read and deserialized much faster than others, because of varying sizes, so _after_ async read & deserialization, messages are no longer guaranteed to be in order // // the solution is a task queue, where each task includes the deserialization and the update handler - task_queue.push(async () => { + last_task.then(async () => { try { const buffer = await event.data.arrayBuffer() const update = unpack(new Uint8Array(buffer)) @@ -130,17 +122,12 @@ const create_ws_connection = (address, { on_message, on_socket_close }, timeout_ console.error("Failed to process update!", ex) console.log(event) - alert( - `Something went wrong!\n\nPlease open an issue on https://github.com/fonsp/Pluto.jl with this info:\n\nFailed to process update\n${ex}\n\n${JSON.stringify( - event - )}` - ) + // prettier-ignore + alert(`Something went wrong!\n\nPlease open an issue on https://github.com/fonsp/Pluto.jl with this info:\n\nFailed to process update\n${ex}\n\n${JSON.stringify(event)}`) } }) - if (task_queue.length == 1) { - do_all(task_queue) - } } + socket.onerror = async (e) => { console.error(`SOCKET DID AN OOPSIE - ${e.type}`, new Date().toLocaleTimeString(), e) @@ -175,10 +162,6 @@ const create_ws_connection = (address, { on_message, on_socket_close }, timeout_ resolve({ socket: socket, send: send_encoded, - kill: () => { - socket.onclose = undefined - try_close_socket_connection(socket) - }, }) } console.log("Waiting for socket to open...", new Date().toLocaleTimeString()) @@ -209,21 +192,6 @@ export const create_pluto_connection = async ({ on_unrequested_update, on_reconn const client_id = get_unique_short_id() const sent_requests = {} - const handle_update = (update) => { - const by_me = update.initiator_id == client_id - const request_id = update.request_id - - if (by_me && request_id) { - const request = sent_requests[request_id] - if (request) { - request(update) - delete sent_requests[request_id] - return - } - } - on_unrequested_update(update, by_me) - } - /** * Send a message to the Pluto backend, and return a promise that resolves when the backend sends a response. Not all messages receive a response. * @param {string} message_type @@ -280,7 +248,20 @@ export const create_pluto_connection = async ({ on_unrequested_update, on_reconn try { ws_connection = await create_ws_connection(String(ws_address), { - on_message: handle_update, + on_message: (update) => { + const by_me = update.initiator_id == client_id + const request_id = update.request_id + + if (by_me && request_id) { + const request = sent_requests[request_id] + if (request) { + request(update) + delete sent_requests[request_id] + return + } + } + on_unrequested_update(update, by_me) + }, on_socket_close: async () => { on_connection_status(false) @@ -298,8 +279,6 @@ export const create_pluto_connection = async ({ on_unrequested_update, on_reconn }, }) - client.kill = ws_connection.kill - // let's say hello console.log("Hello?") const u = await send("connect", {}, connect_metadata) diff --git a/frontend/components/Cell.js b/frontend/components/Cell.js index 93b34a3023..c94579bb0d 100644 --- a/frontend/components/Cell.js +++ b/frontend/components/Cell.js @@ -57,13 +57,6 @@ import { cl } from "../common/ClassTable.js" // * @property {boolean} pasted // */ -// /** -// * -// * @param {CellState} cell -// * @return {boolean} -// */ -export const code_differs = (cell) => cell.remote_code.body !== cell.local_code.body - /** * @param {{ * cell: import("./Editor.js").CellData, @@ -77,14 +70,6 @@ export const Cell = ({ cell: { cell_id, code, code_folded }, cell_state: { queued, running, runtime, errored, output }, cell_local, - // remote_code, - // local_code, - // code_folded, - // queued, - // running, - // runtime, - // errored, - // output, selected, on_change, on_update_doc_query, diff --git a/frontend/components/Editor.js b/frontend/components/Editor.js index c6caeedfa3..752071ac82 100644 --- a/frontend/components/Editor.js +++ b/frontend/components/Editor.js @@ -490,7 +490,7 @@ export class Editor extends Component { }) if (submit) { - await this.requests.set_and_run_multiple(cells_to_add.map((x) => x.cell_id)) + await this.requests.set_and_run_multiple([cell_id, ...cells_to_add.map((x) => x.cell_id)]) } }, interrupt_remote: (cell_id) => { @@ -545,19 +545,20 @@ export class Editor extends Component { }) await this.client.send("run_multiple_cells", { cells: [uuid] }, { notebook_id: this.state.notebook.notebook_id }) }, - confirm_delete_multiple: (verb, cell_ids) => { + confirm_delete_multiple: async (verb, cell_ids) => { if (cell_ids.length <= 1 || confirm(`${verb} ${cell_ids.length} cells?`)) { if (cell_ids.some((cell_id) => this.state.notebook.cells_running[cell_id].running || this.state.notebook.cells_running[cell_id].queued)) { if (confirm("This cell is still running - would you like to interrupt the notebook?")) { this.requests.interrupt_remote(cell_ids[0]) } } else { - update_notebook((notebook) => { + await update_notebook((notebook) => { for (let cell_id of cell_ids) { delete notebook.cell_dict[cell_id] } notebook.cell_order = notebook.cell_order.filter((cell_id) => !cell_ids.includes(cell_id)) }) + await this.client.send("run_multiple_cells", { cells: [] }, { notebook_id: this.state.notebook.notebook_id }) } } }, @@ -783,7 +784,6 @@ export class Editor extends Component { event.returnValue = "" } else { console.warn("unloading 👉 disconnecting websocket") - this.client.kill() // and don't prevent the unload } }) diff --git a/src/evaluation/Run.jl b/src/evaluation/Run.jl index 35a8d3291d..a35ef8ab16 100644 --- a/src/evaluation/Run.jl +++ b/src/evaluation/Run.jl @@ -25,6 +25,19 @@ function run_reactive!(session::ServerSession, notebook::Notebook, old_topology: # make sure that we're the only `run_reactive!` being executed - like a semaphor take!(notebook.executetoken) + removed_cells = setdiff(keys(old_topology.nodes), keys(new_topology.nodes)) + for cell::Cell in removed_cells + cell.code = "" + cell.parsedcode = parse_custom(notebook, cell) + cell.module_usings = Set{Expr}() + cell.rootassignee = nothing + end + cells::Vector{Cell} = [cells..., removed_cells...] + new_topology = NotebookTopology(merge( + new_topology.nodes, + Dict(cell => ReactiveNode() for cell in removed_cells), + )) + # save the old topological order - we'll delete variables assigned from it and re-evalutate its cells old_order = topological_order(notebook, old_topology, cells) @@ -36,6 +49,7 @@ function run_reactive!(session::ServerSession, notebook::Notebook, old_topology: new_order = topological_order(notebook, new_topology, union(cells, keys(old_order.errable))) to_run = setdiff(union(new_order.runnable, old_order.runnable), keys(new_order.errable))::Array{Cell,1} # TODO: think if old error cell order matters + # change the bar on the sides of cells to "queued" # local listeners = ClientSession[] for cell in to_run @@ -47,8 +61,6 @@ function run_reactive!(session::ServerSession, notebook::Notebook, old_topology: relay_reactivity_error!(cell, error) end send_notebook_changes!(NotebookRequest(session=session, notebook=notebook)) - # flushallclients(session, listeners) - # delete new variables that will be defined by a cell new_runnable = new_order.runnable @@ -139,9 +151,11 @@ function update_save_run!(session::ServerSession, notebook::Notebook, cells::Arr # "A Workspace on the main process, used to prerender markdown before starting a notebook process for speedy UI." original_pwd = pwd() offline_workspace = WorkspaceManager.make_workspace( - (ServerSession(options=Configuration.Options(evaluation=Configuration.EvaluationOptions(workspace_use_distributed=false))), - notebook) + ( + ServerSession(options=Configuration.Options(evaluation=Configuration.EvaluationOptions(workspace_use_distributed=false))), + notebook, ) + ) to_run_offline = filter(c -> !c.running && is_just_text(new, c) && is_just_text(old, c), cells) for cell in to_run_offline diff --git a/src/evaluation/Update.jl b/src/evaluation/Update.jl index dc687937d6..0907fef12c 100644 --- a/src/evaluation/Update.jl +++ b/src/evaluation/Update.jl @@ -14,7 +14,6 @@ end "Return a copy of `old_topology`, but with recomputed results from `cells` taken into account." function updated_topology(old_topology::NotebookTopology, notebook::Notebook, cells) - # TODO (performance): deleted cells should not stay in the topology updated_nodes = Dict(cell => ( cell.parsedcode |> @@ -24,5 +23,10 @@ function updated_topology(old_topology::NotebookTopology, notebook::Notebook, ce new_nodes = merge(old_topology.nodes, updated_nodes) + # DONE (performance): deleted cells should not stay in the topology + for removed_cell in setdiff(keys(old_topology.nodes), notebook.cells) + delete!(new_nodes, removed_cell) + end + NotebookTopology(new_nodes) end diff --git a/src/webserver/Dynamic.jl b/src/webserver/Dynamic.jl index cab9e1d0a6..1ebe0aa4d9 100644 --- a/src/webserver/Dynamic.jl +++ b/src/webserver/Dynamic.jl @@ -172,6 +172,7 @@ const mutators = Dict( "in_temp_dir" => function(; _...) nothing end, "cell_dict" => Dict( Wildcard() => function(cell_id, rest; request::NotebookRequest, patch::Firebase.JSONPatch) + cell = request.notebook.cell_dict[UUID(cell_id)] Firebase.update!(request.notebook, patch) @info "Updating cell" patch From 8204a0f8862d0ee1c82bde1446592490fcd32800 Mon Sep 17 00:00:00 2001 From: Michiel Dral Date: Thu, 26 Nov 2020 23:17:25 +0100 Subject: [PATCH 08/98] Cell focus and some stuff --- frontend/common/Feedback.js | 14 +++++---- frontend/common/PlutoConnection.js | 1 - frontend/components/Cell.js | 24 +++++++-------- frontend/components/CellInput.js | 6 ++-- frontend/components/CellOutput.js | 2 -- frontend/components/DropRuler.js | 4 +-- frontend/components/Editor.js | 49 +++++++++++------------------- frontend/components/Notebook.js | 9 +++--- src/evaluation/Run.jl | 1 + src/webserver/Dynamic.jl | 2 +- 10 files changed, 48 insertions(+), 64 deletions(-) diff --git a/frontend/common/Feedback.js b/frontend/common/Feedback.js index 3ab95e48a0..94bc3d5297 100644 --- a/frontend/common/Feedback.js +++ b/frontend/common/Feedback.js @@ -8,7 +8,7 @@ export const create_counter_statistics = () => { } } -const first_line = (cell) => /(.*)/.exec(cell.local_code.body)[0] +const first_line = (cell) => /(.*)/.exec(cell.code)[0] const count_matches = (pattern, haystack) => (haystack.match(pattern) || []).length const value_counts = (values) => values.reduce((prev_counts, val) => { @@ -28,7 +28,7 @@ export const finalize_statistics = async (state, client, counter_statistics) => const cells = state.notebook.cell_order.map((cell_id) => state.notebook.cell_dict[cell_id]) const cells_local = state.notebook.cell_order.map((cell_id) => { return { - ...state.notebook.cell_dict[cell_id], + ...(state.cells_local[cell_id] ?? state.notebook.cell_dict[cell_id]), ...state.cells_local[cell_id], } }) @@ -40,15 +40,17 @@ export const finalize_statistics = async (state, client, counter_statistics) => // integer numFolded: cells.filter((c) => c.code_folded).length, // integer - numCodeDiffers: state.notebook.cell_order.filter((cell_id) => state.notebook.cell_dict[cell_id].code === cells_local[cell_id].code).length, + numCodeDiffers: state.notebook.cell_order.filter( + (cell_id) => state.notebook.cell_dict[cell_id].code === (state.cells_local[cell_id]?.code ?? state.notebook.cell_dict[cell_id].code) + ).length, // integer - numMarkdowns: cells.filter((c) => first_line(c).startsWith('md"')).length, + numMarkdowns: cells_local.filter((c) => first_line(c).startsWith('md"')).length, // integer numBinds: sum(cells_local.map((c) => count_matches(/\@bind/g, c.code))), // integer - numBegins: cells.filter((c) => first_line(c).endsWith("begin")).length, + numBegins: cells_local.filter((c) => first_line(c).endsWith("begin")).length, // integer - numLets: cells.filter((c) => first_line(c).endsWith("let")).length, + numLets: cells_local.filter((c) => first_line(c).endsWith("let")).length, // integer cellSizes: value_counts(cells_local.map((c) => count_matches(/\n/g, c.code) + 1)), // {numLines: numCells, ...} diff --git a/frontend/common/PlutoConnection.js b/frontend/common/PlutoConnection.js index 84e275a74c..6c04c7cc90 100644 --- a/frontend/common/PlutoConnection.js +++ b/frontend/common/PlutoConnection.js @@ -214,7 +214,6 @@ export const create_pluto_connection = async ({ on_unrequested_update, on_reconn var p = resolvable_promise() sent_requests[request_id] = (message) => { - console.log(`Resolving ${message_type}`) p.resolve(message) if (create_promise === false) { on_unrequested_update(message, true) diff --git a/frontend/components/Cell.js b/frontend/components/Cell.js index c94579bb0d..c395a3d43a 100644 --- a/frontend/components/Cell.js +++ b/frontend/components/Cell.js @@ -63,6 +63,8 @@ import { cl } from "../common/ClassTable.js" * cell_state: import("./Editor.js").CellState, * cell_local: import("./Editor.js").CellData, * selected: boolean, + * focus_after_creation: boolean, + * selected_cells: Array, * [key: string]: any, * }} props * */ @@ -77,7 +79,7 @@ export const Cell = ({ disable_input, focus_after_creation, scroll_into_view_after_creation, - selected_friends, + selected_cells, requests, client, notebook_id, @@ -125,8 +127,11 @@ export const Cell = ({ - <${CellOutput} ...${output} requests=${requests} cell_id=${cell_id} /> + <${CellOutput} ...${output} cell_id=${cell_id} /> ${show_input && html`<${CellInput} local_code=${cell_local?.code ?? code} @@ -161,42 +162,41 @@ export const Cell = ({ cm_forced_focus=${cm_forced_focus} set_cm_forced_focus=${set_cm_forced_focus} on_submit=${(new_code) => { - requests.change_remote_cell(cell_id, new_code) + pluto_actions.change_remote_cell(cell_id, new_code) }} on_delete=${() => { let cells_to_delete = selected ? selected_cells : [cell_id] - requests.confirm_delete_multiple("Delete", cells_to_delete) + pluto_actions.confirm_delete_multiple("Delete", cells_to_delete) }} on_add_after=${() => { - requests.add_remote_cell(cell_id, "after") + pluto_actions.add_remote_cell(cell_id, "after") }} - on_fold=${(new_folded) => requests.fold_remote_cell(cell_id, new_folded)} + on_fold=${(new_folded) => pluto_actions.fold_remote_cell(cell_id, new_folded)} on_change=${(new_code) => { if (code_folded && cm_forced_focus != null) { - requests.fold_remote_cell(cell_id, false) + pluto_actions.fold_remote_cell(cell_id, false) } on_change(new_code) }} on_update_doc_query=${on_update_doc_query} on_focus_neighbor=${on_focus_neighbor} - client=${client} cell_id=${cell_id} notebook_id=${notebook_id} />`} <${RunArea} onClick=${() => { if (running || queued) { - requests.interrupt_remote(cell_id) + pluto_actions.interrupt_remote(cell_id) } else { let cell_to_run = selected ? selected_cells : [cell_id] - requests.set_and_run_multiple(cell_to_run) + pluto_actions.set_and_run_multiple(cell_to_run) } }} runtime=${localTimeRunning || runtime} />
+ + +
+ + + + <${Notebook} + is_loading=${this.state.loading} + notebook=${this.state.notebook} + selected_cells=${this.state.selected_cells} + cells_local=${this.state.cells_local} + on_update_doc_query=${this.actions.set_doc_query} + on_cell_input=${this.actions.set_local_cell} + on_focus_neighbor=${this.actions.focus_on_neighbor} + disable_input=${!this.state.connected} + last_created_cell=${this.state.last_created_cell} + /> + + <${DropRuler} actions=${this.actions} selected_cells=${this.state.selected_cells} /> + + <${SelectionArea} + actions=${this.actions} + cell_order=${this.state.notebook.cell_order} + selected_cell_ids=${this.state.selected_cell_ids} + on_selection=${(selected_cell_ids) => { + if (!window._.isEqual(this.state.selected_cells, selected_cell_ids)) { + this.setState({ + selected_cells: selected_cell_ids, + }) + } }} - placeholder="Save notebook..." - button_label=${this.state.notebook.in_temp_dir ? "Choose" : "Move"} /> - - - -
- - - - <${Notebook} - is_loading=${this.state.loading} +
+ <${LiveDocs} + desired_doc_query=${this.state.desired_doc_query} + on_update_doc_query=${this.actions.set_doc_query} notebook=${this.state.notebook} - selected_cells=${this.state.selected_cells} - cells_local=${this.state.cells_local} - on_update_doc_query=${(query) => this.setState({ desired_doc_query: query })} - on_cell_input=${(cell_id, new_val) => { - this.setState( - immer((state) => { - state.cells_local[cell_id] = { - code: new_val, - } - }) - ) - }} - on_focus_neighbor=${(cell_id, delta, line = delta === -1 ? Infinity : -1, ch) => { - const i = this.state.notebook.cell_order.indexOf(cell_id) - const new_i = i + delta - if (new_i >= 0 && new_i < this.state.notebook.cell_order.length) { - window.dispatchEvent( - new CustomEvent("cell_focus", { - detail: { - cell_id: this.state.notebook.cell_order[new_i], - line: line, - ch: ch, - }, - }) - ) - } - }} - disable_input=${!this.state.connected} - last_created_cell=${this.state.last_created_cell} - selected_cells=${this.state.selected_cells} - requests=${this.requests} - client=${this.client} /> - - <${DropRuler} requests=${this.requests} actions=${this.actions} selected_cells=${this.state.selected_cells} /> - - <${SelectionArea} - actions=${this.actions} - cell_order=${this.state.notebook.cell_order} - selected_cell_ids=${this.state.selected_cell_ids} - on_selection=${(selected_cell_ids) => { - this.setState({ - selected_cells: selected_cell_ids, - }) + <${UndoDelete} + recently_deleted=${this.state.recently_deleted} + on_click=${() => { + // TODO Make this work when I made recently_deleted work again + // this.update_notebook((notebook) => { + // let id = uuidv4() + // notebook.cell_dict[id] = { + // cell_id: id, + // code: this.state.recently_deleted.body, + // code_folded: false, + // } + // notebook.cell_order = [...notebook.cell_order.slice(0, index), id, ...notebook.cell_order.slice(index, Infinity)] + // }) }} /> -
- <${LiveDocs} - desired_doc_query=${this.state.desired_doc_query} - on_update_doc_query=${(query) => this.setState({ desired_doc_query: query })} - client=${this.client} - notebook=${this.state.notebook} - /> - <${UndoDelete} - recently_deleted=${this.state.recently_deleted} - on_click=${() => { - // TODO Make this work when I made recently_deleted work again - // this.update_notebook((notebook) => { - // let id = uuidv4() - // notebook.cell_dict[id] = { - // cell_id: id, - // code: this.state.recently_deleted.body, - // code_folded: false, - // } - // notebook.cell_order = [...notebook.cell_order.slice(0, index), id, ...notebook.cell_order.slice(index, Infinity)] - // }) - }} - /> - <${SlideControls} /> - + <${SlideControls} /> + + ` } } diff --git a/frontend/components/ErrorMessage.js b/frontend/components/ErrorMessage.js index e0fdfd1465..79b80d6324 100644 --- a/frontend/components/ErrorMessage.js +++ b/frontend/components/ErrorMessage.js @@ -1,4 +1,5 @@ -import { html } from "../imports/Preact.js" +import { PlutoContext } from "../common/PlutoContext.js" +import { html, useContext } from "../imports/Preact.js" const StackFrameFilename = ({ frame, cell_id }) => { const sep_index = frame.file.indexOf("#==#") @@ -35,7 +36,8 @@ const Funccall = ({ frame }) => { } } -export const ErrorMessage = ({ msg, stacktrace, cell_id, requests }) => { +export const ErrorMessage = ({ msg, stacktrace, cell_id }) => { + let pluto_actions = useContext(PlutoContext) const rewriters = [ { pattern: /syntax: extra token after end of expression/, @@ -44,7 +46,7 @@ export const ErrorMessage = ({ msg, stacktrace, cell_id, requests }) => { href="#" onClick=${(e) => { e.preventDefault() - requests.wrap_remote_cell(cell_id, "begin") + pluto_actions.wrap_remote_cell(cell_id, "begin") }} >Wrap all code in a begin ... end block.` @@ -55,7 +57,7 @@ export const ErrorMessage = ({ msg, stacktrace, cell_id, requests }) => { href="#" onClick=${(e) => { e.preventDefault() - requests.split_remote_cell(cell_id, boundaries, true) + pluto_actions.split_remote_cell(cell_id, boundaries, true) }} >Split this cell into ${boundaries.length} cells, or diff --git a/frontend/components/LiveDocs.js b/frontend/components/LiveDocs.js index b2e76994b8..710eacd399 100644 --- a/frontend/components/LiveDocs.js +++ b/frontend/components/LiveDocs.js @@ -1,11 +1,13 @@ -import { html, useState, useRef, useLayoutEffect, useEffect, useMemo } from "../imports/Preact.js" +import { html, useState, useRef, useLayoutEffect, useEffect, useMemo, useContext } from "../imports/Preact.js" import immer from "../imports/immer.js" import observablehq from "../common/SetupCellEnvironment.js" import { cl } from "../common/ClassTable.js" import { RawHTMLContainer, highlight_julia } from "./CellOutput.js" +import { PlutoContext } from "../common/PlutoContext.js" -export let LiveDocs = ({ desired_doc_query, client, on_update_doc_query, notebook }) => { +export let LiveDocs = ({ desired_doc_query, on_update_doc_query, notebook }) => { + let pluto_actions = useContext(PlutoContext) let container_ref = useRef() let live_doc_search_ref = useRef() let [state, set_state] = useState({ @@ -67,7 +69,7 @@ export let LiveDocs = ({ desired_doc_query, client, on_update_doc_query, noteboo }) Promise.race([ observablehq.Promises.delay(2000, false), - client.send("docs", { query: new_query.replace(/^\?/, "") }, { notebook_id: notebook.notebook_id }).then((u) => { + pluto_actions.send("docs", { query: new_query.replace(/^\?/, "") }, { notebook_id: notebook.notebook_id }).then((u) => { if (u.message.status === "⌛") { return false } diff --git a/frontend/components/Notebook.js b/frontend/components/Notebook.js index 5c0bc56442..07dd78aed6 100644 --- a/frontend/components/Notebook.js +++ b/frontend/components/Notebook.js @@ -1,7 +1,52 @@ -import { html, useEffect, useRef } from "../imports/Preact.js" +import { PlutoContext } from "../common/PlutoContext.js" +import { html, useContext, useEffect, useMemo } from "../imports/Preact.js" import { Cell } from "./Cell.js" +let CellMemo = ({ + cell, + cell_state, + selected, + cell_local, + notebook_id, + on_update_doc_query, + on_cell_input, + on_focus_neighbor, + disable_input, + focus_after_creation, + selected_cells, +}) => { + return useMemo(() => { + return html` + <${Cell} + on_change=${(val) => on_cell_input(cell.cell_id, val)} + cell=${cell} + cell_state=${cell_state} + selected=${selected} + cell_local=${cell_local} + notebook_id=${notebook_id} + on_update_doc_query=${on_update_doc_query} + on_focus_neighbor=${on_focus_neighbor} + disable_input=${disable_input} + focus_after_creation=${focus_after_creation} + selected_cells=${selected_cells} + /> + ` + }, [ + cell, + cell_state, + selected, + cell_local, + notebook_id, + on_update_doc_query, + on_cell_input, + on_focus_neighbor, + disable_input, + focus_after_creation, + selected_cells, + ]) +} + /** * @param {{ * is_loading: boolean @@ -14,8 +59,6 @@ import { Cell } from "./Cell.js" * on_focus_neighbor: any, * disable_input: any, * focus_after_creation: any, - * requests: any, - * client: any, * }} props * */ export const Notebook = ({ @@ -28,21 +71,21 @@ export const Notebook = ({ on_cell_input, on_focus_neighbor, disable_input, - requests, - client, }) => { // This might look kinda silly... // and it is... but it covers all the cases... - DRAL + let pluto_actions = useContext(PlutoContext) useEffect(() => { if (notebook.cell_order.length === 0 && !is_loading) { - // requests.add_remote_cell_at(0) + console.log("GO ADD CELL") + // pluto_actions.add_remote_cell_at(0) } }, [is_loading, notebook.cell_order.length]) return html` ${notebook.cell_order.map( - (cell_id) => html`<${Cell} + (cell_id) => html`<${CellMemo} key=${cell_id} cell=${notebook.cell_dict[cell_id]} cell_state=${notebook.cells_running[cell_id] ?? { @@ -57,16 +100,41 @@ export const Notebook = ({ cell_local=${cells_local[cell_id]} notebook_id=${notebook.notebook_id} on_update_doc_query=${on_update_doc_query} - on_change=${(val) => on_cell_input(cell_id, val)} + on_cell_input=${on_cell_input} on_focus_neighbor=${on_focus_neighbor} disable_input=${disable_input} focus_after_creation=${last_created_cell === cell_id} - scroll_into_view_after_creation=${false /* d.pasted */} selected_cells=${selected_cells} - requests=${requests} - client=${client} />` )} ` } + +export const NotebookMemo = ({ + is_loading, + notebook, + cells_local, + on_update_doc_query, + on_cell_input, + on_focus_neighbor, + disable_input, + last_created_cell, + selected_cells, +}) => { + return useMemo(() => { + return html` + <${Notebook} + is_loading=${is_loading} + notebook=${notebook} + cells_local=${cells_local} + on_update_doc_query=${on_update_doc_query} + on_cell_input=${on_cell_input} + on_focus_neighbor=${on_focus_neighbor} + disable_input=${disable_input} + last_created_cell=${last_created_cell} + selected_cells=${selected_cells} + /> + ` + }, [is_loading, notebook, cells_local, on_update_doc_query, on_cell_input, on_focus_neighbor, disable_input, last_created_cell, selected_cells]) +} diff --git a/frontend/components/TreeView.js b/frontend/components/TreeView.js index c65307c607..b76ce4ec3a 100644 --- a/frontend/components/TreeView.js +++ b/frontend/components/TreeView.js @@ -1,6 +1,7 @@ -import { html, useRef, useState } from "../imports/Preact.js" +import { html, useRef, useState, useContext } from "../imports/Preact.js" import { PlutoImage, RawHTMLContainer } from "./CellOutput.js" +import { PlutoContext } from "../common/PlutoContext.js" // this is different from OutputBody because: // it does not wrap in
. We want to do that in OutputBody for reasons that I forgot (feel free to try and remove it), but we dont want it here @@ -8,7 +9,7 @@ import { PlutoImage, RawHTMLContainer } from "./CellOutput.js" // whatever // // TODO: remove this, use OutputBody instead, and fix the CSS classes so that i all looks nice again -const SimpleOutputBody = ({ mime, body, cell_id, requests, persist_js_state }) => { +const SimpleOutputBody = ({ mime, body, cell_id, persist_js_state }) => { switch (mime) { case "image/png": case "image/jpg": @@ -19,10 +20,10 @@ const SimpleOutputBody = ({ mime, body, cell_id, requests, persist_js_state }) = return html`<${PlutoImage} mime=${mime} body=${body} />` break case "text/html": - return html`<${RawHTMLContainer} body=${body} requests=${requests} persist_js_state=${persist_js_state} />` + return html`<${RawHTMLContainer} body=${body} persist_js_state=${persist_js_state} />` break case "application/vnd.pluto.tree+object": - return html`<${TreeView} cell_id=${cell_id} body=${body} requests=${requests} persist_js_state=${persist_js_state} />` + return html`<${TreeView} cell_id=${cell_id} body=${body} persist_js_state=${persist_js_state} />` break case "text/plain": default: @@ -47,11 +48,12 @@ const More = ({ on_click_more }) => { >` } -export const TreeView = ({ mime, body, cell_id, requests, persist_js_state }) => { +export const TreeView = ({ mime, body, cell_id, persist_js_state }) => { + let pluto_actions = useContext(PlutoContext) const node_ref = useRef(null) const onclick = (e) => { // TODO: this could be reactified but no rush - self = node_ref.current + let self = node_ref.current if (e.target !== self && !self.classList.contains("collapsed")) { return } @@ -69,16 +71,10 @@ export const TreeView = ({ mime, body, cell_id, requests, persist_js_state }) => if (node_ref.current.closest("jltree.collapsed") != null) { return false } - requests.reshow_cell(cell_id, body.objectid, 1) + pluto_actions.reshow_cell(cell_id, body.objectid, 1) } - const mimepair_output = (pair) => html`<${SimpleOutputBody} - cell_id=${cell_id} - mime=${pair[1]} - body=${pair[0]} - requests=${requests} - persist_js_state=${persist_js_state} - />` + const mimepair_output = (pair) => html`<${SimpleOutputBody} cell_id=${cell_id} mime=${pair[1]} body=${pair[0]} persist_js_state=${persist_js_state} />` const more = html`<${More} on_click_more=${on_click_more} />` var inner = null @@ -114,19 +110,14 @@ export const TreeView = ({ mime, body, cell_id, requests, persist_js_state }) => return html`` } -export const TableView = ({ mime, body, cell_id, requests, persist_js_state }) => { +export const TableView = ({ mime, body, cell_id, persist_js_state }) => { + let pluto_actions = useContext(PlutoContext) const node_ref = useRef(null) - const mimepair_output = (pair) => html`<${SimpleOutputBody} - cell_id=${cell_id} - mime=${pair[1]} - body=${pair[0]} - requests=${requests} - persist_js_state=${persist_js_state} - />` + const mimepair_output = (pair) => html`<${SimpleOutputBody} cell_id=${cell_id} mime=${pair[1]} body=${pair[0]} persist_js_state=${persist_js_state} />` const more = (dim) => html`<${More} on_click_more=${() => { - requests.reshow_cell(cell_id, body.objectid, dim) + pluto_actions.reshow_cell(cell_id, body.objectid, dim) }} />` diff --git a/frontend/imports/Preact.d.ts b/frontend/imports/Preact.d.ts index ae81bb1e0c..db9a9b5144 100644 --- a/frontend/imports/Preact.d.ts +++ b/frontend/imports/Preact.d.ts @@ -32,3 +32,11 @@ type EffectFn = () => void | UnsubscribeFn export declare function useEffect(fn: EffectFn, deps?: Array): void export declare function useLayoutEffect(fn: EffectFn, deps?: Array): void + +declare class ReactContextProvider {} +declare class ReactContext { + Provider: ReactContextProvider + Consumer: any // You're on your own with this one +} +export declare function createContext(initialValue: T | void): ReactContext +export declare function useContext(context: ReactContext): T diff --git a/frontend/imports/Preact.js b/frontend/imports/Preact.js index 3df1451d9e..4759b198cd 100644 --- a/frontend/imports/Preact.js +++ b/frontend/imports/Preact.js @@ -8,5 +8,7 @@ import { useState, useRef, useMemo, + createContext, + useContext, } from "https://cdn.jsdelivr.net/npm/htm@3.0.4/preact/standalone.module.js" -export { html, render, Component, useEffect, useLayoutEffect, useState, useRef, useMemo } +export { html, render, Component, useEffect, useLayoutEffect, useState, useRef, useMemo, createContext, useContext } From a30969ffea70eeb125f38a04e6d2854b8d771620 Mon Sep 17 00:00:00 2001 From: Michiel Dral Date: Fri, 27 Nov 2020 21:18:30 +0100 Subject: [PATCH 10/98] So much --- frontend/common/Feedback.js | 4 +-- frontend/components/Cell.js | 2 -- frontend/components/CellInput.js | 43 +++++++++++++++++++----- frontend/components/Editor.js | 50 ++++++++++++++++++---------- frontend/components/SelectionArea.js | 20 ++++++++--- frontend/components/UndoDelete.js | 8 +++-- frontend/imports/lodash.js | 2 ++ src/notebook/Cell.jl | 11 +++--- src/webserver/Dynamic.jl | 2 +- 9 files changed, 99 insertions(+), 43 deletions(-) create mode 100644 frontend/imports/lodash.js diff --git a/frontend/common/Feedback.js b/frontend/common/Feedback.js index 94bc3d5297..3e5bda0be1 100644 --- a/frontend/common/Feedback.js +++ b/frontend/common/Feedback.js @@ -24,8 +24,8 @@ const sum = (values) => values.reduce((a, b) => a + b, 0) * }} state * */ export const finalize_statistics = async (state, client, counter_statistics) => { - const cells_running = state.notebook.cell_order.map((cell_id) => state.notebook.cells_running[cell_id]) - const cells = state.notebook.cell_order.map((cell_id) => state.notebook.cell_dict[cell_id]) + const cells_running = state.notebook.cell_order.map((cell_id) => state.notebook.cells_running[cell_id]).filter((x) => x != null) + const cells = state.notebook.cell_order.map((cell_id) => state.notebook.cell_dict[cell_id]).filter((x) => x != null) const cells_local = state.notebook.cell_order.map((cell_id) => { return { ...(state.cells_local[cell_id] ?? state.notebook.cell_dict[cell_id]), diff --git a/frontend/components/Cell.js b/frontend/components/Cell.js index c1721dbcc6..cb4d77ee42 100644 --- a/frontend/components/Cell.js +++ b/frontend/components/Cell.js @@ -79,7 +79,6 @@ export const Cell = ({ on_focus_neighbor, disable_input, focus_after_creation, - scroll_into_view_after_creation, selected_cells, notebook_id, }) => { @@ -158,7 +157,6 @@ export const Cell = ({ remote_code=${code} disable_input=${disable_input} focus_after_creation=${focus_after_creation} - scroll_into_view_after_creation=${scroll_into_view_after_creation} cm_forced_focus=${cm_forced_focus} set_cm_forced_focus=${set_cm_forced_focus} on_submit=${(new_code) => { diff --git a/frontend/components/CellInput.js b/frontend/components/CellInput.js index 632bd332f1..9c05a48f18 100644 --- a/frontend/components/CellInput.js +++ b/frontend/components/CellInput.js @@ -16,10 +16,24 @@ const clear_selection = (cm) => { const last = (x) => x[x.length - 1] const all_equal = (x) => x.every((y) => y === x[0]) +// Adapted from https://gomakethings.com/how-to-test-if-an-element-is-in-the-viewport-with-vanilla-javascript/ +var offsetFromViewport = function (elem) { + let bounding = elem.getBoundingClientRect() + let is_in_viewport = bounding.top >= 0 && bounding.bottom <= window.innerHeight + if (is_in_viewport) { + return null + } else { + return { + top: bounding.top < 0 ? -bounding.top : window.innerHeight - bounding.bottom, + } + } +} + /** * @param {{ * local_code: string, * remote_code: string, + * scroll_into_view_after_creation: boolean, * [key: string]: any, * }} props */ @@ -28,13 +42,11 @@ export const CellInput = ({ remote_code, disable_input, focus_after_creation, - scroll_into_view_after_creation, cm_forced_focus, set_cm_forced_focus, on_submit, on_delete, on_add_after, - on_fold, on_change, on_update_doc_query, on_focus_neighbor, @@ -44,7 +56,7 @@ export const CellInput = ({ let pluto_actions = useContext(PlutoContext) const cm_ref = useRef(null) - const dom_node_ref = useRef(null) + const dom_node_ref = useRef(/** @type {HTMLElement} */ (null)) const remote_code_ref = useRef(null) const change_handler_ref = useRef(null) change_handler_ref.current = on_change @@ -56,7 +68,7 @@ export const CellInput = ({ remote_code_ref.current = remote_code }, [remote_code]) - useEffect(() => { + useLayoutEffect(() => { const cm = (cm_ref.current = CodeMirror( (el) => { dom_node_ref.current.appendChild(el) @@ -384,11 +396,26 @@ export const CellInput = ({ }) if (focus_after_creation) { + // if (!isInViewport(dom_node_ref.current)) { + // let offset_from_viewport = offsetFromViewport(dom_node_ref.current) + // console.log(`offset_from_viewport:`, offset_from_viewport) + // if (offset_from_viewport) { + // console.log("SCROLLING") + // document.body.scrollBy({ + // behavior: "auto", + // top: offset_from_viewport.top, + // }) + // } + // console.log(`isInViewport(dom_node_ref.current):`, isInViewport(dom_node_ref.current)) + // console.log("Scrolling into view (smooth") + // dom_node_ref.current.scrollIntoView({ + // behavior: "smooth", + // block: "center", + // }) + // } + // console.log("FOCUS") cm.focus() } - if (scroll_into_view_after_creation) { - dom_node_ref.current.scrollIntoView() - } // @ts-ignore document.fonts.ready.then(() => { @@ -404,7 +431,7 @@ export const CellInput = ({ useEffect(() => { cm_ref.current.options.disableInput = disable_input - }) + }, [disable_input]) useEffect(() => { if (cm_forced_focus == null) { diff --git a/frontend/components/Editor.js b/frontend/components/Editor.js index 5a5ac02ef7..270aa4d7b0 100644 --- a/frontend/components/Editor.js +++ b/frontend/components/Editor.js @@ -1,6 +1,7 @@ import { html, Component, useState, useEffect } from "../imports/Preact.js" import immer, { applyPatches, produceWithPatches } from "../imports/immer.js" import { v4 as uuidv4 } from "../imports/uuid.js" +import _ from "../imports/lodash.js" import { create_pluto_connection, resolvable_promise } from "../common/PlutoConnection.js" import { create_counter_statistics, send_statistics_if_enabled, store_statistics_sample, finalize_statistics, init_feedback } from "../common/Feedback.js" @@ -52,7 +53,7 @@ function serialize_cells(cells) { */ function deserialize_cells(serialized_cells) { const segments = serialized_cells.replace(/\r\n/g, "\n").split(/# ╔═╡ \S+\n/) - return segments.map((s) => s.trim()).filter((s) => s.length > 0) + return segments.map((s) => s.trim()).filter((s) => s !== "") } const Circle = ({ fill }) => html` @@ -219,7 +220,7 @@ export class Editor extends Component { }), cells_local: /** @type {{ [id: string]: CellData }} */ ({}), desired_doc_query: null, - recently_deleted: null, + recently_deleted: /** @type {Array<{ index: number, cell: CellData }>} */ (null), connected: false, loading: true, scroller: { @@ -240,6 +241,7 @@ export class Editor extends Component { // these are things that can be done to the local notebook this.actions = { send: (...args) => this.client.send(...args), + update_notebook: this.update_notebook, set_doc_query: (query) => this.setState({ desired_doc_query: query }), set_local_cell: (cell_id, new_val) => { this.setState( @@ -293,6 +295,7 @@ export class Editor extends Component { immer((state) => { for (let cell of new_cells) { state.cells_local[cell.cell_id] = cell + state.last_created_cell = cell.cell_id } }) ) @@ -420,6 +423,14 @@ export class Editor extends Component { this.actions.interrupt_remote(cell_ids[0]) } } else { + this.setState({ + recently_deleted: cell_ids.map((cell_id) => { + return { + index: this.state.notebook.cell_order.indexOf(cell_id), + cell: this.state.notebook.cell_dict[cell_id], + } + }), + }) await update_notebook((notebook) => { for (let cell_id of cell_ids) { delete notebook.cell_dict[cell_id] @@ -745,9 +756,7 @@ export class Editor extends Component { // Disabled until we solve https://github.com/fonsp/Pluto.jl/issues/482 // or we can enable it with a prompt - // Even better would be excel style: grey out until you paste it. If you paste within the same notebook, then it is just a move. - // document.addEventListener("cut", (e) => { // if (!in_textarea_or_input()) { // const serialized = this.actions.serialize_selected() @@ -772,6 +781,7 @@ export class Editor extends Component { // Paste in the cells at the end of the notebook const data = e.clipboardData.getData("text/plain") this.actions.add_deserialized_cells(data, -1) + e.preventDefault() } }) @@ -779,7 +789,6 @@ export class Editor extends Component { const unsaved_cells = this.state.notebook.cell_order.filter( (id) => this.state.cells_local[id] && this.state.notebook.cell_dict[id].code !== this.state.cells_local[id].code ) - console.log(`unsaved_cells:`, unsaved_cells) const first_unsaved = unsaved_cells[0] if (first_unsaved != null) { window.dispatchEvent(new CustomEvent("cell_focus", { detail: { cell_id: first_unsaved } })) @@ -868,8 +877,13 @@ export class Editor extends Component {
- @@ -892,7 +906,11 @@ export class Editor extends Component { cell_order=${this.state.notebook.cell_order} selected_cell_ids=${this.state.selected_cell_ids} on_selection=${(selected_cell_ids) => { - if (!window._.isEqual(this.state.selected_cells, selected_cell_ids)) { + // @ts-ignore + if ( + selected_cell_ids.length !== this.state.selected_cells || + _.difference(selected_cell_ids, this.state.selected_cells).length !== 0 + ) { this.setState({ selected_cells: selected_cell_ids, }) @@ -908,16 +926,12 @@ export class Editor extends Component { <${UndoDelete} recently_deleted=${this.state.recently_deleted} on_click=${() => { - // TODO Make this work when I made recently_deleted work again - // this.update_notebook((notebook) => { - // let id = uuidv4() - // notebook.cell_dict[id] = { - // cell_id: id, - // code: this.state.recently_deleted.body, - // code_folded: false, - // } - // notebook.cell_order = [...notebook.cell_order.slice(0, index), id, ...notebook.cell_order.slice(index, Infinity)] - // }) + this.update_notebook((notebook) => { + for (let { index, cell } of this.state.recently_deleted) { + notebook.cell_dict[cell.cell_id] = cell + notebook.cell_order = [...notebook.cell_order.slice(0, index), cell.cell_id, ...notebook.cell_order.slice(index, Infinity)] + } + }) }} /> <${SlideControls} /> diff --git a/frontend/components/SelectionArea.js b/frontend/components/SelectionArea.js index 9498e06a63..127fadeb7d 100644 --- a/frontend/components/SelectionArea.js +++ b/frontend/components/SelectionArea.js @@ -159,16 +159,28 @@ export class SelectionArea extends Component { return null } + let translateY = `translateY(${Math.min(selection_start.y, selection_end.y)}px)` + let translateX = `translateX(${Math.min(selection_start.x, selection_end.x)}px)` + let scaleX = `scaleX(${Math.abs(selection_start.x - selection_end.x)})` + let scaleY = `scaleY(${Math.abs(selection_start.y - selection_end.y)})` + return html` ` diff --git a/frontend/components/UndoDelete.js b/frontend/components/UndoDelete.js index e027f841a8..fc18d5534f 100644 --- a/frontend/components/UndoDelete.js +++ b/frontend/components/UndoDelete.js @@ -7,16 +7,18 @@ export const UndoDelete = ({ recently_deleted, on_click }) => { useEffect(() => { if (recently_deleted != null) { set_hidden(false) - const interval = setInterval(() => { + const interval = setTimeout(() => { set_hidden(true) }, 8000) return () => { - clearInterval(interval) + clearTimeout(interval) } } }, [recently_deleted]) + let text = recently_deleted == null ? "" : recently_deleted.length === 1 ? "Cell deleted" : `${recently_deleted.length} cells deleted` + return html`