-
Notifications
You must be signed in to change notification settings - Fork 41
feat: RefreshComponent #141
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
JovanGerb
wants to merge
9
commits into
leanprover-community:main
Choose a base branch
from
JovanGerb:Jovan-RefreshComponent
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
adf4885
feat: RefreshComponent
JovanGerb 0027e55
add copyright headers
JovanGerb be1fc0c
add missing doc-strings to `RefreshResult`
JovanGerb 0f94172
WIP
JovanGerb 0465d0b
refactoring and adding examples
JovanGerb d089d31
.
JovanGerb da2f988
add missing docstrings
JovanGerb 999b15b
rename structures
JovanGerb fb590ed
update doc-string
JovanGerb File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,215 @@ | ||
| /- | ||
| Copyright (c) 2025 Jovan Gerbscheid. All rights reserved. | ||
| Released under Apache 2.0 license as described in the file LICENSE. | ||
| Authors: Jovan Gerbscheid | ||
| -/ | ||
| import ProofWidgets.Data.Html | ||
| import ProofWidgets.Util | ||
|
|
||
| /-! | ||
| ## The `RefreshComponent` widget | ||
|
|
||
| This file defines `RefreshComponent`, which allows you to have an HTML widget that updates | ||
| incrementally as more results are computed by a Lean computation. | ||
|
|
||
| For this interaction, we use an `IO.Ref` that the JavaScript reads from. | ||
| It stores the HTML that should currently be on display, and a task returning the next HTML. | ||
| To determine whether the widget is up to date, each computed HTML has an associated version number. | ||
| (So, the `n`-th HTML will have index `n`) | ||
|
|
||
| When the widget (re)loads, it first loads the current HTML from the ref, and then | ||
| repeatedly awaits further HTML result. | ||
| -/ | ||
|
|
||
| namespace ProofWidgets | ||
| open Lean Server Widget Jsx | ||
| namespace RefreshComponent | ||
|
|
||
| /-- The result that is sent to the `RefreshComponent` after each query. -/ | ||
| structure VersionedHtml where | ||
| /-- The new HTML that will replace the current HTML. -/ | ||
| html : Html | ||
| /-- The version number of the HTML. It is a count of how many HTMLs were created. -/ | ||
| idx : Nat | ||
| deriving RpcEncodable, Inhabited | ||
|
|
||
| /-- The `RefreshState` stores the incremental result of the HTML computation. -/ | ||
| structure RefreshState where | ||
| /-- The state that the widget should currently be in. -/ | ||
| curr : VersionedHtml | ||
| /-- A task that returns the next state for the widget. | ||
| It is implemented using `IO.Promise.result?`, or `.pure none`. -/ | ||
| next : Task (Option VersionedHtml) | ||
|
|
||
| /-- A reference to a `RefreshState`. This is used to keep track of the refresh state. -/ | ||
| def RefreshRef := IO.Ref RefreshState | ||
|
|
||
| instance : TypeName RefreshRef := unsafe .mk RefreshRef ``RefreshRef | ||
|
|
||
| /-- The data used to call `awaitRefresh`, for updating the HTML display. -/ | ||
| structure AwaitRefreshParams where | ||
| /-- The reference to the `RefreshState`. -/ | ||
| state : WithRpcRef RefreshRef | ||
| /-- The index of the HTML that is currently on display. -/ | ||
| oldIdx : Nat | ||
| deriving RpcEncodable | ||
|
|
||
|
|
||
| /-- `awaitRefresh` is called through RPC to obtain the next HTML to display. -/ | ||
| @[server_rpc_method] | ||
| def awaitRefresh (props : AwaitRefreshParams) : RequestM (RequestTask (Option VersionedHtml)) := do | ||
| let { curr, next } ← props.state.val.get | ||
| -- If `props.oldIdx < curr.idx`, that means that the state has updated in the meantime. | ||
| -- So, returning `curr` will give a refresh. | ||
| -- If `props.oldIdx = curr.idx`, then we need to await `next` to get a refresh | ||
| if props.oldIdx = curr.idx then | ||
| return .mapCheap .ok ⟨next⟩ | ||
| else | ||
| return .pure curr | ||
|
|
||
| /-- | ||
| `getCurrState` is called through RPC whenever the widget reloads. | ||
| This can be because the infoview was closed and reopened, | ||
| or because a different expression was selected in the goal. | ||
| -/ | ||
| @[server_rpc_method] | ||
| def getCurrState (ref : WithRpcRef RefreshRef) : RequestM (RequestTask VersionedHtml) := do | ||
| return .pure (← ref.val.get).curr | ||
|
|
||
|
|
||
| deriving instance TypeName for IO.CancelToken | ||
|
|
||
| /-- `cancelRefresh` is called through RPC by `RefreshComponent` upon cancellation. | ||
| It sets the cancel token for the task(s) on the Lean side. -/ | ||
| @[server_rpc_method] | ||
| def cancelRefresh (cancelTk : WithRpcRef IO.CancelToken) : RequestM (RequestTask Unit) := do | ||
| cancelTk.val.set | ||
| return .pure () | ||
|
|
||
|
|
||
| /-- The arguments passed to `RefreshComponent`. -/ | ||
| structure RefreshComponentProps where | ||
| /-- The refresh state that is queried for updating the display. -/ | ||
| state : WithRpcRef RefreshRef | ||
| /-- The cancel token that will be set when the component is unloaded/reloaded. -/ | ||
| cancelTk : Option (WithRpcRef IO.CancelToken) | ||
| deriving RpcEncodable | ||
|
|
||
| /-- Display an inital HTML, and repeatedly update the display with new HTML objects | ||
| as they appear in `state`. A dedicated thread should be spawned in order to modify `state`. -/ | ||
| @[widget_module] | ||
| def RefreshComponent : Component RefreshComponentProps where | ||
| javascript := include_str ".." / ".." / ".lake" / "build" / "js" / "RefreshComponent.js" | ||
|
|
||
|
|
||
| /-! ## API for creating `RefreshComponent`s -/ | ||
|
|
||
| /-- The monad transformer for maintaining a `RefreshComponent`. -/ | ||
| abbrev RefreshT (m : Type → Type) := | ||
| ReaderT (IO.Promise VersionedHtml) <| StateRefT' IO.RealWorld RefreshState m | ||
|
|
||
| variable {m : Type → Type} [Monad m] [MonadLiftT BaseIO m] | ||
| [MonadLiftT (ST IO.RealWorld) m] | ||
|
|
||
| /-- `RefreshStep` represents an update to the refresh state. -/ | ||
| inductive RefreshStep (m : Type → Type) where | ||
| /-- Leaves the current HTML in place and stops the refreshing. -/ | ||
| | none | ||
| /-- Sets the current HTML to `html` and stops the refreshing. -/ | ||
| | last (html : Html) | ||
| /-- Sets the current HTML to `html` and continues refreshing with `cont`. -/ | ||
| | cont' (html : Html) (cont : RefreshT m Unit) | ||
| deriving Inhabited | ||
|
|
||
| /-- Update `RefreshState` and resolve `IO.Promise VersionedHtml` using the given `RefreshStep`. -/ | ||
| def runRefreshStep (k : RefreshStep m) : RefreshT m Unit := do | ||
| let idx := (← get).curr.idx + 1 | ||
| match k with | ||
| | .none => | ||
| modify fun { curr, .. } => { curr, next := .pure none } | ||
| -- we drop the reference to the promise, so the corresponding task will return `none`. | ||
| | .last html => | ||
| MonadState.set { curr := { html, idx }, next := .pure none } | ||
| (← read).resolve { html, idx } | ||
| | .cont' html cont => | ||
| let newPromise ← IO.Promise.new | ||
| MonadState.set { curr := { html, idx }, next := newPromise.result? } | ||
| (← read).resolve { html, idx } | ||
| withReader (fun _ => newPromise) cont | ||
|
|
||
| /-- Update `RefreshState` and resolve `IO.Promise VersionedHtml` using the given `RefreshStep`. | ||
| Also catch all exceptions that `k` might throw. -/ | ||
| def runRefreshStepM [i : MonadAlwaysExcept Exception m] (k : m (RefreshStep m)) : RefreshT m Unit := do | ||
| have := i.except | ||
| runRefreshStep <| ← | ||
| try k | ||
| catch e => | ||
| if let .internal id _ := e then | ||
| if id == interruptExceptionId then | ||
| return .last <| .text "This component was cancelled" | ||
| return .last | ||
| <span> | ||
| Error refreshing this component: <InteractiveMessage msg={← WithRpcRef.mk e.toMessageData}/> | ||
| </span> | ||
|
|
||
| @[inherit_doc RefreshStep.cont'] | ||
| def RefreshStep.cont [MonadAlwaysExcept Exception m] | ||
| (html : Html) (cont : m (RefreshStep m)) : RefreshStep m := | ||
| .cont' html (runRefreshStepM cont) | ||
|
|
||
| /-- Store a `IO.CancelToken` for all widgets initialized by `mkGoalRefreshComponent`. -/ | ||
| initialize cancelTokenMap : IO.Ref (Std.HashMap Name IO.CancelToken) ← | ||
| IO.mkRef {} | ||
|
|
||
| end RefreshComponent | ||
|
|
||
| open RefreshComponent | ||
|
|
||
| /-- | ||
| Create a `RefreshComponent` in the context of a `Widget.InteractiveGoal`. | ||
| This should be used when the refreshing widget depends on shift-clicked expressions in the goal. | ||
|
|
||
| Warning: after the expression selections in the goal have been updated, the thread that | ||
| updates the `RefreshState` has started running on the Lean server before the new version of the | ||
| widget is loaded. The advantage is that there is no delay before the computation starts. | ||
| The disadvantage is that if a `RefreshComponent` is reloaded too quickly, | ||
| (for example when rapidly clicking on expressions in the goal) | ||
| it doesn't call `cancelRefresh`, and the thread will continue running unneccessarily. | ||
| To fix this, we globally store a cancel token for each such widget. | ||
| The argument `key` is used to differentiate the widgets for this purpose. | ||
| If the same widget is loaded again, we set the previous cancel token. | ||
| As a result, there can be at most one instance of that particular widget | ||
| running at a time, and any other instances will say `This component was cancelled`. | ||
| This is usually not an issue because there is no reason to have the widget duplicated. | ||
|
|
||
| TODO: when the infoview gets closed, `cancelRefresh` isn't run, and I don't know how to fix this. | ||
| -/ | ||
| def mkGoalRefreshComponent (goal : Widget.InteractiveGoal) (initial : Html) | ||
| (k : MetaM (RefreshStep MetaM)) (key : Name := by exact decl_name%) : BaseIO Html := do | ||
| let cancelTk ← IO.CancelToken.new | ||
| if let some oldTk ← cancelTokenMap.modifyGet fun map => (map[key]?, map.insert key cancelTk) then | ||
| oldTk.set | ||
| let promise ← IO.Promise.new | ||
| let ref ← IO.mkRef { curr := { html := initial, idx := 0 }, next := promise.result? } | ||
| discard <| IO.asTask (prio := .dedicated) do | ||
| goal.ctx.val.runMetaM {} do withTheReader Core.Context ({ · with cancelTk? := cancelTk }) do | ||
| let decl ← goal.mvarId.getDecl | ||
| let lctx := decl.lctx |>.sanitizeNames.run' {options := (← getOptions)} | ||
| Meta.withLCtx lctx decl.localInstances <| (runRefreshStepM k) promise ref | ||
| return <RefreshComponent | ||
| state={← WithRpcRef.mk ref} | ||
| cancelTk={← WithRpcRef.mk cancelTk}/> | ||
|
|
||
| /-- Create a `RefreshComponent`. To support cancellation, `m` should extend `CoreM`, | ||
| and hence have access to a cancel token. -/ | ||
| def mkRefreshComponentM {m ε} [Monad m] [MonadDrop m (EIO ε)] [MonadLiftT BaseIO m] | ||
| [MonadLiftT (ST IO.RealWorld) m] [MonadAlwaysExcept Exception m] | ||
| (initial : Html) (k : m (RefreshStep m)) : m Html := do | ||
| let promise ← IO.Promise.new | ||
| let ref ← IO.mkRef { curr := { html := initial, idx := 0 }, next := promise.result? } | ||
| discard <| EIO.asTask (prio := .dedicated) <| ← dropM <| (runRefreshStepM k) promise ref | ||
| return <RefreshComponent | ||
| state={← WithRpcRef.mk ref} | ||
| cancelTk={none}/> | ||
|
|
||
| end ProofWidgets | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,134 @@ | ||
| import ProofWidgets.Component.RefreshComponent | ||
| import ProofWidgets.Component.HtmlDisplay | ||
| import ProofWidgets.Component.OfRpcMethod | ||
| import ProofWidgets.Component.Panel.SelectionPanel | ||
|
|
||
| /-! | ||
| This file showcases the `RefreshComponent` using some basic examples. | ||
|
|
||
| We use `dbg_trace` to help see which Lean processes are active at any time. | ||
| -/ | ||
|
|
||
| open Lean.Widget ProofWidgets RefreshComponent Jsx Lean Server | ||
|
|
||
|
|
||
| /-! Example 1: Counting up to 10 -/ | ||
|
|
||
| partial def countToTen : CoreM Html := do | ||
| mkRefreshComponentM (.text "Let's count to ten!!!") (count 0) | ||
| where | ||
| count (n : Nat) : CoreM (RefreshStep CoreM) := do | ||
| IO.sleep 1000 | ||
| dbg_trace "counted {n}" | ||
| Core.checkSystem "count to ten" | ||
| if n = 10 then | ||
| return .last <| .text s!"Wie niet weg is, is gezien, ik kom!!!" | ||
| else | ||
| return .cont (.text s!"{n + 1}!!!") (count (n + 1)) | ||
|
|
||
| -- If you close and reopen the infoview, the counting continues as if it was open the whole time. | ||
| #html countToTen | ||
JovanGerb marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| -- Here, we duplicate the widget using the same underlying computation | ||
| -- #html (do let x ← countToTen; return <span>{x}<br/>{x}</span>) | ||
|
|
||
| -- Here, we duplicate the widget and duplicate the underlying computation | ||
| -- #html (return <span>{← countToTen}<br/>{← countToTen}</span>) | ||
|
|
||
|
|
||
|
|
||
| /-! Example 2: print the selected expressions one by one in an infinite loop -/ | ||
|
|
||
| @[server_rpc_method] | ||
| partial def cycleSelections (props : PanelWidgetProps) : RequestM (RequestTask Html) := | ||
| RequestM.asTask do | ||
| let some goal := props.goals[0]? | return .text "there are no goals" | ||
| mkGoalRefreshComponent goal (.text "loading...") do | ||
| let args ← props.selectedLocations.mapM (·.saveExprWithCtx) | ||
| if h : args.size ≠ 0 then | ||
| have : NeZero args.size := ⟨h⟩ | ||
| loop args 0 | ||
| else | ||
| return .last <| .text "please select some expression" | ||
| where | ||
| loop (args : Array ExprWithCtx) (i : Fin args.size) : MetaM (RefreshStep MetaM) := do | ||
| return .cont <InteractiveCode fmt={← args[i].runMetaM Widget.ppExprTagged}/> do | ||
| dbg_trace "cycled through expression {i}" | ||
| IO.sleep 1000 | ||
| Core.checkSystem "cycleSelections" | ||
| have : NeZero args.size := ⟨by cases i; grind⟩ | ||
| loop args (i + 1) | ||
|
|
||
| @[widget_module] | ||
| def cycleComponent : Component PanelWidgetProps := | ||
| mk_rpc_widget% cycleSelections | ||
|
|
||
| elab stx:"cycleSelections" : tactic => do | ||
| Widget.savePanelWidgetInfo (hash cycleComponent.javascript) (pure <| json% {}) stx | ||
|
|
||
| -- show_panel_widgets [cycleComponent, cycleComponent] | ||
| example : 1 + 2 + 3 = 6 ^ 1 := by | ||
| cycleSelections -- place your cursor here and select some expressions | ||
| rfl | ||
| -- If you activate the widget multiple times, then thanks to the global cancel token ref, | ||
| -- all but one of the widget computations will say `This component was cancelled`. | ||
|
|
||
|
|
||
|
|
||
| /-! Example 3: compute the fibonacci numbers from 400000 to 400040, in parallel, | ||
| and show the results as they come up. -/ | ||
|
|
||
| def fibo (n : Nat) : Nat := Id.run do | ||
| let mut (a, b) := (0, 1) | ||
| for _ in 0...n do | ||
| (a, b) := (b, a + b) | ||
| return a | ||
|
|
||
| structure FiboState where | ||
| pending : Array (Nat × Task Html) | ||
| results : Array (Nat × Html) := #[] | ||
|
|
||
| def FiboState.update (s : FiboState) : BaseIO FiboState := do | ||
| let mut results := s.results | ||
| let mut pending := #[] | ||
| for (n, t) in s.pending do | ||
| if ← IO.hasFinished t then | ||
| results := results.push (n, t.get) | ||
| else | ||
| pending := pending.push (n, t) | ||
| results := results.insertionSort (·.1 < ·.1) | ||
| return { results, pending } | ||
|
|
||
| def FiboState.render (s : FiboState) (t : Option Nat := none) : Html := | ||
| let header := match t with | ||
| | some t => s!"Finished in {t}ms" | ||
| | none => "Computing Fibonacci numbers ⏳"; | ||
| <details «open»={true}> | ||
| <summary className="mv2 pointer"> | ||
| {.text header} | ||
| </summary> | ||
| {Html.element "ul" #[("style", json% { "padding-left" : "30px"})] (s.results.map (·.2))} | ||
| </details> | ||
|
|
||
| def generateFibo (n : Nat) : Html := | ||
| <li>{.text s!"the {n}-th Fibonacci number has {(fibo n).log2} binary digits."}</li> | ||
|
|
||
| partial def FiboWidget : CoreM Html := do | ||
| mkRefreshComponentM (.text "loading...") do | ||
| IO.sleep 1 -- If we don't wait 1ms first, the infoview lags too much. | ||
| let pending := (400000...=400040).toArray.map fun n => (n, Task.spawn fun _ => generateFibo n) | ||
| let t0 ← IO.monoMsNow | ||
| loop t0 { pending } | ||
| where | ||
| loop (t0 : Nat) (s : FiboState) : CoreM (RefreshStep CoreM) := do | ||
| Core.checkSystem "FiboWidget" | ||
| while !(← s.pending.anyM (IO.hasFinished ·.2)) do | ||
| IO.sleep 10 | ||
| Core.checkSystem "FiboWidget" | ||
| let s ← s.update | ||
| if s.pending.isEmpty then | ||
| return .last <| s.render ((← IO.monoMsNow) - t0) | ||
| else | ||
| return .cont s.render (loop t0 s) | ||
|
|
||
| -- #html FiboWidget | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I am seeing this in the
countToTendemo. To reproduce, uncomment line 33, then check that the counter on line 30 is running, then uncomment line 36. Now line 30 is cancelled. It seems worrying since no shared cancel token is explicitly created here.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Aha interesting! Although it's true that no cancel token is created explicitly, we are implicitly passing around the cancel token that was given to the command in question.
Apparently this cancel token from line 30 gets set when (un)commenting line 36. I find this a bit surprising. I'd have to look more carefully into how/where the cancel tokens usually get passed around.