diff --git a/frontend/components/Cell.js b/frontend/components/Cell.js index 3647e522e1..2d163d11fb 100644 --- a/frontend/components/Cell.js +++ b/frontend/components/Cell.js @@ -105,6 +105,8 @@ export const Cell = ({ cell_dependencies, cell_input_local, notebook_id, + client_id, + users, selected, force_hide_input, focus_after_creation, @@ -331,6 +333,8 @@ export const Cell = ({ nbpkg=${nbpkg} cell_id=${cell_id} notebook_id=${notebook_id} + client_id=${client_id} + users=${users} metadata=${metadata} any_logs=${any_logs} show_logs=${show_logs} diff --git a/frontend/components/CellInput.js b/frontend/components/CellInput.js index cba162bef3..5918f347ae 100644 --- a/frontend/components/CellInput.js +++ b/frontend/components/CellInput.js @@ -54,7 +54,7 @@ import { import { markdown, html as htmlLang, javascript, sqlLang, python, julia_mixed } from "./CellInput/mixedParsers.js" import { julia_andrey } from "../imports/CodemirrorPlutoSetup.js" import { pluto_autocomplete } from "./CellInput/pluto_autocomplete.js" -import { pluto_collab } from "./CellInput/pluto_collab.js" +import { UsersFacet, pluto_collab } from "./CellInput/pluto_collab.js" import { NotebookpackagesFacet, pkgBubblePlugin } from "./CellInput/pkg_bubble_plugin.js" import { awesome_line_wrapping } from "./CellInput/awesome_line_wrapping.js" import { cell_movement_plugin, prevent_holding_a_key_from_doing_things_across_cells } from "./CellInput/cell_movement_plugin.js" @@ -342,7 +342,7 @@ let line_and_ch_to_cm6_position = (/** @type {import("../imports/CodemirrorPluto } function eventEmitter() { - let events = {} + const events = {} return { subscribe: (/** @type {string} */ name, cb) => { ;(events[name] || (events[name] = [])).push(cb) @@ -408,6 +408,8 @@ export const CellInput = ({ nbpkg, cell_id, notebook_id, + client_id, + users, any_logs, show_logs, set_show_logs, @@ -427,6 +429,8 @@ export const CellInput = ({ throw to_throw } + console.log(users) + const notebook_id_ref = useRef(notebook_id) notebook_id_ref.current = notebook_id @@ -439,6 +443,7 @@ export const CellInput = ({ let highlighted_line_compartment = useCompartment(newcm_ref, HighlightLineFacet.of(cm_highlighted_line)) let highlighted_range_compartment = useCompartment(newcm_ref, HighlightRangeFacet.of(cm_highlighted_range)) let editable_compartment = useCompartment(newcm_ref, EditorState.readOnly.of(disable_input)) + let users_compartment = useCompartment(newcm_ref, UsersFacet.of(users)) let on_change_compartment = useCompartment( newcm_ref, @@ -616,6 +621,7 @@ export const CellInput = ({ if (!update.view.hasFocus) { return } + pluto_actions.update_notebook((nb) => nb.users[client_id] && (nb.users[client_id].focused_cell = cell_id)) if (update.docChanged || update.selectionSet) { let state = update.state @@ -643,6 +649,7 @@ export const CellInput = ({ highlighted_range_compartment, global_definitions_compartment, editable_compartment, + users_compartment, highlightLinePlugin(), highlightRangePlugin(), @@ -690,6 +697,7 @@ export const CellInput = ({ if (!caused_by_window_blur) { // then it's caused by focusing something other than this cell in the editor. // in this case, we want to collapse the selection into a single point, for aesthetic reasons. + pluto_actions.update_notebook((nb) => nb.users[client_id] && (nb.users[client_id].focused_cell = null)) setTimeout(() => { view.dispatch({ selection: { @@ -779,6 +787,8 @@ export const CellInput = ({ pluto_collab(start_version, { push_updates: (data) => pluto_actions.send("push_updates", { ...data, cell_id: cell_id }, { notebook_id }, false), subscribe_to_updates: (cb) => updater.subscribe("updates", cb), + client_id, + cell_id, }), // This is my weird-ass extension that checks the AST and shows you where diff --git a/frontend/components/CellInput/pluto_collab.js b/frontend/components/CellInput/pluto_collab.js index 3d06dd0c11..c076d7b47c 100644 --- a/frontend/components/CellInput/pluto_collab.js +++ b/frontend/components/CellInput/pluto_collab.js @@ -1,15 +1,14 @@ import { showTooltip, tooltips, + Facet, ChangeSet, collab, Decoration, EditorSelection, EditorView, - Facet, getSyncedVersion, receiveUpdates, - SelectionRange, sendableUpdates, StateEffect, StateField, @@ -43,7 +42,7 @@ function pushUpdates(push_updates, version, fullUpdates) { } // Strip off transaction data - let updates = fullUpdates.map((u) => ({ + const updates = fullUpdates.map((u) => ({ client_id: u.clientID, document_length: u.changes.desc.length, effects: u.effects.map((effect) => effect.value.selection.toJSON()), @@ -77,72 +76,99 @@ const CaretEffect = StateEffect.define({ }, }) -const CursorField = (client_id) => +export const UsersFacet = Facet.define({ + combine: (values) => values[0], + compare: (a, b) => a === b, // <-- TODO: not very performant +}) + +/** Shows the name of client on top of its cursor */ +const CursorField = (client_id, cell_id) => StateField.define({ - create: () => [], + create: () => [], update(tooltips, tr) { - const newTooltips = - tr.effects - .filter((effect) => effect.is(CaretEffect) && effect.value.clientID != client_id) - .map(effect => ({ + const users = tr.state.facet(UsersFacet) + const seen = new Set() + const newTooltips = tr.effects + .filter((effect) => { + const clientID = effect.value.clientID + if (!users[clientID]?.focused_cell || users[clientID]?.focused_cell != cell_id) return false + if (effect.is(CaretEffect) && clientID != client_id && !seen.has(clientID)) { + // TODO: still not in sync with caret + seen.add(clientID) + return true + } + return false + }) + .map((effect) => ({ pos: effect.value.selection.main.head, hover: true, above: true, strictSide: true, arrow: false, create: () => { - let dom = document.createElement("div") + const dom = document.createElement("div") dom.className = "cm-tooltip-remoteClientID" - dom.textContent = effect.value.clientID - return {dom} - } + dom.textContent = users[effect.value.clientID]?.name || effect.value.clientID + return { dom } + }, })) return newTooltips }, - provide: (f) => showTooltip.computeN([f], state => state.field(f)) + provide: (f) => showTooltip.computeN([f, UsersFacet], (state) => state.field(f)), }) -const CaretField = (client_id) => + +/** Shows cursor and selection of user */ +const CaretField = (client_id, cell_id) => StateField.define({ - create() { - return {} - }, + create: () => ({}), update(value, tr) { - const new_value = { ...value } + const users = tr.state.facet(UsersFacet) + const new_value = {} + + for (const clientID of Object.keys(value)) { + const client_cell = users[clientID]?.focused_cell + if (client_cell && client_cell == cell_id) { + new_value[clientID] = value[clientID] + } + } /** @type {StateEffect[]} */ const caretEffects = tr.effects.filter((effect) => effect.is(CaretEffect)) for (const effect of caretEffects) { - if (effect.value.clientID == client_id) continue // don't show our own cursor - if (effect.value.clientID) new_value[effect.value.clientID] = effect.value.selection + const clientID = effect.value.clientID + if (clientID == client_id) continue // don't show our own cursor + const client_cell = users[clientID]?.focused_cell + if (!client_cell || client_cell != cell_id) continue // only show when focusing this cell + if (clientID) new_value[clientID] = { selection: effect.value.selection, color: users[clientID]?.color ?? "#ff00aa" } } return new_value }, provide: (f) => - EditorView.decorations.from(f, (/** @type {{[key: string]: EditorSelection}} */ value) => { + EditorView.decorations.compute([f, UsersFacet], (/** @type EditorState */ state) => { + const value = state.field(f) const decorations = [] - for (const selection of Object.values(value)) { + for (const { selection, color } of Object.values(value)) { decorations.push( Decoration.widget({ - widget: new ReactWidget( - html`` - ), + widget: new ReactWidget(html``), }).range(selection.main.head) // Let's assume the remote cursor is here ) for (const range of selection.ranges) { if (range.from != range.to) { - decorations.push(Decoration.mark({ class: "cm-remoteSelection" }).range(range.from, range.to)) + decorations.push( + Decoration.mark({ class: "cm-remoteSelection", attributes: { style: `background-color: ${color};` } }).range( + range.from, + range.to + ) + ) } } } - - let decs = Decoration.set(decorations, true) - console.log({decs}) - return decs - // return showTooltip.computeN([f], state => state.field(f)) + return Decoration.set(decorations, true) }), }) @@ -156,12 +182,15 @@ const CaretField = (client_id) => /** * @param {number} startVersion * @param {{ + * get_notebook: () => Notebook, * subscribe_to_updates: (cb: Function) => EventHandler, * push_updates: (updates: Array) => Promise + * client_id: string, + * cell_id: string, * }} param1 * @returns */ -export const pluto_collab = (startVersion, { subscribe_to_updates, push_updates }) => { +export const pluto_collab = (startVersion, { subscribe_to_updates, push_updates, client_id, cell_id }) => { const plugin = ViewPlugin.fromClass( class { pushing = false @@ -170,7 +199,6 @@ export const pluto_collab = (startVersion, { subscribe_to_updates, push_updates * @param {EditorView} view */ constructor(view) { - console.log("BUILD", view) this.view = view this.handler = subscribe_to_updates((updates) => this.sync(updates)) } @@ -180,13 +208,13 @@ export const pluto_collab = (startVersion, { subscribe_to_updates, push_updates } async push() { - let updates = sendableUpdates(this.view.state) + const updates = sendableUpdates(this.view.state) if (this.pushing || !updates.length) { return } this.pushing = true - let version = getSyncedVersion(this.view.state) + const version = getSyncedVersion(this.view.state) await pushUpdates(push_updates, version, updates) this.pushing = false @@ -201,8 +229,15 @@ export const pluto_collab = (startVersion, { subscribe_to_updates, push_updates * @param {Array} updates */ sync(updates) { - let version = getSyncedVersion(this.view.state) - updates = updates.slice(version).map((u) => ({ + const version = getSyncedVersion(this.view.state) + this.syncNewUpdates(updates.slice(version)) + } + + /** + * @param {Array} updates + */ + syncNewUpdates(newUpdates) { + const updates = newUpdates.map((u) => ({ changes: ChangeSet.of(u.specs, u.document_length, "\n"), effects: u.effects.map((selection) => CaretEffect.of({ selection: EditorSelection.fromJSON(selection), clientID: u.client_id })), clientID: u.client_id, @@ -227,24 +262,23 @@ export const pluto_collab = (startVersion, { subscribe_to_updates, push_updates } ) - const clientID = Math.random() + "_ok" const cursorPlugin = EditorView.updateListener.of((update) => { if (!update.selectionSet) { return } - const effect = CaretEffect.of({ selection: update.view.state.selection, clientID }) + const effect = CaretEffect.of({ selection: update.view.state.selection, clientID: client_id }) update.view.dispatch({ effects: [effect], }) }) return [ - collab({ clientID, startVersion, sharedEffects: (tr) => tr.effects.filter((effect) => effect.is(CaretEffect) || effect.is(RunEffect)) }), + collab({ clientID: client_id, startVersion, sharedEffects: (tr) => tr.effects.filter((effect) => effect.is(CaretEffect) || effect.is(RunEffect)) }), plugin, cursorPlugin, // tooltips(), - // CaretField(clientID), - // CursorField(clientID), + CaretField(client_id, cell_id), + CursorField(client_id, cell_id), ] } diff --git a/frontend/components/Editor.js b/frontend/components/Editor.js index a62b09e341..0cb3e1870c 100644 --- a/frontend/components/Editor.js +++ b/frontend/components/Editor.js @@ -11,6 +11,7 @@ import { serialize_cells, deserialize_cells, detect_deserializer } from "../comm import { FilePicker } from "./FilePicker.js" import { Preamble } from "./Preamble.js" import { NotebookMemo as Notebook } from "./Notebook.js" +import { MultiplayerPanel } from "./MultiplayerPanel.js" import { BottomRightPanel } from "./BottomRightPanel.js" import { DropRuler } from "./DropRuler.js" import { SelectionArea } from "./SelectionArea.js" @@ -682,6 +683,7 @@ export class Editor extends Component { }, } this.actions = { ...this.real_actions } + window.pluto_actions = this.actions const apply_notebook_patches = (patches, /** @type {NotebookData?} */ old_state = null, get_reverse_patches = false) => new Promise((resolve) => { @@ -866,6 +868,13 @@ patch: ${JSON.stringify( this.client.send("complete", { query: "sq" }, { notebook_id: this.state.notebook.notebook_id }) this.client.send("complete", { query: "\\sq" }, { notebook_id: this.state.notebook.notebook_id }) + const users = this.actions.get_notebook().users + const names = ["Mars", "Earth", "Moon", "Sun"] + const colors = ["#ffc09f", "#a0ced9", "#adf7b6", "#fcf5c7"] + this.actions.update_notebook((nb) => { + nb.users[this.client_id] = { name: names[Object.keys(users).length], color: colors[Object.keys(users).length] } + }) + setTimeout(init_feedback, 2 * 1000) // 2 seconds - load feedback a little later for snappier UI } @@ -904,6 +913,7 @@ patch: ${JSON.stringify( ? `./${u}?id=${this.state.notebook.notebook_id}` : `${this.state.binder_session_url}${u}?id=${this.state.notebook.notebook_id}&token=${this.state.binder_session_token}` + this.client_id = Math.random().toString(36).slice(2) /** @type {import('../common/PlutoConnection').PlutoConnection} */ this.client = /** @type {import('../common/PlutoConnection').PlutoConnection} */ ({}) @@ -1572,8 +1582,10 @@ patch: ${JSON.stringify( last_hot_reload_time=${notebook.last_hot_reload_time} connected=${this.state.connected} /> + <${MultiplayerPanel} users=${notebook.users}/> <${Notebook} notebook=${notebook} + client_id=${this.client_id} cell_inputs_local=${this.state.cell_inputs_local} disable_input=${this.state.disable_ui || !this.state.connected /* && this.state.backend_launch_phase == null*/} last_created_cell=${this.state.last_created_cell} diff --git a/frontend/components/MultiplayerPanel.js b/frontend/components/MultiplayerPanel.js new file mode 100644 index 0000000000..3245445eac --- /dev/null +++ b/frontend/components/MultiplayerPanel.js @@ -0,0 +1,13 @@ +import { html } from "../imports/Preact.js" + +export const MultiplayerPanel = ({ users }) => { + if (!users) return + + return html` +
    + ${Object.entries(users).map( + ([clientID, { name, color, focused_cell }]) => html`
  • ${name} - ${focused_cell}
  • ` + )} +
+ ` +} diff --git a/frontend/components/Notebook.js b/frontend/components/Notebook.js index 77db8c8a52..b1e469b9c4 100644 --- a/frontend/components/Notebook.js +++ b/frontend/components/Notebook.js @@ -1,9 +1,7 @@ import { PlutoActionsContext } from "../common/PlutoContext.js" import { html, useContext, useEffect, useMemo, useRef, useState } from "../imports/Preact.js" -import { EditorView } from "../imports/index.es.js" import { Cell } from "./Cell.js" -import { RunEffect } from "./CellInput/pluto_collab.js" import { nbpkg_fingerprint } from "./PkgStatusMark.js" /** Like `useMemo`, but explain to the console what invalidated the memo. */ @@ -31,6 +29,8 @@ let CellMemo = ({ cell_input, cell_input_local, notebook_id, + client_id, + users, cell_dependencies, selected, focus_after_creation, @@ -51,6 +51,8 @@ let CellMemo = ({ cell_input=${cell_input} cell_input_local=${cell_input_local} notebook_id=${notebook_id} + client_id=${client_id} + users=${users} selected=${selected} force_hide_input=${force_hide_input} focus_after_creation=${focus_after_creation} @@ -83,6 +85,8 @@ let CellMemo = ({ code_folded, cell_input_local, notebook_id, + client_id, + users, cell_dependencies, selected, force_hide_input, @@ -118,7 +122,7 @@ const render_cell_outputs_minimum = 20 * disable_input: boolean, * }} props * */ -export const Notebook = ({ notebook, cell_inputs_local, last_created_cell, selected_cells, is_initializing, is_process_ready, disable_input }) => { +export const Notebook = ({ notebook, client_id, cell_inputs_local, last_created_cell, selected_cells, is_initializing, is_process_ready, disable_input }) => { let pluto_actions = useContext(PlutoActionsContext) // Add new cell when the last cell gets deleted @@ -167,6 +171,8 @@ export const Notebook = ({ notebook, cell_inputs_local, last_created_cell, selec cell_dependencies=${notebook?.cell_dependencies?.[cell_id] ?? {}} cell_input_local=${cell_inputs_local[cell_id]} notebook_id=${notebook.notebook_id} + client_id=${client_id} + users=${notebook.users} selected=${selected_cells.includes(cell_id)} focus_after_creation=${last_created_cell === cell_id} force_hide_input=${false} diff --git a/src/Pluto.jl b/src/Pluto.jl index 5327bffb90..6cfab66ad5 100644 --- a/src/Pluto.jl +++ b/src/Pluto.jl @@ -56,6 +56,7 @@ include("./webserver/Status.jl") module OperationalTransform include("./notebook/OperationalTransform.jl") end +include("./collab/OperationalTransform.jl") include("./notebook/Cell.jl") include("./analysis/data structures.jl") diff --git a/src/collab/OperationalTransform.jl b/src/collab/OperationalTransform.jl index 1cbe9814e8..a22192cf48 100644 --- a/src/collab/OperationalTransform.jl +++ b/src/collab/OperationalTransform.jl @@ -134,13 +134,12 @@ function compose(a, b) out end - struct OpIterator r::Vector{Range} i::UInt32 # op index - ℓ::UInt32 # consumed length in r + ℓ::UInt32 # consumed length in r[i] end -OpIterator(r) = OpIterator(r, firstindex(r), 0) +OpIterator(r) = OpIterator(r, firstindex(r), zero(UInt32)) function peek_length(it::OpIterator) it.i > lastindex(it.r) && return typemax(UInt32) @@ -161,15 +160,14 @@ function next(it::OpIterator, ℓ=nothing) it.i > lastindex(it.r) && return Range(Retain, typemax(UInt32), nothing), it op = it.r[it.i] if op.type == Insert - return Range( - Insert, op.length, op.insert - ), OpIterator(it.r, it.i+1, 0) + @assert isnothing(ℓ) + return Range(Insert, op.length, op.insert), OpIterator(it.r, it.i+one(UInt32), zero(UInt32)) end ty = op.type ℓ = @something(ℓ, peek_length(it)) ni, nℓ = it.i, it.ℓ+ℓ r = Range(ty, ℓ, nothing) - if it.ℓ + ℓ == op.length + if it.ℓ + ℓ == op.length # move to next ni += 1 nℓ = 0 end @@ -187,76 +185,36 @@ function transform(a, b, priority) out = Range[] @assert priority ∈ (:left, :right) - priority == :left + before = priority == :left - i = firstindex(a) - j = firstindex(b) - - ca = a[i] - cb = b[j] + itA = OpIterator(a) + itB = OpIterator(b) - while i <= lastindex(a) && - j <= lastindex(b) - @show ca cb - if ca.type == Insert && priority === :left + while has_next(itA) || has_next(itB) + if peek_type(itA) == Insert && (before || peek_type(itB) != Insert) + ca, itA = next(itA) retain!(out, sizeof(ca.insert)) - i += 1 - i > lastindex(a) && break - ca = a[i] - elseif cb.type == Insert + elseif peek_type(itB) == Insert + cb, itB = next(itB) push!(out, cb) - j += 1 - j > lastindex(b) && break - cb = b[j] else # ca, cb are either Retain or Delete - ℓ = min(ca.length, cb.length) - - # move forward - if ca.length == cb.length - i += 1 - j += 1 - elseif ca.length < cb.length - i += 1 - else - j += 1 - end + ℓ = min(peek_length(itA), peek_length(itB)) - if ca.type == Delete + if peek_type(itA) == Delete # our delete either makes their delete redundant or removes their retain - elseif cb.type == Delete + elseif peek_type(itB) == Delete push!(out, Range(Delete, ℓ, nothing)) else # ca and cb are Retain retain!(out, ℓ) end - if i > lastindex(a) || j > lastindex(b) - break - end - - if ca.length == cb.length - ca = a[i] - cb = b[j] - elseif ca.length < cb.length - ca = a[i] - cb = Range(cb.type, b[j].length - ℓ, cb.insert) - else - ca = Range(ca.type, a[i].length - ℓ, ca.insert) - cb = b[j] - end + _, itA = next(itA, ℓ) + _, itB = next(itB, ℓ) end end - if i <= lastindex(a) - push!(out, ca) - - end - - if j <= lastindex(b) - push!(out, cb) - end - out end @@ -326,12 +284,10 @@ updateC = Update(changesC,sizeof(text)+2,"clientC",[]) updateB = Update(changesB, sizeof(text), "clientB", []) rA = ranges(updateA) -@show apply(text, rA) rB = ranges(updateB) -@show apply(text, rB) rC = ranges(updateC) export apply, ranges, Range, Delete, Retain, Insert, transform -end # module Delta \ No newline at end of file +end # module Delta diff --git a/src/notebook/Notebook.jl b/src/notebook/Notebook.jl index f7b2dbaea8..595e5b4157 100644 --- a/src/notebook/Notebook.jl +++ b/src/notebook/Notebook.jl @@ -62,6 +62,9 @@ Base.@kwdef mutable struct Notebook bonds::Dict{Symbol,BondValue}=Dict{Symbol,BondValue}() metadata::Dict{String, Any}=copy(DEFAULT_NOTEBOOK_METADATA) + + # stores the metadata related to each client such as name, id, color and so on + users::Dict{String,Dict}=Dict{String,Dict}() end function _initial_nb_status() diff --git a/src/webserver/Dynamic.jl b/src/webserver/Dynamic.jl index 3305a81aed..4cb512fbc3 100644 --- a/src/webserver/Dynamic.jl +++ b/src/webserver/Dynamic.jl @@ -171,6 +171,7 @@ function notebook_to_js(notebook::Notebook) end, "status_tree" => Status.tojs(notebook.status_tree), "cell_execution_order" => cell_id.(collect(topological_order(notebook))), + "users" => notebook.users, ) end @@ -237,6 +238,9 @@ struct BondChanged <: Changed bond_name::Symbol is_first_value::Bool end +struct UserCreated <: Changed + client_id::String +end # to support push!(x, y...) # with y = [] Base.push!(x::Set{Changed}) = x @@ -284,7 +288,16 @@ const effects_of_changed_state = Dict( Firebasey.applypatch!(request.notebook, patch) [FileChanged()] end - ) + ), + "users" => Dict( + Wildcard() => function(client_id, path...; request::ClientRequest, patch::Firebasey.JSONPatch) + Firebasey.applypatch!(request.notebook, patch) + if patch isa Firebasey.AddPatch && isempty(path) + return [UserCreated(client_id)] + end + return no_changes + end, + ), ) responses[:update_notebook] = function response_update_notebook(🙋::ClientRequest) @@ -343,6 +356,13 @@ responses[:update_notebook] = function response_update_notebook(🙋::ClientRequ save_notebook(🙋.session, notebook) end + let created_user = filter(x -> x isa UserCreated, changes) + if !isempty(created_user) && !isnothing(🙋.initiator) + created_user = only(created_user) + 🙋.initiator.client.client_id = created_user.client_id + end + end + let bond_changes = filter(x -> x isa BondChanged, changes) bound_sym_names = Symbol[x.bond_name for x in bond_changes] is_first_values = Bool[x.is_first_value for x in bond_changes] @@ -444,11 +464,24 @@ responses[:push_updates] = function response_push_updates(🙋::ClientRequest) withtoken(cell.cm_token) do current_version = length(cell.cm_updates) + # change_ranges = map(Delta.ranges, updates) # Refuse client changes if it is not up to date. if current_version != version + # Client synced version is out of date, transform updates over past changes + updates_to_transform = @view cell.cm_updates[version+1:end] + # @assert !any(up->up.client_id==first(updates).client_id,updates_to_transform) + # updates_to_transform = map(Delta.ranges, updates_to_transform) + + # new_changes = map(change_ranges) do cu + # for tu in updates_to_transform + # cu = Delta.transform(tu,cu,:left) + # end + # cu + # end + # new_text = foldl(Delta.apply, new_changes; init=string(cell.code_text)) @warn "Wrong version" current_version version - send_notebook_changes!(🙋; commentary=:👎) + send_notebook_changes!(🙋; commentary=updates_to_transform) return end diff --git a/src/webserver/PutUpdates.jl b/src/webserver/PutUpdates.jl index 9149abe4a5..725b173cf0 100644 --- a/src/webserver/PutUpdates.jl +++ b/src/webserver/PutUpdates.jl @@ -124,6 +124,12 @@ function flushallclients(session::ServerSession, subset::Union{Set{ClientSession end end for to_delete_id in disconnected + client_id = session.connected_clients[to_delete_id].client_id + if !isnothing(client_id) + for nb in values(session.notebooks) + pop!(nb.users, client_id, nothing) + end + end delete!(session.connected_clients, to_delete_id) end end diff --git a/src/webserver/Session.jl b/src/webserver/Session.jl index c7dfb2a240..04f3f14532 100644 --- a/src/webserver/Session.jl +++ b/src/webserver/Session.jl @@ -11,10 +11,11 @@ mutable struct ClientSession connected_notebook::Union{Notebook,Nothing} pendingupdates::Channel simulated_lag::Float64 + client_id::Union{String,Nothing} end ClientSession(id::Symbol, stream, simulated_lag=0.0) = let - ClientSession(id, stream, nothing, Channel(1024), simulated_lag) + ClientSession(id, stream, nothing, Channel(1024), simulated_lag, nothing) 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(...)`.)"