diff --git a/Project.toml b/Project.toml index 59be668981..92015eb550 100644 --- a/Project.toml +++ b/Project.toml @@ -2,7 +2,7 @@ name = "Pluto" uuid = "c3e4b0f8-55cb-11ea-2926-15256bba5781" license = "MIT" authors = ["Fons van der Plas ", "Mikołaj Bochenski "] -version = "0.3.1" +version = "0.3.2" [deps] HTTP = "cd3eb016-35fb-5094-929b-558a96fad6f3" diff --git a/src/Pluto.jl b/src/Pluto.jl index 50b794b118..269fdf8227 100644 --- a/src/Pluto.jl +++ b/src/Pluto.jl @@ -1,636 +1,27 @@ - module Pluto -export Notebook, prints +export Notebook, Cell, run -using Pkg +import Pkg -const packagerootdir = normpath(joinpath(@__DIR__, "..")) -const version_str = 'v' * Pkg.TOML.parsefile(joinpath(packagerootdir, "Project.toml"))["version"] +const PKG_ROOT_DIR = normpath(joinpath(@__DIR__, "..")) +const VERSION_STR = 'v' * Pkg.TOML.parsefile(joinpath(PKG_ROOT_DIR, "Project.toml"))["version"] @info """\n - Welcome to Pluto $(version_str)! ⚡ + Welcome to Pluto $(VERSION_STR)! ⚡ Let us know what you think: https://github.com/fonsp/Pluto.jl \n""" -include("./Cell.jl") -include("./Notebook.jl") -include("./ExploreExpression.jl") -include("./React.jl") - -using JSON -using UUIDs -using HTTP -using Sockets -using Markdown -import Base: show -import Markdown: html, htmlinline, LaTeX, withtag, htmlesc - -# We add a method for the Markdown -> HTML conversion that takes a LaTeX chunk from the Markdown tree and adds our custom span -function htmlinline(io::IO, x::LaTeX) - withtag(io, :span, :class => "tex") do - print(io, '$') - htmlesc(io, x.formula) - print(io, '$') - end -end - -# This one for block equations: (double $$) -function html(io::IO, x::LaTeX) - withtag(io, :p, :class => "tex") do - print(io, '$', '$') - htmlesc(io, x.formula) - print(io, '$', '$') - end -end - -"The `IOContext` used for converting arbitrary objects to pretty strings." -iocontext = IOContext(stdout, :color => false, :compact => true, :limit => true, :displaysize => (18, 120)) - - -mutable struct Client - id::Symbol - stream::Any - connected_notebook::Union{Notebook,Nothing} - pendingupdates::Channel -end - -Client(id::Symbol, stream) = Client(id, stream, nothing, Channel(128)) - -struct UpdateMessage - type::Symbol - message::Any - notebook::Union{Notebook,Nothing} - cell::Union{Cell,Nothing} - initiator::Union{Client,Nothing} -end - -UpdateMessage(type::Symbol, message::Any) = UpdateMessage(type, message, nothing, nothing, nothing) -UpdateMessage(type::Symbol, message::Any, notebook::Notebook) = UpdateMessage(type, message, notebook, nothing, nothing) - - -function serialize_message(message::UpdateMessage) - to_send = Dict(:type => message.type, :message => message.message) - if message.notebook !== nothing - to_send[:notebookID] = string(message.notebook.uuid) - end - if message.cell !== nothing - to_send[:cellID] = string(message.cell.uuid) - end - if message.initiator !== nothing - to_send[:initiatorID] = string(message.initiator.id) - end - - JSON.json(to_send) -end - -function clientupdate_cell_output(initiator::Client, notebook::Notebook, cell::Cell) - # TODO: Here we could do even richer formatting - # interactive Arrays! - # use Weave.jl? that would be sw€€t - - # in order of coolness - # text/plain always matches - mimes = ["text/html", "text/plain"] - - mime = first(filter(m->Base.invokelatest(showable, m, cell.output), mimes)) - - # TODO: limit output! - - payload = Base.invokelatest(repr, mime, cell.output; context = iocontext) - - if cell.output === nothing - payload = "" - end - - return UpdateMessage(:cell_output, - Dict(:mime => mime, - :output => payload, - :errormessage => cell.errormessage, - ), - notebook, cell, initiator) -end - -function clientupdate_cell_input(initiator::Client, notebook::Notebook, cell::Cell) - return UpdateMessage(:cell_input, - Dict(:code => cell.code), notebook, cell, initiator) -end - -function clientupdate_cell_added(initiator::Client, notebook::Notebook, cell::Cell, new_index::Integer) - return UpdateMessage(:cell_added, - Dict(:index => new_index - 1, # 1-based index (julia) to 0-based index (js) - ), notebook, cell, initiator) -end - -function clientupdate_cell_deleted(initiator::Client, notebook::Notebook, cell::Cell) - return UpdateMessage(:cell_deleted, - Dict(), notebook, cell, initiator) -end - -function clientupdate_cell_moved(initiator::Client, notebook::Notebook, cell::Cell, new_index::Integer) - return UpdateMessage(:cell_moved, - Dict(:index => new_index - 1, # 1-based index (julia) to 0-based index (js) - ), notebook, cell, initiator) -end - -function clientupdate_cell_dependecies(initiator::Client, notebook::Notebook, cell::Cell, dependentcells) - return UpdateMessage(:cell_dependecies, - Dict(:depenentcells => [string(c.uuid) for c in dependentcells], - ), notebook, cell, initiator) -end - -function clientupdate_cell_running(initiator::Client, notebook::Notebook, cell::Cell) - return UpdateMessage(:cell_running, - Dict(), notebook, cell, initiator) -end - -function clientupdate_notebook_list(initiator::Client, notebook_list) - return UpdateMessage(:notebook_list, - Dict(:notebooks => [Dict(:uuid => string(notebook.uuid), - :path => notebook.path, - ) for notebook in notebook_list]), nothing, nothing, initiator) -end - - -# struct PlutoDisplay <: AbstractDisplay -# io::IO -# end - -# display(d::PlutoDisplay, x) = display(d, MIME"text/plain"(), x) -# function display(d::PlutoDisplay, M::MIME, x) -# displayable(d, M) || throw(MethodError(display, (d, M, x))) -# println(d.io, "SATURN 1") -# println(d.io, repr(M, x)) -# end -# function display(d::PlutoDisplay, M::MIME"text/plain", x) -# println(d.io, "SATURN 2") -# println(d.io, repr(M, x)) -# end -# displayable(d::PlutoDisplay, M::MIME"text/plain") = true - - -# sd_io = IOBuffer() -# plutodisplay = PlutoDisplay(sd_io) -# pushdisplay(plutodisplay) - - -struct RawDisplayString - s::String -end - -function show(io::IO, z::RawDisplayString) - print(io, z.s) -end - -prints(x::String) = RawDisplayString(x) -prints(x) = RawDisplayString(repr("text/plain", x; context = iocontext)) - - -#### SERVER #### - -# STATIC: Serve index.html, which is the same for every notebook - it's a ⚡🤑🌈 web app -# index.html also contains the CSS and JS - -"Attempts to find the MIME pair corresponding to the extension of a filename. Defaults to `text/plain`." -function mime_fromfilename(filename) - # This bad boy is from: https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types - mimepairs = Dict(".aac" => "audio/aac", ".bin" => "application/octet-stream", ".bmp" => "image/bmp", ".css" => "text/css", ".csv" => "text/csv", ".eot" => "application/vnd.ms-fontobject", ".gz" => "application/gzip", ".gif" => "image/gif", ".htm" => "text/html", ".html" => "text/html", ".ico" => "image/vnd.microsoft.icon", ".jpeg" => "image/jpeg", ".jpg" => "image/jpeg", ".js" => "text/javascript", ".json" => "application/json", ".jsonld" => "application/ld+json", ".mjs" => "text/javascript", ".mp3" => "audio/mpeg", ".mpeg" => "video/mpeg", ".oga" => "audio/ogg", ".ogv" => "video/ogg", ".ogx" => "application/ogg", ".opus" => "audio/opus", ".otf" => "font/otf", ".png" => "image/png", ".pdf" => "application/pdf", ".rtf" => "application/rtf", ".sh" => "application/x-sh", ".svg" => "image/svg+xml", ".tar" => "application/x-tar", ".tif" => "image/tiff", ".tiff" => "image/tiff", ".ttf" => "font/ttf", ".txt" => "text/plain", ".wav" => "audio/wav", ".weba" => "audio/webm", ".webm" => "video/webm", ".webp" => "image/webp", ".woff" => "font/woff", ".woff2" => "font/woff2", ".xhtml" => "application/xhtml+xml", ".xml" => "application/xml", ".xul" => "application/vnd.mozilla.xul+xml", ".zip" => "application/zip") - file_extension = getkey(mimepairs, '.' * split(filename, '.')[end], ".txt") - MIME(mimepairs[file_extension]) -end - -function assetresponse(path) - try - @assert isfile(path) - response = HTTP.Response(200, read(path, String)) - push!(response.headers, "Content-Type" => string(mime_fromfilename(path))) - return response - catch e - HTTP.Response(404, "Not found!: $(e)") - end -end - -function serveonefile(path) - return request::HTTP.Request->assetresponse(normpath(path)) -end - -function serveasset(req::HTTP.Request) - reqURI = HTTP.URI(req.target) - - filepath = joinpath(packagerootdir, relpath(reqURI.path, "/")) - assetresponse(filepath) -end - -const PLUTOROUTER = HTTP.Router() - -function serve_editor(req::HTTP.Request) - p=HTTP.URI(req.target).path - b = String(req.body) - HTTP.Response(200, "Path: $(p) \n\n Body: $(b)") -end - -function notebook_redirect(notebook) - response = HTTP.Response(302, "") - push!(response.headers, "Location" => "/edit?uuid=" * string(notebook.uuid)) - return response -end - -function serve_openfile(req::HTTP.Request) - uri=HTTP.URI(req.target) - query = HTTP.URIs.unescapeuri(replace(uri.query, '+' => ' ')) - - if length(query) > 5 - path = query[6:end] - - if(isfile(path)) - try - for nb in values(notebooks) - if realpath(nb.path) == realpath(path) - return notebook_redirect(nb) - end - end - - nb = load_notebook(path) - save_notebook(nb) - notebooks[nb.uuid] = nb - return notebook_redirect(nb) - - catch e - return HTTP.Response(500, "Failed to load notebook:\n\n$(e)\n\nGo back") - end - # return HTTP.Response(200, """Path: $(path)""") - else - return HTTP.Response(404, "Can't find a file here.\n\nGo back") - end - end - return HTTP.Response(400, "Bad query.\n\nGo back") -end - -function serve_samplefile(req::HTTP.Request) - nb = samplenotebook() - save_notebook(nb) - notebooks[nb.uuid] = nb - return notebook_redirect(nb) -end - -function serve_newfile(req::HTTP.Request) - nb = emptynotebook() - save_notebook(nb) - notebooks[nb.uuid] = nb - return notebook_redirect(nb) -end - -HTTP.@register(PLUTOROUTER, "GET", "/", serveonefile(joinpath(packagerootdir, "assets", "welcome.html"))) -HTTP.@register(PLUTOROUTER, "GET", "/edit", serveonefile(joinpath(packagerootdir, "assets", "editor.html"))) -HTTP.@register(PLUTOROUTER, "GET", "/sample", serve_samplefile) -HTTP.@register(PLUTOROUTER, "GET", "/new", serve_newfile) -HTTP.@register(PLUTOROUTER, "GET", "/open", serve_openfile) -HTTP.@register(PLUTOROUTER, "GET", "/index", serveonefile(joinpath(packagerootdir, "assets", "welcome.html"))) -HTTP.@register(PLUTOROUTER, "GET", "/index.html", serveonefile(joinpath(packagerootdir, "assets", "welcome.html"))) - -HTTP.@register(PLUTOROUTER, "GET", "/favicon.ico", serveonefile(joinpath(packagerootdir, "assets", "favicon.ico"))) - -HTTP.@register(PLUTOROUTER, "GET", "/assets/*", serveasset) - -HTTP.@register(PLUTOROUTER, "GET", "/ping", r->HTTP.Response(200, JSON.json("OK!"))) - -function handle_changecell(initiator, notebook, cell, newcode) - # i.e. Ctrl+Enter was pressed on this cell - # we update our `Notebook` and start execution - - # don't reparse when code is identical (?) - if cell.code != newcode - cell.code = newcode - cell.parsedcode = nothing - end - - putnotebookupdates!(notebook, clientupdate_cell_input(initiator, notebook, cell)) - - # TODO: evaluation async - @time to_update = run_reactive!(initiator, notebook, cell) - - # TODO: feedback to user about File IO - save_notebook(notebook) -end - - -# These will hold all 'response handlers': functions that respond to a WebSocket request from the client -# There are three levels: - -responses = Dict{Symbol,Function}() -addresponse(f::Function, endpoint::Symbol) = responses[endpoint] = f - -# DYNAMIC: Input from user -# TODO: actions on the notebook are not thread safe -addresponse(:addcell) do (initiator, body, notebook) - new_index = body["index"] + 1 # 0-based index (js) to 1-based index (julia) - - new_cell = createcell_fromcode("") - - insert!(notebook.cells, new_index, new_cell) - - putnotebookupdates!(notebook, clientupdate_cell_added(initiator, notebook, new_cell, new_index)) - nothing -end - -addresponse(:deletecell) do (initiator, body, notebook, cell) - to_delete = cell - - changecell_succes = handle_changecell(initiator, notebook, to_delete, "") - - filter!(c->c.uuid ≠ to_delete.uuid, notebook.cells) - - putnotebookupdates!(notebook, clientupdate_cell_deleted(initiator, notebook, to_delete)) - nothing -end - -addresponse(:movecell) do (initiator, body, notebook, cell) - to_move = cell - - # Indexing works as if a new cell is added. - # e.g. if the third cell (at julia-index 3) of [0, 1, 2, 3, 4] - # is moved to the end, that would be new julia-index 6 - - new_index = body["index"] + 1 # 0-based index (js) to 1-based index (julia) - old_index = findfirst(isequal(to_move), notebook.cells) - - # Because our cells run in _topological_ order, we don't need to reevaluate anything. - if new_index < old_index - deleteat!(notebook.cells, old_index) - insert!(notebook.cells, new_index, to_move) - elseif new_index > old_index + 1 - insert!(notebook.cells, new_index, to_move) - deleteat!(notebook.cells, old_index) - end - - putnotebookupdates!(notebook, clientupdate_cell_moved(initiator, notebook, to_move, new_index)) - nothing -end - -addresponse(:changecell) do (initiator, body, notebook, cell) - newcode = body["code"] - - handle_changecell(initiator, notebook, cell, newcode) - nothing -end - - -# DYNAMIC: Returning cell output to user - -# TODO: -# addresponse(:getcell) do (initiator, body, notebook, cell) - -# end - -addresponse(:getallcells) do (initiator, body, notebook) - # TODO: - updates = [] - for (i, cell) in enumerate(notebook.cells) - push!(updates, clientupdate_cell_added(initiator, notebook, cell, i)) - push!(updates, clientupdate_cell_input(initiator, notebook, cell)) - push!(updates, clientupdate_cell_output(initiator, notebook, cell)) - end - # [clientupdate_cell_added(notebook, c, i) for (i, c) in enumerate(notebook.cells)] - - updates -end - - -connectedclients = Dict{Symbol,Client}() -notebooks = Dict{UUID,Notebook}() -# sn = samplenotebook() -# notebooks[sn.uuid] = sn - -addresponse(:getallnotebooks) do (initiator, body) - [clientupdate_notebook_list(initiator, values(notebooks))] -end - - -function putnotebookupdates!(notebook, messages...) - listeners = filter(c->c.connected_notebook.uuid == notebook.uuid, collect(values(connectedclients))) - if isempty(listeners) - @info "no clients connected to this notebook!" - else - for next_to_send in messages, client in listeners - put!(client.pendingupdates, next_to_send) - end - end - flushallclients(listeners) - listeners -end - - -function putplutoupdates!(notebook, messages...) - listeners = collect(values(connectedclients)) - if isempty(listeners) - @info "no clients connected to pluto!" - else - for next_to_send in messages, client in listeners - put!(client.pendingupdates, next_to_send) - end - end - flushallclients(listeners) - listeners -end - - -# flushtoken = Channel{Nothing}(1) -# put!(flushtoken, nothing) - -function flushclient(client) - # take!(flushtoken) - # println("$(client.id) requesting update") - didsomething = false - while isready(client.pendingupdates) - next_to_send = take!(client.pendingupdates) - didsomething = true - - try - if isopen(client.stream) - write(client.stream, serialize_message(next_to_send)) - else - @info "Client $(client.id) stream closed." - return false - end - catch e - @warn "Failed to write to WebSocket of $(client.id) " e - return false - end - end - # put!(flushtoken, nothing) - # !didsomething && println("$(client.id) had no updates") - true -end - -function flushallclients(subset) - disconnected = Set{Symbol}() - for client in subset - stillconnected = flushclient(client) - if !stillconnected - push!(disconnected, client.id) - end - end - for to_deleteID in disconnected - delete!(connectedclients, to_deleteID) - end -end - -function flushallclients() - flushallclients(values(connectedclients)) -end - -# function startflushloopclientasync(client) -# global t = @task flushloopclient(client) -# schedule(t) -# end - -"Will _synchronously_ run the notebook server. (i.e. blocking call)" -function run(port=1234, launchbrowser = false) - serversocket = Sockets.listen(UInt16(port)) - @async HTTP.serve(Sockets.localhost, UInt16(port), stream = true, server=serversocket) do http::HTTP.Stream - # messy messy code so that we can use the websocket on the same port as the HTTP server - - if HTTP.WebSockets.is_upgrade(http.message) - try - HTTP.WebSockets.upgrade(http) do clientstream - if !isopen(clientstream) - return - end - while !eof(clientstream) - try - data = String(readavailable(clientstream)) - parentbody = JSON.parse(data) - - clientID = Symbol(parentbody["clientID"]) - client = Client(clientID, clientstream) - connectedclients[clientID] = client - - type = Symbol(parentbody["type"]) - - if type == :disconnect - delete!(connectedclients, clientID) - elseif type == :connect - - else - body = parentbody["body"] - - args = [] - if haskey(parentbody, "notebookID") - notebookID = UUID(parentbody["notebookID"]) - notebook = get(notebooks, notebookID, nothing) - if notebook === nothing - # TODO: returning a http 404 response is not what we want, - # we should send back a websocket message. - # does 404 close the socket? - @warn "Remote notebook not found locally!" - return HTTP.Response(200, "OK") - end - client.connected_notebook = notebook - push!(args, notebook) - end - - if haskey(parentbody, "cellID") - cellID = UUID(parentbody["cellID"]) - cell = selectcell_byuuid(notebook, cellID) - if cell === nothing - @warn "Remote cell not found locally!" - return HTTP.Response(200, "OK") - end - push!(args, cell) - end - - if haskey(responses, type) - responsefunc = responses[type] - response = responsefunc((client, body, args...)) - if response !== nothing - putplutoupdates!(notebook, response...) - end - else - @warn "Don't know how to respond to $(type)" - end - end - catch e - # TODO: idem - if !isa(e, HTTP.WebSockets.WebSocketError) && !isa(e, InexactError) - @warn "Reading WebSocket client stream failed for unknown reason:" e - end - if isa(e, InterruptException) - rethrow(e) - end - end - nothing - end - HTTP.Response(200, "OK") - end - catch e - if isa(e, InterruptException) - rethrow(e) - end - @info "HTTP upgrade failed, should be fine" e - end - nothing - HTTP.Response(200, "OK") - - else - request::HTTP.Request = http.message - request.body = read(http) - closeread(http) - - request_body = IOBuffer(HTTP.payload(request)) - if eof(request_body) - # no request body - response_body = HTTP.handle(PLUTOROUTER, request) - else - # there's a body, so pass it on to the handler we dispatch to - response_body = HTTP.handle(PLUTOROUTER, request, JSON.parse(request_body)) - end - - request.response::HTTP.Response = response_body - request.response.request = request - try - startwrite(http) - write(http, request.response.body) - catch e - if isa(e, HTTP.IOError) || isa(e, ArgumentError) - @warn "Attempted to write to a closed stream at $(request.target)" - else - rethrow(e) - end - end - end - end - - # for notebook in values(notebooks) - # # TODO: this needs to be done when notebooks are added, not here - # println("starting flush $(notebook.uuid)") - # @async flushloopnotebook(notebook.uuid, connectedclients) - # end - - println("Go to http://localhost:$(port)/ to start writing! ⚙") - println() - controlkey = Sys.isapple() ? "Command" : "Ctrl" - println("Press $controlkey+C to stop Pluto") - println() - - launchbrowser && @warn "Not implemented yet" +include("./react/Cell.jl") +include("./react/Notebook.jl") +include("./react/ExploreExpression.jl") +include("./react/ModuleManager.jl") +include("./react/React.jl") - - # create blocking call: - try - # take!(Channel(0)) - while true - sleep(typemax(UInt64)) - # yield() - end - catch e - if isa(e, InterruptException) - println("\nClosing Pluto... Bye! 🎈") - close(serversocket) - else - rethrow(e) - end - end -end +include("./webserver/NotebookServer.jl") +include("./webserver/Static.jl") +include("./webserver/FormatOutput.jl") +include("./webserver/Dynamic.jl") end \ No newline at end of file diff --git a/src/Cell.jl b/src/react/Cell.jl similarity index 100% rename from src/Cell.jl rename to src/react/Cell.jl diff --git a/src/ExploreExpression.jl b/src/react/ExploreExpression.jl similarity index 100% rename from src/ExploreExpression.jl rename to src/react/ExploreExpression.jl diff --git a/src/react/ModuleManager.jl b/src/react/ModuleManager.jl new file mode 100644 index 0000000000..6bf1e57198 --- /dev/null +++ b/src/react/ModuleManager.jl @@ -0,0 +1,67 @@ +module ModuleManager + "These expressions get executed whenever a new workspace is created." + workspace_preamble = [:(using Markdown), :(ENV["GKSwstype"] = "nul")] + + workspace_count = 0 + + get_workspace(id=workspace_count) = Core.eval(ModuleManager, Symbol("workspace", id)) + + function make_workspace() + global workspace_count += 1 + + new_workspace_name = Symbol("workspace", workspace_count) + workspace_creation = :(module $(new_workspace_name) $(workspace_preamble...) end) + + # We suppress this warning: + # Expr(:module, true, :workspace1, Expr(:block, #= Symbol("/mnt/c/dev/julia/Pluto.jl/src/React.jl"):13 =#, #= Symbol("/mnt/c/dev/julia/Pluto.jl/src/React.jl"):13 =#, Expr(:using, Expr(:., :Markdown)))) + # ** incremental compilation may be broken for this module ** + + # TODO: a more elegant way? + # TODO: check for other warnings + original_stderr = stderr + (rd, wr) = redirect_stderr(); + + Core.eval(ModuleManager, workspace_creation) + + redirect_stderr(original_stderr) + close(wr) + close(rd) + end + make_workspace() # so that there's immediately something to work with + + forbiddenmove(sym::Symbol) = sym == :eval || sym == :include || string(sym)[1] == '#' + + function move_vars(old_index::Integer, new_index::Integer, to_delete::Set{Symbol}=Set{Symbol}(), module_usings::Set{Expr}=Set{Expr}()) + old_workspace = get_workspace(old_index) + old_workspace_name = Symbol("workspace", old_index) + new_workspace = get_workspace(new_index) + new_workspace_name = Symbol("workspace", new_index) + Core.eval(new_workspace, :(import ..($(old_workspace_name)))) + + for mu in module_usings + # modules are 'cached' + # there seems to be little overhead for this, but this should be tested + Core.eval(new_workspace, mu) + end + + for symbol in names(old_workspace, all=true, imported=true) + if !forbiddenmove(symbol) && symbol != Symbol("workspace",old_index - 1) && symbol != Symbol("workspace",old_index) + if symbol in to_delete + try + Core.eval(old_workspace, :($(symbol) = nothing)) + catch; end # sometimes impossible, eg. when $symbol was constant + else + Core.eval(new_workspace, :($(symbol) = $(old_workspace_name).$(symbol))) + end + end + end + end + + function delete_vars(to_delete::Set{Symbol}=Set{Symbol}(), module_usings::Set{Expr}=Set{Expr}()) + if !isempty(to_delete) + old_index = workspace_count + make_workspace() + move_vars(old_index, old_index+1, to_delete, module_usings) + end + end +end \ No newline at end of file diff --git a/src/Notebook.jl b/src/react/Notebook.jl similarity index 95% rename from src/Notebook.jl rename to src/react/Notebook.jl index 2a8b0d03e8..be307101a0 100644 --- a/src/Notebook.jl +++ b/src/react/Notebook.jl @@ -1,7 +1,5 @@ using UUIDs -import Pkg - mutable struct Notebook path::String @@ -58,7 +56,7 @@ end function save_notebook(io, notebook) write(io, "### A Pluto.jl notebook ###\n") - write(io, "# " * version_str * "\n") + write(io, "# " * VERSION_STR * "\n") # TODO: order cells cells_ordered = notebook.cells @@ -90,9 +88,9 @@ function load_notebook(io, path) @error "File is not a Pluto.jl notebook" end - file_version_str = readline(io)[3:end] - if file_version_str != version_str - @warn "Loading a notebook saved with Pluto $(file_version_str). This is Pluto $(version_str)." + file_VERSION_STR = readline(io)[3:end] + if file_VERSION_STR != VERSION_STR + @warn "Loading a notebook saved with Pluto $(file_VERSION_STR). This is Pluto $(VERSION_STR)." end diff --git a/src/React.jl b/src/react/React.jl similarity index 62% rename from src/React.jl rename to src/react/React.jl index 90ca326355..a0a40dd519 100644 --- a/src/React.jl +++ b/src/react/React.jl @@ -1,71 +1,3 @@ -module ModuleManager - "These expressions get executed whenever a new workspace is created." - workspace_preamble = [:(using Markdown), :(ENV["GKSwstype"] = "nul")] - - workspace_count = 0 - - get_workspace(id=workspace_count) = Core.eval(ModuleManager, Symbol("workspace", id)) - - function make_workspace() - global workspace_count += 1 - - new_workspace_name = Symbol("workspace", workspace_count) - workspace_creation = :(module $(new_workspace_name) $(workspace_preamble...) end) - - # We suppress this warning: - # Expr(:module, true, :workspace1, Expr(:block, #= Symbol("/mnt/c/dev/julia/Pluto.jl/src/React.jl"):13 =#, #= Symbol("/mnt/c/dev/julia/Pluto.jl/src/React.jl"):13 =#, Expr(:using, Expr(:., :Markdown)))) - # ** incremental compilation may be broken for this module ** - - # TODO: a more elegant way? - # TODO: check for other warnings - original_stderr = stderr - (rd, wr) = redirect_stderr(); - - Core.eval(ModuleManager, workspace_creation) - - redirect_stderr(original_stderr) - close(wr) - close(rd) - end - make_workspace() # so that there's immediately something to work with - - forbiddenmove(sym::Symbol) = sym == :eval || sym == :include || string(sym)[1] == '#' - - function move_vars(old_index::Integer, new_index::Integer, to_delete::Set{Symbol}=Set{Symbol}(), module_usings::Set{Expr}=Set{Expr}()) - old_workspace = get_workspace(old_index) - old_workspace_name = Symbol("workspace", old_index) - new_workspace = get_workspace(new_index) - new_workspace_name = Symbol("workspace", new_index) - Core.eval(new_workspace, :(import ..($(old_workspace_name)))) - - for mu in module_usings - # modules are 'cached' - # there seems to be little overhead for this, but this should be tested - Core.eval(new_workspace, mu) - end - - for symbol in names(old_workspace, all=true, imported=true) - if !forbiddenmove(symbol) && symbol != Symbol("workspace",old_index - 1) && symbol != Symbol("workspace",old_index) - if symbol in to_delete - try - Core.eval(old_workspace, :($(symbol) = nothing)) - catch; end # sometimes impossible, eg. when $symbol was constant - else - Core.eval(new_workspace, :($(symbol) = $(old_workspace_name).$(symbol))) - end - end - end - end - - function delete_vars(to_delete::Set{Symbol}=Set{Symbol}(), module_usings::Set{Expr}=Set{Expr}()) - if !isempty(to_delete) - old_index = workspace_count - make_workspace() - move_vars(old_index, old_index+1, to_delete, module_usings) - end - end -end - "Run a cell and all the cells that depend on it" function run_reactive!(initiator, notebook::Notebook, cell::Cell) cell.parsedcode = Meta.parse(cell.code, raise=false) diff --git a/src/webserver/Dynamic.jl b/src/webserver/Dynamic.jl new file mode 100644 index 0000000000..bc8bad4d01 --- /dev/null +++ b/src/webserver/Dynamic.jl @@ -0,0 +1,180 @@ +import JSON + +struct UpdateMessage + type::Symbol + message::Any + notebook::Union{Notebook,Nothing} + cell::Union{Cell,Nothing} + initiator::Union{Client,Nothing} +end + +UpdateMessage(type::Symbol, message::Any) = UpdateMessage(type, message, nothing, nothing, nothing) +UpdateMessage(type::Symbol, message::Any, notebook::Notebook) = UpdateMessage(type, message, notebook, nothing, nothing) + + +function serialize_message(message::UpdateMessage) + to_send = Dict(:type => message.type, :message => message.message) + if message.notebook !== nothing + to_send[:notebookID] = string(message.notebook.uuid) + end + if message.cell !== nothing + to_send[:cellID] = string(message.cell.uuid) + end + if message.initiator !== nothing + to_send[:initiatorID] = string(message.initiator.id) + end + + JSON.json(to_send) +end + + +function clientupdate_cell_output(initiator::Client, notebook::Notebook, cell::Cell) + payload, mime = format_output(cell.output) + + return UpdateMessage(:cell_output, + Dict(:mime => mime, + :output => payload, + :errormessage => cell.errormessage, + ), + notebook, cell, initiator) +end + +function clientupdate_cell_input(initiator::Client, notebook::Notebook, cell::Cell) + return UpdateMessage(:cell_input, + Dict(:code => cell.code), notebook, cell, initiator) +end + +function clientupdate_cell_added(initiator::Client, notebook::Notebook, cell::Cell, new_index::Integer) + return UpdateMessage(:cell_added, + Dict(:index => new_index - 1, # 1-based index (julia) to 0-based index (js) + ), notebook, cell, initiator) +end + +function clientupdate_cell_deleted(initiator::Client, notebook::Notebook, cell::Cell) + return UpdateMessage(:cell_deleted, + Dict(), notebook, cell, initiator) +end + +function clientupdate_cell_moved(initiator::Client, notebook::Notebook, cell::Cell, new_index::Integer) + return UpdateMessage(:cell_moved, + Dict(:index => new_index - 1, # 1-based index (julia) to 0-based index (js) + ), notebook, cell, initiator) +end + +function clientupdate_cell_dependecies(initiator::Client, notebook::Notebook, cell::Cell, dependentcells) + return UpdateMessage(:cell_dependecies, + Dict(:depenentcells => [string(c.uuid) for c in dependentcells], + ), notebook, cell, initiator) +end + +function clientupdate_cell_running(initiator::Client, notebook::Notebook, cell::Cell) + return UpdateMessage(:cell_running, + Dict(), notebook, cell, initiator) +end + +function clientupdate_notebook_list(initiator::Client, notebook_list) + return UpdateMessage(:notebook_list, + Dict(:notebooks => [Dict(:uuid => string(notebook.uuid), + :path => notebook.path, + ) for notebook in notebook_list]), nothing, nothing, initiator) +end + + + + +function handle_changecell(initiator, notebook, cell, newcode) + # i.e. Ctrl+Enter was pressed on this cell + # we update our `Notebook` and start execution + + # don't reparse when code is identical (?) + if cell.code != newcode + cell.code = newcode + cell.parsedcode = nothing + end + + putnotebookupdates!(notebook, clientupdate_cell_input(initiator, notebook, cell)) + + # TODO: evaluation async + @time to_update = run_reactive!(initiator, notebook, cell) + + # TODO: feedback to user about File IO + save_notebook(notebook) +end + + + +# TODO: actions on the notebook are not thread safe +addresponse(:addcell) do (initiator, body, notebook) + new_index = body["index"] + 1 # 0-based index (js) to 1-based index (julia) + + new_cell = createcell_fromcode("") + + insert!(notebook.cells, new_index, new_cell) + + putnotebookupdates!(notebook, clientupdate_cell_added(initiator, notebook, new_cell, new_index)) + nothing +end + +addresponse(:deletecell) do (initiator, body, notebook, cell) + to_delete = cell + + changecell_succes = handle_changecell(initiator, notebook, to_delete, "") + + filter!(c->c.uuid ≠ to_delete.uuid, notebook.cells) + + putnotebookupdates!(notebook, clientupdate_cell_deleted(initiator, notebook, to_delete)) + nothing +end + +addresponse(:movecell) do (initiator, body, notebook, cell) + to_move = cell + + # Indexing works as if a new cell is added. + # e.g. if the third cell (at julia-index 3) of [0, 1, 2, 3, 4] + # is moved to the end, that would be new julia-index 6 + + new_index = body["index"] + 1 # 0-based index (js) to 1-based index (julia) + old_index = findfirst(isequal(to_move), notebook.cells) + + # Because our cells run in _topological_ order, we don't need to reevaluate anything. + if new_index < old_index + deleteat!(notebook.cells, old_index) + insert!(notebook.cells, new_index, to_move) + elseif new_index > old_index + 1 + insert!(notebook.cells, new_index, to_move) + deleteat!(notebook.cells, old_index) + end + + putnotebookupdates!(notebook, clientupdate_cell_moved(initiator, notebook, to_move, new_index)) + nothing +end + +addresponse(:changecell) do (initiator, body, notebook, cell) + newcode = body["code"] + + handle_changecell(initiator, notebook, cell, newcode) + nothing +end + + +# TODO: +# addresponse(:getcell) do (initiator, body, notebook, cell) + +# end + +addresponse(:getallcells) do (initiator, body, notebook) + # TODO: + updates = [] + for (i, cell) in enumerate(notebook.cells) + push!(updates, clientupdate_cell_added(initiator, notebook, cell, i)) + push!(updates, clientupdate_cell_input(initiator, notebook, cell)) + push!(updates, clientupdate_cell_output(initiator, notebook, cell)) + end + # [clientupdate_cell_added(notebook, c, i) for (i, c) in enumerate(notebook.cells)] + + updates +end + +addresponse(:getallnotebooks) do (initiator, body) + [clientupdate_notebook_list(initiator, values(notebooks))] +end \ No newline at end of file diff --git a/src/webserver/FormatOutput.jl b/src/webserver/FormatOutput.jl new file mode 100644 index 0000000000..7f4273faf9 --- /dev/null +++ b/src/webserver/FormatOutput.jl @@ -0,0 +1,46 @@ +using Markdown +import Base: show +import Markdown: html, htmlinline, LaTeX, withtag, htmlesc + + +# We add a method for the Markdown -> HTML conversion that takes a LaTeX chunk from the Markdown tree and adds our custom span +function htmlinline(io::IO, x::LaTeX) + withtag(io, :span, :class => "tex") do + print(io, '$') + htmlesc(io, x.formula) + print(io, '$') + end +end + +# This one for block equations: (double $$) +function html(io::IO, x::LaTeX) + withtag(io, :p, :class => "tex") do + print(io, '$', '$') + htmlesc(io, x.formula) + print(io, '$', '$') + end +end + +"The `IOContext` used for converting arbitrary objects to pretty strings." +iocontext = IOContext(stdout, :color => false, :compact => true, :limit => true, :displaysize => (18, 120)) + +function format_output(val::Any) + # TODO: Here we could do even richer formatting + # interactive Arrays! + # use Weave.jl? that would be sw€€t + + # in order of coolness + # text/plain always matches + mime = let + mimes = ["text/html", "text/plain"] + first(filter(m->Base.invokelatest(showable, m, val), mimes)) + end + + if val === nothing + "", mime + else + + # TODO: limit output! + Base.invokelatest(repr, mime, val; context = iocontext), mime + end +end \ No newline at end of file diff --git a/src/webserver/NotebookServer.jl b/src/webserver/NotebookServer.jl new file mode 100644 index 0000000000..12c36ae33a --- /dev/null +++ b/src/webserver/NotebookServer.jl @@ -0,0 +1,241 @@ +import JSON +import UUIDs: UUID +import HTTP +import Sockets + +mutable struct Client + id::Symbol + stream::Any + connected_notebook::Union{Notebook,Nothing} + pendingupdates::Channel +end + +Client(id::Symbol, stream) = Client(id, stream, nothing, Channel(128)) + +connectedclients = Dict{Symbol,Client}() +notebooks = Dict{UUID,Notebook}() + + +function putnotebookupdates!(notebook, messages...) + listeners = filter(c->c.connected_notebook.uuid == notebook.uuid, collect(values(connectedclients))) + if isempty(listeners) + @info "no clients connected to this notebook!" + else + for next_to_send in messages, client in listeners + put!(client.pendingupdates, next_to_send) + end + end + flushallclients(listeners) + listeners +end + + +function putplutoupdates!(notebook, messages...) + listeners = collect(values(connectedclients)) + if isempty(listeners) + @info "no clients connected to pluto!" + else + for next_to_send in messages, client in listeners + put!(client.pendingupdates, next_to_send) + end + end + flushallclients(listeners) + listeners +end + + +# flushtoken = Channel{Nothing}(1) +# put!(flushtoken, nothing) + +function flushclient(client) + # take!(flushtoken) + didsomething = false + while isready(client.pendingupdates) + next_to_send = take!(client.pendingupdates) + didsomething = true + + try + if isopen(client.stream) + write(client.stream, serialize_message(next_to_send)) + else + @info "Client $(client.id) stream closed." + return false + end + catch e + @warn "Failed to write to WebSocket of $(client.id) " e + return false + end + end + # put!(flushtoken, nothing) + true +end + +function flushallclients(subset) + disconnected = Set{Symbol}() + for client in subset + stillconnected = flushclient(client) + if !stillconnected + push!(disconnected, client.id) + end + end + for to_deleteID in disconnected + delete!(connectedclients, to_deleteID) + end +end + +function flushallclients() + flushallclients(values(connectedclients)) +end + + +"Will hold all 'response handlers': functions that respond to a WebSocket request from the client. These are defined in `src/webserver/Dynamic.jl`." +responses = Dict{Symbol,Function}() +addresponse(f::Function, endpoint::Symbol) = responses[endpoint] = f + + +"Will _synchronously_ run the notebook server. (i.e. blocking call)" +function run(port = 1234, launchbrowser = false) + serversocket = Sockets.listen(UInt16(port)) + @async HTTP.serve(Sockets.localhost, UInt16(port), stream = true, server = serversocket) do http::HTTP.Stream + # messy messy code so that we can use the websocket on the same port as the HTTP server + + if HTTP.WebSockets.is_upgrade(http.message) + try + HTTP.WebSockets.upgrade(http) do clientstream + if !isopen(clientstream) + return + end + while !eof(clientstream) + # This stream contains data received over the WebSocket. + # It is formatted and JSON-encoded by send(...) in editor.html + try + parentbody = let + data = String(readavailable(clientstream)) + JSON.parse(data) + end + + client = let + clientID = Symbol(parentbody["clientID"]) + Client(clientID, clientstream) + end + # add to our list of connected clients + connectedclients[client.id] = client + + messagetype = Symbol(parentbody["type"]) + + if messagetype == :disconnect + delete!(connectedclients, client.id) + elseif messagetype == :connect + # nothing more to do + else + body = parentbody["body"] + + args = [] + if haskey(parentbody, "notebookID") + notebook = let + notebookID = UUID(parentbody["notebookID"]) + get(notebooks, notebookID, nothing) + end + if notebook === nothing + @warn "Remote notebook not found locally!" + return + end + client.connected_notebook = notebook + push!(args, notebook) + end + + if haskey(parentbody, "cellID") + cell = let + cellID = UUID(parentbody["cellID"]) + selectcell_byuuid(notebook, cellID) + end + if cell === nothing + @warn "Remote cell not found locally!" + return + end + push!(args, cell) + end + + if haskey(responses, messagetype) + responsefunc = responses[messagetype] + response = responsefunc((client, body, args...)) + if response !== nothing + putplutoupdates!(notebook, response...) + end + else + @warn "Message of type $(messagetype) not recognised" + end + end + catch e + if e isa InterruptException + rethrow(e) + elseif e isa HTTP.WebSockets.WebSocketError + # that's fine! + elseif e isa InexactError + # that's fine! this is a (fixed) HTTP.jl bug: https://github.com/JuliaWeb/HTTP.jl/issues/471 + # TODO: remove this switch + else + @warn "Reading WebSocket client stream failed for unknown reason:" e + end + end + end + end + catch e + if e isa InterruptException + rethrow(e) + else + @info "HTTP upgrade failed, should be fine" e + end + end + else + request::HTTP.Request = http.message + request.body = read(http) + closeread(http) + + request_body = IOBuffer(HTTP.payload(request)) + if eof(request_body) + # no request body + response_body = HTTP.handle(PLUTOROUTER, request) + else + # there's a body, so pass it on to the handler we dispatch to + response_body = HTTP.handle(PLUTOROUTER, request, JSON.parse(request_body)) + end + + request.response::HTTP.Response = response_body + request.response.request = request + try + startwrite(http) + write(http, request.response.body) + catch e + if isa(e, HTTP.IOError) || isa(e, ArgumentError) + @warn "Attempted to write to a closed stream at $(request.target)" + else + rethrow(e) + end + end + end + end + + println("Go to http://localhost:$(port)/ to start writing! ⚙") + println() + controlkey = Sys.isapple() ? "Command" : "Ctrl" + println("Press $controlkey+C to stop Pluto") + println() + + launchbrowser && @warn "Not implemented yet" + + + # create blocking call: + try + while true + sleep(typemax(UInt64)) + end + catch e + if isa(e, InterruptException) + println("\nClosing Pluto... Bye! 🎈") + close(serversocket) + else + rethrow(e) + end + end +end \ No newline at end of file diff --git a/src/webserver/Static.jl b/src/webserver/Static.jl new file mode 100644 index 0000000000..ec982c8368 --- /dev/null +++ b/src/webserver/Static.jl @@ -0,0 +1,107 @@ +import HTTP + +# STATIC: Serve index.html, which is the same for every notebook - it's a ⚡🤑🌈 web app +# index.html also contains the CSS and JS + +"Attempts to find the MIME pair corresponding to the extension of a filename. Defaults to `text/plain`." +function mime_fromfilename(filename) + # This bad boy is from: https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types + mimepairs = Dict(".aac" => "audio/aac", ".bin" => "application/octet-stream", ".bmp" => "image/bmp", ".css" => "text/css", ".csv" => "text/csv", ".eot" => "application/vnd.ms-fontobject", ".gz" => "application/gzip", ".gif" => "image/gif", ".htm" => "text/html", ".html" => "text/html", ".ico" => "image/vnd.microsoft.icon", ".jpeg" => "image/jpeg", ".jpg" => "image/jpeg", ".js" => "text/javascript", ".json" => "application/json", ".jsonld" => "application/ld+json", ".mjs" => "text/javascript", ".mp3" => "audio/mpeg", ".mpeg" => "video/mpeg", ".oga" => "audio/ogg", ".ogv" => "video/ogg", ".ogx" => "application/ogg", ".opus" => "audio/opus", ".otf" => "font/otf", ".png" => "image/png", ".pdf" => "application/pdf", ".rtf" => "application/rtf", ".sh" => "application/x-sh", ".svg" => "image/svg+xml", ".tar" => "application/x-tar", ".tif" => "image/tiff", ".tiff" => "image/tiff", ".ttf" => "font/ttf", ".txt" => "text/plain", ".wav" => "audio/wav", ".weba" => "audio/webm", ".webm" => "video/webm", ".webp" => "image/webp", ".woff" => "font/woff", ".woff2" => "font/woff2", ".xhtml" => "application/xhtml+xml", ".xml" => "application/xml", ".xul" => "application/vnd.mozilla.xul+xml", ".zip" => "application/zip") + file_extension = getkey(mimepairs, '.' * split(filename, '.')[end], ".txt") + MIME(mimepairs[file_extension]) +end + +function assetresponse(path) + try + @assert isfile(path) + response = HTTP.Response(200, read(path, String)) + push!(response.headers, "Content-Type" => string(mime_fromfilename(path))) + return response + catch e + HTTP.Response(404, "Not found!: $(e)") + end +end + +function serveonefile(path) + return request::HTTP.Request->assetresponse(normpath(path)) +end + +function serveasset(req::HTTP.Request) + reqURI = HTTP.URI(req.target) + + filepath = joinpath(PKG_ROOT_DIR, relpath(reqURI.path, "/")) + assetresponse(filepath) +end + +const PLUTOROUTER = HTTP.Router() + +function serve_editor(req::HTTP.Request) + p=HTTP.URI(req.target).path + b = String(req.body) + HTTP.Response(200, "Path: $(p) \n\n Body: $(b)") +end + +function notebook_redirect(notebook) + response = HTTP.Response(302, "") + push!(response.headers, "Location" => "/edit?uuid=" * string(notebook.uuid)) + return response +end + +function serve_openfile(req::HTTP.Request) + uri=HTTP.URI(req.target) + query = HTTP.URIs.unescapeuri(replace(uri.query, '+' => ' ')) + + if length(query) > 5 + path = query[6:end] + + if(isfile(path)) + try + for nb in values(notebooks) + if realpath(nb.path) == realpath(path) + return notebook_redirect(nb) + end + end + + nb = load_notebook(path) + save_notebook(nb) + notebooks[nb.uuid] = nb + return notebook_redirect(nb) + + catch e + return HTTP.Response(500, "Failed to load notebook:\n\n$(e)\n\nGo back") + end + # return HTTP.Response(200, """Path: $(path)""") + else + return HTTP.Response(404, "Can't find a file here.\n\nGo back") + end + end + return HTTP.Response(400, "Bad query.\n\nGo back") +end + +function serve_samplefile(req::HTTP.Request) + nb = samplenotebook() + save_notebook(nb) + notebooks[nb.uuid] = nb + return notebook_redirect(nb) +end + +function serve_newfile(req::HTTP.Request) + nb = emptynotebook() + save_notebook(nb) + notebooks[nb.uuid] = nb + return notebook_redirect(nb) +end + +HTTP.@register(PLUTOROUTER, "GET", "/", serveonefile(joinpath(PKG_ROOT_DIR, "assets", "welcome.html"))) +HTTP.@register(PLUTOROUTER, "GET", "/index", serveonefile(joinpath(PKG_ROOT_DIR, "assets", "welcome.html"))) +HTTP.@register(PLUTOROUTER, "GET", "/index.html", serveonefile(joinpath(PKG_ROOT_DIR, "assets", "welcome.html"))) + +HTTP.@register(PLUTOROUTER, "GET", "/edit", serveonefile(joinpath(PKG_ROOT_DIR, "assets", "editor.html"))) +HTTP.@register(PLUTOROUTER, "GET", "/sample", serve_samplefile) +HTTP.@register(PLUTOROUTER, "GET", "/new", serve_newfile) +HTTP.@register(PLUTOROUTER, "GET", "/open", serve_openfile) + +HTTP.@register(PLUTOROUTER, "GET", "/favicon.ico", serveonefile(joinpath(PKG_ROOT_DIR, "assets", "favicon.ico"))) +HTTP.@register(PLUTOROUTER, "GET", "/assets/*", serveasset) + +HTTP.@register(PLUTOROUTER, "GET", "/ping", r->HTTP.Response(200, JSON.json("OK!"))) \ No newline at end of file