Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support running inside VS Code webview #1493

Draft
wants to merge 29 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
37e252b
Use vscode extension API instead of websocket when detected
fonsp Sep 23, 2021
376be71
Update editor.css
fonsp Sep 23, 2021
932e4ca
Fix https://github.com/JuliaComputing/pluto-vscode/issues/1
fonsp Oct 20, 2021
5bf0145
Fix https://github.com/JuliaComputing/pluto-vscode/issues/2
fonsp Oct 20, 2021
58fc26e
Add recipient check to support multiple clients on the same WS stream
fonsp Oct 20, 2021
f15cbef
Fix quotes (consistently\!)
pankgeorg Nov 9, 2021
74af7c5
VSCode extension: store unsubmitted cell state in vscode (#1649)
pankgeorg Nov 9, 2021
d4fa084
pluto-vscode issue 3: hide file manager on vscode
pankgeorg Nov 15, 2021
0c43595
Merge branch 'main' into vscode-webview-proxy
pankgeorg Nov 16, 2021
19c9258
Merge remote-tracking branch 'origin/main' into vscode-webview-proxy
pankgeorg Nov 16, 2021
51f156c
Override Control + S as Shift + Enter in VSCode
pankgeorg Nov 16, 2021
0afcc2b
Fix tests maybe
fonsp Nov 17, 2021
224fd29
Merge branch 'main' into vscode-webview-proxy
fonsp Nov 17, 2021
8500b49
Merge branch 'main' into vscode-webview-proxy
fonsp Nov 17, 2021
6897f95
Merge branch 'main' into vscode-webview-proxy
fonsp Nov 19, 2021
7efb1b6
Merge branch 'main' into vscode-webview-proxy
fonsp Nov 19, 2021
2a4e1fe
Add save (and more\!) listeners (experiment\!)
pankgeorg Nov 30, 2021
091769c
Remove unnecessary stderr
pankgeorg Nov 30, 2021
376b088
Revert Control+S override
pankgeorg Nov 30, 2021
4fdf4f7
Merge branch 'main' into vscode-webview-proxy
pankgeorg Dec 2, 2021
7ba8575
Add event listener provision
pankgeorg Dec 2, 2021
88fbce2
OOps
pankgeorg Dec 2, 2021
12fc159
Use event listeners
pankgeorg Dec 2, 2021
2d33788
whitespace
fonsp Dec 6, 2021
8ab4a3d
Restore FilePicker.js
fonsp Dec 6, 2021
b1ada02
Merge branch 'main' into vscode-webview-proxy
pankgeorg Dec 21, 2021
7f00dfd
Merge branch 'main' into vscode-webview-proxy
pankgeorg Dec 23, 2021
069cfd5
Merge branch 'main' into vscode-webview-proxy
pankgeorg Jan 4, 2022
2a6874b
Merge branch 'main' into vscode-webview-proxy
pankgeorg Jan 11, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions frontend/common/Binder.js
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
1 change: 1 addition & 0 deletions frontend/common/Feedback.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
66 changes: 61 additions & 5 deletions frontend/common/PlutoConnection.js
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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))
}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
Expand Down
5 changes: 5 additions & 0 deletions frontend/common/PlutoHash.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
28 changes: 28 additions & 0 deletions frontend/common/VSCodeApi.js
Original file line number Diff line number Diff line change
@@ -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,
}
35 changes: 35 additions & 0 deletions frontend/common/alert_confirm.js
Original file line number Diff line number Diff line change
@@ -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
16 changes: 11 additions & 5 deletions frontend/components/Editor.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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] = {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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")
}
}
Expand Down Expand Up @@ -1171,6 +1175,8 @@ patch: ${JSON.stringify(
${
status.binder
? html`<pluto-filepicker><a href=${this.export_url("notebookfile")} target="_blank">Save notebook...</a></pluto-filepicker>`
: vscode_available
? null
: html`<${FilePicker}
client=${this.client}
value=${notebook.in_temp_dir ? "" : notebook.path}
Expand Down
1 change: 1 addition & 0 deletions frontend/components/LiveDocs.js
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
5 changes: 3 additions & 2 deletions frontend/components/PkgPopup.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 })
}
Expand Down
7 changes: 4 additions & 3 deletions frontend/components/Welcome.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions frontend/editor.css
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ html {

body {
margin: 0px;
padding: 0px;
overflow-anchor: none;
overflow-x: hidden;
position: relative;
Expand Down Expand Up @@ -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 | */
Expand Down
6 changes: 6 additions & 0 deletions frontend/editor.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
3 changes: 3 additions & 0 deletions src/webserver/Dynamic.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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(
Expand Down Expand Up @@ -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
Expand Down
Loading