From e6084c20b881e0fd685f7e4532acbf0c33e9d9fa Mon Sep 17 00:00:00 2001 From: Matt Keenan <31668468+Negabinary@users.noreply.github.com> Date: Tue, 3 Feb 2026 16:26:20 -0500 Subject: [PATCH 01/11] Select by range not id in stepper --- src/haz3lcore/zipper/action/Action.re | 2 ++ src/haz3lcore/zipper/action/Perform.re | 7 ++++ src/web/app/editors/result/StepperEditor.re | 39 +++++++++++++++------ 3 files changed, 38 insertions(+), 10 deletions(-) diff --git a/src/haz3lcore/zipper/action/Action.re b/src/haz3lcore/zipper/action/Action.re index 9b1e045e3a..fdad7e46a8 100644 --- a/src/haz3lcore/zipper/action/Action.re +++ b/src/haz3lcore/zipper/action/Action.re @@ -34,6 +34,7 @@ type rel = [@deriving (show({with_path: false}), sexp, yojson, eq)] type select = | All + | PointToPoint((Point.t, Point.t)) | Resize(move) | Smart(int) | Tile(rel) @@ -224,6 +225,7 @@ let should_animate: t => bool = switch (s) { | Resize(_) => false | All + | PointToPoint(_) | Smart(_) | Tile(_) | Term(_) diff --git a/src/haz3lcore/zipper/action/Perform.re b/src/haz3lcore/zipper/action/Perform.re index 79ac700901..604e80aecb 100644 --- a/src/haz3lcore/zipper/action/Perform.re +++ b/src/haz3lcore/zipper/action/Perform.re @@ -94,6 +94,13 @@ let go = |> return(Cant_select) | Select(Resize(Goal(_))) => failwith("Select not implemented for goals") | Select(All) => Ok(Select.all(z)) + | Select(PointToPoint((p1, p2))) => + z + |> Move.to_point(~measured=syntax.measured, ~goal=p1) + |> OptUtil.and_then(z => + Select.to_point(~measured=syntax.measured, ~goal=p2, z) + ) + |> return(Cant_select) | Select(Term(Current)) => Select.current_term( syntax.term_data, diff --git a/src/web/app/editors/result/StepperEditor.re b/src/web/app/editors/result/StepperEditor.re index 5b4cbb9aa1..0361bc8a7f 100644 --- a/src/web/app/editors/result/StepperEditor.re +++ b/src/web/app/editors/result/StepperEditor.re @@ -130,17 +130,36 @@ module View = { taken_steps(model.taken_steps) @ next_steps(model.next_steps, ~inject=x => - Some(List.nth(model.next_steps, x)) == selected_id - ? signal(TakeStep(x)) - : inject(Select(Term(Id(List.nth(model.next_steps, x), Right)))) + { + open OptUtil.Syntax; + let+ range = + TermData.extreme_measures( + List.nth(model.next_steps, x), + model.editor.editor.syntax.term_data, + model.editor.editor.syntax.measured, + ); + Some(List.nth(model.next_steps, x)) == selected_id + ? signal(TakeStep(x)) : inject(Select(PointToPoint(range))); + } + |> Option.value(~default=Ui_effect.Ignore) ) - @ refl_steps(model.refls, ~inject=x => { - Some(List.nth(model.refls, x)) == selected_id - ? signal(Refl(x)) - : { - inject(Select(Term(Id(List.nth(model.refls, x), Right)))); - } - }); + @ refl_steps(model.refls, ~inject=x => + { + open OptUtil.Syntax; + let+ range = + TermData.extreme_measures( + List.nth(model.refls, x), + model.editor.editor.syntax.term_data, + model.editor.editor.syntax.measured, + ); + Some(List.nth(model.refls, x)) == selected_id + ? signal(Refl(x)) + : { + inject(Select(PointToPoint(range))); + }; + } + |> Option.value(~default=Ui_effect.Ignore) + ); }; /* Steppers don't support probe dynamics - expressions shown are From 787420c403907dc35136756f1dd90e8835b73aa1 Mon Sep 17 00:00:00 2001 From: Matt Keenan <31668468+Negabinary@users.noreply.github.com> Date: Tue, 11 Nov 2025 14:09:11 -0500 Subject: [PATCH 02/11] add basic replay functionality --- src/web/Export.re | 18 ++++++ src/web/Main.re | 7 ++- src/web/app/History.re | 74 +++++++++++++++++++++++ src/web/app/Log.re | 23 +++++++ src/web/app/Page.re | 39 ++++++++++-- src/web/app/editors/mode/ExercisesMode.re | 14 ++++- src/web/app/globals/Globals.re | 6 ++ 7 files changed, 173 insertions(+), 8 deletions(-) diff --git a/src/web/Export.re b/src/web/Export.re index 189018f2c1..c51dae1736 100644 --- a/src/web/Export.re +++ b/src/web/Export.re @@ -80,3 +80,21 @@ let import_all = ); import_log(all.log); }; + +let import_just_log = (data: string) => { + let all = + try(data |> Yojson.Safe.from_string |> all_of_yojson) { + | _ => + let all_public = data |> Yojson.Safe.from_string |> all_public_of_yojson; + { + settings: all_public.settings, + scratch: all_public.scratch, + documentation: "", + exercise: all_public.exercise, + tutorial: all_public.tutorial, + log: all_public.log, + explainThisModel: "", + }; + }; + all.log; +}; diff --git a/src/web/Main.re b/src/web/Main.re index 6ae86a3787..36d0869b11 100644 --- a/src/web/Main.re +++ b/src/web/Main.re @@ -200,7 +200,12 @@ let start = { // View function let%arr app_model = app_model and app_inject = app_inject; - History.View.view(app_model, ~inject=app_inject, ~get_log_and=Log.get_and); + History.View.view( + app_model, + ~inject=app_inject, + ~get_log_and=Log.get_and, + ~next_log=Util.ListUtil.hd_opt(app_model.future_log), + ); }; switch (JsUtil.Fragment.get_current()) { diff --git a/src/web/app/History.re b/src/web/app/History.re index d28b319f43..22e5d72b31 100644 --- a/src/web/app/History.re +++ b/src/web/app/History.re @@ -9,6 +9,7 @@ module Model = { current: state, undo_stack: list(Updated.t(state)), redo_stack: list(Updated.t(state)), + future_log: list(Page.Update.t), }; let equal = (===); @@ -17,6 +18,7 @@ module Model = { current: Page.Store.load(), undo_stack: [], redo_stack: [], + future_log: [], }; }; @@ -46,6 +48,7 @@ module Update = { ...x, model: { current: x.model, + future_log: model.future_log, undo_stack: rest, redo_stack: [ { @@ -66,6 +69,7 @@ module Update = { ...x, model: { current: x.model, + future_log: model.future_log, undo_stack: [ { ...x, @@ -77,6 +81,73 @@ module Update = { }, } } + | Globals(NextLog) => + switch (model.future_log) { + | [] => + print_endline("No next log action to perform"); + model |> return_quiet; + | [next, ...rest] => + print_endline( + "Applying next log action: " ++ Page.Update.show(next), + ); + let updated = + try( + Page.Update.update( + ~import_log, + ~get_log_and, + ~schedule_action, + next, + model.current, + ) + ) { + | _ => + print_endline("Failed to apply log action"); + model.current |> Updated.return_quiet; + }; + { + ...updated, + model: { + current: updated.model, + undo_stack: [ + { + ...updated, + model: model.current, + }, + ...model.undo_stack, + ], + redo_stack: model.redo_stack, + future_log: rest, + }, + }; + } + | Globals(InitImportLog(f)) => + JsUtil.read_file(f, data => + schedule_action(Globals(FinishImportLog(data))) + ); + model |> return_quiet; + | Globals(FinishImportLog(None)) => + print_endline("Log import failed"); + model |> return_quiet; + | Globals(FinishImportLog(Some(data))) => + let actions = + data + |> Export.import_just_log + |> Sexplib.Sexp.of_string + |> Log.Entry.s_of_sexp + |> ( + x => { + print_endline( + "Imported log entries: " ++ string_of_int(List.length(x)), + ); + x; + } + ) + |> List.map(((_ts, action)) => action); + { + ...model, + future_log: model.future_log @ actions, + } + |> return_quiet; | action => let current = Page.Update.update( @@ -92,6 +163,7 @@ module Update = { ...current, model: { current: current.model, + future_log: model.future_log, undo_stack: [ { ...current, @@ -109,6 +181,7 @@ module Update = { current: current.model, undo_stack: model.undo_stack, redo_stack: model.redo_stack, + future_log: model.future_log, }, }; }; @@ -127,6 +200,7 @@ module Update = { |> Page.Update.calculate(~schedule_action, ~is_edited, ~dynamics), undo_stack: model.undo_stack, redo_stack: model.redo_stack, + future_log: model.future_log, }; }; diff --git a/src/web/app/Log.re b/src/web/app/Log.re index c2b596cc14..9babdcdae1 100644 --- a/src/web/app/Log.re +++ b/src/web/app/Log.re @@ -85,3 +85,26 @@ let update = (action: Page.Update.t, result: Updated.t('a)): unit => let get_and = (f: string => unit): unit => DB.get_all(entries => f("(" ++ String.concat(" ", entries) ++ ")")); + +let to_actions = () => { + print_endline("HELLO??"); + let actions = ref([]); + DB.get_all(entries => { + print_endline( + "num of entries: " ++ string_of_int(List.length(entries)), + ); + entries + |> List.iter(entry_str => + try({ + let (_ts, action) = + entry_str |> Sexplib.Sexp.of_string |> Entry.t_of_sexp; + actions := [action, ...actions^]; + }) { + | _ => print_endline("Log.to_actions: Deserialization error") + } + ); + actions := List.rev(actions^); + }); + print_endline("num of actions: " ++ string_of_int(List.length(actions^))); + actions^; +}; diff --git a/src/web/app/Page.re b/src/web/app/Page.re index 518c72374a..c8f56e10a9 100644 --- a/src/web/app/Page.re +++ b/src/web/app/Page.re @@ -244,8 +244,12 @@ module Update = { editors, }; }; + | InitImportLog(_) + | FinishImportLog(_) + | NextLog | Undo - | Redo => failwith("Undo/Redo are handled in the history module") + | Redo => + failwith("Undo/Redo/Log import are handled in the history module") }; }; @@ -630,7 +634,13 @@ module View = { ); }; - let top_bar = (~globals, ~inject: Update.t => Ui_effect.t(unit), ~editors) => + let top_bar = + ( + ~globals, + ~inject: Update.t => Ui_effect.t(unit), + ~editors, + ~next_log: option(Update.t), + ) => div( ~attrs=[Attr.id("top-bar")], [ @@ -653,7 +663,18 @@ module View = { ), ], ), - ], + ] + @ ( + next_log + |> Option.map(_ => + Widgets.button( + text(">>"), + _ => Ui_effect.Many([inject(Globals(NextLog))]), + ~tooltip="Play next action from imported log", + ) + ) + |> Option.to_list + ), ); let main_view = @@ -661,6 +682,7 @@ module View = { ~get_log_and: (string => unit) => unit, ~inject: Update.t => Ui_effect.t(unit), ~cursor: Cursor.cursor(Editors.Update.t), + ~next_log: option(Update.t), { globals, editors, @@ -738,7 +760,7 @@ module View = { }; [ - top_bar(~globals, ~inject, ~editors), + top_bar(~globals, ~inject, ~editors, ~next_log), div( ~attrs=[ Attr.id("main"), @@ -754,12 +776,17 @@ module View = { }; let view = - (~get_log_and, ~inject: Update.t => Ui_effect.t(unit), model: Model.t) => { + ( + ~get_log_and, + ~inject: Update.t => Ui_effect.t(unit), + ~next_log: option(Update.t), + model: Model.t, + ) => { let cursor = Selection.get_cursor_info(~selection=model.selection, model); div( ~attrs=[Attr.id("page"), ...handlers(~cursor, ~inject, model)], [FontSpecimen.view, JsUtil.clipboard_shim] - @ main_view(~get_log_and, ~cursor, ~inject, model), + @ main_view(~get_log_and, ~cursor, ~inject, ~next_log, model), ); }; }; diff --git a/src/web/app/editors/mode/ExercisesMode.re b/src/web/app/editors/mode/ExercisesMode.re index 341c614796..ead605307f 100644 --- a/src/web/app/editors/mode/ExercisesMode.re +++ b/src/web/app/editors/mode/ExercisesMode.re @@ -568,6 +568,18 @@ module View = { }, ~tooltip="Import Submission", ); + let import_logs = + Widgets.file_select_button_named( + "import-logs", + Icons.import, + file => { + switch (file) { + | None => Virtual_dom.Vdom.Effect.Ignore + | Some(file) => globals.inject_global(InitImportLog(file)) + } + }, + ~tooltip="Import Logs", + ); let reset_hazel = button_named( @@ -597,7 +609,7 @@ module View = { NutMenu.item_group( ~inject, "File", - [export_submission, import_submission], + [export_submission, import_submission, import_logs], ); let reset_group_exercises = () => diff --git a/src/web/app/globals/Globals.re b/src/web/app/globals/Globals.re index 6cfa9f51f9..6fc6a3293a 100644 --- a/src/web/app/globals/Globals.re +++ b/src/web/app/globals/Globals.re @@ -51,8 +51,11 @@ module Action = { | JumpToTile(Haz3lcore.Id.t) // Perform(Select(Term(Id(id, Left)))) | InitImportAll([@opaque] Js_of_ocaml.Js.t(Js_of_ocaml.File.file)) | FinishImportAll(option(string)) + | InitImportLog([@opaque] Js_of_ocaml.Js.t(Js_of_ocaml.File.file)) + | FinishImportLog(option(string)) | ExportForInit | ActiveEditor(Haz3lcore.Action.t) + | NextLog | Undo // These two currently happen at the editor level, and are just | Redo // global actions so they can be accessed by the command palette | SetMetaDown(bool) @@ -141,6 +144,9 @@ module Update = { | Redo => false | SetMetaDown(_) => false | UpdateVisibleRows(_) => false + | InitImportLog(_) => false + | FinishImportLog(_) => false + | NextLog => false }; }; }; From 3c8a5df6bad06c3c1b975f94633a642af2868f46 Mon Sep 17 00:00:00 2001 From: Matt Keenan <31668468+Negabinary@users.noreply.github.com> Date: Wed, 12 Nov 2025 10:18:00 -0500 Subject: [PATCH 03/11] pull out logs from imports within logs --- src/web/app/History.re | 14 +++++++++----- src/web/app/Log.re | 15 +++++++++++++++ 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/src/web/app/History.re b/src/web/app/History.re index 22e5d72b31..1e88c36d15 100644 --- a/src/web/app/History.re +++ b/src/web/app/History.re @@ -129,11 +129,16 @@ module Update = { print_endline("Log import failed"); model |> return_quiet; | Globals(FinishImportLog(Some(data))) => - let actions = - data - |> Export.import_just_log + let of_data = (data: string): list(Page.Update.t) => + Export.import_just_log(data) |> Sexplib.Sexp.of_string |> Log.Entry.s_of_sexp + |> List.map(((_ts, action)) => action); + let actions = + data + |> of_data + |> Log.flatten_imports(~of_data) + |> List.rev |> ( x => { print_endline( @@ -141,8 +146,7 @@ module Update = { ); x; } - ) - |> List.map(((_ts, action)) => action); + ); { ...model, future_log: model.future_log @ actions, diff --git a/src/web/app/Log.re b/src/web/app/Log.re index 9babdcdae1..0625044f4d 100644 --- a/src/web/app/Log.re +++ b/src/web/app/Log.re @@ -108,3 +108,18 @@ let to_actions = () => { print_endline("num of actions: " ++ string_of_int(List.length(actions^))); actions^; }; + +// If the user switched browsers or devices, they may have imported a save state from another device, this includes the log from the previous device in a complete stitched log. +let rec flatten_imports = + (~of_data: string => list(Page.Update.t), log: list(Page.Update.t)) + : list(Page.Update.t) => { + let rec inner = (log: list(Page.Update.t)) => { + switch (log) { + | [] => [] + | [Globals(FinishImportAll(Some(data))), ..._rest] => + flatten_imports(~of_data, of_data(data)) + | [x, ...rest] => [x, ...inner(rest)] + }; + }; + log |> List.rev |> inner; +}; From 22238b86b847965379182ffdee11cacb3a86d4cb Mon Sep 17 00:00:00 2001 From: Matt Keenan <31668468+Negabinary@users.noreply.github.com> Date: Fri, 21 Nov 2025 18:02:52 -0500 Subject: [PATCH 04/11] add more replay controls --- src/util/JsUtil.re | 7 + src/web/Main.re | 61 ++++-- src/web/Updated.re | 6 + src/web/app/History.re | 199 ++++++++++++------- src/web/app/Log.re | 39 +++- src/web/app/Page.re | 38 +++- src/web/app/editors/Editors.re | 6 +- src/web/app/editors/mode/ExercisesMode.re | 6 +- src/web/app/editors/result/EvalResult.re | 4 +- src/web/app/editors/result/Theorems.re | 2 +- src/web/app/editors/stepper/InductionStep.re | 4 +- src/web/app/editors/stepper/MissingStep.re | 8 +- src/web/app/editors/stepper/StepperBase.re | 18 +- src/web/app/globals/Globals.re | 17 +- src/web/view/ExerciseMode.re | 4 +- src/web/view/TutorialMode.re | 2 +- 16 files changed, 289 insertions(+), 132 deletions(-) diff --git a/src/util/JsUtil.re b/src/util/JsUtil.re index 7973607f92..15a6e7f687 100644 --- a/src/util/JsUtil.re +++ b/src/util/JsUtil.re @@ -48,6 +48,13 @@ let timestamp = () => date_now()##valueOf; let precise_timestamp = () => Js.Unsafe.global##.performance##now()##valueOf; +let print_timestamp = (ts: float): string => { + let date = + Js.Unsafe.new_obj(Js.date_fromTimeValue, [|Js.Unsafe.inject(ts)|]); + let date_str = date##toLocaleString(Js.undefined, Js.undefined); + date_str; +}; + let download_string_file = (~filename: string, ~content_type: string, ~contents: string) => { let blob = File.blob_from_string(~contentType=content_type, contents); diff --git a/src/web/Main.re b/src/web/Main.re index 36d0869b11..9169bfd1f6 100644 --- a/src/web/Main.re +++ b/src/web/Main.re @@ -56,12 +56,21 @@ let apply = }; // ---------- CALCULATE PHASE ---------- let model' = - updated.model - |> History.Update.calculate( - ~schedule_action, - ~is_edited=updated.is_edit, - ~dynamics=true, - ); + try( + updated.model + |> History.Update.calculate( + ~schedule_action, + ~is_edited=updated.is_edit, + ~dynamics=true, + ) + ) { + | _ => + print_endline("ERROR: Exception during calculate phase"); + { + ...model, + replay_toggle: false, + }; + }; if (updated.is_edit) { schedule_autosave( @@ -114,6 +123,22 @@ let start = { Bonsai.Value.map(~f=g => g(Page.Update.Save), app_inject); let%sub () = BonsaiUtil.Alarm.listen(save_scheduler, ~event=save_effect); + let replay_effect = { + let%map app_inject = app_inject + and model = app_model; + Ui_effect.Many( + model.replay_toggle + ? [app_inject(Page.Update.Globals(Log(NextLog)))] : [], + ); + }; + + let%sub () = + Bonsai.Clock.every( + ~when_to_start_next_effect=`Wait_period_after_previous_effect_finishes_blocking, + Core.Time_ns.Span.of_sec(0.1), + replay_effect, + ); + // Update font metrics on resize let%sub size = BonsaiUtil.SizeObserver.observer( @@ -200,12 +225,24 @@ let start = { // View function let%arr app_model = app_model and app_inject = app_inject; - History.View.view( - app_model, - ~inject=app_inject, - ~get_log_and=Log.get_and, - ~next_log=Util.ListUtil.hd_opt(app_model.future_log), - ); + try( + History.View.view( + app_model, + ~inject=app_inject, + ~get_log_and=Log.get_and, + ~next_log= + Util.ListUtil.hd_opt(app_model.future_log) |> Option.map(snd), + ) + ) { + | exc => + print_endline( + "ERROR: Exception during view: " ++ Printexc.to_string(exc), + ); + WebUtil.Node.div( + ~attrs=[WebUtil.Attr.id("page")], + [WebUtil.Node.text("An error occurred.")], + ); + }; }; switch (JsUtil.Fragment.get_current()) { diff --git a/src/web/Updated.re b/src/web/Updated.re index 7a323f9660..1ea479270f 100644 --- a/src/web/Updated.re +++ b/src/web/Updated.re @@ -54,3 +54,9 @@ let return_quiet = historic, }; }; + +exception InvalidAction; + +let raise_invalid_action = _ => { + raise(InvalidAction); +}; diff --git a/src/web/app/History.re b/src/web/app/History.re index 1e88c36d15..63da187a6e 100644 --- a/src/web/app/History.re +++ b/src/web/app/History.re @@ -9,7 +9,8 @@ module Model = { current: state, undo_stack: list(Updated.t(state)), redo_stack: list(Updated.t(state)), - future_log: list(Page.Update.t), + future_log: list((float, Page.Update.t)), + replay_toggle: bool, }; let equal = (===); @@ -19,6 +20,7 @@ module Model = { undo_stack: [], redo_stack: [], future_log: [], + replay_toggle: false, }; }; @@ -28,6 +30,15 @@ module Update = { [@deriving (show({with_path: false}), sexp, yojson)] type t = Page.Update.t; + // let sexp = Page.Update.sexp_of_t(action); + // For now, we don't ignore any actions; add here if needed + // check if str contains "(Select (Term (Id" (ignoring whitespace) + // let str = Sexplib.Sexp.to_string(sexp); + // StringUtil.match(StringUtil.regexp("Select\\s*\\(Term\\s*\\(Id"), str); + let ignore_if_action_fails_in_log_replay = (_action: t): bool => { + false; + }; + [@deriving (show({with_path: false}), sexp, yojson)] let update = ( @@ -43,7 +54,7 @@ module Update = { switch (model.undo_stack) { | [] => print_endline("Cannot undo"); - model |> return_quiet; + model |> Updated.raise_invalid_action; | [x, ...rest] => { ...x, model: { @@ -57,6 +68,7 @@ module Update = { }, ...model.redo_stack, ], + replay_toggle: model.replay_toggle, }, } } @@ -64,7 +76,7 @@ module Update = { switch (model.redo_stack) { | [] => print_endline("Cannot redo"); - model |> return_quiet; + model |> Updated.raise_invalid_action; | [x, ...rest] => { ...x, model: { @@ -78,80 +90,130 @@ module Update = { ...model.undo_stack, ], redo_stack: rest, + replay_toggle: model.replay_toggle, }, } } - | Globals(NextLog) => - switch (model.future_log) { - | [] => - print_endline("No next log action to perform"); - model |> return_quiet; - | [next, ...rest] => - print_endline( - "Applying next log action: " ++ Page.Update.show(next), + | Globals(Log(a)) => + switch (a) { + | InitImport(f) => + JsUtil.read_file(f, data => + schedule_action(Globals(Log(FinishImport(data)))) ); - let updated = - try( - Page.Update.update( - ~import_log, - ~get_log_and, - ~schedule_action, - next, - model.current, - ) - ) { + model |> Updated.return_quiet; + | FinishImport(None) => + print_endline("Log import failed"); + model |> Updated.return_quiet; + | FinishImport(Some(data)) => + let of_data = (data: string): list((float, Page.Update.t)) => + Export.import_just_log(data) + |> Sexplib.Sexp.of_string + |> Log.Entry.s_of_sexp_opt + |> List.filter_map(x => x); + // |> List.filter(((ts, _action)) => ts > 1757794060000.0); + let actions = + data + |> of_data + |> Log.flatten_imports(~of_data) + |> ( + x => { + print_endline( + "Imported log entries: " ++ string_of_int(List.length(x)), + ); + x; + } + ); + { + ...model, + future_log: model.future_log @ actions, + } + |> Updated.return_quiet; + | NextLog => + switch (model.future_log) { + | [] => + print_endline("No next log action to perform"); + model |> Updated.return_quiet; + | [(t, next), ...rest] => + print_endline( + "Applying next log action: " + ++ JsUtil.print_timestamp(t) + ++ " :: " + ++ Page.Update.show(next), + ); + try({ + let updated = + Page.Update.update( + ~import_log, + ~get_log_and, + ~schedule_action, + next, + model.current, + ); + { + ...updated, + model: { + current: updated.model, + undo_stack: [ + { + ...updated, + model: model.current, + }, + ...model.undo_stack, + ], + redo_stack: model.redo_stack, + future_log: rest, + replay_toggle: model.replay_toggle, + }, + }; + }) { | _ => print_endline("Failed to apply log action"); - model.current |> Updated.return_quiet; + Model.{ + ...model, + future_log: + ignore_if_action_fails_in_log_replay(next) + ? rest : model.future_log, + replay_toggle: + ignore_if_action_fails_in_log_replay(next) + ? model.replay_toggle : false, + } + |> Updated.return_quiet; }; - { - ...updated, - model: { - current: updated.model, - undo_stack: [ - { - ...updated, - model: model.current, - }, - ...model.undo_stack, - ], - redo_stack: model.redo_stack, + } + | SkipLog => + print_endline("Skipping the next log entry"); + switch (model.future_log) { + | [] => + print_endline("No log entry to skip"); + model |> return_quiet; + | [(_, _), ...rest] => + { + ...model, future_log: rest, - }, - }; - } - | Globals(InitImportLog(f)) => - JsUtil.read_file(f, data => - schedule_action(Globals(FinishImportLog(data))) - ); - model |> return_quiet; - | Globals(FinishImportLog(None)) => - print_endline("Log import failed"); - model |> return_quiet; - | Globals(FinishImportLog(Some(data))) => - let of_data = (data: string): list(Page.Update.t) => - Export.import_just_log(data) - |> Sexplib.Sexp.of_string - |> Log.Entry.s_of_sexp - |> List.map(((_ts, action)) => action); - let actions = - data - |> of_data - |> Log.flatten_imports(~of_data) - |> List.rev - |> ( - x => { - print_endline( - "Imported log entries: " ++ string_of_int(List.length(x)), - ); - x; } - ); - { - ...model, - future_log: model.future_log @ actions, + |> return_quiet + }; + | SkipExercise => + print_endline("Skipping to the next exercise in the log"); + let rec skip_to_next_exercise = (log: list((float, Page.Update.t))) => + switch (log) { + | [] => [] + | [(_, Editors(SwitchMode(_))), ..._] as rest => rest + | [(_, Editors(Exercises(SwitchExercise(_)))), ..._] as rest => rest + | [(_, _), ...rest] => skip_to_next_exercise(rest) + }; + { + ...model, + future_log: skip_to_next_exercise(model.future_log), + } + |> return_quiet; + | ToggleReplay => + Model.{ + ...model, + replay_toggle: !model.replay_toggle, + } + |> return_quiet } - |> return_quiet; | action => let current = Page.Update.update( @@ -176,6 +238,7 @@ module Update = { ...model.undo_stack, ], redo_stack: [], + replay_toggle: model.replay_toggle, }, }; } else { @@ -186,6 +249,7 @@ module Update = { undo_stack: model.undo_stack, redo_stack: model.redo_stack, future_log: model.future_log, + replay_toggle: model.replay_toggle, }, }; }; @@ -205,6 +269,7 @@ module Update = { undo_stack: model.undo_stack, redo_stack: model.redo_stack, future_log: model.future_log, + replay_toggle: model.replay_toggle, }; }; diff --git a/src/web/app/Log.re b/src/web/app/Log.re index 0625044f4d..7ae7c1d596 100644 --- a/src/web/app/Log.re +++ b/src/web/app/Log.re @@ -63,6 +63,20 @@ module Entry = { Printf.sprintf("%.0f", ts), (ts, action) |> sexp_of_t |> Sexplib.Sexp.to_string, ); + + let s_of_sexp_opt = (sexp: Sexplib.Sexp.t): list(option(t)) => + switch (sexp) { + | Sexplib.Sexp.List(lst) => + List.rev_map( + entry_sexp => + try(Some(t_of_sexp(entry_sexp))) { + | _ => None + }, + lst, + ) + |> List.rev + | _ => [] + }; }; let import = (data: string): unit => @@ -110,16 +124,23 @@ let to_actions = () => { }; // If the user switched browsers or devices, they may have imported a save state from another device, this includes the log from the previous device in a complete stitched log. -let rec flatten_imports = - (~of_data: string => list(Page.Update.t), log: list(Page.Update.t)) - : list(Page.Update.t) => { - let rec inner = (log: list(Page.Update.t)) => { +let flatten_imports = + ( + ~of_data: string => list((float, Page.Update.t)), + log: list((float, Page.Update.t)), + ) + : list((float, Page.Update.t)) => { + let rec inner = + ( + log: list((float, Page.Update.t)), + acc: list((float, Page.Update.t)), + ) => { switch (log) { - | [] => [] - | [Globals(FinishImportAll(Some(data))), ..._rest] => - flatten_imports(~of_data, of_data(data)) - | [x, ...rest] => [x, ...inner(rest)] + | [] => acc + | [(_t, Globals(FinishImportAll(Some(data)))), ..._rest] => + inner(List.rev(of_data(data)), acc) + | [x, ...rest] => inner(rest, [x, ...acc]) }; }; - log |> List.rev |> inner; + log |> List.rev |> inner(_, []); }; diff --git a/src/web/app/Page.re b/src/web/app/Page.re index c8f56e10a9..457523c0c6 100644 --- a/src/web/app/Page.re +++ b/src/web/app/Page.re @@ -131,7 +131,7 @@ module Update = { model.editors, ); switch (jump) { - | None => model |> Updated.return_quiet + | None => model |> Updated.raise_invalid_action | Some((action, selection)) => let* editors = Editors.Update.update( @@ -244,9 +244,7 @@ module Update = { editors, }; }; - | InitImportLog(_) - | FinishImportLog(_) - | NextLog + | Log(_) | Undo | Redo => failwith("Undo/Redo/Log import are handled in the history module") @@ -667,14 +665,34 @@ module View = { @ ( next_log |> Option.map(_ => - Widgets.button( - text(">>"), - _ => Ui_effect.Many([inject(Globals(NextLog))]), - ~tooltip="Play next action from imported log", - ) + [ + Widgets.button( + text("next"), + _ => Ui_effect.Many([inject(Globals(Log(NextLog)))]), + ~tooltip="Play next action from imported log", + ), + Widgets.button( + text("skip"), + _ => Ui_effect.Many([inject(Globals(Log(SkipLog)))]), + ~tooltip="Skip the rest of the imported log", + ), + Widgets.button( + text("skip exercise"), + _ => Ui_effect.Many([inject(Globals(Log(SkipExercise)))]), + ~tooltip="Skip to the next exercise in the imported log", + ), + ] ) |> Option.to_list - ), + |> List.flatten + ) + @ [ + Widgets.button( + text("play/pause"), + _ => Ui_effect.Many([inject(Globals(Log(ToggleReplay)))]), + ~tooltip="Toggle replaying imported log automatically", + ), + ], ); let main_view = diff --git a/src/web/app/editors/Editors.re b/src/web/app/editors/Editors.re index 2958025af7..5ec1e91cda 100644 --- a/src/web/app/editors/Editors.re +++ b/src/web/app/editors/Editors.re @@ -168,10 +168,10 @@ module Update = { | (Scratch(_), Exercises(_)) | (Scratch(_), Tutorial(_)) | (Exercises(_), Scratch(_)) - | (Exercises(_), Documentation(_)) => model |> return_quiet + | (Exercises(_), Tutorial(_)) + | (Exercises(_), Documentation(_)) => model |> raise_invalid_action | (SwitchMode(Scratch), Scratch(_)) | (SwitchMode(Documentation), Documentation(_)) - | (Exercises(_), Tutorial(_)) => model |> return_quiet | (SwitchMode(Exercises), Exercises(_)) => model |> return_quiet | (SwitchMode(Scratch), _) => Model.Scratch( @@ -185,7 +185,7 @@ module Update = { |> ScratchMode.Model.unpersist(~settings=globals.settings.core), ) |> return - | (SwitchMode(Tutorial), Tutorial(_)) => model |> return_quiet + | (SwitchMode(Tutorial), Tutorial(_)) => model |> raise_invalid_action | (SwitchMode(Tutorial), _) => Model.Tutorial( TutorialsMode.Store.load( diff --git a/src/web/app/editors/mode/ExercisesMode.re b/src/web/app/editors/mode/ExercisesMode.re index ead605307f..d650df79a7 100644 --- a/src/web/app/editors/mode/ExercisesMode.re +++ b/src/web/app/editors/mode/ExercisesMode.re @@ -347,7 +347,7 @@ module Update = { current: model.current, exercises: new_exercises, }; - | (_, Exercise(_)) => model |> return_quiet + | (_, Exercise(_)) => model |> raise_invalid_action | (Theorem(ex), TheoremExercise(action)) => let* new_current = TheoremExerciseMode.Update.update( @@ -365,7 +365,7 @@ module Update = { current: model.current, exercises: new_exercises, }; - | (_, TheoremExercise(_)) => model |> return_quiet + | (_, TheoremExercise(_)) => model |> raise_invalid_action | (_, SwitchExercise(n)) => Model.{ current: n, @@ -575,7 +575,7 @@ module View = { file => { switch (file) { | None => Virtual_dom.Vdom.Effect.Ignore - | Some(file) => globals.inject_global(InitImportLog(file)) + | Some(file) => globals.inject_global(Log(InitImport(file))) } }, ~tooltip="Import Logs", diff --git a/src/web/app/editors/result/EvalResult.re b/src/web/app/editors/result/EvalResult.re index fe8688e5a4..bb34f4edd9 100644 --- a/src/web/app/editors/result/EvalResult.re +++ b/src/web/app/editors/result/EvalResult.re @@ -132,7 +132,7 @@ module Update = { ...model, display: Stepper(stepper), }; - | (StepperAction(_), _) => model |> Updated.return_quiet + | (StepperAction(_), _) => model |> Updated.raise_invalid_action | ( EvalEditorAction(a), {display: Evaluation(Calculated(Some((exp, editor)))), _}, @@ -142,7 +142,7 @@ module Update = { ...model, display: Evaluation(Calculated(Some((exp, editor)))), }; - | (EvalEditorAction(_), _) => model |> Updated.return_quiet + | (EvalEditorAction(_), _) => model |> Updated.raise_invalid_action | (TheoremsAction(action), _) => let* theorems = Theorems.Update.update(~settings, action, model.theorems); diff --git a/src/web/app/editors/result/Theorems.re b/src/web/app/editors/result/Theorems.re index 89d0dca6b3..645e8632f7 100644 --- a/src/web/app/editors/result/Theorems.re +++ b/src/web/app/editors/result/Theorems.re @@ -139,7 +139,7 @@ module Update = { ...model, thm_map, }; - | None => model |> Updated.return_quiet + | None => model |> Updated.raise_invalid_action }; }; }; diff --git a/src/web/app/editors/stepper/InductionStep.re b/src/web/app/editors/stepper/InductionStep.re index fea6a6c537..073cba731e 100644 --- a/src/web/app/editors/stepper/InductionStep.re +++ b/src/web/app/editors/stepper/InductionStep.re @@ -137,7 +137,7 @@ module F = ...model, cases: ListUtil.put_nth(i, new_case, model.cases), }; - | None => model |> return_quiet + | None => model |> raise_invalid_action } | AddCase => let new_case = InductionCase.init; @@ -154,7 +154,7 @@ module F = cases: new_cases, } |> return - | None => model |> return_quiet + | None => model |> raise_invalid_action } } ); diff --git a/src/web/app/editors/stepper/MissingStep.re b/src/web/app/editors/stepper/MissingStep.re index b5a577c737..e787f86fcd 100644 --- a/src/web/app/editors/stepper/MissingStep.re +++ b/src/web/app/editors/stepper/MissingStep.re @@ -106,7 +106,7 @@ module Update = { editor: new_editor, }), }; - | (RewriteEditorAction(_), _) => model |> Updated.return_quiet + | (RewriteEditorAction(_), _) => model |> Updated.raise_invalid_action | (UpdateResult(result), RewritesOpen(r)) => Model.{ ...model, @@ -116,15 +116,15 @@ module Update = { cached_result: Some(result), }), } - |> Updated.return_quiet - | (UpdateResult(_), _) => model |> Updated.return_quiet + |> Updated.raise_invalid_action + | (UpdateResult(_), _) => model |> Updated.raise_invalid_action | (AxiomBoxAction(action), AxiomsOpen(m)) => let* updated = AxiomsBox.Update.update(~settings, action, m); Model.{ ...model, open_box: Model.AxiomsOpen(updated), }; - | (AxiomBoxAction(_), _) => model |> Updated.return_quiet(~logged=true) + | (AxiomBoxAction(_), _) => model |> Updated.raise_invalid_action }; }; diff --git a/src/web/app/editors/stepper/StepperBase.re b/src/web/app/editors/stepper/StepperBase.re index 740e0b76d4..52fe31fa13 100644 --- a/src/web/app/editors/stepper/StepperBase.re +++ b/src/web/app/editors/stepper/StepperBase.re @@ -173,7 +173,7 @@ module rec StepKind: { AlgebriteStep(_), _, ) => - model |> Updated.return_quiet + model |> Updated.raise_invalid_action } ); }; @@ -632,7 +632,7 @@ and Stepper: { switch (action, model.step_kind, model.next_step) { | (EditorAction(ea), _, _) => switch (model.editor) { - | Calc.Pending => model |> return_quiet + | Calc.Pending => model |> raise_invalid_action | Calc.Calculated(editor) => let* new_editor = CodeSelectable.Update.update(~settings, ea, editor); @@ -647,7 +647,7 @@ and Stepper: { ...model, next_step: Some(new_next_step), }; - | (NextStep(_), _, None) => model |> return_quiet + | (NextStep(_), _, None) => model |> raise_invalid_action | (RemoveStep, _, _) => { ...model, @@ -675,23 +675,23 @@ and Stepper: { }), } |> return - | None => model |> return_quiet + | None => model |> raise_invalid_action }; - | (StepForward(_), _, _) => model |> return_quiet + | (StepForward(_), _, _) => model |> raise_invalid_action | (AddInduction(exp), MissingStep(_), _) => { ...model, step_kind: InductionStep(InductionStep.init(~exp?, ())), } |> return - | (AddInduction(_), _, _) => model |> return_quiet + | (AddInduction(_), _, _) => model |> raise_invalid_action | (AddForall, MissingStep(_), _) => { ...model, step_kind: ForallStep(ForallStep.init(init)), } |> return - | (AddForall, _, _) => model |> return_quiet + | (AddForall, _, _) => model |> raise_invalid_action | ( AddAxiomStep(name, at_idx, at_exp, direction, equality), MissingStep(_), @@ -710,7 +710,7 @@ and Stepper: { }), } |> return - | (AddAxiomStep(_, _, _, _, _), _, _) => model |> return_quiet + | (AddAxiomStep(_, _, _, _, _), _, _) => model |> raise_invalid_action | (AddAlgebriteStep(at_idx, at_exp, with_exp), MissingStep(_), _) => { ...model, @@ -723,7 +723,7 @@ and Stepper: { }), } |> return - | (AddAlgebriteStep(_, _, _), _, _) => model |> return_quiet + | (AddAlgebriteStep(_, _, _), _, _) => model |> raise_invalid_action | (StepKindAction(sk_action), _, _) => let* new_step_kind = StepKind.update(~settings, sk_action, model.step_kind); diff --git a/src/web/app/globals/Globals.re b/src/web/app/globals/Globals.re index 6fc6a3293a..24fbfc10d9 100644 --- a/src/web/app/globals/Globals.re +++ b/src/web/app/globals/Globals.re @@ -44,6 +44,15 @@ module VisibleRows = { }; module Action = { + [@deriving (show({with_path: false}), yojson, sexp)] + type log = + | InitImport([@opaque] Js_of_ocaml.Js.t(Js_of_ocaml.File.file)) + | FinishImport(option(string)) + | NextLog + | SkipLog + | SkipExercise + | ToggleReplay; + [@deriving (show({with_path: false}), sexp, yojson)] type t = | SetFontMetrics(FontMetrics.t) @@ -51,13 +60,11 @@ module Action = { | JumpToTile(Haz3lcore.Id.t) // Perform(Select(Term(Id(id, Left)))) | InitImportAll([@opaque] Js_of_ocaml.Js.t(Js_of_ocaml.File.file)) | FinishImportAll(option(string)) - | InitImportLog([@opaque] Js_of_ocaml.Js.t(Js_of_ocaml.File.file)) - | FinishImportLog(option(string)) | ExportForInit | ActiveEditor(Haz3lcore.Action.t) - | NextLog | Undo // These two currently happen at the editor level, and are just | Redo // global actions so they can be accessed by the command palette + | Log(log) | SetMetaDown(bool) | UpdateVisibleRows(VisibleRows.t); }; @@ -144,9 +151,7 @@ module Update = { | Redo => false | SetMetaDown(_) => false | UpdateVisibleRows(_) => false - | InitImportLog(_) => false - | FinishImportLog(_) => false - | NextLog => false + | Log(_) => false }; }; }; diff --git a/src/web/view/ExerciseMode.re b/src/web/view/ExerciseMode.re index 2ff29a527e..5519a76c15 100644 --- a/src/web/view/ExerciseMode.re +++ b/src/web/view/ExerciseMode.re @@ -337,9 +337,7 @@ module Update = { ...model, cells: Exercise.put_stitched(pos, model.cells, new_cell), }; - | Editor(_, ResultAction(_)) => - print_endline("IMPOSSIBLE!!!!"); - Updated.return_quiet(model); // TODO: I think this case should never happen + | Editor(_, ResultAction(_)) => Updated.raise_invalid_action(model) // TODO: I think this case should never happen | ResetEditor(pos) => let spec = Exercise.main_editor_of_state(~selection=pos, model.spec); let new_editor = Editor.Model.mk(spec); diff --git a/src/web/view/TutorialMode.re b/src/web/view/TutorialMode.re index 349e10c0a0..b9fe31c2a7 100644 --- a/src/web/view/TutorialMode.re +++ b/src/web/view/TutorialMode.re @@ -183,7 +183,7 @@ module Update = { ...model, cells: Tutorial.put_stitched(pos, model.cells, new_cell), }; - | Editor(_, ResultAction(_)) => Updated.return_quiet(model) // TODO: I think this case should never happen + | Editor(_, ResultAction(_)) => Updated.raise_invalid_action(model) // TODO: I think this case should never happen | ResetEditor(pos) => let spec = Tutorial.main_editor_of_state(~selection=pos, model.spec); let new_editor = Editor.Model.mk(spec); From acbb15c4cd7f6cbe656a5daf490a9d3d92518a43 Mon Sep 17 00:00:00 2001 From: Matt Keenan <31668468+Negabinary@users.noreply.github.com> Date: Fri, 21 Nov 2025 18:05:54 -0500 Subject: [PATCH 05/11] log undo/redo --- src/web/app/History.re | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/web/app/History.re b/src/web/app/History.re index 63da187a6e..c2ea718835 100644 --- a/src/web/app/History.re +++ b/src/web/app/History.re @@ -55,7 +55,9 @@ module Update = { | [] => print_endline("Cannot undo"); model |> Updated.raise_invalid_action; - | [x, ...rest] => { + | [x, ...rest] => + Log.Entry.save(Log.Entry.mk(action)); + { ...x, model: { current: x.model, @@ -70,14 +72,16 @@ module Update = { ], replay_toggle: model.replay_toggle, }, - } + }; } | Globals(Redo) => switch (model.redo_stack) { | [] => print_endline("Cannot redo"); model |> Updated.raise_invalid_action; - | [x, ...rest] => { + | [x, ...rest] => + Log.Entry.save(Log.Entry.mk(action)); + { ...x, model: { current: x.model, @@ -92,7 +96,7 @@ module Update = { redo_stack: rest, replay_toggle: model.replay_toggle, }, - } + }; } | Globals(Log(a)) => switch (a) { From bce34401aa8846fe948274167caf78dfdb38c20f Mon Sep 17 00:00:00 2001 From: Matt Keenan <31668468+Negabinary@users.noreply.github.com> Date: Mon, 2 Feb 2026 11:18:17 -0500 Subject: [PATCH 06/11] Add log sidebar --- src/web/Main.re | 17 +- src/web/app/History.re | 32 +- src/web/app/Log.re | 29 +- src/web/app/LogCount.re | 17 ++ src/web/app/Page.re | 60 +--- src/web/app/editors/stepper/MissingStep.re | 2 +- src/web/app/globals/Globals.re | 8 +- src/web/app/log/LogSidebar.re | 322 +++++++++++++++++++++ src/web/app/sidebar/Sidebar.re | 19 ++ src/web/app/sidebar/SidebarModel.re | 3 +- src/web/www/style/probesystem.css | 187 ++++++++++++ 11 files changed, 617 insertions(+), 79 deletions(-) create mode 100644 src/web/app/LogCount.re create mode 100644 src/web/app/log/LogSidebar.re diff --git a/src/web/Main.re b/src/web/Main.re index 9169bfd1f6..77142529aa 100644 --- a/src/web/Main.re +++ b/src/web/Main.re @@ -64,8 +64,11 @@ let apply = ~dynamics=true, ) ) { - | _ => - print_endline("ERROR: Exception during calculate phase"); + | exc => + Printf.printf( + "ERROR: Exception during calculate: %s\n", + Printexc.to_string(exc), + ); { ...model, replay_toggle: false, @@ -180,6 +183,8 @@ let start = { JsUtil.focus_clipboard_shim(); /* Setup scroll listener for floating elements (backpack) */ FloatingElement.setup_scroll_listener(); + // Sync log count from database + Log.sync_count(); schedule_action( Assistant(AssistantUpdate.ChatAction(FilterLoadingMessages)), ); @@ -226,13 +231,7 @@ let start = { let%arr app_model = app_model and app_inject = app_inject; try( - History.View.view( - app_model, - ~inject=app_inject, - ~get_log_and=Log.get_and, - ~next_log= - Util.ListUtil.hd_opt(app_model.future_log) |> Option.map(snd), - ) + History.View.view(app_model, ~inject=app_inject, ~get_log_and=Log.get_and) ) { | exc => print_endline( diff --git a/src/web/app/History.re b/src/web/app/History.re index c2ea718835..8551fb1b11 100644 --- a/src/web/app/History.re +++ b/src/web/app/History.re @@ -106,7 +106,7 @@ module Update = { ); model |> Updated.return_quiet; | FinishImport(None) => - print_endline("Log import failed"); + LogSidebar.log_error("Log import failed"); model |> Updated.return_quiet; | FinishImport(Some(data)) => let of_data = (data: string): list((float, Page.Update.t)) => @@ -114,14 +114,13 @@ module Update = { |> Sexplib.Sexp.of_string |> Log.Entry.s_of_sexp_opt |> List.filter_map(x => x); - // |> List.filter(((ts, _action)) => ts > 1757794060000.0); let actions = data |> of_data |> Log.flatten_imports(~of_data) |> ( x => { - print_endline( + LogSidebar.log_info( "Imported log entries: " ++ string_of_int(List.length(x)), ); x; @@ -135,15 +134,15 @@ module Update = { | NextLog => switch (model.future_log) { | [] => - print_endline("No next log action to perform"); + LogSidebar.log_info("No next log action to perform"); model |> Updated.return_quiet; | [(t, next), ...rest] => - print_endline( - "Applying next log action: " - ++ JsUtil.print_timestamp(t) - ++ " :: " - ++ Page.Update.show(next), + LogSidebar.log_action( + "Applying next log action", + Some(JsUtil.print_timestamp(t)), ); + // Keep full action expression in console for detailed debugging + print_endline("Full action: " ++ Page.Update.show(next)); try({ let updated = Page.Update.update( @@ -171,7 +170,7 @@ module Update = { }; }) { | _ => - print_endline("Failed to apply log action"); + LogSidebar.log_error("Failed to apply log action"); Model.{ ...model, future_log: @@ -185,10 +184,10 @@ module Update = { }; } | SkipLog => - print_endline("Skipping the next log entry"); + LogSidebar.log_action("Skipping the next log entry", None); switch (model.future_log) { | [] => - print_endline("No log entry to skip"); + LogSidebar.log_info("No log entry to skip"); model |> return_quiet; | [(_, _), ...rest] => { @@ -198,7 +197,10 @@ module Update = { |> return_quiet }; | SkipExercise => - print_endline("Skipping to the next exercise in the log"); + LogSidebar.log_action( + "Skipping to the next exercise in the log", + None, + ); let rec skip_to_next_exercise = (log: list((float, Page.Update.t))) => switch (log) { | [] => [] @@ -217,6 +219,10 @@ module Update = { replay_toggle: !model.replay_toggle, } |> return_quiet + | ClearLog => + Log.DB.clear_and(() => ()); + LogSidebar.log_info("Log cleared"); + model |> return_quiet; } | action => let current = diff --git a/src/web/app/Log.re b/src/web/app/Log.re index 7ae7c1d596..2501af9a2b 100644 --- a/src/web/app/Log.re +++ b/src/web/app/Log.re @@ -23,10 +23,12 @@ module DB = { openDB(~upgrade, ~error, ~version=1, db_name, db => f(db)); }; - let add = (key: string, value: string): unit => + let add = (key: string, value: string): unit => { + LogCount.increment(); with_db(db => Store.add(~key, ~callback=_key => (), kv_store(db), value) ); + }; let get = (key: string, f: option(string) => unit): unit => { let error = _ => Printf.printf("ERROR: Log.IDBKV.get"); @@ -40,6 +42,7 @@ module DB = { let clear_and = (callback): unit => { let error = _ => Printf.printf("ERROR: Log.IDBKV.clear"); + LogCount.clear(); with_db(db => Store.clear(~error, ~callback, kv_store(db))); }; }; @@ -79,9 +82,22 @@ module Entry = { }; }; +let get_and = (f: string => unit): unit => + DB.get_all(entries => f("(" ++ String.concat(" ", entries) ++ ")")); + +let get_count = (f: int => unit): unit => + DB.get_all(entries => f(List.length(entries))); + +// Synchronously get the cached count (may be stale until sync_count is called) +let get_count_sync = (): int => LogCount.get(); + +// Sync the cached count with the database +let sync_count = (): unit => + DB.get_all(entries => LogCount.set(List.length(entries))); + let import = (data: string): unit => /* Should be fine to fire saves concurrently? */ - DB.clear_and(() => + DB.clear_and(() => { try( data |> Sexplib.Sexp.of_string @@ -89,17 +105,16 @@ let import = (data: string): unit => |> List.iter(Entry.save) ) { | _ => Printf.printf("Log.Entry.import: Deserialization error") - } - ); + }; + // Sync count after import completes + sync_count(); + }); let update = (action: Page.Update.t, result: Updated.t('a)): unit => if (result.logged) { Entry.save(Entry.mk(action)); }; -let get_and = (f: string => unit): unit => - DB.get_all(entries => f("(" ++ String.concat(" ", entries) ++ ")")); - let to_actions = () => { print_endline("HELLO??"); let actions = ref([]); diff --git a/src/web/app/LogCount.re b/src/web/app/LogCount.re new file mode 100644 index 0000000000..3c5ebb34f7 --- /dev/null +++ b/src/web/app/LogCount.re @@ -0,0 +1,17 @@ +/* Cached log entry count to avoid async queries on every render */ + +let count = ref(0); + +let get = (): int => count^; + +let set = (n: int): unit => { + count := n; +}; + +let increment = (): unit => { + count := count^ + 1; +}; + +let clear = (): unit => { + count := 0; +}; diff --git a/src/web/app/Page.re b/src/web/app/Page.re index 457523c0c6..75c3f6e7c8 100644 --- a/src/web/app/Page.re +++ b/src/web/app/Page.re @@ -17,6 +17,7 @@ module Model = { editors: Editors.Model.t, explain_this: ExplainThisModel.t, assistant: AssistantModel.t, + log_sidebar: LogSidebar.Model.t, selection, }; @@ -33,11 +34,13 @@ module Store = { ); let explain_this = ExplainThisModel.Store.load(); let assistant = AssistantModel.Store.load(); + let log_sidebar = LogSidebar.Model.init(); { editors, globals, explain_this, assistant, + log_sidebar, selection: Editors.Selection.default_selection(editors), }; }; @@ -632,13 +635,7 @@ module View = { ); }; - let top_bar = - ( - ~globals, - ~inject: Update.t => Ui_effect.t(unit), - ~editors, - ~next_log: option(Update.t), - ) => + let top_bar = (~globals, ~inject: Update.t => Ui_effect.t(unit), ~editors) => div( ~attrs=[Attr.id("top-bar")], [ @@ -661,37 +658,6 @@ module View = { ), ], ), - ] - @ ( - next_log - |> Option.map(_ => - [ - Widgets.button( - text("next"), - _ => Ui_effect.Many([inject(Globals(Log(NextLog)))]), - ~tooltip="Play next action from imported log", - ), - Widgets.button( - text("skip"), - _ => Ui_effect.Many([inject(Globals(Log(SkipLog)))]), - ~tooltip="Skip the rest of the imported log", - ), - Widgets.button( - text("skip exercise"), - _ => Ui_effect.Many([inject(Globals(Log(SkipExercise)))]), - ~tooltip="Skip to the next exercise in the imported log", - ), - ] - ) - |> Option.to_list - |> List.flatten - ) - @ [ - Widgets.button( - text("play/pause"), - _ => Ui_effect.Many([inject(Globals(Log(ToggleReplay)))]), - ~tooltip="Toggle replaying imported log automatically", - ), ], ); @@ -700,19 +666,22 @@ module View = { ~get_log_and: (string => unit) => unit, ~inject: Update.t => Ui_effect.t(unit), ~cursor: Cursor.cursor(Editors.Update.t), - ~next_log: option(Update.t), { globals, editors, explain_this: explainThisModel, assistant: assistantModel, + log_sidebar, selection, } as model: Model.t, ) => { + let log_count = LogCount.get(); let globals = { ...globals, inject_global: x => inject(Globals(x)), get_log_and, + get_log_count: _ => + failwith("get_log_count is deprecated, use Log.get_count_sync"), export_all: Export.export_all, }; let bottom_bar = CursorInspector.view(~globals, cursor); @@ -727,6 +696,8 @@ module View = { | MakeActive(s) => inject(MakeActive(Scratch(s))), ~explainThisModel, ~assistantModel, + ~log_model=log_sidebar, + ~log_count, ~editor=Update.get_editor(model), cursor.info, ); @@ -778,7 +749,7 @@ module View = { }; [ - top_bar(~globals, ~inject, ~editors, ~next_log), + top_bar(~globals, ~inject, ~editors), div( ~attrs=[ Attr.id("main"), @@ -794,17 +765,12 @@ module View = { }; let view = - ( - ~get_log_and, - ~inject: Update.t => Ui_effect.t(unit), - ~next_log: option(Update.t), - model: Model.t, - ) => { + (~get_log_and, ~inject: Update.t => Ui_effect.t(unit), model: Model.t) => { let cursor = Selection.get_cursor_info(~selection=model.selection, model); div( ~attrs=[Attr.id("page"), ...handlers(~cursor, ~inject, model)], [FontSpecimen.view, JsUtil.clipboard_shim] - @ main_view(~get_log_and, ~cursor, ~inject, ~next_log, model), + @ main_view(~get_log_and, ~cursor, ~inject, model), ); }; }; diff --git a/src/web/app/editors/stepper/MissingStep.re b/src/web/app/editors/stepper/MissingStep.re index e787f86fcd..a19fbdcf1f 100644 --- a/src/web/app/editors/stepper/MissingStep.re +++ b/src/web/app/editors/stepper/MissingStep.re @@ -116,7 +116,7 @@ module Update = { cached_result: Some(result), }), } - |> Updated.raise_invalid_action + |> Updated.return_quiet(~logged=true) | (UpdateResult(_), _) => model |> Updated.raise_invalid_action | (AxiomBoxAction(action), AxiomsOpen(m)) => let* updated = AxiomsBox.Update.update(~settings, action, m); diff --git a/src/web/app/globals/Globals.re b/src/web/app/globals/Globals.re index 24fbfc10d9..6ef7793980 100644 --- a/src/web/app/globals/Globals.re +++ b/src/web/app/globals/Globals.re @@ -51,7 +51,8 @@ module Action = { | NextLog | SkipLog | SkipExercise - | ToggleReplay; + | ToggleReplay + | ClearLog; [@deriving (show({with_path: false}), sexp, yojson)] type t = @@ -86,6 +87,7 @@ module Model = { convenience to avoid having to pass it around everywhere. Can only be used in view functions. */ get_log_and: (string => unit) => unit, + get_log_count: (int => unit) => unit, export_all: ( ~settings: Language.CoreSettings.t, @@ -112,6 +114,10 @@ module Model = { failwith( "Cannot use get_log_and outside of the main view or update functions!", ), + get_log_count: _ => + failwith( + "Cannot use get_log_count outside of the main view or update functions!", + ), export_all: (~settings as _, ~instructor_mode as _, ~log as _) => failwith( "Cannot use export_all outside of the main view or update functions!", diff --git a/src/web/app/log/LogSidebar.re b/src/web/app/log/LogSidebar.re new file mode 100644 index 0000000000..3b3adce36a --- /dev/null +++ b/src/web/app/log/LogSidebar.re @@ -0,0 +1,322 @@ +open Virtual_dom.Vdom; +open Node; +open Util.WebUtil; +open Util; + +module Model = { + [@deriving (show({with_path: false}), sexp, yojson)] + type t = { + messages: list(string), + is_playing: bool, + current_step: int, + total_steps: int, + show_details: bool, + }; + + let init = () => { + messages: [], + is_playing: false, + current_step: 0, + total_steps: 0, + show_details: false, + }; +}; + +module Update = { + [@deriving (show({with_path: false}), sexp, yojson)] + type t = + | AddMessage(string) + | ClearMessages + | SetPlaying(bool) + | SetSteps(int, int) + | ToggleDetails; + + let update = (action: t, model: Model.t): Model.t => + switch (action) { + | AddMessage(msg) => { + ...model, + messages: [msg, ...Util.ListUtil.take(100, model.messages)], + } + | ClearMessages => { + ...model, + messages: [], + } + | SetPlaying(playing) => { + ...model, + is_playing: playing, + } + | SetSteps(current, total) => { + ...model, + current_step: current, + total_steps: total, + } + | ToggleDetails => { + ...model, + show_details: !model.show_details, + } + }; +}; + +let button = (~attrs=[], ~tooltip="", ~onclick, ~disabled=false, children) => { + let base_attrs = [ + Attr.class_("log-button"), + Attr.on_pointerdown(onclick), + disabled ? Attr.disabled : Attr.empty, + ]; + let all_attrs = + tooltip != "" ? [Attr.title(tooltip), ...base_attrs] : base_attrs; + div(~attrs=all_attrs @ attrs, children); +}; + +let file_input = (~onchange, ~id, ~accept_extensions) => { + label( + ~attrs=[Attr.for_(id)], + [ + Vdom_input_widgets.File_select.single( + ~extra_attrs=[Attr.class_("file-select-button"), Attr.id(id)], + ~accept=accept_extensions, + ~on_input=onchange, + (), + ), + ], + ); +}; + +let progress_bar = (~current, ~total) => { + let percentage = total > 0 ? current * 100 / total : 0; + div( + ~attrs=[Attr.class_("log-progress")], + [ + div( + ~attrs=[ + Attr.class_("log-progress-bar"), + Attr.style( + Css_gen.create( + ~field="width", + ~value=Printf.sprintf("%d%%", percentage), + ), + ), + ], + [], + ), + div( + ~attrs=[Attr.class_("log-progress-text")], + [text(Printf.sprintf("%d / %d", current, total))], + ), + ], + ); +}; + +let message_item = (message: string) => { + let timestamp = Printf.sprintf("%.0f", Js_of_ocaml.Js.date##now); + div( + ~attrs=[Attr.class_("log-message")], + [ + span( + ~attrs=[Attr.class_("log-timestamp")], + [text("[" ++ timestamp ++ "]")], + ), + span(~attrs=[Attr.class_("log-content")], [text(message)]), + ], + ); +}; + +let controls_section = + (~inject: Globals.Action.t => Ui_effect.t(unit), ~model: Model.t) => { + let play_pause_icon = model.is_playing ? "⏸️" : "▶️"; + let play_pause_tooltip = + model.is_playing ? "Pause log replay" : "Start log replay"; + + div( + ~attrs=[Attr.class_("log-controls")], + [ + div( + ~attrs=[Attr.class_("log-control-row")], + [ + file_input( + ~onchange= + file => { + switch (file) { + | None => Virtual_dom.Vdom.Effect.Ignore + | Some(file) => inject(Log(InitImport(file))) + } + }, + ~id="log-import-input", + ~accept_extensions=[ + `Extension("txt"), + `Extension("log"), + `Extension("json"), + ], + ), + button( + ~tooltip="Import log file", + ~onclick= + _ => { + let elem = Util.JsUtil.get_elem_by_id("log-import-input"); + elem##click; + Virtual_dom.Vdom.Effect.Ignore; + }, + [text("Import Log")], + ), + button( + ~tooltip="Export current log", + ~onclick=_ => inject(Log(ToggleReplay)), // This should trigger export + [text("Export Log")], + ), + ], + ), + div( + ~attrs=[Attr.class_("log-control-row")], + [ + button( + ~tooltip=play_pause_tooltip, + ~onclick=_ => inject(Log(ToggleReplay)), + [text(play_pause_icon)], + ), + button( + ~tooltip="Execute next log step", + ~onclick=_ => inject(Log(NextLog)), + [text("Next Step")], + ), + button( + ~tooltip="Skip current log entry", + ~onclick=_ => inject(Log(SkipLog)), + [text("Skip")], + ), + button( + ~tooltip="Skip to next exercise", + ~onclick=_ => inject(Log(SkipExercise)), + [text("Skip Exercise")], + ), + ], + ), + progress_bar(~current=model.current_step, ~total=model.total_steps), + ], + ); +}; + +let messages_section = + (~model: Model.t, ~inject_log: Update.t => Ui_effect.t(unit)) => { + div( + ~attrs=[Attr.class_("log-messages")], + [ + div( + ~attrs=[Attr.class_("log-messages-header")], + [ + span([text("Log Messages")]), + button( + ~attrs=[Attr.class_("log-clear-btn")], + ~tooltip="Clear all messages", + ~onclick=_ => inject_log(ClearMessages), + [text("Clear")], + ), + button( + ~attrs=[Attr.class_("log-details-btn")], + ~tooltip="Toggle detailed view", + ~onclick=_ => inject_log(ToggleDetails), + [text(model.show_details ? "Hide Details" : "Show Details")], + ), + ], + ), + div( + ~attrs=[ + Attr.class_("log-messages-content"), + Attr.style(Css_gen.create(~field="max-height", ~value="20em")), + Attr.style(Css_gen.create(~field="overflow", ~value="auto")), + ], + model.messages == [] + ? [ + div( + ~attrs=[Attr.class_("log-empty")], + [text("No log messages")], + ), + ] + : List.map(message_item, model.messages), + ), + ], + ); +}; + +let debug_section = + (~inject: Globals.Action.t => Ui_effect.t(unit), ~log_entries_count: int) => { + div( + ~attrs=[Attr.class_("log-debug")], + [ + div( + ~attrs=[Attr.class_("log-debug-header")], + [text("Log Debug Info")], + ), + div( + ~attrs=[Attr.class_("log-debug-content")], + [ + div( + ~attrs=[Attr.class_("log-debug-info")], + [ + text( + Printf.sprintf("Current log entries: %d", log_entries_count), + ), + ], + ), + div( + ~attrs=[ + Attr.class_("log-button"), + Attr.title("Clear all log entries"), + Attr.on_pointerdown(_ => inject(Log(ClearLog))), + ], + [text("Clear Log")], + ), + ], + ), + ], + ); +}; + +let view = + ( + ~inject: Globals.Action.t => Ui_effect.t(unit), + ~inject_log: Update.t => Ui_effect.t(unit), + ~model: Model.t, + ~log_entries_count: int, + ) => { + div( + ~attrs=[Attr.class_("log-sidebar")], + [ + div(~attrs=[Attr.class_("log-header")], [text("Log Control Panel")]), + controls_section(~inject, ~model), + messages_section(~inject_log, ~model), + debug_section(~inject, ~log_entries_count), + ], + ); +}; + +// Helper functions for external use +let log_message = (message: string): unit => { + // This would be called from History.re to add messages to the sidebar + // For now, we'll use a simple print as a fallback, but in a full implementation + // this would update the log sidebar model + Printf.printf( + "[LOG SIDEBAR] %s\n", + message, + ); +}; + +let log_action = (action_name: string, details: option(string)): unit => { + let message = + switch (details) { + | None => action_name + | Some(d) => action_name ++ ": " ++ d + }; + log_message(message); +}; + +let log_error = (error: string): unit => { + log_message("ERROR: " ++ error); +}; + +let log_info = (info: string): unit => { + log_message("INFO: " ++ info); +}; + +let log_step_update = (current: int, total: int): unit => { + log_message(Printf.sprintf("Step %d of %d", current, total)); +}; diff --git a/src/web/app/sidebar/Sidebar.re b/src/web/app/sidebar/Sidebar.re index 875474dba6..f1a226a16e 100644 --- a/src/web/app/sidebar/Sidebar.re +++ b/src/web/app/sidebar/Sidebar.re @@ -73,6 +73,15 @@ let probes_tab = (~globals: Globals.t): Node.t => ~globals, ); +let log_control_tab = (~globals: Globals.t): Node.t => + tab_of( + ~panel=LogControl, + ~cls=["log-control-button"], + ~icon=Icons.gear, + ~tooltip="Switch to Log Control Panel", + ~globals, + ); + let collapse_tab = (~globals: Globals.t): Node.t => { let tooltip = globals.settings.sidebar.show ? "Collapse Sidebar" : "Expand Sidebar"; @@ -93,6 +102,7 @@ let persistent_view = (~globals: Globals.t) => explain_this_tab(~globals), assistant_tab(~globals), probes_tab(~globals), + log_control_tab(~globals), ], ), ], @@ -193,6 +203,8 @@ let view = ~signal, ~explainThisModel: ExplainThisModel.t, ~assistantModel: AssistantModel.t, + ~log_model: LogSidebar.Model.t, + ~log_count: int, ~editor, info: option(Language.Info.t), ) => { @@ -225,6 +237,13 @@ let view = ~cursor, ~editor, ) + | LogControl => + LogSidebar.view( + ~inject=globals.inject_global, + ~inject_log=_ => Effect.Ignore, // For now, ignore LogSidebar internal updates + ~model=log_model, + ~log_entries_count=log_count, + ) }, ], ) diff --git a/src/web/app/sidebar/SidebarModel.re b/src/web/app/sidebar/SidebarModel.re index 73b1cdddaf..ee35b2493c 100644 --- a/src/web/app/sidebar/SidebarModel.re +++ b/src/web/app/sidebar/SidebarModel.re @@ -5,7 +5,8 @@ module Settings = { type panel = | LanguageDocumentation | HelpfulAssistant - | Probes; + | Probes + | LogControl; [@deriving (show({with_path: false}), sexp, yojson)] type t = { diff --git a/src/web/www/style/probesystem.css b/src/web/www/style/probesystem.css index f076233c93..e6c57b3a62 100644 --- a/src/web/www/style/probesystem.css +++ b/src/web/www/style/probesystem.css @@ -226,3 +226,190 @@ align-items: center; gap: 0.5em; } + +/* Log Sidebar Styles */ +.log-sidebar { + background-color: var(--T1); + border: 1px solid var(--BR1); + border-radius: 0.3em; + padding: 1em; + margin-bottom: 1em; + display: flex; + flex-direction: column; + gap: 0.75em; +} + +.log-header { + font-weight: bold; + font-size: 1.1em; + color: var(--ui-header-text); + text-transform: uppercase; + border-bottom: 1px solid var(--BR1); + padding-bottom: 0.5em; +} + +.log-controls { + display: flex; + flex-direction: column; + gap: 0.5em; +} + +.log-control-row { + display: flex; + gap: 0.5em; + align-items: center; + flex-wrap: wrap; +} + +.log-button { + padding: 0.4em 0.8em; + background-color: var(--T2); + border: 1px solid var(--BR1); + border-radius: 0.3em; + color: var(--text-primary); + cursor: pointer; + font-size: 0.9em; + transition: background-color 0.2s ease; + user-select: none; +} + +.log-button:hover { + background-color: var(--BR1); +} + +.log-button:active { + background-color: var(--BR2); +} + +.log-button[disabled] { + background-color: var(--T2); + color: var(--text-disabled); + cursor: not-allowed; + opacity: 0.6; +} + +.log-progress { + position: relative; + background-color: var(--T2); + border: 1px solid var(--BR1); + border-radius: 0.3em; + height: 1.5em; + overflow: hidden; +} + +.log-progress-bar { + background-color: var(--G1); + height: 100%; + transition: width 0.3s ease; +} + +.log-progress-text { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.8em; + color: var(--text-primary); + font-weight: bold; +} + +.log-messages { + display: flex; + flex-direction: column; + gap: 0.5em; +} + +.log-messages-header { + display: flex; + justify-content: space-between; + align-items: center; + font-weight: bold; + color: var(--ui-header-text); +} + +.log-clear-btn, +.log-details-btn { + padding: 0.2em 0.5em; + background-color: var(--T2); + border: 1px solid var(--BR1); + border-radius: 0.2em; + color: var(--text-primary); + cursor: pointer; + font-size: 0.8em; + transition: background-color 0.2s ease; +} + +.log-clear-btn:hover, +.log-details-btn:hover { + background-color: var(--BR1); +} + +.log-messages-content { + border: 1px solid var(--BR1); + border-radius: 0.3em; + padding: 0.5em; + background-color: var(--T2); + max-height: 20em; + overflow-y: auto; +} + +.log-message { + display: flex; + gap: 0.5em; + margin-bottom: 0.3em; + font-family: monospace; + font-size: 0.85em; + line-height: 1.3; +} + +.log-message:last-child { + margin-bottom: 0; +} + +.log-timestamp { + color: var(--G2); + flex-shrink: 0; + font-weight: bold; +} + +.log-content { + color: var(--text-primary); + word-break: break-word; +} + +.log-empty { + color: var(--text-disabled); + font-style: italic; + text-align: center; + padding: 1em; +} + +.log-debug { + border-top: 1px solid var(--BR1); + padding-top: 0.75em; + margin-top: 0.75em; +} + +.log-debug-header { + font-weight: bold; + font-size: 0.9em; + color: var(--ui-header-text); + margin-bottom: 0.5em; +} + +.log-debug-content { + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.5em; +} + +.log-debug-info { + font-size: 0.85em; + color: var(--text-primary); + font-family: monospace; +} From a1b67fb0acf68da5c2f8e54085a08eb6562372b5 Mon Sep 17 00:00:00 2001 From: Matt Keenan <31668468+Negabinary@users.noreply.github.com> Date: Tue, 3 Feb 2026 11:14:47 -0500 Subject: [PATCH 07/11] Add setting to hide sidebar --- src/web/Settings.re | 8 ++++++++ src/web/app/sidebar/Sidebar.re | 6 ++++-- src/web/view/NutMenu.re | 21 +++++++++++++++++++-- 3 files changed, 31 insertions(+), 4 deletions(-) diff --git a/src/web/Settings.re b/src/web/Settings.re index 72d069474a..8903390d81 100644 --- a/src/web/Settings.re +++ b/src/web/Settings.re @@ -10,6 +10,7 @@ module Model = { context_inspector: bool, instructor_mode: bool, benchmark: bool, + show_log_panel: bool, explainThis: ExplainThisModel.Settings.t, assistant: AssistantSettings.t, sidebar: SidebarModel.Settings.t, @@ -43,6 +44,7 @@ module Model = { context_inspector: false, instructor_mode: false, benchmark: false, + show_log_panel: false, explainThis: { show: true, show_feedback: false, @@ -113,6 +115,7 @@ module Update = { | Benchmark | ContextInspector | InstructorMode + | ShowLogPanel | Evaluation(evaluation) | Sidebar(SidebarModel.Settings.action) | ExplainThis(ExplainThisModel.Settings.action) @@ -312,6 +315,11 @@ module Update = { }, } } + | ShowLogPanel => { + ...settings, + show_log_panel: + !settings.show_log_panel && ExerciseSettings.show_instructor, + } | Benchmark => { ...settings, benchmark: !settings.benchmark, diff --git a/src/web/app/sidebar/Sidebar.re b/src/web/app/sidebar/Sidebar.re index f1a226a16e..6b3620f905 100644 --- a/src/web/app/sidebar/Sidebar.re +++ b/src/web/app/sidebar/Sidebar.re @@ -102,8 +102,10 @@ let persistent_view = (~globals: Globals.t) => explain_this_tab(~globals), assistant_tab(~globals), probes_tab(~globals), - log_control_tab(~globals), - ], + ] + @ ( + globals.settings.show_log_panel ? [log_control_tab(~globals)] : [] + ), ), ], ); diff --git a/src/web/view/NutMenu.re b/src/web/view/NutMenu.re index 1f862d02ac..c193c5411e 100644 --- a/src/web/view/NutMenu.re +++ b/src/web/view/NutMenu.re @@ -116,7 +116,12 @@ let dev_group = (~globals) => { ~globals, "Developer", [ - ("✓", "Benchmarks", globals.settings.benchmark, Benchmark), + ( + "✓", + "Benchmarks", + globals.settings.benchmark, + Settings.Update.Benchmark, + ), ("𝑒", "Elaboration", globals.settings.core.elaborate, Elaborate), ("↵", "Whitespace", globals.settings.secondary_icons, SecondaryIcons), ( @@ -125,7 +130,19 @@ let dev_group = (~globals) => { globals.settings.core.flip_animations, FlipAnimations, ), - ], + ] + @ ( + ExerciseSettings.show_instructor + ? [ + ( + "📃", + "Log Panel", + globals.settings.show_log_panel, + ShowLogPanel, + ), + ] + : [] + ), ); }; From 02111e8b591ebbff31a00faab87a7eba14beaf02 Mon Sep 17 00:00:00 2001 From: Matt Keenan <31668468+Negabinary@users.noreply.github.com> Date: Tue, 3 Feb 2026 15:57:41 -0500 Subject: [PATCH 08/11] Separate "logged" --- src/web/Main.re | 25 ++-- src/web/app/History.re | 160 +------------------------- src/web/app/Logged.re | 201 +++++++++++++++++++++++++++++++++ src/web/app/globals/Globals.re | 1 - src/web/app/log/LogSidebar.re | 5 - 5 files changed, 217 insertions(+), 175 deletions(-) create mode 100644 src/web/app/Logged.re diff --git a/src/web/Main.re b/src/web/Main.re index 77142529aa..d3cd8d372c 100644 --- a/src/web/Main.re +++ b/src/web/Main.re @@ -19,21 +19,21 @@ let restart_caret_animation = () => let apply = ( - model: History.Model.t, - action: History.Update.t, + model: Logged.Model.t, + action: Logged.Update.t, ~schedule_action, ~schedule_autosave, ) - : History.Model.t => { + : Logged.Model.t => { restart_caret_animation(); /* This function is split into two phases, update and calculate. The intention is that eventually, the calculate phase will be done automatically by incremental calculation. */ // ---------- UPDATE PHASE ---------- - let updated: Updated.t(History.Model.t) = + let updated: Updated.t(Logged.Model.t) = try( - History.Update.update( + Logged.Update.update( ~import_log=Log.import, ~get_log_and=Log.get_and, ~schedule_action, @@ -58,7 +58,7 @@ let apply = let model' = try( updated.model - |> History.Update.calculate( + |> Logged.Update.calculate( ~schedule_action, ~is_edited=updated.is_edit, ~dynamics=true, @@ -98,8 +98,8 @@ let start = { let%sub save_scheduler = BonsaiUtil.Alarm.alarm; let%sub (app_model, app_inject) = Bonsai.state_machine1( - (module History.Model), - (module History.Update), + (module Logged.Model), + (module Logged.Update), ~apply_action= (~inject, ~schedule_event, input) => { let schedule_action = x => schedule_event(inject(x)); @@ -112,8 +112,8 @@ let start = { apply(~schedule_action, ~schedule_autosave); }, ~default_model= - History.Model.init() - |> History.Update.calculate( + Logged.Model.init() + |> Logged.Update.calculate( ~schedule_action=_ => (), ~is_edited=true, ~dynamics=false, @@ -220,7 +220,8 @@ let start = { let _ = Haz3lcore.ProbePerform.FocusEffect.execute(); /* Update floating elements (backpack) to viewport coordinates */ FloatingElement.update_all(); - model.current.globals.settings.core.statics ? Animation.go() : (); + model.current.current.globals.settings.core.statics + ? Animation.go() : (); }, (), ); @@ -231,7 +232,7 @@ let start = { let%arr app_model = app_model and app_inject = app_inject; try( - History.View.view(app_model, ~inject=app_inject, ~get_log_and=Log.get_and) + Logged.View.view(app_model, ~inject=app_inject, ~get_log_and=Log.get_and) ) { | exc => print_endline( diff --git a/src/web/app/History.re b/src/web/app/History.re index 8551fb1b11..4a1d0281ea 100644 --- a/src/web/app/History.re +++ b/src/web/app/History.re @@ -9,8 +9,6 @@ module Model = { current: state, undo_stack: list(Updated.t(state)), redo_stack: list(Updated.t(state)), - future_log: list((float, Page.Update.t)), - replay_toggle: bool, }; let equal = (===); @@ -19,8 +17,6 @@ module Model = { current: Page.Store.load(), undo_stack: [], redo_stack: [], - future_log: [], - replay_toggle: false, }; }; @@ -30,15 +26,6 @@ module Update = { [@deriving (show({with_path: false}), sexp, yojson)] type t = Page.Update.t; - // let sexp = Page.Update.sexp_of_t(action); - // For now, we don't ignore any actions; add here if needed - // check if str contains "(Select (Term (Id" (ignoring whitespace) - // let str = Sexplib.Sexp.to_string(sexp); - // StringUtil.match(StringUtil.regexp("Select\\s*\\(Term\\s*\\(Id"), str); - let ignore_if_action_fails_in_log_replay = (_action: t): bool => { - false; - }; - [@deriving (show({with_path: false}), sexp, yojson)] let update = ( @@ -55,13 +42,10 @@ module Update = { | [] => print_endline("Cannot undo"); model |> Updated.raise_invalid_action; - | [x, ...rest] => - Log.Entry.save(Log.Entry.mk(action)); - { + | [x, ...rest] => { ...x, model: { current: x.model, - future_log: model.future_log, undo_stack: rest, redo_stack: [ { @@ -70,22 +54,18 @@ module Update = { }, ...model.redo_stack, ], - replay_toggle: model.replay_toggle, }, - }; + } } | Globals(Redo) => switch (model.redo_stack) { | [] => print_endline("Cannot redo"); model |> Updated.raise_invalid_action; - | [x, ...rest] => - Log.Entry.save(Log.Entry.mk(action)); - { + | [x, ...rest] => { ...x, model: { current: x.model, - future_log: model.future_log, undo_stack: [ { ...x, @@ -94,135 +74,8 @@ module Update = { ...model.undo_stack, ], redo_stack: rest, - replay_toggle: model.replay_toggle, }, - }; - } - | Globals(Log(a)) => - switch (a) { - | InitImport(f) => - JsUtil.read_file(f, data => - schedule_action(Globals(Log(FinishImport(data)))) - ); - model |> Updated.return_quiet; - | FinishImport(None) => - LogSidebar.log_error("Log import failed"); - model |> Updated.return_quiet; - | FinishImport(Some(data)) => - let of_data = (data: string): list((float, Page.Update.t)) => - Export.import_just_log(data) - |> Sexplib.Sexp.of_string - |> Log.Entry.s_of_sexp_opt - |> List.filter_map(x => x); - let actions = - data - |> of_data - |> Log.flatten_imports(~of_data) - |> ( - x => { - LogSidebar.log_info( - "Imported log entries: " ++ string_of_int(List.length(x)), - ); - x; - } - ); - { - ...model, - future_log: model.future_log @ actions, - } - |> Updated.return_quiet; - | NextLog => - switch (model.future_log) { - | [] => - LogSidebar.log_info("No next log action to perform"); - model |> Updated.return_quiet; - | [(t, next), ...rest] => - LogSidebar.log_action( - "Applying next log action", - Some(JsUtil.print_timestamp(t)), - ); - // Keep full action expression in console for detailed debugging - print_endline("Full action: " ++ Page.Update.show(next)); - try({ - let updated = - Page.Update.update( - ~import_log, - ~get_log_and, - ~schedule_action, - next, - model.current, - ); - { - ...updated, - model: { - current: updated.model, - undo_stack: [ - { - ...updated, - model: model.current, - }, - ...model.undo_stack, - ], - redo_stack: model.redo_stack, - future_log: rest, - replay_toggle: model.replay_toggle, - }, - }; - }) { - | _ => - LogSidebar.log_error("Failed to apply log action"); - Model.{ - ...model, - future_log: - ignore_if_action_fails_in_log_replay(next) - ? rest : model.future_log, - replay_toggle: - ignore_if_action_fails_in_log_replay(next) - ? model.replay_toggle : false, - } - |> Updated.return_quiet; - }; - } - | SkipLog => - LogSidebar.log_action("Skipping the next log entry", None); - switch (model.future_log) { - | [] => - LogSidebar.log_info("No log entry to skip"); - model |> return_quiet; - | [(_, _), ...rest] => - { - ...model, - future_log: rest, - } - |> return_quiet - }; - | SkipExercise => - LogSidebar.log_action( - "Skipping to the next exercise in the log", - None, - ); - let rec skip_to_next_exercise = (log: list((float, Page.Update.t))) => - switch (log) { - | [] => [] - | [(_, Editors(SwitchMode(_))), ..._] as rest => rest - | [(_, Editors(Exercises(SwitchExercise(_)))), ..._] as rest => rest - | [(_, _), ...rest] => skip_to_next_exercise(rest) - }; - { - ...model, - future_log: skip_to_next_exercise(model.future_log), - } - |> return_quiet; - | ToggleReplay => - Model.{ - ...model, - replay_toggle: !model.replay_toggle, } - |> return_quiet - | ClearLog => - Log.DB.clear_and(() => ()); - LogSidebar.log_info("Log cleared"); - model |> return_quiet; } | action => let current = @@ -233,13 +86,11 @@ module Update = { action, model.current, ); - let _ = Log.update(action, current); if (Page.Update.can_undo(action)) { { ...current, model: { current: current.model, - future_log: model.future_log, undo_stack: [ { ...current, @@ -248,7 +99,6 @@ module Update = { ...model.undo_stack, ], redo_stack: [], - replay_toggle: model.replay_toggle, }, }; } else { @@ -258,8 +108,6 @@ module Update = { current: current.model, undo_stack: model.undo_stack, redo_stack: model.redo_stack, - future_log: model.future_log, - replay_toggle: model.replay_toggle, }, }; }; @@ -278,8 +126,6 @@ module Update = { |> Page.Update.calculate(~schedule_action, ~is_edited, ~dynamics), undo_stack: model.undo_stack, redo_stack: model.redo_stack, - future_log: model.future_log, - replay_toggle: model.replay_toggle, }; }; diff --git a/src/web/app/Logged.re b/src/web/app/Logged.re new file mode 100644 index 0000000000..8906915782 --- /dev/null +++ b/src/web/app/Logged.re @@ -0,0 +1,201 @@ +open Util; + +module Model = { + [@deriving (show({with_path: false}), sexp, yojson)] + type state = History.Model.t; + + [@deriving (show({with_path: false}), sexp, yojson)] + type t = { + current: state, + future_log: list((float, History.Update.t)), + replay_toggle: bool, + }; + + let equal = (===); + + let init = () => { + current: History.Model.init(), + future_log: [], + replay_toggle: false, + }; +}; + +module Update = { + open Updated; + + [@deriving (show({with_path: false}), sexp, yojson)] + type t = History.Update.t; + + // let sexp = History.Update.sexp_of_t(action); + // For now, we don't ignore any actions; add here if needed + // check if str contains "(Select (Term (Id" (ignoring whitespace) + // let str = Sexplib.Sexp.to_string(sexp); + // StringUtil.match(StringUtil.regexp("Select\\s*\\(Term\\s*\\(Id"), str); + let ignore_if_action_fails_in_log_replay = (_action: t): bool => { + false; + }; + + [@deriving (show({with_path: false}), sexp, yojson)] + let update = + ( + ~import_log, + ~get_log_and, + ~schedule_action: t => unit, + action: t, + model: Model.t, + ) + : Updated.t(Model.t) => + switch (action) { + | Globals(Log(a)) => + switch (a) { + | InitImport(f) => + JsUtil.read_file(f, data => + schedule_action(Globals(Log(FinishImport(data)))) + ); + model |> Updated.return_quiet; + | FinishImport(None) => + LogSidebar.log_error("Log import failed"); + model |> Updated.return_quiet; + | FinishImport(Some(data)) => + let of_data = (data: string): list((float, History.Update.t)) => + Export.import_just_log(data) + |> Sexplib.Sexp.of_string + |> Log.Entry.s_of_sexp_opt + |> List.filter_map(x => x); + let actions = + data + |> of_data + |> Log.flatten_imports(~of_data) + |> ( + x => { + LogSidebar.log_info( + "Imported log entries: " ++ string_of_int(List.length(x)), + ); + x; + } + ); + { + ...model, + future_log: model.future_log @ actions, + } + |> Updated.return_quiet; + | NextLog => + switch (model.future_log) { + | [] => + LogSidebar.log_info("No next log action to perform"); + model |> Updated.return_quiet; + | [(t, next), ...rest] => + LogSidebar.log_action( + "Applying next log action", + Some(JsUtil.print_timestamp(t)), + ); + // Keep full action expression in console for detailed debugging + print_endline("Full action: " ++ History.Update.show(next)); + try({ + let updated = + History.Update.update( + ~import_log, + ~get_log_and, + ~schedule_action, + next, + model.current, + ); + { + ...updated, + model: { + current: updated.model, + future_log: rest, + replay_toggle: model.replay_toggle, + }, + }; + }) { + | _ => + LogSidebar.log_error("Failed to apply log action"); + Model.{ + ...model, + future_log: + ignore_if_action_fails_in_log_replay(next) + ? rest : model.future_log, + replay_toggle: + ignore_if_action_fails_in_log_replay(next) + ? model.replay_toggle : false, + } + |> Updated.return_quiet; + }; + } + | SkipLog => + LogSidebar.log_action("Skipping the next log entry", None); + switch (model.future_log) { + | [] => + LogSidebar.log_info("No log entry to skip"); + model |> return_quiet; + | [(_, _), ...rest] => + { + ...model, + future_log: rest, + } + |> return_quiet + }; + | ToggleReplay => + Model.{ + ...model, + replay_toggle: !model.replay_toggle, + } + |> return_quiet + | ClearLog => + Log.DB.clear_and(() => ()); + LogSidebar.log_info("Log cleared"); + model |> return_quiet; + } + | action => + let current = + History.Update.update( + ~import_log, + ~get_log_and, + ~schedule_action, + action, + model.current, + ); + let _ = Log.update(action, current); + { + ...current, + model: { + current: current.model, + future_log: model.future_log, + replay_toggle: model.replay_toggle, + }, + }; + }; + + let calculate = + ( + ~schedule_action: t => unit, + ~is_edited: bool, + ~dynamics, + model: Model.t, + ) + : Model.t => { + current: + model.current + |> History.Update.calculate(~schedule_action, ~is_edited, ~dynamics), + future_log: model.future_log, + replay_toggle: model.replay_toggle, + }; +}; + +module Selection = { + type t = History.Selection.t; + + let handle_key_event = (model: Model.t) => + History.Selection.handle_key_event(model.current); + + let get_cursor_info = (model: Model.t) => + History.Selection.get_cursor_info(model.current); +}; + +module View = { + let view = + (~get_log_and, ~inject: Update.t => Ui_effect.t(unit), model: Model.t) => { + History.View.view(~get_log_and, ~inject, model.current); + }; +}; diff --git a/src/web/app/globals/Globals.re b/src/web/app/globals/Globals.re index 6ef7793980..6c4d6cd18d 100644 --- a/src/web/app/globals/Globals.re +++ b/src/web/app/globals/Globals.re @@ -50,7 +50,6 @@ module Action = { | FinishImport(option(string)) | NextLog | SkipLog - | SkipExercise | ToggleReplay | ClearLog; diff --git a/src/web/app/log/LogSidebar.re b/src/web/app/log/LogSidebar.re index 3b3adce36a..d12d1afe1f 100644 --- a/src/web/app/log/LogSidebar.re +++ b/src/web/app/log/LogSidebar.re @@ -183,11 +183,6 @@ let controls_section = ~onclick=_ => inject(Log(SkipLog)), [text("Skip")], ), - button( - ~tooltip="Skip to next exercise", - ~onclick=_ => inject(Log(SkipExercise)), - [text("Skip Exercise")], - ), ], ), progress_bar(~current=model.current_step, ~total=model.total_steps), From 65727a513133bbe7b30be3f1c97ebd2fc7b315cb Mon Sep 17 00:00:00 2001 From: Matt Keenan <31668468+Negabinary@users.noreply.github.com> Date: Fri, 6 Feb 2026 13:27:05 -0500 Subject: [PATCH 09/11] Log interface fixes --- src/web/app/Logged.re | 109 ++++++++++++++++++++------------- src/web/app/Page.re | 16 ++--- src/web/app/log/LogSidebar.re | 101 ++++++------------------------ src/web/app/sidebar/Sidebar.re | 3 +- 4 files changed, 98 insertions(+), 131 deletions(-) diff --git a/src/web/app/Logged.re b/src/web/app/Logged.re index 8906915782..70d78bcb99 100644 --- a/src/web/app/Logged.re +++ b/src/web/app/Logged.re @@ -8,6 +8,8 @@ module Model = { type t = { current: state, future_log: list((float, History.Update.t)), + past_log: list((float, History.Update.t)), + replay_messages: list(string), replay_toggle: bool, }; @@ -16,6 +18,8 @@ module Model = { let init = () => { current: History.Model.init(), future_log: [], + past_log: [], + replay_messages: [], replay_toggle: false, }; }; @@ -27,13 +31,6 @@ module Update = { type t = History.Update.t; // let sexp = History.Update.sexp_of_t(action); - // For now, we don't ignore any actions; add here if needed - // check if str contains "(Select (Term (Id" (ignoring whitespace) - // let str = Sexplib.Sexp.to_string(sexp); - // StringUtil.match(StringUtil.regexp("Select\\s*\\(Term\\s*\\(Id"), str); - let ignore_if_action_fails_in_log_replay = (_action: t): bool => { - false; - }; [@deriving (show({with_path: false}), sexp, yojson)] let update = @@ -62,35 +59,29 @@ module Update = { |> Sexplib.Sexp.of_string |> Log.Entry.s_of_sexp_opt |> List.filter_map(x => x); - let actions = - data - |> of_data - |> Log.flatten_imports(~of_data) - |> ( - x => { - LogSidebar.log_info( - "Imported log entries: " ++ string_of_int(List.length(x)), - ); - x; - } - ); + let actions = data |> of_data |> Log.flatten_imports(~of_data); { ...model, future_log: model.future_log @ actions, + replay_messages: [ + "Imported log entries: " ++ string_of_int(List.length(actions)), + ...model.replay_messages, + ], } |> Updated.return_quiet; | NextLog => switch (model.future_log) { | [] => - LogSidebar.log_info("No next log action to perform"); - model |> Updated.return_quiet; + { + ...model, + replay_messages: [ + "No next log action to perform", + ...model.replay_messages, + ], + } + |> Updated.return_quiet | [(t, next), ...rest] => - LogSidebar.log_action( - "Applying next log action", - Some(JsUtil.print_timestamp(t)), - ); - // Keep full action expression in console for detailed debugging - print_endline("Full action: " ++ History.Update.show(next)); + print_endline("Applying next log action..."); try({ let updated = History.Update.update( @@ -105,6 +96,8 @@ module Update = { model: { current: updated.model, future_log: rest, + past_log: [(t, next), ...model.past_log], + replay_messages: model.replay_messages, replay_toggle: model.replay_toggle, }, }; @@ -113,29 +106,38 @@ module Update = { LogSidebar.log_error("Failed to apply log action"); Model.{ ...model, - future_log: - ignore_if_action_fails_in_log_replay(next) - ? rest : model.future_log, - replay_toggle: - ignore_if_action_fails_in_log_replay(next) - ? model.replay_toggle : false, + replay_messages: [ + "Error applying log action : " ++ History.Update.show(next), + ...model.replay_messages, + ], + future_log: model.future_log, + replay_toggle: false, } - |> Updated.return_quiet; + |> return_quiet; }; } | SkipLog => - LogSidebar.log_action("Skipping the next log entry", None); switch (model.future_log) { | [] => - LogSidebar.log_info("No log entry to skip"); - model |> return_quiet; + { + ...model, + replay_messages: [ + "No log entry to skip", + ...model.replay_messages, + ], + } + |> return_quiet | [(_, _), ...rest] => { ...model, + replay_messages: [ + "Skipped a log entry", + ...model.replay_messages, + ], future_log: rest, } |> return_quiet - }; + } | ToggleReplay => Model.{ ...model, @@ -144,8 +146,16 @@ module Update = { |> return_quiet | ClearLog => Log.DB.clear_and(() => ()); - LogSidebar.log_info("Log cleared"); - model |> return_quiet; + { + ...model, + future_log: [], + past_log: [], + replay_messages: [ + "Cleared all log entries", + ...model.replay_messages, + ], + } + |> return_quiet; } | action => let current = @@ -162,7 +172,9 @@ module Update = { model: { current: current.model, future_log: model.future_log, + past_log: model.past_log, replay_toggle: model.replay_toggle, + replay_messages: model.replay_messages, }, }; }; @@ -179,6 +191,8 @@ module Update = { model.current |> History.Update.calculate(~schedule_action, ~is_edited, ~dynamics), future_log: model.future_log, + past_log: model.past_log, + replay_messages: model.replay_messages, replay_toggle: model.replay_toggle, }; }; @@ -196,6 +210,19 @@ module Selection = { module View = { let view = (~get_log_and, ~inject: Update.t => Ui_effect.t(unit), model: Model.t) => { - History.View.view(~get_log_and, ~inject, model.current); + History.View.view( + ~log_model= + LogSidebar.Model.{ + messages: model.replay_messages, + is_playing: model.replay_toggle, + current_step: List.length(model.past_log), + total_steps: + List.length(model.past_log) + List.length(model.future_log), + show_details: true, + }, + ~get_log_and, + ~inject, + model.current, + ); }; }; diff --git a/src/web/app/Page.re b/src/web/app/Page.re index 75c3f6e7c8..a296939763 100644 --- a/src/web/app/Page.re +++ b/src/web/app/Page.re @@ -17,7 +17,6 @@ module Model = { editors: Editors.Model.t, explain_this: ExplainThisModel.t, assistant: AssistantModel.t, - log_sidebar: LogSidebar.Model.t, selection, }; @@ -34,13 +33,11 @@ module Store = { ); let explain_this = ExplainThisModel.Store.load(); let assistant = AssistantModel.Store.load(); - let log_sidebar = LogSidebar.Model.init(); { editors, globals, explain_this, assistant, - log_sidebar, selection: Editors.Selection.default_selection(editors), }; }; @@ -664,6 +661,7 @@ module View = { let main_view = ( ~get_log_and: (string => unit) => unit, + ~log_model, ~inject: Update.t => Ui_effect.t(unit), ~cursor: Cursor.cursor(Editors.Update.t), { @@ -671,7 +669,6 @@ module View = { editors, explain_this: explainThisModel, assistant: assistantModel, - log_sidebar, selection, } as model: Model.t, ) => { @@ -696,7 +693,7 @@ module View = { | MakeActive(s) => inject(MakeActive(Scratch(s))), ~explainThisModel, ~assistantModel, - ~log_model=log_sidebar, + ~log_model, ~log_count, ~editor=Update.get_editor(model), cursor.info, @@ -765,12 +762,17 @@ module View = { }; let view = - (~get_log_and, ~inject: Update.t => Ui_effect.t(unit), model: Model.t) => { + ( + ~log_model, + ~get_log_and, + ~inject: Update.t => Ui_effect.t(unit), + model: Model.t, + ) => { let cursor = Selection.get_cursor_info(~selection=model.selection, model); div( ~attrs=[Attr.id("page"), ...handlers(~cursor, ~inject, model)], [FontSpecimen.view, JsUtil.clipboard_shim] - @ main_view(~get_log_and, ~cursor, ~inject, model), + @ main_view(~log_model, ~get_log_and, ~cursor, ~inject, model), ); }; }; diff --git a/src/web/app/log/LogSidebar.re b/src/web/app/log/LogSidebar.re index d12d1afe1f..b0261416b0 100644 --- a/src/web/app/log/LogSidebar.re +++ b/src/web/app/log/LogSidebar.re @@ -12,49 +12,6 @@ module Model = { total_steps: int, show_details: bool, }; - - let init = () => { - messages: [], - is_playing: false, - current_step: 0, - total_steps: 0, - show_details: false, - }; -}; - -module Update = { - [@deriving (show({with_path: false}), sexp, yojson)] - type t = - | AddMessage(string) - | ClearMessages - | SetPlaying(bool) - | SetSteps(int, int) - | ToggleDetails; - - let update = (action: t, model: Model.t): Model.t => - switch (action) { - | AddMessage(msg) => { - ...model, - messages: [msg, ...Util.ListUtil.take(100, model.messages)], - } - | ClearMessages => { - ...model, - messages: [], - } - | SetPlaying(playing) => { - ...model, - is_playing: playing, - } - | SetSteps(current, total) => { - ...model, - current_step: current, - total_steps: total, - } - | ToggleDetails => { - ...model, - show_details: !model.show_details, - } - }; }; let button = (~attrs=[], ~tooltip="", ~onclick, ~disabled=false, children) => { @@ -121,8 +78,7 @@ let message_item = (message: string) => { ); }; -let controls_section = - (~inject: Globals.Action.t => Ui_effect.t(unit), ~model: Model.t) => { +let controls_section = (~globals: Globals.t, ~model: Model.t) => { let play_pause_icon = model.is_playing ? "⏸️" : "▶️"; let play_pause_tooltip = model.is_playing ? "Pause log replay" : "Start log replay"; @@ -138,7 +94,8 @@ let controls_section = file => { switch (file) { | None => Virtual_dom.Vdom.Effect.Ignore - | Some(file) => inject(Log(InitImport(file))) + | Some(file) => + globals.inject_global(Log(InitImport(file))) } }, ~id="log-import-input", @@ -154,13 +111,17 @@ let controls_section = _ => { let elem = Util.JsUtil.get_elem_by_id("log-import-input"); elem##click; - Virtual_dom.Vdom.Effect.Ignore; + Ui_effect.Ignore; }, [text("Import Log")], ), button( ~tooltip="Export current log", - ~onclick=_ => inject(Log(ToggleReplay)), // This should trigger export + ~onclick= + _ => { + ExercisesMode.Update.export_submission(~globals); + Ui_effect.Ignore; + }, [text("Export Log")], ), ], @@ -170,17 +131,17 @@ let controls_section = [ button( ~tooltip=play_pause_tooltip, - ~onclick=_ => inject(Log(ToggleReplay)), + ~onclick=_ => globals.inject_global(Log(ToggleReplay)), [text(play_pause_icon)], ), button( ~tooltip="Execute next log step", - ~onclick=_ => inject(Log(NextLog)), + ~onclick=_ => globals.inject_global(Log(NextLog)), [text("Next Step")], ), button( ~tooltip="Skip current log entry", - ~onclick=_ => inject(Log(SkipLog)), + ~onclick=_ => globals.inject_global(Log(SkipLog)), [text("Skip")], ), ], @@ -190,28 +151,13 @@ let controls_section = ); }; -let messages_section = - (~model: Model.t, ~inject_log: Update.t => Ui_effect.t(unit)) => { +let messages_section = (~model: Model.t) => { div( ~attrs=[Attr.class_("log-messages")], [ div( ~attrs=[Attr.class_("log-messages-header")], - [ - span([text("Log Messages")]), - button( - ~attrs=[Attr.class_("log-clear-btn")], - ~tooltip="Clear all messages", - ~onclick=_ => inject_log(ClearMessages), - [text("Clear")], - ), - button( - ~attrs=[Attr.class_("log-details-btn")], - ~tooltip="Toggle detailed view", - ~onclick=_ => inject_log(ToggleDetails), - [text(model.show_details ? "Hide Details" : "Show Details")], - ), - ], + [span([text("Log Messages")])], ), div( ~attrs=[ @@ -232,8 +178,7 @@ let messages_section = ); }; -let debug_section = - (~inject: Globals.Action.t => Ui_effect.t(unit), ~log_entries_count: int) => { +let debug_section = (~globals: Globals.t, ~log_entries_count: int) => { div( ~attrs=[Attr.class_("log-debug")], [ @@ -256,7 +201,7 @@ let debug_section = ~attrs=[ Attr.class_("log-button"), Attr.title("Clear all log entries"), - Attr.on_pointerdown(_ => inject(Log(ClearLog))), + Attr.on_pointerdown(_ => globals.inject_global(Log(ClearLog))), ], [text("Clear Log")], ), @@ -266,20 +211,14 @@ let debug_section = ); }; -let view = - ( - ~inject: Globals.Action.t => Ui_effect.t(unit), - ~inject_log: Update.t => Ui_effect.t(unit), - ~model: Model.t, - ~log_entries_count: int, - ) => { +let view = (~globals: Globals.t, ~model: Model.t, ~log_entries_count: int) => { div( ~attrs=[Attr.class_("log-sidebar")], [ div(~attrs=[Attr.class_("log-header")], [text("Log Control Panel")]), - controls_section(~inject, ~model), - messages_section(~inject_log, ~model), - debug_section(~inject, ~log_entries_count), + controls_section(~globals, ~model), + messages_section(~model), + debug_section(~globals, ~log_entries_count), ], ); }; diff --git a/src/web/app/sidebar/Sidebar.re b/src/web/app/sidebar/Sidebar.re index 6b3620f905..8f1472b99e 100644 --- a/src/web/app/sidebar/Sidebar.re +++ b/src/web/app/sidebar/Sidebar.re @@ -241,8 +241,7 @@ let view = ) | LogControl => LogSidebar.view( - ~inject=globals.inject_global, - ~inject_log=_ => Effect.Ignore, // For now, ignore LogSidebar internal updates + ~globals, ~model=log_model, ~log_entries_count=log_count, ) From 3f660085bf5b928b24d379b6924742a90e2100f8 Mon Sep 17 00:00:00 2001 From: Matt Keenan <31668468+Negabinary@users.noreply.github.com> Date: Mon, 9 Feb 2026 10:10:32 -0500 Subject: [PATCH 10/11] Reset on log import --- src/web/Main.re | 2 +- src/web/Store.re | 6 +-- src/web/app/History.re | 8 +++- src/web/app/Logged.re | 27 +++++++++-- src/web/app/Page.re | 14 ++++++ src/web/app/editors/Editors.re | 9 ++++ src/web/app/editors/mode/ExercisesMode.re | 11 +++++ src/web/app/editors/mode/TutorialsMode.re | 11 +++++ src/web/app/globals/Globals.re | 55 ++++++++++++----------- src/web/app/log/LogSidebar.re | 13 +----- 10 files changed, 110 insertions(+), 46 deletions(-) diff --git a/src/web/Main.re b/src/web/Main.re index d3cd8d372c..569500332e 100644 --- a/src/web/Main.re +++ b/src/web/Main.re @@ -112,7 +112,7 @@ let start = { apply(~schedule_action, ~schedule_autosave); }, ~default_model= - Logged.Model.init() + Logged.Model.load() |> Logged.Update.calculate( ~schedule_action=_ => (), ~is_edited=true, diff --git a/src/web/Store.re b/src/web/Store.re index 95c0155551..905602ef77 100644 --- a/src/web/Store.re +++ b/src/web/Store.re @@ -54,21 +54,21 @@ module F = let save = (data: t): unit => JsUtil.set_localstore(key_to_string(key), serialize(data)); - let init = () => { + let reset = () => { JsUtil.set_localstore(key_to_string(key), serialize(default())); default(); }; let load = (): t => switch (JsUtil.get_localstore(key_to_string(key))) { - | None => init() + | None => reset() | Some(data) => deserialize(data, default()) }; let rec export = () => switch (JsUtil.get_localstore(key_to_string(key))) { | None => - let _ = init(); + let _ = reset(); export(); | Some(data) => data }; diff --git a/src/web/app/History.re b/src/web/app/History.re index 4a1d0281ea..1d564e543c 100644 --- a/src/web/app/History.re +++ b/src/web/app/History.re @@ -13,11 +13,17 @@ module Model = { let equal = (===); - let init = () => { + let load = () => { current: Page.Store.load(), undo_stack: [], redo_stack: [], }; + + let reset = (~font_metrics=?, ()) => { + current: Page.Model.reset(~font_metrics?, ()), + undo_stack: [], + redo_stack: [], + }; }; module Update = { diff --git a/src/web/app/Logged.re b/src/web/app/Logged.re index 70d78bcb99..4aaae3eff8 100644 --- a/src/web/app/Logged.re +++ b/src/web/app/Logged.re @@ -15,8 +15,8 @@ module Model = { let equal = (===); - let init = () => { - current: History.Model.init(), + let load = () => { + current: History.Model.load(), future_log: [], past_log: [], replay_messages: [], @@ -60,9 +60,30 @@ module Update = { |> Log.Entry.s_of_sexp_opt |> List.filter_map(x => x); let actions = data |> of_data |> Log.flatten_imports(~of_data); + let current = + History.Model.reset( + ~font_metrics=model.current.current.globals.font_metrics, // Keep old font metrics - otherwise it goes weird + (), + ); + // Retain log panel after import + let current = { + ...current, + current: { + ...current.current, + globals: { + ...current.current.globals, + settings: { + ...current.current.globals.settings, + show_log_panel: true, + sidebar: model.current.current.globals.settings.sidebar, + }, + }, + }, + }; { ...model, - future_log: model.future_log @ actions, + current, + future_log: actions, replay_messages: [ "Imported log entries: " ++ string_of_int(List.length(actions)), ...model.replay_messages, diff --git a/src/web/app/Page.re b/src/web/app/Page.re index a296939763..fe84d552f1 100644 --- a/src/web/app/Page.re +++ b/src/web/app/Page.re @@ -21,6 +21,20 @@ module Model = { }; let equal = (===); + + let reset = (~font_metrics=?, ()) => { + let globals = Globals.Model.init(~font_metrics?, ()); + let settings = globals.settings; + let instructor_mode = globals.settings.instructor_mode; + let editors = Editors.Store.reset(~settings, ~instructor_mode); + { + globals, + editors, + explain_this: ExplainThisModel.init, + assistant: AssistantModel.init(), + selection: Editors.Selection.default_selection(editors), + }; + }; }; module Store = { diff --git a/src/web/app/editors/Editors.re b/src/web/app/editors/Editors.re index 5ec1e91cda..8d5a0425b2 100644 --- a/src/web/app/editors/Editors.re +++ b/src/web/app/editors/Editors.re @@ -90,6 +90,15 @@ module Store = { ExercisesMode.Store.save(~instructor_mode, m); }; }; + + let reset = (~settings, ~instructor_mode) => { + StoreMode.save(Tutorial); + let _ = ScratchMode.Store.reset(); + let _ = ScratchMode.StoreDocumentation.reset(); + let _ = TutorialsMode.Store.reset(~settings, ~instructor_mode); + let _ = ExercisesMode.Store.reset(~settings, ~instructor_mode); + load(~settings, ~instructor_mode); + }; }; module Update = { diff --git a/src/web/app/editors/mode/ExercisesMode.re b/src/web/app/editors/mode/ExercisesMode.re index d650df79a7..7d9068fe67 100644 --- a/src/web/app/editors/mode/ExercisesMode.re +++ b/src/web/app/editors/mode/ExercisesMode.re @@ -271,6 +271,17 @@ module Store = { exercise_export.exercise_data, ); }; + + let reset = (~settings, ~instructor_mode) => { + let _ = StoreExerciseKey.reset(); + List.iter( + spec => { + let _ = init_exercise(~settings, spec, ~instructor_mode); + (); + }, + ExerciseSettings.exercises, + ); + }; }; module Update = { diff --git a/src/web/app/editors/mode/TutorialsMode.re b/src/web/app/editors/mode/TutorialsMode.re index 46b12fc2de..8e55d2a74c 100644 --- a/src/web/app/editors/mode/TutorialsMode.re +++ b/src/web/app/editors/mode/TutorialsMode.re @@ -161,6 +161,17 @@ module Store = { exercise_export.exercise_data, ); }; + + let reset = (~settings, ~instructor_mode) => { + let _ = StoreTutorialKey.reset(); + List.iter( + spec => { + let _ = init_exercise(~settings, spec, ~instructor_mode); + (); + }, + TutorialSettings.lessons, + ); + }; }; module Update = { open Updated; diff --git a/src/web/app/globals/Globals.re b/src/web/app/globals/Globals.re index 6c4d6cd18d..5fba99da06 100644 --- a/src/web/app/globals/Globals.re +++ b/src/web/app/globals/Globals.re @@ -97,35 +97,36 @@ module Model = { export_persistent: unit => unit, }; + let init = + (~settings=Settings.Model.init, ~font_metrics=FontMetrics.init, ()) => { + settings, + font_metrics, + meta_down: false, + visible_rows: None, + color_highlights: None, + inject_global: _ => + failwith("Cannot use inject_global outside of the main view function!"), + get_log_and: _ => + failwith( + "Cannot use get_log_and outside of the main view or update functions!", + ), + get_log_count: _ => + failwith( + "Cannot use get_log_count outside of the main view or update functions!", + ), + export_all: (~settings as _, ~instructor_mode as _, ~log as _) => + failwith( + "Cannot use export_all outside of the main view or update functions!", + ), + export_persistent: () => + failwith( + "Cannot use export_persistent outside of the main view function!", + ), + }; + let load = () => { let settings = Settings.Store.load(); - { - font_metrics: FontMetrics.init, - settings, - meta_down: false, - visible_rows: None, - color_highlights: None, - inject_global: _ => - failwith( - "Cannot use inject_global outside of the main view function!", - ), - get_log_and: _ => - failwith( - "Cannot use get_log_and outside of the main view or update functions!", - ), - get_log_count: _ => - failwith( - "Cannot use get_log_count outside of the main view or update functions!", - ), - export_all: (~settings as _, ~instructor_mode as _, ~log as _) => - failwith( - "Cannot use export_all outside of the main view or update functions!", - ), - export_persistent: () => - failwith( - "Cannot use export_persistent outside of the main view function!", - ), - }; + init(~settings, ()); }; let save = model => { diff --git a/src/web/app/log/LogSidebar.re b/src/web/app/log/LogSidebar.re index b0261416b0..f869c9b5eb 100644 --- a/src/web/app/log/LogSidebar.re +++ b/src/web/app/log/LogSidebar.re @@ -106,23 +106,14 @@ let controls_section = (~globals: Globals.t, ~model: Model.t) => { ], ), button( - ~tooltip="Import log file", + ~tooltip="Import log file and reset state", ~onclick= _ => { let elem = Util.JsUtil.get_elem_by_id("log-import-input"); elem##click; Ui_effect.Ignore; }, - [text("Import Log")], - ), - button( - ~tooltip="Export current log", - ~onclick= - _ => { - ExercisesMode.Update.export_submission(~globals); - Ui_effect.Ignore; - }, - [text("Export Log")], + [text("Import & Reset")], ), ], ), From 2302144c9128ccb043ca8bd6d8f880f6d5a86783 Mon Sep 17 00:00:00 2001 From: Matt Keenan <31668468+Negabinary@users.noreply.github.com> Date: Tue, 10 Feb 2026 13:07:27 -0500 Subject: [PATCH 11/11] Add export button; stop replay --- src/web/app/Logged.re | 3 ++- src/web/app/log/LogSidebar.re | 37 +++++++++++++++++++++++++---------- 2 files changed, 29 insertions(+), 11 deletions(-) diff --git a/src/web/app/Logged.re b/src/web/app/Logged.re index 4aaae3eff8..48ec424b61 100644 --- a/src/web/app/Logged.re +++ b/src/web/app/Logged.re @@ -96,9 +96,10 @@ module Update = { { ...model, replay_messages: [ - "No next log action to perform", + "log replay finished", ...model.replay_messages, ], + replay_toggle: false, } |> Updated.return_quiet | [(t, next), ...rest] => diff --git a/src/web/app/log/LogSidebar.re b/src/web/app/log/LogSidebar.re index f869c9b5eb..c9336571fc 100644 --- a/src/web/app/log/LogSidebar.re +++ b/src/web/app/log/LogSidebar.re @@ -89,6 +89,33 @@ let controls_section = (~globals: Globals.t, ~model: Model.t) => { div( ~attrs=[Attr.class_("log-control-row")], [ + button( + ~tooltip="Import log file and reset state", + ~onclick= + _ => { + let elem = Util.JsUtil.get_elem_by_id("log-import-input"); + elem##click; + Ui_effect.Ignore; + }, + [text("Import & Reset")], + ), + button( + ~tooltip="Export current submission", + ~onclick= + _ => { + globals.get_log_and(log => { + let data = + globals.export_all( + ~settings=globals.settings.core, + ~instructor_mode=globals.settings.instructor_mode, + ~log, + ); + JsUtil.download_json(ExerciseSettings.filename, data); + }); + Ui_effect.Ignore; + }, + [text("Export Submission")], + ), file_input( ~onchange= file => { @@ -105,16 +132,6 @@ let controls_section = (~globals: Globals.t, ~model: Model.t) => { `Extension("json"), ], ), - button( - ~tooltip="Import log file and reset state", - ~onclick= - _ => { - let elem = Util.JsUtil.get_elem_by_id("log-import-input"); - elem##click; - Ui_effect.Ignore; - }, - [text("Import & Reset")], - ), ], ), div(