diff --git a/frontend/common/Binder.js b/frontend/common/Binder.js index e907a83815..c897d402be 100644 --- a/frontend/common/Binder.js +++ b/frontend/common/Binder.js @@ -1,5 +1,6 @@ import immer from "../imports/immer.js" import { timeout_promise, ws_address_from_base } from "./PlutoConnection.js" +import { alert, confirm } from "./alert_confirm.js" export const BinderPhase = { wait_for_user: 0, diff --git a/frontend/common/Feedback.js b/frontend/common/Feedback.js index 0d074f2851..06f6931994 100644 --- a/frontend/common/Feedback.js +++ b/frontend/common/Feedback.js @@ -2,6 +2,7 @@ import { timeout_promise } from "./PlutoConnection.js" // Sorry Fons, even this part of the code is now unnessarily overengineered. // But at least, I overengineered this on purpose. - DRAL +import { alert, confirm } from "./alert_confirm.js" let async = async (async) => async() diff --git a/frontend/common/PlutoConnection.js b/frontend/common/PlutoConnection.js index 0cf400e352..a9aead455e 100644 --- a/frontend/common/PlutoConnection.js +++ b/frontend/common/PlutoConnection.js @@ -1,6 +1,9 @@ import { Promises } from "../common/SetupCellEnvironment.js" import { pack, unpack } from "./MsgPack.js" +import { base64_arraybuffer, decode_base64_to_arraybuffer } from "./PlutoHash.js" import "./Polyfill.js" +import { available as vscode_available, api as vscode_api } from "./VSCodeApi.js" +import { alert, confirm } from "./alert_confirm.js" // https://github.com/denysdovhan/wtfjs/issues/61 const different_Infinity_because_js_is_yuck = 2147483646 @@ -62,7 +65,7 @@ export const resolvable_promise = () => { /** * @returns {string} */ -const get_unique_short_id = () => crypto.getRandomValues(new Uint32Array(1))[0].toString(36) +export const get_unique_short_id = () => crypto.getRandomValues(new Uint32Array(1))[0].toString(36) const socket_is_alright = (socket) => socket.readyState == WebSocket.OPEN || socket.readyState == WebSocket.CONNECTING @@ -179,6 +182,54 @@ const create_ws_connection = (address, { on_message, on_socket_close }, timeout_ }) } +const create_vscode_connection = (address, { on_message, on_socket_close }, timeout_s = 30) => { + return new Promise((resolve, reject) => { + window.addEventListener("message", (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 + last_task = last_task.then(async () => { + try { + const raw = event.data // The json-encoded data that the extension sent + if (raw.type === "ws_proxy") { + const buffer = await decode_base64_to_arraybuffer(raw.base64_encoded) + const message = unpack(new Uint8Array(buffer)) + + try { + window.DEBUG && console.info("message received!", message) + on_message(message) + } catch (process_err) { + console.error("Failed to process message from websocket", process_err, { message }) + // prettier-ignore + alert(`Something went wrong! You might need to refresh the page.\n\nPlease open an issue on https://github.com/fonsp/Pluto.jl with this info:\n\nFailed to process update\n${process_err.message}\n\n${JSON.stringify(event)}`) + } + } + } catch (unpack_err) { + console.error("Failed to unpack message from websocket", unpack_err, { event }) + + // prettier-ignore + alert(`Something went wrong! You might need to refresh the page.\n\nPlease open an issue on https://github.com/fonsp/Pluto.jl with this info:\n\nFailed to unpack message\n${unpack_err}\n\n${JSON.stringify(event)}`) + } + }) + }) + + const send_encoded = async (message) => { + window.DEBUG && console.log("Sending message!", message) + const encoded = pack(message) + await vscode_api.postMessage({ type: "ws_proxy", base64_encoded: await base64_arraybuffer(encoded) }) + } + + let last_task = Promise.resolve() + + resolve({ + socket: {}, + send: send_encoded, + }) + }) +} + let next_tick_promise = () => { return new Promise((resolve) => setTimeout(resolve, 0)) } @@ -275,7 +326,7 @@ export const create_pluto_connection = async ({ connect_metadata = {}, ws_address = default_ws_address(), }) => { - var ws_connection = null // will be defined later i promise + let ws_connection = null // will be defined later i promise const client = { send: null, session_options: null, @@ -346,11 +397,16 @@ export const create_pluto_connection = async ({ update_url_with_binder_token() try { - ws_connection = await create_ws_connection(String(ws_address), { + ws_connection = await (vscode_available ? create_vscode_connection : create_ws_connection)(String(ws_address), { on_message: (update) => { - const by_me = update.initiator_id == client_id + const for_me = update.recipient_id === client_id + const by_me = update.initiator_id === client_id const request_id = update.request_id + if (!for_me) { + return + } + if (by_me && request_id) { const request = sent_requests.get(request_id) if (request) { @@ -389,7 +445,7 @@ export const create_pluto_connection = async ({ if (connect_metadata.notebook_id != null && !u.message.notebook_exists) { // https://github.com/fonsp/Pluto.jl/issues/55 - if (confirm("A new server was started - this notebook session is no longer running.\n\nWould you like to go back to the main menu?")) { + if (await confirm("A new server was started - this notebook session is no longer running.\n\nWould you like to go back to the main menu?")) { window.location.href = "./" } on_connection_status(false) diff --git a/frontend/common/PlutoHash.js b/frontend/common/PlutoHash.js index 8c8333738a..3ce93c30ea 100644 --- a/frontend/common/PlutoHash.js +++ b/frontend/common/PlutoHash.js @@ -10,6 +10,11 @@ export const base64_arraybuffer = async (/** @type {BufferSource} */ data) => { return base64url.split(",", 2)[1] } +export const decode_base64_to_arraybuffer = async (/** @type {string} */ data) => { + let r = await fetch(`data:;base64,${data}`) + return await r.arrayBuffer() +} + export const hash_arraybuffer = async (/** @type {BufferSource} */ data) => { // @ts-ignore const hashed_buffer = await window.crypto.subtle.digest("SHA-256", data) diff --git a/frontend/common/VSCodeApi.js b/frontend/common/VSCodeApi.js new file mode 100644 index 0000000000..64532936fa --- /dev/null +++ b/frontend/common/VSCodeApi.js @@ -0,0 +1,28 @@ +// If we are running inside a VS Code WebView, then this exposes the API. + +// @ts-ignore +export const available = window.acquireVsCodeApi != null +// @ts-ignore +const vscode = window.acquireVsCodeApi?.() ?? {} +const store_cell_input_in_vscode_state = (cell_id, new_val) => { + if (available) { + const currentVSCodeState = vscode.getState() ?? {} + const { cell_inputs_local, ...rest } = currentVSCodeState ?? {} + api.setState({ cell_inputs_local: { ...(cell_inputs_local || {}), [cell_id]: { code: new_val } }, ...(rest ?? {}) }) + } +} + +const load_cell_inputs_from_vscode_state = () => { + if (available) { + const state = vscode.getState() ?? {} + return state.cell_inputs_local ?? {} + } + return {} +} + +export const api = { + postMessage: console.error, + ...vscode, + store_cell_input_in_vscode_state, + load_cell_inputs_from_vscode_state, +} diff --git a/frontend/common/alert_confirm.js b/frontend/common/alert_confirm.js new file mode 100644 index 0000000000..f8cf5e007a --- /dev/null +++ b/frontend/common/alert_confirm.js @@ -0,0 +1,35 @@ +import { available, api } from "../common/VSCodeApi.js" +import { resolvable_promise, get_unique_short_id } from "./PlutoConnection.js" + +const sent_requests = new Map() + +if (available) { + window.addEventListener("message", (event) => { + const raw = event.data + + if (raw.type === "alert_confirm_callback") { + const request = sent_requests.get(raw.token) + if (request) { + request(raw.value) + sent_requests.delete(raw.token) + } + } + }) +} + +const create_alert_confirm = (name) => (x) => + new Promise((resolve) => { + let request_id = get_unique_short_id() + + sent_requests.set(request_id, (response_message) => { + resolve(response_message) + }) + api.postMessage({ + type: name, + text: x, + token: request_id, + }) + }) + +export const alert = available ? create_alert_confirm("alert") : window.alert +export const confirm = available ? create_alert_confirm("confirm") : window.confirm diff --git a/frontend/components/Editor.js b/frontend/components/Editor.js index 5402d0f2e4..64bae347a0 100644 --- a/frontend/components/Editor.js +++ b/frontend/components/Editor.js @@ -30,10 +30,13 @@ import { BinderButton } from "./BinderButton.js" import { slider_server_actions, nothing_actions } from "../common/SliderServerClient.js" import { ProgressBar } from "./ProgressBar.js" import { IsolatedCell } from "./Cell.js" +import { available as vscode_available, api as vscode } from "../common/VSCodeApi.js" import { RawHTMLContainer } from "./CellOutput.js" import { RecordingPlaybackUI, RecordingUI } from "./RecordingUI.js" const default_path = "..." +import { alert, confirm } from "../common/alert_confirm.js" + const DEBUG_DIFFING = false let pending_local_updates = 0 // from our friends at https://stackoverflow.com/a/2117523 @@ -161,7 +164,7 @@ const url_logo_small = document.head.querySelector("link[rel='pluto-logo-small'] const url_params = new URLSearchParams(window.location.search) const launch_params = { //@ts-ignore - notebook_id: url_params.get("id") ?? window.pluto_notebook_id, + notebook_id: (vscode_available ? null : url_params.get("id")) ?? window.pluto_notebook_id, //@ts-ignore statefile: url_params.get("statefile") ?? window.pluto_statefile, //@ts-ignore @@ -211,7 +214,7 @@ export class Editor extends Component { this.state = { notebook: /** @type {NotebookData} */ initial_notebook(), - cell_inputs_local: /** @type {{ [id: string]: CellInputData }} */ ({}), + cell_inputs_local: /** @type {{ [id: string]: CellInputData }} */ vscode.load_cell_inputs_from_vscode_state(), desired_doc_query: null, recently_deleted: /** @type {Array<{ index: number, cell: CellInputData }>} */ (null), last_update_time: 0, @@ -253,6 +256,7 @@ export class Editor extends Component { update_notebook: (...args) => this.update_notebook(...args), set_doc_query: (query) => this.setState({ desired_doc_query: query }), set_local_cell: (cell_id, new_val) => { + vscode.store_cell_input_in_vscode_state(cell_id, new_val) return this.setStatePromise( immer((state) => { state.cell_inputs_local[cell_id] = { @@ -439,9 +443,9 @@ export class Editor extends Component { return await this.actions.add_remote_cell_at(index + delta, code) }, confirm_delete_multiple: async (verb, cell_ids) => { - if (cell_ids.length <= 1 || confirm(`${verb} ${cell_ids.length} cells?`)) { + if (cell_ids.length <= 1 || (await confirm(`${verb} ${cell_ids.length} cells?`))) { if (cell_ids.some((cell_id) => this.state.notebook.cell_results[cell_id].running || this.state.notebook.cell_results[cell_id].queued)) { - if (confirm("This cell is still running - would you like to interrupt the notebook?")) { + if (await confirm("This cell is still running - would you like to interrupt the notebook?")) { this.actions.interrupt_remote(cell_ids[0]) } } else { @@ -854,7 +858,7 @@ patch: ${JSON.stringify( return } if (!this.state.notebook.in_temp_dir) { - if (!confirm("Are you sure? Will move from\n\n" + old_path + "\n\nto\n\n" + new_path)) { + if (!(await confirm("Are you sure? Will move from\n\n" + old_path + "\n\nto\n\n" + new_path))) { throw new Error("Declined by user") } } @@ -1171,6 +1175,8 @@ patch: ${JSON.stringify( ${ status.binder ? html`Save notebook...` + : vscode_available + ? null : html`<${FilePicker} client=${this.client} value=${notebook.in_temp_dir ? "" : notebook.path} diff --git a/frontend/components/LiveDocs.js b/frontend/components/LiveDocs.js index 3e5559b4f2..0ee3d8acf7 100644 --- a/frontend/components/LiveDocs.js +++ b/frontend/components/LiveDocs.js @@ -2,6 +2,7 @@ import { html, useState, useRef, useLayoutEffect, useEffect, useMemo, useContext import immer from "../imports/immer.js" import observablehq from "../common/SetupCellEnvironment.js" import { cl } from "../common/ClassTable.js" +import { alert, confirm } from "../common/alert_confirm.js" import { RawHTMLContainer, highlight } from "./CellOutput.js" import { PlutoContext } from "../common/PlutoContext.js" diff --git a/frontend/components/PkgPopup.js b/frontend/components/PkgPopup.js index ea25cc6fdf..67cd25358d 100644 --- a/frontend/components/PkgPopup.js +++ b/frontend/components/PkgPopup.js @@ -8,6 +8,7 @@ import { PlutoContext } from "../common/PlutoContext.js" import { package_status, nbpkg_fingerprint_without_terminal } from "./PkgStatusMark.js" import { PkgTerminalView } from "./PkgTerminalView.js" import { useDebouncedTruth } from "./RunArea.js" +import { alert, confirm } from "../common/alert_confirm.js" export const PkgPopup = ({ notebook }) => { let pluto_actions = useContext(PlutoContext) @@ -115,11 +116,11 @@ export const PkgPopup = ({ notebook }) => { title="Update packages" style=${(!!showupdate ? "" : "opacity: .4;") + (recent_event?.is_disable_pkg ? "display: none;" : "")} href="#" - onClick=${(e) => { + onClick=${async (e) => { if (busy) { alert("Pkg is currently busy with other packages... come back later!") } else { - if (confirm("Would you like to check for updates and install them? A backup of the notebook file will be created.")) { + if (await confirm("Would you like to check for updates and install them? A backup of the notebook file will be created.")) { console.warn("Pkg.updating!") pluto_actions.send("pkg_update", {}, { notebook_id: notebook.notebook_id }) } diff --git a/frontend/components/Welcome.js b/frontend/components/Welcome.js index c58ac800a1..f06d8a9c01 100644 --- a/frontend/components/Welcome.js +++ b/frontend/components/Welcome.js @@ -5,6 +5,7 @@ import { FilePicker } from "./FilePicker.js" import { create_pluto_connection, fetch_pluto_releases } from "../common/PlutoConnection.js" import { cl } from "../common/ClassTable.js" import { PasteHandler } from "./PasteHandler.js" +import { alert, confirm } from "../common/alert_confirm.js" /** * @typedef CombinedNotebook @@ -237,20 +238,20 @@ export class Welcome extends Component { document.body.classList.add("loading") window.location.href = link_open_path(processed.path_or_url) } else { - if (confirm("Are you sure? This will download and run the file at\n\n" + processed.path_or_url)) { + if (await confirm("Are you sure? This will download and run the file at\n\n" + processed.path_or_url)) { document.body.classList.add("loading") window.location.href = link_open_url(processed.path_or_url) } } } - this.on_session_click = (nb) => { + this.on_session_click = async (nb) => { if (nb.transitioning) { return } const running = nb.notebook_id != null if (running) { - if (confirm("Shut down notebook process?")) { + if (await confirm("Shut down notebook process?")) { set_notebook_state(nb.path, { running: false, transitioning: true, diff --git a/frontend/editor.css b/frontend/editor.css index 9ce8caff2d..8798e86ee7 100644 --- a/frontend/editor.css +++ b/frontend/editor.css @@ -39,6 +39,7 @@ html { body { margin: 0px; + padding: 0px; overflow-anchor: none; overflow-x: hidden; position: relative; @@ -66,6 +67,7 @@ body:not(.disable_ui) main { body:not(.disable_ui) { overscroll-behavior-x: contain; + background: white; } /* | main=25px+700px+6px=731px | pluto-helpbox=350px - 500px | */ diff --git a/frontend/editor.js b/frontend/editor.js index 7ee766bedd..2d34473896 100644 --- a/frontend/editor.js +++ b/frontend/editor.js @@ -2,6 +2,12 @@ import { html, render } from "./imports/Preact.js" import "./common/NodejsCompatibilityPolyfill.js" import { Editor } from "./components/Editor.js" +import { available as vscode_available } from "./common/VSCodeApi.js" + +// remove default stylesheet inserted by VS Code +if (vscode_available) { + document.head.querySelector("style#_defaultStyles").remove() +} // it's like a Rube Goldberg machine render(html`<${Editor} />`, document.body) diff --git a/src/webserver/Dynamic.jl b/src/webserver/Dynamic.jl index 7860f35033..832569cb56 100644 --- a/src/webserver/Dynamic.jl +++ b/src/webserver/Dynamic.jl @@ -174,6 +174,7 @@ Update the local state of all clients connected to this notebook. """ function send_notebook_changes!(🙋::ClientRequest; commentary::Any=nothing) notebook_dict = notebook_to_js(🙋.notebook) + # @info "Connected clients" length(🙋.session.connected_clients) Set(c -> c.stream for c in 🙋.session.connected_clients) for (_, client) in 🙋.session.connected_clients if client.connected_notebook !== nothing && client.connected_notebook.notebook_id == 🙋.notebook.notebook_id current_dict = get(current_state_for_clients, client, :empty) @@ -183,6 +184,7 @@ function send_notebook_changes!(🙋::ClientRequest; commentary::Any=nothing) # Make sure we do send a confirmation to the client who made the request, even without changes is_response = 🙋.initiator !== nothing && client == 🙋.initiator.client + # @info "Responding" is_response (🙋.initiator !== nothing) (commentary === nothing) if !isempty(patches) || is_response response = Dict( @@ -278,6 +280,7 @@ responses[:update_notebook] = function response_update_notebook(🙋::ClientRequ patches = (Base.convert(Firebasey.JSONPatch, update) for update in 🙋.body["updates"]) if length(patches) == 0 + # @info "Empty patches" send_notebook_changes!(🙋) return nothing end diff --git a/src/webserver/PutUpdates.jl b/src/webserver/PutUpdates.jl index ea541777d0..dcac8e9dd5 100644 --- a/src/webserver/PutUpdates.jl +++ b/src/webserver/PutUpdates.jl @@ -1,6 +1,10 @@ -function serialize_message_to_stream(io::IO, message::UpdateMessage) - to_send = Dict(:type => message.type, :message => message.message) +function serialize_message_to_stream(io::IO, message::UpdateMessage, recipient::ClientSession) + to_send = Dict{Symbol,Any}( + :type => message.type, + :message => message.message, + :recipient_id => recipient.id, + ) if message.notebook !== nothing to_send[:notebook_id] = message.notebook.notebook_id end @@ -15,8 +19,8 @@ function serialize_message_to_stream(io::IO, message::UpdateMessage) pack(io, to_send) end -function serialize_message(message::UpdateMessage) - sprint(serialize_message_to_stream, message) +function serialize_message(message::UpdateMessage, recipient::ClientSession) + sprint(serialize_message_to_stream, message, recipient) end "Send `messages` to all clients connected to the `notebook`." @@ -76,7 +80,7 @@ function flushclient(client::ClientSession) if client.stream isa HTTP.WebSockets.WebSocket client.stream.frame_type = HTTP.WebSockets.WS_BINARY end - write(client.stream, serialize_message(next_to_send)) + write(client.stream, serialize_message(next_to_send, client)) else put!(flushtoken) return false