this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add worker busy indicator, stop/reset, localStorage persistence, and Colab-style button

Three features for x-ocaml interactive cells:

1. Worker busy indicator + stop/reset: When a cell is running (e.g. infinite
loop), the run button transforms into a spinning stop button. Clicking it
terminates the web worker, creates a fresh one, replays init messages, and
resets all cells to Not_run. Both builtin and jtw worker backends support
reset.

2. localStorage persistence: Exercise cells with data-id save their source
to localStorage (debounced 300ms). On reload, saved source is restored.
A small Reset button (bottom-left) appears when saved state exists,
allowing revert to the original HTML source.

3. Colab-style circular run/stop button: Replaces the old boxy text button
with a filled circle + white play triangle (CSS mask + ::after pseudo-
element). Running state shows a spinning arc with red accent and stop
square. Test cells get collapsible details with pass/fail/error indicators.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

+490 -75
+9
src/backend.ml
··· 10 10 val post : t -> X_protocol.request -> unit 11 11 val eval : id:int -> line_number:int -> t -> string -> unit 12 12 val fmt : id:int -> t -> string -> unit 13 + val reset : t -> unit 13 14 val has_merlin : bool 14 15 end 15 16 ··· 55 56 | Builtin client -> Client.fmt ~id client code 56 57 | Jtw client -> Jtw_client.fmt ~id client code 57 58 59 + let reset t = 60 + match t with 61 + | Builtin client -> Client.reset client 62 + | Jtw client -> Jtw_client.reset client 63 + 58 64 (** Module-based interface for advanced use cases *) 59 65 60 66 module Builtin_mod : S = struct ··· 64 70 let post = Client.post 65 71 let eval = Client.eval 66 72 let fmt = Client.fmt 73 + let reset = Client.reset 67 74 let has_merlin = true 68 75 end 69 76 ··· 82 89 let eval = Jtw_client.eval 83 90 84 91 let fmt = Jtw_client.fmt 92 + 93 + let reset = Jtw_client.reset 85 94 86 95 let has_merlin = true (* js_top_worker has complete, type_at, and errors *) 87 96 end
+8
src/backend.mli
··· 42 42 @param code OCaml code to format *) 43 43 val fmt : id:int -> t -> string -> unit 44 44 45 + (** Terminate the worker and create a fresh one. *) 46 + val reset : t -> unit 47 + 45 48 (** Whether this backend supports Merlin integration *) 46 49 val has_merlin : bool 47 50 end ··· 81 84 82 85 (** Format OCaml code *) 83 86 val fmt : id:int -> t -> string -> unit 87 + 88 + (** Terminate the current worker and create a fresh one. 89 + After reset, the backend must be re-initialised (Setup, Format_config, etc.) 90 + and all cells should be considered invalidated. *) 91 + val reset : t -> unit 84 92 85 93 (** {1 Module-based Interface} *) 86 94
+283 -10
src/cell.ml
··· 33 33 run_on : [ `Click | `Load ]; 34 34 filename : string option; 35 35 mutable on_completed : (t -> unit) option; 36 + (* Test result tracking *) 37 + test_indicator : El.t option; 38 + mutable run_has_stderr : bool; 39 + mutable run_has_exception : bool; 40 + (* Busy/stop UI *) 41 + run_btn : El.t option; 42 + mutable stop_fn : (unit -> unit) option; 43 + (* localStorage persistence *) 44 + mutable save_timer : int option; 45 + original_source : string ref; 46 + reset_btn : El.t option; 36 47 } 37 48 38 49 let id t = t.id ··· 42 53 let cell_env t = t.cell_env 43 54 let has_completed t = t.status = Run_ok 44 55 let set_on_completed t f = t.on_completed <- Some f 56 + let set_stop_fn t f = t.stop_fn <- Some f 45 57 46 58 let source t = 47 59 match t.cm with ··· 86 98 with_cm editor (fun cm -> Editor.set_previous_lines cm count); 87 99 refresh_lines_from ~editor 88 100 101 + let contains_substring haystack needle = 102 + let hl = String.length haystack and nl = String.length needle in 103 + if nl = 0 || nl > hl then nl = 0 104 + else 105 + let rec loop i = 106 + if i > hl - nl then false 107 + else if String.sub haystack i nl = needle then true 108 + else loop (i + 1) 109 + in 110 + loop 0 111 + 112 + (** Scan a list of output messages, setting the stderr/exception flags. *) 113 + let track_outputs t msgs = 114 + List.iter (function 115 + | X_protocol.Stderr s -> 116 + t.run_has_stderr <- true; 117 + if contains_substring s "Exception" then 118 + t.run_has_exception <- true 119 + | X_protocol.Meta s -> 120 + (* Runtime exceptions (e.g. Failure, Assert_failure) are reported 121 + as Meta messages by the toplevel, not Stderr. *) 122 + if contains_substring s "Exception" then begin 123 + t.run_has_stderr <- true; 124 + t.run_has_exception <- true 125 + end 126 + | _ -> () 127 + ) msgs 128 + 129 + (** Update the test indicator element based on accumulated flags. *) 130 + let update_test_indicator t = 131 + match t.test_indicator with 132 + | None -> () 133 + | Some ind -> 134 + let cls, txt = 135 + if not t.run_has_stderr then ("test-pass", "\xE2\x9C\x93") (* checkmark *) 136 + else if t.run_has_exception then ("test-fail", "\xE2\x9C\x97") (* x mark *) 137 + else ("test-error", "!") 138 + in 139 + (* Remove previous state classes, add new one *) 140 + El.set_class (Jstr.of_string "test-pass") false ind; 141 + El.set_class (Jstr.of_string "test-fail") false ind; 142 + El.set_class (Jstr.of_string "test-error") false ind; 143 + El.set_class (Jstr.of_string cls) true ind; 144 + Jv.set (El.to_jv ind) "textContent" (Jv.of_string txt) 145 + 146 + (** Set the run button to "Stop" state with running class. *) 147 + let set_btn_running editor = 148 + match editor.run_btn with 149 + | None -> () 150 + | Some btn -> 151 + El.set_at (Jstr.of_string "aria-label") (Some (Jstr.of_string "Stop")) btn; 152 + El.set_class (Jstr.of_string "running") true btn 153 + 154 + (** Set the run button back to "Run" state. *) 155 + let set_btn_idle editor = 156 + match editor.run_btn with 157 + | None -> () 158 + | Some btn -> 159 + El.set_at (Jstr.of_string "aria-label") (Some (Jstr.of_string "Run")) btn; 160 + El.set_class (Jstr.of_string "running") false btn 161 + 89 162 let rec run editor = 90 163 if editor.status = Running then () 91 164 else ( 92 165 editor.status <- Request_run; 166 + editor.run_has_stderr <- false; 167 + editor.run_has_exception <- false; 93 168 with_cm editor Editor.clear_messages; 94 169 match editor.prev with 95 170 | Some e when e.status <> Run_ok -> run e 96 171 | _ -> 97 172 editor.status <- Running; 173 + set_btn_running editor; 98 174 let code_txt = source editor in 99 175 let line_number = 100 176 1 + (match editor.cm with ··· 102 178 | None -> 0) 103 179 in 104 180 editor.eval_fn ~id:editor.id ~line_number code_txt) 181 + 182 + let clear_test_indicator t = 183 + match t.test_indicator with 184 + | None -> () 185 + | Some ind -> 186 + El.set_class (Jstr.of_string "test-pass") false ind; 187 + El.set_class (Jstr.of_string "test-fail") false ind; 188 + El.set_class (Jstr.of_string "test-error") false ind; 189 + Jv.set (El.to_jv ind) "textContent" (Jv.of_string "") 190 + 191 + let reset_status editor = 192 + editor.status <- Not_run; 193 + editor.run_has_stderr <- false; 194 + editor.run_has_exception <- false; 195 + with_cm editor Editor.clear_messages; 196 + set_btn_idle editor; 197 + clear_test_indicator editor 105 198 106 199 let set_prev ~prev t = 107 200 let () = match t.prev with None -> () | Some prev -> prev.next <- None in ··· 115 208 p.next <- Some t; 116 209 refresh_lines_from ~editor:p 117 210 211 + (* ── localStorage persistence helpers ────────────────────────────── *) 212 + 213 + let page_pathname () = 214 + Jstr.to_string (Uri.path (Window.location G.window)) 215 + 216 + let storage_key cell_id = 217 + "xo:" ^ page_pathname () ^ ":" ^ cell_id 218 + 219 + let local_storage_get key = 220 + try 221 + let ls = Jv.get (Window.to_jv G.window) "localStorage" in 222 + let v = Jv.call ls "getItem" [| Jv.of_string key |] in 223 + if Jv.is_null v then None else Some (Jv.to_string v) 224 + with _ -> None 225 + 226 + let local_storage_set key value = 227 + try 228 + let ls = Jv.get (Window.to_jv G.window) "localStorage" in 229 + ignore (Jv.call ls "setItem" [| Jv.of_string key; Jv.of_string value |]) 230 + with _ -> () 231 + 232 + let local_storage_remove key = 233 + try 234 + let ls = Jv.get (Window.to_jv G.window) "localStorage" in 235 + ignore (Jv.call ls "removeItem" [| Jv.of_string key |]) 236 + with _ -> () 237 + 238 + let update_reset_button_visibility editor = 239 + match editor.reset_btn, editor.cell_id with 240 + | Some btn, Some cid -> 241 + let has_saved = local_storage_get (storage_key cid) <> None in 242 + let display = if has_saved then Jstr.of_string "block" else Jstr.of_string "none" in 243 + El.set_inline_style (Jstr.of_string "display") display btn 244 + | _ -> () 245 + 246 + let save_to_storage editor = 247 + match editor.cell_id with 248 + | None -> () 249 + | Some cid -> 250 + let current = source editor in 251 + if current = !(editor.original_source) then 252 + local_storage_remove (storage_key cid) 253 + else 254 + local_storage_set (storage_key cid) current; 255 + update_reset_button_visibility editor 256 + 257 + let clear_timeout id = 258 + ignore (Jv.call (Window.to_jv G.window) "clearTimeout" [| Jv.of_int id |]) 259 + 260 + let set_timeout f ms = 261 + Jv.to_int (Jv.call (Window.to_jv G.window) "setTimeout" 262 + [| Jv.repr f; Jv.of_int ms |]) 263 + 264 + let debounced_save editor = 265 + (match editor.save_timer with 266 + | Some id -> clear_timeout id 267 + | None -> ()); 268 + editor.save_timer <- Some (set_timeout (fun () -> 269 + editor.save_timer <- None; 270 + save_to_storage editor) 300) 271 + 272 + (* ── End localStorage helpers ────────────────────────────────────── *) 273 + 118 274 let set_source_from_html editor this = 119 275 let doc = Webcomponent.text_content this in 120 276 let doc = String.trim doc in ··· 165 321 if not is_hidden then 166 322 init_css shadow ~extra_style ~inline_style; 167 323 168 - let cm, merlin_info, editor_ref = 169 - if is_hidden then (None, None, None) 324 + let cm, merlin_info, editor_ref, test_indicator, run_btn_el, reset_btn_el = 325 + if is_hidden then (None, None, None, None, None, None) 170 326 else begin 171 - let run_btn = El.button [ El.txt (Jstr.of_string "Run") ] in 172 - El.append_children shadow 173 - [ El.div ~at:[ At.class' (Jstr.of_string "run_btn") ] [ run_btn ] ]; 327 + let run_btn = El.button 328 + ~at:[ At.v (Jstr.of_string "aria-label") (Jstr.of_string "Run") ] 329 + [] in 330 + 331 + (* For test cells, wrap the editor in a collapsible <details> element. 332 + Output elements are appended to shadow directly, so they remain 333 + visible even when the details is collapsed. *) 334 + let editor_parent, indicator = 335 + if mode = Test then begin 336 + let details = 337 + El.v (Jstr.of_string "details") 338 + ~at:[ At.class' (Jstr.of_string "test-details") ] 339 + [] 340 + in 341 + let ind = 342 + El.span ~at:[ At.class' (Jstr.of_string "test-indicator") ] [] 343 + in 344 + let summary = 345 + El.v (Jstr.of_string "summary") 346 + [ El.txt (Jstr.of_string "Tests "); ind ] 347 + in 348 + El.append_children details [ summary ]; 349 + El.append_children shadow [ details ]; 350 + El.append_children details 351 + [ El.div ~at:[ At.class' (Jstr.of_string "run_btn") ] [ run_btn ] ]; 352 + (details, Some ind) 353 + end else begin 354 + El.append_children shadow 355 + [ El.div ~at:[ At.class' (Jstr.of_string "run_btn") ] [ run_btn ] ]; 356 + (shadow, None) 357 + end 358 + in 174 359 175 - let cm = Editor.make ~read_only:is_readonly shadow in 360 + let cm = Editor.make ~read_only:is_readonly editor_parent in 361 + 362 + (* Reset button for exercise cells *) 363 + let reset_btn = 364 + if mode = Exercise && cell_id <> None then begin 365 + let btn = El.button 366 + ~at:[ At.class' (Jstr.of_string "reset_exercise_btn") ] 367 + [ El.txt (Jstr.of_string "Reset") ] 368 + in 369 + El.set_inline_style (Jstr.of_string "display") (Jstr.of_string "none") btn; 370 + let wrap = El.div 371 + ~at:[ At.class' (Jstr.of_string "reset_btn_wrap") ] 372 + [ btn ] 373 + in 374 + El.append_children shadow [ wrap ]; 375 + Some btn 376 + end else None 377 + in 176 378 177 379 let merlin_info = 178 380 if merlin then begin ··· 195 397 let _ : Ev.listener = 196 398 Ev.listen Ev.click 197 399 (fun _ev -> 198 - match !editor_ref with Some editor -> run editor | None -> ()) 400 + match !editor_ref with 401 + | Some editor -> 402 + if editor.status = Running then 403 + (match editor.stop_fn with 404 + | Some f -> f () 405 + | None -> ()) 406 + else 407 + run editor 408 + | None -> ()) 199 409 (El.as_target run_btn) 200 410 in 201 411 202 - (Some cm, merlin_info, Some editor_ref) 412 + (* Reset button click handler *) 413 + (match reset_btn with 414 + | Some rbtn -> 415 + let _ : Ev.listener = 416 + Ev.listen Ev.click 417 + (fun _ev -> 418 + match !editor_ref with 419 + | Some editor -> 420 + (match editor.cell_id with 421 + | Some cid -> local_storage_remove (storage_key cid) 422 + | None -> ()); 423 + let orig = !(editor.original_source) in 424 + editor.source_text := orig; 425 + (match editor.cm with 426 + | Some cm -> Editor.set_source cm orig 427 + | None -> ()); 428 + invalidate_after ~editor; 429 + update_reset_button_visibility editor 430 + | None -> ()) 431 + (El.as_target rbtn) 432 + in 433 + () 434 + | None -> ()); 435 + 436 + (Some cm, merlin_info, Some editor_ref, indicator, Some run_btn, reset_btn) 203 437 end 204 438 in 205 439 ··· 222 456 run_on; 223 457 filename; 224 458 on_completed = None; 459 + test_indicator; 460 + run_has_stderr = false; 461 + run_has_exception = false; 462 + run_btn = run_btn_el; 463 + stop_fn = None; 464 + save_timer = None; 465 + original_source = ref ""; 466 + reset_btn = reset_btn_el; 225 467 } 226 468 in 227 469 228 470 (match cm with 229 - | Some cm -> Editor.on_change cm (fun () -> invalidate_after ~editor) 471 + | Some cm -> 472 + Editor.on_change cm (fun () -> 473 + invalidate_after ~editor; 474 + if editor.mode = Exercise && editor.cell_id <> None then 475 + debounced_save editor) 230 476 | None -> ()); 231 477 232 478 (match editor_ref with Some r -> r := Some editor | None -> ()); ··· 241 487 editor 242 488 243 489 let start editor this = 244 - set_source_from_html editor this 490 + let doc = Webcomponent.text_content this in 491 + let doc = String.trim doc in 492 + editor.original_source := doc; 493 + (* For exercise cells with data-id, check localStorage for saved source *) 494 + let effective_doc = 495 + if editor.mode = Exercise then 496 + match editor.cell_id with 497 + | Some cid -> 498 + (match local_storage_get (storage_key cid) with 499 + | Some saved -> saved 500 + | None -> doc) 501 + | None -> doc 502 + else doc 503 + in 504 + editor.source_text := effective_doc; 505 + (match editor.cm with 506 + | Some cm -> 507 + Editor.set_source cm effective_doc; 508 + invalidate_from ~editor; 509 + if effective_doc = doc then 510 + editor.fmt_fn ~id:editor.id doc 511 + | None -> 512 + invalidate_from ~editor); 513 + update_reset_button_visibility editor 245 514 246 515 let set_source editor doc = 247 516 editor.source_text := doc; ··· 267 536 El.pre ~at:[ At.class' (Jstr.of_string ("caml_" ^ kind)) ] [ text ] 268 537 269 538 let add_message t loc msg = 539 + track_outputs t msg; 270 540 match t.cm with 271 541 | Some cm -> Editor.add_message cm loc (List.map render_message msg) 272 542 | None -> () 273 543 274 544 let completed_run ed msg = 545 + track_outputs ed msg; 275 546 (if msg <> [] then 276 547 let loc = String.length (source ed) in 277 548 add_message ed loc msg); 278 549 ed.status <- Run_ok; 550 + set_btn_idle ed; 551 + update_test_indicator ed; 279 552 (match ed.next with Some e when e.status = Request_run -> run e | _ -> ()); 280 553 match ed.on_completed with Some f -> f ed | None -> () 281 554
+10
src/cell.mli
··· 141 141 142 142 Replaces any previously registered callback. *) 143 143 144 + val set_stop_fn : t -> (unit -> unit) -> unit 145 + (** [set_stop_fn cell f] registers [f] to be called when the user clicks 146 + "Stop" while the cell is running. Typically [f] resets the backend 147 + and all cells. *) 148 + 149 + val reset_status : t -> unit 150 + (** Reset the cell to {!Not_run} state: clears output messages, restores 151 + the "Run" button text, and resets test-result tracking flags. 152 + Does {e not} invalidate the cell chain. *) 153 + 144 154 (** {1 Chain management} *) 145 155 146 156 val set_prev : prev:t option -> t -> unit
+28 -9
src/client.ml
··· 1 1 module Worker = Brr_webworkers.Worker 2 2 open Brr 3 3 4 - type t = Worker.t 4 + type t = { 5 + mutable worker : Worker.t; 6 + url : string; (* wrapped data: URL *) 7 + extra_load : string option; 8 + mutable on_message_cb : (X_protocol.response -> unit); 9 + } 5 10 6 11 let current_url = 7 12 let url = Window.location G.window in ··· 43 48 in 44 49 "data:text/javascript;base64," ^ url 45 50 46 - let make ?extra_load url = 51 + let create_worker ?extra_load url = 47 52 Worker.create @@ Jstr.of_string @@ wrap_url ?extra_load url 48 53 49 - let on_message t fn = 54 + let register_listener t = 50 55 let fn m = 51 56 let m = Ev.as_type m in 52 57 let msg = Bytes.to_string @@ Brr_io.Message.Ev.data m in 53 - fn (X_protocol.resp_of_string msg) 58 + t.on_message_cb (X_protocol.resp_of_string msg) 54 59 in 55 60 let _listener = 56 - Ev.listen Brr_io.Message.Ev.message fn @@ Worker.as_target t 61 + Ev.listen Brr_io.Message.Ev.message fn @@ Worker.as_target t.worker 57 62 in 58 63 () 59 64 60 - let post worker msg = Worker.post worker (X_protocol.req_to_bytes msg) 65 + let make ?extra_load url = 66 + let worker = create_worker ?extra_load url in 67 + let t = { worker; url; extra_load; on_message_cb = (fun _ -> ()) } in 68 + t 61 69 62 - let eval ~id ~line_number worker code = 63 - post worker (Eval (id, line_number, code)) 70 + let on_message t fn = 71 + t.on_message_cb <- fn; 72 + register_listener t 73 + 74 + let post t msg = Worker.post t.worker (X_protocol.req_to_bytes msg) 75 + 76 + let eval ~id ~line_number t code = 77 + post t (Eval (id, line_number, code)) 64 78 65 - let fmt ~id worker code = post worker (Format (id, code)) 79 + let fmt ~id t code = post t (Format (id, code)) 80 + 81 + let reset t = 82 + Worker.terminate t.worker; 83 + t.worker <- create_worker ?extra_load:t.extra_load t.url; 84 + register_listener t
+1
src/client.mli
··· 5 5 val post : t -> X_protocol.request -> unit 6 6 val eval : id:int -> line_number:int -> t -> string -> unit 7 7 val fmt : id:int -> t -> string -> unit 8 + val reset : t -> unit
+36 -25
src/jtw_client.cppo.ml
··· 7 7 module Msg = Js_top_worker_message.Message 8 8 9 9 type t = { 10 - client : Jtw.t; 10 + mutable client : Jtw.t; 11 + url : string; 12 + mutable init_config : Msg.init_config option; 11 13 mutable on_message_cb : X_protocol.response -> unit; 12 14 } 13 15 ··· 19 21 page_origin <> url_origin 20 22 with _ -> false 21 23 24 + let make_effective_url url = 25 + if is_cross_origin url then begin 26 + let base_dir = 27 + match String.rindex_opt url '/' with 28 + | Some i -> String.sub url 0 i 29 + | None -> url 30 + in 31 + let js_code = Printf.sprintf 32 + {|globalThis.__global_rel_url="%s";importScripts("%s");|} 33 + base_dir url in 34 + let blob = Jv.new' (Jv.get Jv.global "Blob") 35 + [| Jv.of_jv_array [| Jv.of_string js_code |]; 36 + Jv.obj [| "type", Jv.of_string "application/javascript" |] |] in 37 + Jv.to_string 38 + (Jv.call (Jv.get Jv.global "URL") "createObjectURL" [| blob |]) 39 + end else 40 + url 41 + 22 42 let make url = 23 - let effective_url = 24 - if is_cross_origin url then begin 25 - (* Cross-origin workers are blocked by browsers. Work around this by 26 - creating a blob: URL that uses importScripts to load the real script. 27 - We also set __global_rel_url so the worker can resolve relative paths 28 - (e.g., lib/ocaml/stdlib.cmi) back to the correct origin. *) 29 - let base_dir = 30 - match String.rindex_opt url '/' with 31 - | Some i -> String.sub url 0 i 32 - | None -> url 33 - in 34 - let js_code = Printf.sprintf 35 - {|globalThis.__global_rel_url="%s";importScripts("%s");|} 36 - base_dir url in 37 - let blob = Jv.new' (Jv.get Jv.global "Blob") 38 - [| Jv.of_jv_array [| Jv.of_string js_code |]; 39 - Jv.obj [| "type", Jv.of_string "application/javascript" |] |] in 40 - let blob_url = Jv.to_string 41 - (Jv.call (Jv.get Jv.global "URL") "createObjectURL" [| blob |]) in 42 - blob_url 43 - end else 44 - url 45 - in 43 + let effective_url = make_effective_url url in 46 44 let client = Jtw.create effective_url in 47 - { client; on_message_cb = (fun _ -> ()) } 45 + { client; url; init_config = None; on_message_cb = (fun _ -> ()) } 48 46 49 47 let on_message t fn = t.on_message_cb <- fn 50 48 ··· 146 144 stdlib_dcs; 147 145 findlib_index; 148 146 } in 147 + t.init_config <- Some config; 149 148 Lwt.async (fun () -> 150 149 let open Lwt.Infix in 151 150 Jtw.init t.client config >>= fun () -> 152 151 Lwt.return_unit) 152 + 153 + let reset t = 154 + Jtw.terminate t.client; 155 + let effective_url = make_effective_url t.url in 156 + t.client <- Jtw.create effective_url; 157 + match t.init_config with 158 + | Some config -> 159 + Lwt.async (fun () -> 160 + let open Lwt.Infix in 161 + Jtw.init t.client config >>= fun () -> 162 + Lwt.return_unit) 163 + | None -> () 153 164 154 165 let post t (req : X_protocol.request) = 155 166 match req with
+1
src/jtw_client.mli
··· 6 6 val post : t -> X_protocol.request -> unit 7 7 val eval : id:int -> line_number:int -> t -> string -> unit 8 8 val fmt : id:int -> t -> string -> unit 9 + val reset : t -> unit
+30 -5
src/page.ml
··· 8 8 mutable cells : Cell.t list; 9 9 (* test_links: maps exercise cell id -> list of test cells linked to it *) 10 10 mutable test_links : (int * Cell.t list) list; 11 + (* Messages to replay after a backend reset *) 12 + init_messages : X_protocol.request list ref; 11 13 } 12 14 13 15 let find_by_id t id = List.find (fun c -> Cell.id c = id) t.cells ··· 53 55 if Jv.is_none content then None 54 56 else Some (Jv.to_string content) 55 57 58 + let reset_all t = 59 + Backend.reset t.backend; 60 + (* Re-register the message handler on the fresh worker *) 61 + Backend.on_message t.backend (function 62 + | X_protocol.Formatted_source (id, code_fmt) -> 63 + Cell.set_source (find_by_id t id) code_fmt 64 + | Top_response_at (id, loc, msg) -> 65 + Cell.add_message (find_by_id t id) loc msg 66 + | Top_response (id, msg) -> 67 + Cell.completed_run (find_by_id t id) msg 68 + | Merlin_response (id, msg) -> 69 + Cell.receive_merlin (find_by_id t id) msg); 70 + (* Replay init messages *) 71 + List.iter (Backend.post t.backend) !(t.init_messages); 72 + (* Reset all cells to Not_run *) 73 + List.iter Cell.reset_status t.cells 74 + 56 75 let create ~backend ?extra_style ?inline_style ?(default_run_on = "load") 57 76 ?format_config () = 77 + let init_messages = ref [] in 58 78 let t = 59 79 { backend; extra_style; inline_style; default_run_on; 60 - cells = []; test_links = [] } 80 + cells = []; test_links = []; init_messages } 61 81 in 62 82 (* Route backend responses to the appropriate cell *) 63 83 Backend.on_message backend (function ··· 69 89 Cell.completed_run (find_by_id t id) msg 70 90 | Merlin_response (id, msg) -> 71 91 Cell.receive_merlin (find_by_id t id) msg); 72 - (* Initialise backend *) 73 - Backend.post backend Setup; 92 + (* Initialise backend and record messages for replay after reset *) 93 + let send msg = 94 + init_messages := !(init_messages) @ [msg]; 95 + Backend.post backend msg 96 + in 97 + send Setup; 74 98 (match format_config with 75 - | Some conf -> Backend.post backend (Format_config conf) 99 + | Some conf -> send (Format_config conf) 76 100 | None -> ()); 77 101 (* Universe discovery: register external package CMIs with merlin. 78 102 <meta name="x-ocaml-packages" content="pkg1,pkg2,..."> ··· 98 122 dcs_toplevel_modules = [ capitalised ]; 99 123 dcs_file_prefixes = [ pkg ^ "__" ]; 100 124 } in 101 - Backend.post backend 125 + send 102 126 (Add_cmis { Protocol.static_cmis = []; dynamic_cmis = Some dcs })) 103 127 packages); 104 128 t ··· 139 163 in 140 164 t.cells <- cell :: t.cells; 141 165 Cell.set_prev ~prev cell; 166 + Cell.set_stop_fn cell (fun () -> reset_all t); 142 167 (* Test linking: connect Test cells to their target Exercise cell *) 143 168 (match mode with 144 169 | Cell.Test ->
+8
src/page.mli
··· 106 106 + Started (initial source loaded from element [textContent]). 107 107 + If all cells so far are loadable, the last cell is auto-run. *) 108 108 109 + (** {1 Reset} *) 110 + 111 + val reset_all : t -> unit 112 + (** [reset_all page] terminates the current worker, creates a fresh one, 113 + replays initialisation messages (Setup, Format_config, Add_cmis), and 114 + resets every cell to {!Cell.Not_run}. The user must re-run cells from 115 + the top since the new worker has no accumulated state. *) 116 + 109 117 (** {1 Lookups} *) 110 118 111 119 val find_by_id : t -> int -> Cell.t
+76 -26
src/style.css
··· 1 - :host { display: block; position: relative; font-family: var(--xo-font, monospace); font-size: var(--xo-font-size, 1.2em) } 1 + :host { display: block; position: relative; font-family: var(--xo-font, monospace); font-size: var(--xo-font-size, 1em); margin-bottom: var(--xo-margin-bottom, 0.75em) } 2 + :host([mode="exercise"]) .cm-editor { border-left: 3px solid var(--xo-exercise-border, #4CAF50); } 3 + :host([mode="exercise"]) .cm-gutters { border-left: none !important; } 2 4 .cm-editor { background: var(--xo-bg, transparent); color: var(--xo-text, inherit); } 3 5 .cm-editor.cm-focused { outline: 1px dotted var(--xo-focus-outline, #AAA) !important } 4 6 .cm-gutters { background: var(--xo-gutter-bg, inherit) !important; color: var(--xo-gutter-text, #6c6c6c) !important; border-right: 1px solid var(--xo-gutter-border, #ddd) !important; } ··· 25 27 } 26 28 27 29 .run_btn { 28 - position: absolute; width: 100%; height: 100%; z-index: 999; pointer-events: none 30 + position: absolute; width: 100%; height: 100%; z-index: 999; pointer-events: none; 29 31 } 30 32 .run_btn button { 31 33 pointer-events: auto; 32 - position: absolute; right: 0; top: 0; 33 - display: block; 34 + position: absolute; right: 2px; top: 2px; 35 + width: 28px; height: 28px; 36 + padding: 0; margin: 0; 37 + border: 2.5px solid transparent; 38 + border-radius: 50%; 39 + background-color: var(--xo-btn-icon, currentColor); 34 40 cursor: pointer; 35 - display: inline-block; 36 - --radius: calc(0.5rem + 4px); 41 + opacity: 0.4; 42 + transition: opacity 0.15s ease; 37 43 box-sizing: border-box; 38 - min-width: calc(2 * var(--radius)); 39 - height: calc(2 * var(--radius)); 40 - padding-left: 0.4rem; 41 - padding-right: 0.4rem; 42 - background-color: var(--xo-btn-bg, #F5F5F5); 43 - border: 1px solid var(--xo-btn-border, #6D6D6D); 44 - color: var(--xo-btn-text, #6D6D6D); 44 + -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Ccircle cx='12' cy='12' r='10' fill='white'/%3E%3C/svg%3E") center / contain no-repeat; 45 + mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Ccircle cx='12' cy='12' r='10' fill='white'/%3E%3C/svg%3E") center / contain no-repeat; 45 46 } 46 - .run_btn button:hover { background-color: var(--xo-btn-hover-bg, #6D6D6D); color: var(--xo-btn-hover-text, #F5F5F5); } 47 - .run_btn button:hover::after { border-color: transparent transparent transparent var(--xo-btn-hover-text, #F5F5F5); } 48 47 .run_btn button::after { 49 48 content: ""; 50 - display: inline-block; 51 - position: relative; 52 - left: 0; 53 - top: 1px; 54 - margin-left: 0.4rem; 55 - box-sizing: border-box; 56 - --radius: 0.35rem; 57 - width: calc(2 * var(--radius)); 58 - height: calc(2 * var(--radius)); 59 - border-color: transparent transparent transparent var(--xo-btn-text, #6D6D6D); 49 + position: absolute; 50 + top: 50%; left: 53%; 51 + transform: translate(-50%, -50%); 52 + width: 0; height: 0; 60 53 border-style: solid; 61 - border-width: var(--radius) 0 var(--radius) calc(2 * var(--radius)); 54 + border-width: 5px 0 5px 8.5px; 55 + border-color: transparent transparent transparent var(--xo-btn-play, white); 62 56 } 57 + .run_btn button:hover { opacity: 0.8; } 58 + 59 + /* Running state: spinning arc with stop square */ 60 + .run_btn button.running { 61 + background-color: transparent; 62 + -webkit-mask: none; 63 + mask: none; 64 + border-color: var(--xo-btn-spinner-track, rgba(128,128,128,0.15)); 65 + border-top-color: var(--xo-btn-spinner, #E53935); 66 + opacity: 1; 67 + animation: xo-spin 0.8s linear infinite; 68 + } 69 + .run_btn button.running::after { 70 + width: 8px; height: 8px; 71 + border: none; 72 + left: 50%; 73 + background: var(--xo-btn-spinner, #E53935); 74 + border-radius: 1px; 75 + } 76 + .run_btn button.running:hover { opacity: 0.7; } 77 + @keyframes xo-spin { to { transform: rotate(360deg); } } 78 + 79 + .test-details { position: relative } 80 + .test-details summary { 81 + cursor: pointer; 82 + font-size: 0.85em; 83 + color: var(--xo-test-summary-text, #666); 84 + padding: 0.3em 0.5em; 85 + user-select: none; 86 + } 87 + .test-details summary:hover { color: var(--xo-test-summary-hover, #333); } 88 + .test-indicator { font-weight: bold; margin-left: 0.2em; } 89 + .test-indicator.test-pass { color: var(--xo-test-pass, #4CAF50); } 90 + .test-indicator.test-fail { color: var(--xo-test-fail, #E53935); } 91 + .test-indicator.test-error { color: var(--xo-test-error, #FF9800); } 63 92 64 93 .caml_stdout { 65 94 background: var(--xo-stdout-bg, #E8F6FF); ··· 89 118 color: var(--xo-text, black); 90 119 border: 1px solid transparent; 91 120 white-space: collapse; 121 + } 122 + 123 + .reset_btn_wrap { 124 + position: absolute; left: 0; bottom: 0; z-index: 999; 125 + pointer-events: none; 126 + } 127 + .reset_exercise_btn { 128 + pointer-events: auto; 129 + cursor: pointer; 130 + font-size: 0.75em; 131 + padding: 0.15em 0.5em; 132 + background: var(--xo-reset-btn-bg, rgba(0,0,0,0.05)); 133 + border: 1px solid var(--xo-reset-btn-border, rgba(0,0,0,0.15)); 134 + color: var(--xo-reset-btn-text, #888); 135 + opacity: 0.5; 136 + transition: opacity 0.15s; 137 + } 138 + .reset_exercise_btn:hover { 139 + opacity: 1; 140 + background: var(--xo-reset-btn-hover-bg, rgba(0,0,0,0.1)); 141 + color: var(--xo-reset-btn-hover-text, #333); 92 142 } 93 143 94 144 table, tr, td, th {