diff --git a/src/web/Main.re b/src/web/Main.re index 569500332e..2dff78037f 100644 --- a/src/web/Main.re +++ b/src/web/Main.re @@ -19,61 +19,35 @@ let restart_caret_animation = () => let apply = ( - model: Logged.Model.t, - action: Logged.Update.t, + model: CrashHandling.Model.t, + action: CrashHandling.Update.t, ~schedule_action, ~schedule_autosave, ) - : Logged.Model.t => { + : CrashHandling.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(Logged.Model.t) = - try( - Logged.Update.update( - ~import_log=Log.import, - ~get_log_and=Log.get_and, - ~schedule_action, - action, - model, - ) - ) { - | Haz3lcore.Action.Failure.Exception(t) => - Printf.printf( - "ERROR: Action.Failure: %s\n", - t |> Haz3lcore.Action.Failure.show, - ); - model |> Updated.return_quiet; - | exc => - Printf.printf( - "ERROR: Exception during apply: %s\n", - Printexc.to_string(exc), - ); - model |> Updated.return_quiet; - }; + let updated: Updated.t(CrashHandling.Model.t) = + CrashHandling.Update.update( + ~import_log=Log.import, + ~get_log_and=Log.get_and, + ~schedule_action, + action, + model, + ); // ---------- CALCULATE PHASE ---------- let model' = - try( - updated.model - |> Logged.Update.calculate( - ~schedule_action, - ~is_edited=updated.is_edit, - ~dynamics=true, - ) - ) { - | exc => - Printf.printf( - "ERROR: Exception during calculate: %s\n", - Printexc.to_string(exc), - ); - { - ...model, - replay_toggle: false, - }; - }; + CrashHandling.Update.calculate( + ~schedule_action, + ~is_edited=updated.is_edit, + ~dynamics=true, + model, + updated.model, + ); if (updated.is_edit) { schedule_autosave( @@ -98,8 +72,8 @@ let start = { let%sub save_scheduler = BonsaiUtil.Alarm.alarm; let%sub (app_model, app_inject) = Bonsai.state_machine1( - (module Logged.Model), - (module Logged.Update), + (module CrashHandling.Model), + (module CrashHandling.Update), ~apply_action= (~inject, ~schedule_event, input) => { let schedule_action = x => schedule_event(inject(x)); @@ -112,12 +86,13 @@ let start = { apply(~schedule_action, ~schedule_autosave); }, ~default_model= - Logged.Model.load() - |> Logged.Update.calculate( - ~schedule_action=_ => (), - ~is_edited=true, - ~dynamics=false, - ), + CrashHandling.Update.calculate( + ~schedule_action=_ => (), + ~is_edited=true, + ~dynamics=false, + CrashHandling.Model.load(), + CrashHandling.Model.load(), + ), save_scheduler, ); @@ -130,7 +105,7 @@ let start = { let%map app_inject = app_inject and model = app_model; Ui_effect.Many( - model.replay_toggle + model.model.replay_toggle ? [app_inject(Page.Update.Globals(Log(NextLog)))] : [], ); }; @@ -220,7 +195,7 @@ let start = { let _ = Haz3lcore.ProbePerform.FocusEffect.execute(); /* Update floating elements (backpack) to viewport coordinates */ FloatingElement.update_all(); - model.current.current.globals.settings.core.statics + model.model.current.current.globals.settings.core.statics ? Animation.go() : (); }, (), @@ -232,7 +207,11 @@ let start = { let%arr app_model = app_model and app_inject = app_inject; try( - Logged.View.view(app_model, ~inject=app_inject, ~get_log_and=Log.get_and) + CrashHandling.View.view( + ~get_log_and=Log.get_and, + ~inject=app_inject, + app_model, + ) ) { | exc => print_endline( diff --git a/src/web/app/CrashHandling.re b/src/web/app/CrashHandling.re new file mode 100644 index 0000000000..994f603a40 --- /dev/null +++ b/src/web/app/CrashHandling.re @@ -0,0 +1,250 @@ +open Util; + +type current_exception = + | Update(string) + | Calculate(string); + +let last_exception: ref(option(exn)) = ref(None); +let current_exception: ref(option(current_exception)) = ref(None); + +let set_last_exception = exn => { + last_exception := Some(exn); +}; + +let clear_last_exception = () => { + last_exception := None; +}; + +let set_current_exception = exn_type => { + current_exception := Some(exn_type); +}; + +let clear_current_exception = () => { + current_exception := None; +}; + +module Model = { + [@deriving (sexp, yojson)] + type state = Logged.Model.t; + + [@deriving (sexp, yojson)] + type t = {model: state}; + + let equal = (===); + + let load = () => {model: Logged.Model.load()}; +}; + +module Update = { + [@deriving (sexp, yojson)] + type t = Logged.Update.t; + + let update = + ( + ~import_log, + ~get_log_and, + ~schedule_action: t => unit, + action: t, + model: Model.t, + ) + : Updated.t(Model.t) => + switch (action) { + | Globals(ClearException) => + clear_last_exception(); + clear_current_exception(); + model |> Updated.return_quiet; + | Globals(RethrowException) => + switch (last_exception^) { + | None => model |> Updated.return_quiet + | Some(exn) => raise(exn) + } + | _ when current_exception^ == None => + try({ + let updated = + Logged.Update.update( + ~import_log, + ~get_log_and, + ~schedule_action, + action, + model.model, + ); + { + ...updated, + model: { + model: updated.model, + }, + }; + }) { + | Haz3lcore.Action.Failure.Exception(t) => + Printf.printf( + "ERROR: Action.Failure: %s\n", + t |> Haz3lcore.Action.Failure.show, + ); + model |> Updated.return_quiet; + | Updated.InvalidAction => + print_endline("cannot perform action"); + model |> Updated.return_quiet; + | exn => + set_last_exception(exn); + let msg = Printexc.to_string(exn); + print_endline("CrashHandling: Caught exception in update: " ++ msg); + set_current_exception(Update(msg)); + model |> Updated.return_quiet; + } + | _ => model |> Updated.return_quiet + }; + + let calculate = + ( + ~schedule_action: t => unit, + ~is_edited: bool, + ~dynamics, + previous_model: Model.t, + model: Model.t, + ) + : Model.t => + try({ + model: + model.model + |> Logged.Update.calculate(~schedule_action, ~is_edited, ~dynamics), + }) { + | exn => + set_last_exception(exn); + let msg = Printexc.to_string(exn); + print_endline("CrashHandling: Caught exception in calculate: " ++ msg); + set_current_exception(Calculate(msg)); + previous_model; + }; +}; + +module View = { + open Virtual_dom.Vdom; + open WebUtil.Node; + + let hsod_view = + ( + ~title: string, + ~msg: string, + ~inject_backtrack: Ui_effect.t(unit), + ~inject_rethrow: Ui_effect.t(unit), + ) => + div( + ~attrs=[Attr.class_("hsod-container")], + [ + div( + ~attrs=[Attr.class_("hsod")], + [ + div( + ~attrs=[Attr.class_("hsod-inner")], + [ + div( + ~attrs=[Attr.class_("hsod-img")], + [ + Node.img( + ~attrs=[ + Attr.create("src", "img/dead-hazel.png"), + Attr.create("alt", "dead hazel"), + ], + (), + ), + ], + ), + div( + ~attrs=[Attr.class_("hsod-body")], + [ + h1([Node.text(title)]), + pre([Node.text(msg)]), + div( + ~attrs=[Attr.class_("hsod-links")], + [ + // button( + // ~attrs=[ + // Attr.create("type", "button"), + // Attr.class_("hsod-button"), + // Attr.on_click(_ => { + // let confirmed = + // JsUtil.confirm( + // "Are you SURE you want to reset Hazel to its initial state? You will lose any existing code that you have written!", + // ); + // if (confirmed) { + // JsUtil.clear_localstore(); + // Js_of_ocaml.Dom_html.window##.location##reload; + // }; + // Virtual_dom.Vdom.Effect.Ignore; + // }), + // ], + // [Node.text("Reset Hazel")], + // ), + a( + ~attrs=[ + Attr.create( + "href", + "https://github.com/hazelgrove/hazel/issues/new", + ), + Attr.create("target", "_blank"), + Attr.class_("hsod-link"), + ], + [Node.text("Report this issue on GitHub")], + ), + button( + ~attrs=[ + Attr.create("type", "button"), + Attr.classes([ + "hsod-button", + "hsod-button-primary", + ]), + Attr.on_click(_ => inject_backtrack), + ], + [Node.text("Revert to previous state")], + ), + button( + ~attrs=[ + Attr.create("type", "button"), + Attr.class_("hsod-button"), + Attr.on_click(_ => inject_rethrow), + ], + [Node.text("Rethrow exception")], + ), + ], + ), + ], + ), + ], + ), + ], + ), + ], + ); + + let view = + (~get_log_and, ~inject: Update.t => Ui_effect.t(unit), model: Model.t) => + switch (current_exception^) { + | None => + try(Logged.View.view(~get_log_and, ~inject, model.model)) { + | exn => + set_last_exception(exn); + let msg = Printexc.to_string(exn); + set_current_exception(Update(msg)); + hsod_view( + ~title="Exception during View", + ~msg, + ~inject_backtrack=inject(Globals(Undo)), + ~inject_rethrow=inject(Globals(RethrowException)), + ); + } + | Some(Update(msg)) => + hsod_view( + ~title="Exception during Update", + ~msg, + ~inject_backtrack=inject(Globals(ClearException)), + ~inject_rethrow=inject(Globals(RethrowException)), + ) + | Some(Calculate(msg)) => + hsod_view( + ~title="Exception during Calculate", + ~msg, + ~inject_backtrack=inject(Globals(ClearException)), + ~inject_rethrow=inject(Globals(RethrowException)), + ) + }; +}; diff --git a/src/web/app/Page.re b/src/web/app/Page.re index fe84d552f1..25f60d57e4 100644 --- a/src/web/app/Page.re +++ b/src/web/app/Page.re @@ -260,8 +260,12 @@ module Update = { }; | Log(_) | Undo - | Redo => - failwith("Undo/Redo/Log import are handled in the history module") + | Redo + | RethrowException + | ClearException => + failwith( + "Undo/Redo/Log import/RethrowException/ClearException are handled in higher-level modules", + ) }; }; diff --git a/src/web/app/globals/Globals.re b/src/web/app/globals/Globals.re index 5fba99da06..c38ede22e9 100644 --- a/src/web/app/globals/Globals.re +++ b/src/web/app/globals/Globals.re @@ -66,7 +66,9 @@ module Action = { | Redo // global actions so they can be accessed by the command palette | Log(log) | SetMetaDown(bool) - | UpdateVisibleRows(VisibleRows.t); + | UpdateVisibleRows(VisibleRows.t) + | RethrowException + | ClearException; }; module Model = { @@ -158,6 +160,8 @@ module Update = { | SetMetaDown(_) => false | UpdateVisibleRows(_) => false | Log(_) => false + | RethrowException => false + | ClearException => false }; }; }; diff --git a/src/web/www/img/dead-hazel.png b/src/web/www/img/dead-hazel.png new file mode 100644 index 0000000000..07165ec238 Binary files /dev/null and b/src/web/www/img/dead-hazel.png differ diff --git a/src/web/www/style.css b/src/web/www/style.css index c884ca1972..b35d5d2263 100644 --- a/src/web/www/style.css +++ b/src/web/www/style.css @@ -19,6 +19,7 @@ @import "style/stepper.css"; @import "style/toggle.css"; @import "style/theorems.css"; +@import "style/hsod.css"; /* BASE ELEMENTS */ diff --git a/src/web/www/style/hsod.css b/src/web/www/style/hsod.css new file mode 100644 index 0000000000..8087e84094 --- /dev/null +++ b/src/web/www/style/hsod.css @@ -0,0 +1,158 @@ +/* Hazel Screen of Death (HSOD) styling */ + +.hsod-container { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + background: var(--T3); + z-index: 10000; + padding: 1em; + font-family: var(--ui-font); +} + +.hsod { + max-width: 900px; + width: 100%; + max-height: 85vh; + overflow-y: auto; + padding: 2.5em; +} + +.hsod-inner { + display: flex; + gap: 2em; + align-items: flex-start; +} + +.hsod-img { + flex: 0 0 280px; +} + +.hsod-img img { + max-width: 100%; + height: auto; + display: block; + border-radius: 0.4em; +} + +.hsod-body { + flex: 1; +} + +.hsod-body h1 { + margin: 0 0 1em 0; + font-size: 1.8em; + font-weight: 600; + color: var(--BR4); + font-family: var(--ui-font); +} + +.hsod-body pre { + background: var(--T4); + border: 1px solid var(--T2); + border-radius: 0.4em; + padding: 1.2em; + overflow-x: auto; + font-family: var(--code-font); + font-size: 0.9em; + line-height: 1.5; + color: var(--STONE); + margin: 0 0 1.5em 0; +} + +.hsod-links { + display: flex; + gap: 1em; + flex-wrap: wrap; + align-items: center; +} + +.hsod-link { + color: var(--BR4); + font-size: 0.9em; + padding: 0.65em 1em; + border-radius: 4px; + font-weight: bold; + cursor: pointer; + border: 0.75px solid var(--BR3); + background-color: var(--T1); + text-decoration: none; +} + +.hsod-link:hover { + background-color: var(--Y1); +} + +.hsod-link:active { + background-color: var(--T3); +} + +.hsod-button { + color: var(--BR4); + font-size: 0.9em; + padding: 0.65em 1em; + border-radius: 4px; + font-weight: bold; + cursor: pointer; + border: 0.75px solid var(--BR3); + background-color: var(--T1); + font-family: var(--ui-font); +} + +.hsod-button:hover { + background-color: var(--Y1); +} + +.hsod-button:active { + background-color: var(--T3); +} + +.hsod-button-primary { + background-color: var(--BR2); + border: 0.75px solid var(--BR4); + color: var(--SAND); +} + +.hsod-button-primary:hover { + background-color: var(--Y2); +} + +.hsod-button-primary:active { + background-color: var(--BR3); +} + +/* Responsive design */ +@media (max-width: 768px) { + .hsod { + padding: 1.5em; + } + + .hsod-inner { + flex-direction: column; + gap: 1.5em; + } + + .hsod-img { + flex: none; + width: 100%; + } + + .hsod-body h1 { + font-size: 1.5em; + } + + .hsod-links { + flex-direction: column; + } + + .hsod-link, + .hsod-button { + width: 100%; + text-align: center; + } +}