From 3e98c97d4c8387a4c3d0e4e32c98ae39ee7a40db Mon Sep 17 00:00:00 2001 From: Jon Eugster Date: Thu, 2 Apr 2026 16:16:13 +0200 Subject: [PATCH 01/36] refactor: eliminate old infoview; fix layout; insert default infoview --- client/package.json | 4 +- client/src/app.tsx | 10 +- client/src/components/hints.tsx | 7 +- .../components/infoview/ExerciseStatement.tsx | 39 + client/src/components/infoview/GameEditor.tsx | 26 + client/src/components/infoview/context.ts | 131 ---- client/src/components/infoview/goals.tsx | 413 ----------- client/src/components/infoview/info.tsx | 461 ------------ client/src/components/infoview/infos.tsx | 135 ---- client/src/components/infoview/main.tsx | 686 ------------------ client/src/components/infoview/messages.tsx | 241 ------ client/src/components/infoview/rpc_api.ts | 83 --- client/src/components/infoview/types.ts | 17 + client/src/components/infoview/typewriter.tsx | 302 +------- .../components/inventory/inventory_item.tsx | 8 +- .../components/inventory/inventory_panel.tsx | 15 +- client/src/components/level.tsx | 214 +----- client/src/components/welcome.tsx | 2 +- client/src/components/world_tree.tsx | 20 +- client/src/css/app.css | 23 - client/src/css/infoview.css | 10 +- client/src/css/inventory.css | 15 +- client/src/css/layout.css | 92 +++ client/src/css/level.css | 47 +- client/src/css/typewriter.css | 9 + client/src/css/welcome.css | 20 +- client/src/css/world_tree.css | 8 - client/src/store/chat-atoms.ts | 2 +- client/src/store/editor-atoms.ts | 2 +- client/tsconfig.json | 2 +- package-lock.json | 38 +- 31 files changed, 272 insertions(+), 2810 deletions(-) create mode 100644 client/src/components/infoview/ExerciseStatement.tsx create mode 100644 client/src/components/infoview/GameEditor.tsx delete mode 100644 client/src/components/infoview/context.ts delete mode 100644 client/src/components/infoview/goals.tsx delete mode 100644 client/src/components/infoview/info.tsx delete mode 100644 client/src/components/infoview/infos.tsx delete mode 100644 client/src/components/infoview/main.tsx delete mode 100644 client/src/components/infoview/messages.tsx delete mode 100644 client/src/components/infoview/rpc_api.ts create mode 100644 client/src/components/infoview/types.ts create mode 100644 client/src/css/layout.css create mode 100644 client/src/css/typewriter.css diff --git a/client/package.json b/client/package.json index f922949c..56a6e16e 100644 --- a/client/package.json +++ b/client/package.json @@ -11,7 +11,6 @@ "@fortawesome/free-regular-svg-icons": "^7.2.0", "@fortawesome/free-solid-svg-icons": "^7.2.0", "@fortawesome/react-fontawesome": "^3.2.0", - "@leanprover/infoview": "^0.4.3", "@mui/material": "^5.11.1", "@reduxjs/toolkit": "^1.9.1", "@tanstack/query-core": "^5.90.20", @@ -34,8 +33,7 @@ "react-split": "^2.0.14", "rehype-katex": "^7.0.1", "remark-gfm": "^4.0.1", - "remark-math": "^6.0.0", - "vscode-lean4": "github:leanprover/vscode-lean4#de0062c" + "remark-math": "^6.0.0" }, "devDependencies": { "@codingame/esbuild-import-meta-url-plugin": "^1.0.3", diff --git a/client/src/app.tsx b/client/src/app.tsx index f8086fdf..8cf78ce3 100644 --- a/client/src/app.tsx +++ b/client/src/app.tsx @@ -9,6 +9,10 @@ import '@fontsource/roboto/700.css'; import './css/reset.css'; import './css/app.css'; +import './css/infoview.css' +import "./css/tab_bar.css" +import "./css/layout.css" + import i18n from './i18n'; import { Popup } from './components/popup/popup'; import { leanMonacoAtom, leanMonacoOptionsAtom } from './store/editor-atoms'; @@ -17,7 +21,6 @@ import { preferencesAtom } from './store/preferences-atoms'; function App({ children }: { children?: React.ReactNode }) { - const infoviewRef = useRef(null) const [leanMonaco, setLeanMonaco] = useAtom(leanMonacoAtom) const [leanMonacoOptions] = useAtom(leanMonacoOptionsAtom) const [preferences] = useAtom(preferencesAtom) @@ -30,7 +33,6 @@ function App({ children }: { children?: React.ReactNode }) { useEffect(() => { const _leanMonaco = new LeanMonaco() setLeanMonaco(_leanMonaco) - _leanMonaco.setInfoviewElement(infoviewRef.current!) ;(async () => { await _leanMonaco.start(leanMonacoOptions) @@ -45,12 +47,12 @@ function App({ children }: { children?: React.ReactNode }) { }, [leanMonacoOptions, setLeanMonaco]) return ( -
+ <> {children} -
+ ) } diff --git a/client/src/components/hints.tsx b/client/src/components/hints.tsx index 6ef2ad72..204f9450 100644 --- a/client/src/components/hints.tsx +++ b/client/src/components/hints.tsx @@ -1,7 +1,5 @@ -import { GameHint, InteractiveGoalsWithHints } from "./infoview/rpc_api"; import * as React from 'react'; import { Markdown } from './markdown'; -import { lastStepHasErrors } from "./infoview/goals"; import { Button } from "./button"; import { useGameTranslation } from "../utils/translation"; import { useTranslation } from "react-i18next"; @@ -9,6 +7,11 @@ import { useAtom } from "jotai"; import { gameIdAtom } from "../store/location-atoms"; import { deletedChatAtom, helpAtom } from "../store/chat-atoms"; import { proofAtom } from "../store/editor-atoms"; +import { GameHint, InteractiveGoalsWithHints } from './infoview/types'; + +//FIXME: implement +function lastStepHasErrors(x: any) {return false} + /** Plug-in the variable names in a hint. We do this client-side to prepare * for i18n in the future. i.e. one should be able translate the `rawText` diff --git a/client/src/components/infoview/ExerciseStatement.tsx b/client/src/components/infoview/ExerciseStatement.tsx new file mode 100644 index 00000000..bd32ea30 --- /dev/null +++ b/client/src/components/infoview/ExerciseStatement.tsx @@ -0,0 +1,39 @@ +import React from "react" +import { useTranslation } from "react-i18next" +import { useGameTranslation } from "../../utils/translation" +import { useAtom } from "jotai" +import { gameIdAtom } from "../../store/location-atoms" +import { levelInfoAtom } from "../../store/query-atoms" +import { Markdown } from "../markdown" + +/** The mathematical formulation of the statement, supporting e.g. Latex + * It takes three forms, depending on the precence of name and description: + * - Theorem xyz: description + * - Theorem xyz + * - Exercises: description + * + * If `showLeanStatement` is true, it will additionally display the lean code. + */ +export function ExerciseStatement({ showLeanStatement = false }) { + const { t : gT } = useGameTranslation() + const { t } = useTranslation() + const [gameId] = useAtom(gameIdAtom) + const [{ data: levelInfo }] = useAtom(levelInfoAtom) + + if (!(levelInfo?.descrText || levelInfo?.descrFormat)) { return <> } + return <> +
+ {levelInfo?.descrText ? + + {(levelInfo?.displayName ? `**${t("Theorem")}** \`${levelInfo?.displayName}\`: ` : '') + t(levelInfo?.descrText, {ns: gameId})} + : levelInfo?.displayName && + + {(levelInfo?.displayName ? `**${t("Theorem")}** \`${levelInfo?.displayName}\`: ` : '') + gT(levelInfo?.descrText ?? "")} + + } + {levelInfo?.descrFormat && showLeanStatement && +

{levelInfo?.descrFormat}

+ } +
+ +} diff --git a/client/src/components/infoview/GameEditor.tsx b/client/src/components/infoview/GameEditor.tsx new file mode 100644 index 00000000..6a85b2f4 --- /dev/null +++ b/client/src/components/infoview/GameEditor.tsx @@ -0,0 +1,26 @@ +import { useAtom } from "jotai" +import { leanMonacoAtom, typewriterModeAtom } from "../../store/editor-atoms" +import React, { useEffect, useLayoutEffect, useRef } from "react" +import { ExerciseStatement } from "./ExerciseStatement" +import { Typewriter } from "./Typewriter" + +/** + * Note: It is important that the `div` with `codeViewRef` is + * always present, or the monaco editor cannot start. + */ +export function GameEditor({ codeviewRef } : { codeviewRef: React.MutableRefObject }) { + const infoviewRef = useRef(null) + const [leanMonaco] = useAtom(leanMonacoAtom) + const [typewriterMode] = useAtom(typewriterModeAtom) + + useLayoutEffect(() => { + leanMonaco?.setInfoviewElement(infoviewRef.current!) + }) + + return <> + +
+
+ {typewriterMode && } + +} diff --git a/client/src/components/infoview/context.ts b/client/src/components/infoview/context.ts deleted file mode 100644 index c3556e7b..00000000 --- a/client/src/components/infoview/context.ts +++ /dev/null @@ -1,131 +0,0 @@ -/** - * @fileOverview This file contains the the react contexts used in the project. - */ -import * as React from 'react'; -import * as monaco from 'monaco-editor/esm/vs/editor/editor.api.js' -import { InteractiveDiagnostic } from '@leanprover/infoview-api'; -import { InteractiveTermGoal,InteractiveGoalsWithHints } from './rpc_api'; -import { Preferences, preferencesAtom } from '../../store/preferences-atoms'; -import { useAtom } from 'jotai'; -import { typewriterContentAtom } from '../../store/editor-atoms'; - -export const MonacoEditorContext = React.createContext( - null as any) - -export type InfoStatus = 'updating' | 'error' | 'ready'; - -// /** One step of the proof */ -// export type ProofStep = { -// /** The command in this step */ -// command : string -// /** List of goals *after* this command */ -// goals: InteractiveGoal[] // TODO: Add correct type -// /** Story relevant messages */ -// hints: GameHint[] // TODO: Add correct type -// /** Errors and warnings */ -// errors: InteractiveDiagnostic[] // TODO: Add correct type -// } - - -// TODO: Do we still need that? -export interface ProofStateProps { - // pos: DocumentPosition; - status: InfoStatus; - messages: InteractiveDiagnostic[]; - goals?: InteractiveGoalsWithHints; - termGoal?: InteractiveTermGoal; - error?: string; - // userWidgets: UserWidgetInstance[]; - // rpcSess: RpcSessionAtPos; - // triggerUpdate: () => Promise; -} - -// export const ProofStateContext = React.createContext<{ -// proofState : ProofStateProps, -// setProofState: React.Dispatch> -// }>({ -// proofState : { -// status: 'updating', -// messages: [], -// goals: undefined, -// termGoal: undefined, -// error: undefined}, -// setProofState: () => {}, -// }) - -export interface IPreferencesContext extends Preferences{ - mobile: boolean, // The variables that actually control the page 'layout' can only be changed through layout. - setLayout: React.Dispatch>; - setIsSavePreferences: React.Dispatch>; - setLanguage: React.Dispatch>; -} - - -const SUFFIX_OVERRIDES: Record = { - "induction": "generalizing", - "left": ".left", - "rewrite": "←", - "right": ".right", - "rw": "←", -} -const PREFIX_OVERRIDES: Record = { - "by_cases": "by_cases this :", - "contrapose": "contrapose!", - "have": "have :", - "obtain": "obtain ⟨⟩ :=", - "rewrite": "rw []", - "rw": "rw []", - "simp": "simp only []", -} -export function useAppendTypewriterInput() { - const [typewriter, setTypewriter] = useAtom(typewriterContentAtom) - const [{ isSuggestionsMobileMode }] = useAtom(preferencesAtom) - return (shiftKey: boolean, suffix: string, isTheorem: boolean, isAssumption: boolean) => { - if (!isSuggestionsMobileMode && !shiftKey) { - return false - } - // Automagically detect and adjust punctuation for mobile keyboardless usage - let _typewriter = typewriter.trim() - if (!typewriter.length) { - _typewriter = Object.hasOwn(PREFIX_OVERRIDES, suffix) ? PREFIX_OVERRIDES[suffix] : isTheorem ? `rw [${suffix}]` : suffix - setTypewriter(_typewriter + " ") - return true - } - suffix = !isAssumption && Object.hasOwn(SUFFIX_OVERRIDES, suffix) ? SUFFIX_OVERRIDES[suffix] : suffix - if (suffix === "ℕ") { - if (/ \d$/.test(_typewriter)) { - suffix = ((+_typewriter.slice(-2) + 1) % 10).toString() - _typewriter = _typewriter.slice(0, -2) - } else { - suffix = "0" - } - suffix = " " + suffix - } else if (suffix === "∈" && _typewriter.endsWith("∈")) { - suffix = " {} " - } else if (isAssumption && /^apply |^symm|^push_neg/.test(_typewriter)) { - suffix = " at " + suffix - } else if (suffix === "have") { - suffix = _typewriter === "have :" ? "=" : " :=" - } else if (/[\]}]$/.test(_typewriter)) { - if (isAssumption) { - suffix = " at " + suffix - } else { - const closing = _typewriter.slice(-1) - _typewriter = _typewriter.slice(0, -1) - if (suffix === "←") { - const imbalance = (_typewriter.match(/\(/)?.length ?? 0) - (_typewriter.match(/\)/)?.length ?? 0) - suffix = /[[,({]$/.test(_typewriter) ? "←" : /\([^)]*$/.test(_typewriter) ? ")" : " (" - } else { - if (!/[[,({]$/.test(_typewriter)) { - suffix = (isTheorem && !_typewriter.endsWith("←") && closing === "]" ? ", " : /^[ᶜ.]/.test(suffix) ? "" : " ") + suffix - } - } - suffix = suffix + closing - } - } else if (!/^[ᶜ.]/.test(suffix)) { - suffix = " " + suffix - } - setTypewriter(`${_typewriter}${suffix} `.trimStart()) - return true - } -} diff --git a/client/src/components/infoview/goals.tsx b/client/src/components/infoview/goals.tsx deleted file mode 100644 index 862f8d3f..00000000 --- a/client/src/components/infoview/goals.tsx +++ /dev/null @@ -1,413 +0,0 @@ -/** - * @fileOverview - * - * Mostly copied from https://github.com/leanprover/vscode-lean4/blob/master/lean4-infoview/src/infoview/goals.tsx - */ -import * as React from 'react' -import { InteractiveHypothesisBundle_nonAnonymousNames, MVarId, TaggedText_stripTags } from '@leanprover/infoview-api' -import { EditorContext } from '../../../../node_modules/vscode-lean4/lean4-infoview/src/infoview/contexts'; -import { Locations, LocationsContext, SelectableLocation } from '../../../../node_modules/vscode-lean4/lean4-infoview/src/infoview/goalLocation'; -import { InteractiveCode } from '../../../../node_modules/vscode-lean4/lean4-infoview/src/infoview/interactiveCode' -import { WithTooltipOnHover } from '../../../../node_modules/vscode-lean4/lean4-infoview/src/infoview/tooltips'; -import { useAppendTypewriterInput } from './context'; -import { InteractiveGoal, InteractiveGoals, InteractiveGoalsWithHints, InteractiveHypothesisBundle, ProofState } from './rpc_api'; -import { RpcSessionAtPos } from '@leanprover/infoview/*'; -import { DocumentPosition } from '../../../../node_modules/vscode-lean4/lean4-infoview/src/infoview/util'; -import { DiagnosticSeverity } from 'vscode-languageserver-protocol'; -import { useTranslation } from 'react-i18next'; -import { useAtom } from 'jotai'; -import { gameIdAtom } from '../../store/location-atoms'; -import { typewriterModeAtom } from '../../store/editor-atoms'; -import { gameInfoAtom } from '../../store/query-atoms'; -import { mobileAtom } from '../../store/preferences-atoms'; - -/** Returns true if `h` is inaccessible according to Lean's default name rendering. */ -function isInaccessibleName(h: string): boolean { - return h.indexOf('✝') >= 0; -} - -function goalToString(g: InteractiveGoal): string { - let ret = '' - - if (g.userName) { - ret += `case ${g.userName}\n` - } - - for (const h of g.hyps) { - const names = InteractiveHypothesisBundle_nonAnonymousNames(h).join(' ') - ret += `${names} : ${TaggedText_stripTags(h.type)}` - if (h.val) { - ret += ` := ${TaggedText_stripTags(h.val)}` - } - ret += '\n' - } - - ret += `⊢ ${TaggedText_stripTags(g.type)}` - - return ret -} - -export function goalsToString(goals: InteractiveGoals): string { - return goals.goals.map(g => goalToString(g)).join('\n\n') -} - -export function goalsWithHintsToString(goals: InteractiveGoalsWithHints): string { - return goals.goals.map(g => goalToString(g.goal)).join('\n\n') -} - -interface GoalFilterState { - /** If true reverse the list of hypotheses, if false present the order received from LSP. */ - reverse: boolean, - /** If true show hypotheses that have isType=True, otherwise hide them. */ - showType: boolean, - /** If true show hypotheses that have isInstance=True, otherwise hide them. */ - showInstance: boolean, - /** If true show hypotheses that contain a dagger in the name, otherwise hide them. */ - showHiddenAssumption: boolean - /** If true show the bodies of let-values, otherwise hide them. */ - showLetValue: boolean; -} - -function getFilteredHypotheses(hyps: InteractiveHypothesisBundle[], filter: GoalFilterState): InteractiveHypothesisBundle[] { - return hyps.reduce((acc: InteractiveHypothesisBundle[], h) => { - if (h.isInstance && !filter.showInstance) return acc - if (h.isType && !filter.showType) return acc - const names = filter.showHiddenAssumption ? h.names : h.names.filter(n => !isInaccessibleName(n)) - const hNew: InteractiveHypothesisBundle = filter.showLetValue ? { ...h, names } : { ...h, names, val: undefined } - if (names.length !== 0) acc.push(hNew) - return acc - }, []) -} - -interface HypProps { - hyp: InteractiveHypothesisBundle - mvarId?: MVarId -} - -function Hyp({ hyp: h, mvarId }: HypProps) { - const locs = React.useContext(LocationsContext) - - const namecls: string = 'mr1 hyp-name ' + - (h.isInserted ? 'inserted-text ' : '') + - (h.isRemoved ? 'removed-text ' : '') - - const appendTypewriterInput = useAppendTypewriterInput() - const names = InteractiveHypothesisBundle_nonAnonymousNames(h).map((n, i) => - { - appendTypewriterInput(ev.shiftKey, n, h.isAssumption ?? false, h.isAssumption ?? false) - ev.stopPropagation() - } } - > - i ? - { mvarId, loc: { hyp: h.fvarIds[i] }} : - undefined - } - alwaysHighlight={false} - >{n} - ) - - const typeLocs: Locations | undefined = React.useMemo(() => - locs && mvarId && h.fvarIds && h.fvarIds.length > 0 ? - { ...locs, subexprTemplate: { mvarId, loc: { hypType: [h.fvarIds[0], ''] }}} : - undefined, - [locs, mvarId, h.fvarIds]) - - const valLocs: Locations | undefined = React.useMemo(() => - h.val && locs && mvarId && h.fvarIds && h.fvarIds.length > 0 ? - { ...locs, subexprTemplate: { mvarId, loc: { hypValue: [h.fvarIds[0], ''] }}} : - undefined, - [h.val, locs, mvarId, h.fvarIds]) - - return
- {names} - :  - - - - {h.val && - -  :=  - } -
-} - -interface GoalProps2 { - goals: InteractiveGoal[] - filter: GoalFilterState -} - -interface GoalProps { - goal: InteractiveGoal - filter: GoalFilterState - showHints?: boolean - typewriter: boolean -} - -/** - * Displays the hypotheses, target type and optional case label of a goal according to the - * provided `filter`. */ -export const Goal = React.memo((props: GoalProps) => { - const [gameId] = useAtom(gameIdAtom) - const [typewriterMode] = useAtom(typewriterModeAtom) - const [{ data: gameInfo }] = useAtom(gameInfoAtom) - const [mobile] = useAtom(mobileAtom) - const { goal, filter, showHints, typewriter } = props - let { t } = useTranslation() - - // TODO: Apparently `goal` can be `undefined` - if (!goal) {return <>} - - const filteredList = getFilteredHypotheses(goal.hyps, filter); - const hyps = filter.reverse ? filteredList.slice().reverse() : filteredList; - const locs = React.useContext(LocationsContext) - const goalLocs = React.useMemo(() => - locs && goal.mvarId ? - { ...locs, subexprTemplate: { mvarId: goal.mvarId, loc: { target: '' }}} : - undefined, - [locs, goal.mvarId]) - const goalLi =
- {(mobile || !typewriterMode) &&
{t("Goal")}:
} - - - -
- - // let cn = 'font-code tl pre-wrap bl bw1 pl1 b--transparent ' - // if (props.goal.isInserted) cn += 'b--inserted ' - // if (props.goal.isRemoved) cn += 'b--removed ' - - function unbundleHyps (hyps: InteractiveHypothesisBundle[]) : InteractiveHypothesisBundle[] { - return hyps.flatMap(hyp => ( - gameInfo?.settings?.unbundleHyps ? hyp.names.map(name => {return {...hyp, names: [name]}}) : [hyp] - )) - } - - // const hints = - const objectHyps = unbundleHyps(hyps.filter(hyp => !hyp.isAssumption)) - const assumptionHyps = unbundleHyps(hyps.filter(hyp => hyp.isAssumption)) - - return <> - {/* {goal.userName &&
case {goal.userName}
} */} - {filter.reverse && goalLi} -
- {! typewriter && objectHyps.length > 0 && -
{t("Objects")}:
- {objectHyps.map((h, i) => )}
} - {!typewriter && assumptionHyps.length > 0 && -
{t("Assumptions")}:
- {assumptionHyps.map((h, i) => { - h = {...h, val: undefined} // JE: never show value of assumptions (proof irrelevance) - return })}
} -
- {!filter.reverse && <> - { (!mobile && typewriterMode) && -
- - - - -
- } - {goalLi} - } - {/* {showHints && hints} */} - -}) - -export const MainAssumptions = React.memo((props: GoalProps2) => { - let { t } = useTranslation() - const { goals, filter } = props - - const goal = goals[0] - const filteredList = getFilteredHypotheses(goal.hyps, filter); - const hyps = filter.reverse ? filteredList.slice().reverse() : filteredList; - const locs = React.useContext(LocationsContext) - - const goalLocs = React.useMemo(() => - locs && goal.mvarId ? - { ...locs, subexprTemplate: { mvarId: goal.mvarId, loc: { target: '' }}} : - undefined, - [locs, goal.mvarId]) - - const goalLi =
-
{t("Goal") + ":"}
- - - -
- - const objectHyps = hyps.filter(hyp => !hyp.isAssumption) - const assumptionHyps = hyps.filter(hyp => hyp.isAssumption) - - return
-
{t("Current Goal")}
- {filter.reverse && goalLi} - { objectHyps.length > 0 && -
{t("Objects") + ":"}
- {objectHyps.map((h, i) => )}
} - { assumptionHyps.length > 0 && -
-
{t("Assumptions") + ":"}
- {assumptionHyps.map((h, i) => )} -
} -
-}) - -export const OtherGoals = React.memo((props: GoalProps2) => { - let { t } = useTranslation() - const { goals, filter } = props - return <> - {goals && goals.length > 1 && -
-
{t("Further Goals")}
- {goals.slice(1).map((goal, i) => -
- - - - -
)} -
} - -}) - -interface GoalsProps { - goals: InteractiveGoalsWithHints - filter: GoalFilterState -} - -export function Goals({ goals, filter }: GoalsProps) { - if (goals.goals.length === 0) { - return <> - } else { - return <> - {goals.goals.map((g, i) => )} - - } -} - -interface FilteredGoalsProps { - /** Components to render in the header. */ - headerChildren: React.ReactNode - /** - * When this is `undefined`, the component will not appear at all but will remember its state - * by virtue of still being mounted in the React tree. When it does appear again, the filter - * settings and collapsed state will be as before. */ - goals?: InteractiveGoalsWithHints -} - -/** - * Display goals together with a header containing the provided children as well as buttons - * to control how the goals are displayed. - */ -export const FilteredGoals = React.memo(({ headerChildren, goals }: FilteredGoalsProps) => { - const ec = React.useContext(EditorContext) - - const copyToCommentButton = - { - e.preventDefault(); - if (goals) void ec.copyToComment(goalsWithHintsToString(goals)) - }} - title="copy state to comment" /> - - const [goalFilters, setGoalFilters] = React.useState( - { reverse: false, showType: true, showInstance: true, showHiddenAssumption: true, showLetValue: true }); - - const sortClasses = 'link pointer mh2 dim codicon ' + (goalFilters.reverse ? 'codicon-arrow-up ' : 'codicon-arrow-down '); - const sortButton = - setGoalFilters(s => ({ ...s, reverse: !s.reverse }))} /> - - const mkFilterButton = (filterFn: React.SetStateAction, filledFn: (_: GoalFilterState) => boolean, name: string) => - { setGoalFilters(filterFn) }}> -   - {name} - - const filterMenu = - {mkFilterButton(s => ({ ...s, showType: !s.showType }), gf => gf.showType, 'types')} -
- {mkFilterButton(s => ({ ...s, showInstance: !s.showInstance }), gf => gf.showInstance, 'instances')} -
- {mkFilterButton(s => ({ ...s, showHiddenAssumption: !s.showHiddenAssumption }), gf => gf.showHiddenAssumption, 'hidden assumptions')} -
- {mkFilterButton(s => ({ ...s, showLetValue: !s.showLetValue }), gf => gf.showLetValue, 'let-values')} -
- - const isFiltered = !goalFilters.showInstance || !goalFilters.showType || !goalFilters.showHiddenAssumption || !goalFilters.showLetValue - const filterButton = - filterMenu}> - - - - return
-
- - {headerChildren} - {copyToCommentButton}{sortButton}{filterButton} - -
- {goals && } -
-
-
-}) - -export function loadGoals( - rpcSess: RpcSessionAtPos, - uri: string, - worldId: string, - levelId: number, - setProof: React.Dispatch>, - setCrashed: React.Dispatch>) { -console.info('sending rpc request to load the proof state') - -rpcSess.call('Game.getProofState', - { - ...DocumentPosition.toTdpp({line: 0, character: 0, uri: uri}), - worldId, levelId - } -).then( - (proof) => { - if (typeof proof !== 'undefined') { - console.info(`received a proof state!`) - console.log(proof) - setProof(proof as ProofState) - setCrashed(false) - } else { - console.warn('received undefined proof state!') - // Avoid transient crash state while the server warms up. - } - } -).catch((error) => { - if (error === 'No connection to Lean') { - console.warn(error) - return - } - setCrashed(true) - console.warn(error) -}) -} - - -export function lastStepHasErrors (proof : ProofState): boolean { - if (!proof?.steps.length) {return false} - - let diags = [...proof.steps[proof.steps.length - 1].diags, ...proof.diagnostics] - - return diags.some( - (d) => (d.severity == DiagnosticSeverity.Error ) // || d.severity == DiagnosticSeverity.Warning - ) -} - -export function isLastStepWithErrors (proof : ProofState, i: number): boolean { - if (!proof?.steps.length) {return false} - return (i == proof.steps.length - 1) && lastStepHasErrors(proof) -} diff --git a/client/src/components/infoview/info.tsx b/client/src/components/infoview/info.tsx deleted file mode 100644 index e1198fb0..00000000 --- a/client/src/components/infoview/info.tsx +++ /dev/null @@ -1,461 +0,0 @@ -/* Mostly copied from https://github.com/leanprover/vscode-lean4/blob/master/lean4-infoview/src/infoview/info.tsx */ - -import * as React from 'react' -import { CircularProgress } from '@mui/material' -import type { Location, Diagnostic } from 'vscode-languageserver-protocol' -import { getInteractiveTermGoal, InteractiveDiagnostic, UserWidgetInstance, Widget_getWidgets, RpcSessionAtPos, isRpcError, - RpcErrorCode, getInteractiveDiagnostics } from '@leanprover/infoview-api' -import { basename, DocumentPosition, RangeHelpers, useEvent, usePausableState, discardMethodNotFound, - mapRpcError, useAsyncWithTrigger, PausableProps } from '../../../../node_modules/vscode-lean4/lean4-infoview/src/infoview/util' -import { ConfigContext, EditorContext, LspDiagnosticsContext, ProgressContext } from '../../../../node_modules/vscode-lean4/lean4-infoview/src/infoview/contexts' -import { PanelWidgetDisplay } from '../../../../node_modules/vscode-lean4/lean4-infoview/src/infoview/userWidget' -import { RpcContext, useRpcSessionAtPos } from '../../../../node_modules/vscode-lean4/lean4-infoview/src/infoview/rpcSessions' -import { GoalsLocation, Locations, LocationsContext } from '../../../../node_modules/vscode-lean4/lean4-infoview/src/infoview/goalLocation' - -import { AllMessages, lspDiagToInteractive } from './messages' -import { goalsToString, Goal, MainAssumptions, OtherGoals } from './goals' -import { InteractiveTermGoal, InteractiveGoals, ProofState } from './rpc_api' -import { MonacoEditorContext, InfoStatus } from './context' -import { useTranslation } from 'react-i18next' -import { useAtom } from 'jotai' -import { levelIdAtom, worldIdAtom } from '../../store/location-atoms' -import { proofAtom, typewriterModeAtom } from '../../store/editor-atoms' - -// TODO: All about pinning could probably be removed -type InfoKind = 'cursor' | 'pin' - -interface InfoPinnable { - kind: InfoKind - /** Takes an argument for caching reasons, but should only ever (un)pin itself. */ - onPin: (pos: DocumentPosition) => void -} - -interface InfoStatusBarProps extends InfoPinnable, PausableProps { - pos: DocumentPosition - status: InfoStatus - triggerUpdate: () => Promise -} - -const InfoStatusBar = React.memo((props: InfoStatusBarProps) => { - const { kind, onPin, status, pos, isPaused, setPaused, triggerUpdate } = props - - const ec = React.useContext(EditorContext) - - const statusColTable: {[T in InfoStatus]: string} = { - 'updating': 'gold ', - 'error': 'dark-red ', - 'ready': '', - } - const statusColor = statusColTable[status] - const locationString = `${basename(pos.uri)}:${pos.line+1}:${pos.character}` - const isPinned = kind === 'pin' - - return ( -
- {locationString} - {isPinned && !isPaused && ' (pinned)'} - {!isPinned && isPaused && ' (paused)'} - {isPinned && isPaused && ' (pinned and paused)'} - - {isPinned && - { e.preventDefault(); void ec.revealPosition(pos); }} - title='reveal file location' />} - { e.preventDefault(); onPin(pos); }} - title={isPinned ? 'unpin' : 'pin'} /> - { e.preventDefault(); setPaused(!isPaused) }} - title={isPaused ? 'continue updating' : 'pause updating'} /> - { e.preventDefault(); void triggerUpdate() }} - title='update'/> - - - ) -}) - -interface InfoDisplayContentProps extends PausableProps { - pos: DocumentPosition - messages: InteractiveDiagnostic[] - goals?: InteractiveGoals - termGoal?: InteractiveTermGoal - error?: string - userWidgets: UserWidgetInstance[] - triggerUpdate: () => Promise - proofString? : string -} - -const InfoDisplayContent = React.memo((props: InfoDisplayContentProps) => { - let { t } = useTranslation() - const [typewriterMode, setTypewriterMode] = useAtom(typewriterModeAtom) - const {pos, messages, goals, termGoal, error, userWidgets, triggerUpdate, isPaused, setPaused, proofString} = props - - const hasWidget = userWidgets.length > 0 - const hasError = !!error - const hasMessages = messages.length !== 0 - - const nothingToShow = !hasError && !goals && !termGoal && !hasMessages && !hasWidget - - const [selectedLocs, setSelectedLocs] = React.useState([]) - React.useEffect(() => setSelectedLocs([]), [pos.uri, pos.line, pos.character]) - - const locs: Locations = React.useMemo(() => ({ - isSelected: (l: GoalsLocation) => selectedLocs.some(v => GoalsLocation.isEqual(v, l)), - setSelected: (l, act) => setSelectedLocs(ls => { - // We ensure that `selectedLocs` maintains its reference identity if the selection - // status of `l` didn't change. - const newLocs = ls.filter(v => !GoalsLocation.isEqual(v, l)) - const wasSelected = newLocs.length !== ls.length - const isSelected = typeof act === 'function' ? act(wasSelected) : act - if (isSelected) newLocs.push(l) - return wasSelected === isSelected ? ls : newLocs - }), - subexprTemplate: undefined - }), [selectedLocs]) - - const goalFilter = { reverse: false, showType: true, showInstance: true, showHiddenAssumption: true, showLetValue: true } - /* Adding {' '} to manage string literals properly: https://reactjs.org/docs/jsx-in-depth.html#string-literals-1 */ - - return <> - {hasError && - } - -
- { goals && goals.goals.length > 0 && <> - - - } -
-
- { goals && (goals.goals.length > 0 - ? - :
{t("No Goals")}
- )} -
-
- {goals && goals.goals.length > 0 && !(typewriterMode) && ( -
-
unsolved goals
-
{goalsToString(goals)}
-
- )} - {userWidgets.map(widget => -
- {widget.name} - -
- )} - {nothingToShow && ( - isPaused ? - /* Adding {' '} to manage string literals properly: https://reactjs.org/docs/jsx-in-depth.html#string-literals-1 */ - Updating is paused.{' '} - { e.preventDefault(); void triggerUpdate() }}>Refresh - {' '}or { e.preventDefault(); setPaused(false) }}>resume updating - {' '}to see information. - : - <>
{t("Loading goal…")}
)} - - {/* - {goals && goals.goals.length > 1 &&
-
Weitere Goals
- - {goals.goals.slice(1).map((goal, i) => -
)} -
} -
*/} - -}) - -interface InfoDisplayProps { - pos: DocumentPosition, - status: InfoStatus, - messages: InteractiveDiagnostic[], - proof?: ProofState, - goals?: InteractiveGoals, - termGoal?: InteractiveTermGoal, - error?: string, - userWidgets: UserWidgetInstance[], - rpcSess: RpcSessionAtPos, - triggerUpdate: () => Promise, -} - -/** Displays goal state and messages. Can be paused. */ -function InfoDisplay(props0: InfoDisplayProps & InfoPinnable) { - // Used to update the paused state *just once* if it is paused, - // but a display update is triggered - const [shouldRefresh, setShouldRefresh] = React.useState(false) - const [{ isPaused, setPaused }, props, propsRef] = usePausableState(false, props0) - if (shouldRefresh) { - propsRef.current = props0 - setShouldRefresh(false) - } - const triggerDisplayUpdate = async () => { - await props0.triggerUpdate() - setShouldRefresh(true) - } - - const {kind, goals, rpcSess} = props - - const ec = React.useContext(EditorContext) - - // If we are the cursor infoview, then we should subscribe to - // some commands from the editor extension - const isCursor = kind === 'cursor' - useEvent(ec.events.requestedAction, act => { - if (!isCursor) return - if (act.kind !== 'copyToComment') return - if (goals) void ec.copyToComment(goalsToString(goals)) - }, [goals]) - useEvent(ec.events.requestedAction, act => { - if (!isCursor) return - if (act.kind !== 'togglePaused') return - setPaused(isPaused => !isPaused) - }) - - const editor = React.useContext(MonacoEditorContext) - - return ( - - {/*
*/} - {/* */} -
- -
- {/*
*/} -
- ) -} - -/** - * Note: in the cursor view, we have to keep the cursor position as part of the component state - * to avoid flickering when the cursor moved. Otherwise, the component is re-initialised and the - * goal states reset to `undefined` on cursor moves. - */ -export type InfoProps = InfoPinnable & { pos?: DocumentPosition } - -/** Fetches info from the server and renders an {@link InfoDisplay}. */ -export function Info(props: InfoProps) { - if (props.kind === 'cursor') return - else return -} - -function InfoAtCursor(props: InfoProps) { - const ec = React.useContext(EditorContext) - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const [curLoc, setCurLoc] = React.useState(ec.events.changedCursorLocation.current!) - useEvent(ec.events.changedCursorLocation, loc => loc && setCurLoc(loc), []) - const start = curLoc.range.start ?? { line: 0, character: 0 } - const pos = { - uri: curLoc.uri, - line: start.line ?? 0, - character: start.character ?? 0, - } - return -} - -function useIsProcessingAt(p: DocumentPosition): boolean { - const allProgress = React.useContext(ProgressContext) - const processing = allProgress.get(p.uri) - if (!processing) return false - return processing.some(i => RangeHelpers.contains(i.range, p)) -} - -function InfoAux(props: InfoProps) { - - const [, setProof] = useAtom(proofAtom) - - const config = React.useContext(ConfigContext) - - const [worldId] = useAtom(worldIdAtom) - const [levelId] = useAtom(levelIdAtom) - - - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const pos = props.pos! - const rpcSess = useRpcSessionAtPos(pos) - - // Compute the LSP diagnostics at this info's position. We try to ensure that if these remain - // the same, then so does the identity of `lspDiagsHere` so that it can be used as a dep. - const lspDiags = React.useContext(LspDiagnosticsContext) - const [lspDiagsHere, setLspDiagsHere] = React.useState([]) - React.useEffect(() => { - // Note: the curly braces are important. https://medium.com/geekculture/react-uncaught-typeerror-destroy-is-not-a-function-192738a6e79b - setLspDiagsHere(diags0 => { - const diagPred = (d: Diagnostic) => - RangeHelpers.contains(d.range, pos, config.allErrorsOnLine) - const newDiags = (lspDiags.get(pos.uri) || []).filter(diagPred) - if (newDiags.length === diags0.length && newDiags.every((d, i) => d === diags0[i])) return diags0 - return newDiags - }) - }, [lspDiags, pos.uri, pos.line, pos.character, config.allErrorsOnLine]) - - const serverIsProcessing = useIsProcessingAt(pos) - - // This is a virtual dep of the info-requesting function. It is bumped whenever the Lean server - // indicates that another request should be made. Bumping it dirties the dep state of - // `useAsyncWithTrigger` below, causing the `useEffect` lower down in this component to - // make the request. We cannot simply call `triggerUpdateCore` because `useAsyncWithTrigger` - // does not support reentrancy like that. - const [updaterTick, setUpdaterTick] = React.useState(0) - - // For atomicity, we use a single update function that fetches all the info at `pos` at once. - // Besides what the server replies with, we also include the inputs (deps) in this type so - // that the displayed state cannot ever get out of sync by showing an old reply together - // with e.g. a new `pos`. - type InfoRequestResult = Omit - const [state, triggerUpdateCore] = useAsyncWithTrigger(() => new Promise((resolve, reject) => { - - const proofReq = rpcSess.call('Game.getProofState', { - ...DocumentPosition.toTdpp(pos), - worldId, levelId - }).catch((error) => { - console.warn(error) - }) - const goalsReq = rpcSess.call('Game.getInteractiveGoals', DocumentPosition.toTdpp(pos)) - const termGoalReq = getInteractiveTermGoal(rpcSess, DocumentPosition.toTdpp(pos)) - const widgetsReq = Widget_getWidgets(rpcSess, pos).catch(discardMethodNotFound) - const messagesReq = getInteractiveDiagnostics(rpcSess, {start: pos.line, end: pos.line+1}) - // fall back to non-interactive diagnostics when lake fails - // (see https://github.com/leanprover/vscode-lean4/issues/90) - .then(diags => diags.length === 0 ? lspDiagsHere.map(lspDiagToInteractive) : diags) - - // While `lake print-paths` is running, the output of Lake is shown as - // info diagnostics on line 1. However, all RPC requests block until - // Lake is finished, so we don't see these diagnostics while Lake is - // building. Therefore we show the LSP diagnostics on line 1 if the - // server does not respond within half a second. - if (pos.line === 0 && lspDiagsHere.length) { - setTimeout(() => resolve({ - pos, - status: 'updating', - messages: lspDiagsHere.map(lspDiagToInteractive), - proof: undefined, - goals: undefined, - termGoal: undefined, - error: undefined, - userWidgets: [], - rpcSess - }), 500) - } - - // NB: it is important to await await reqs at once, otherwise - // if both throw then one exception becomes unhandled. - Promise.all([proofReq, goalsReq, termGoalReq, widgetsReq, messagesReq]).then( - ([proof, goals, termGoal, userWidgets, messages]) => resolve({ - pos, - status: 'ready', - messages, - proof : proof as any, - goals: goals as any, - termGoal, - error: undefined, - userWidgets: userWidgets?.widgets ?? [], - rpcSess - }), - ex => { - if (ex?.code === RpcErrorCode.ContentModified || - ex?.code === RpcErrorCode.RpcNeedsReconnect) { - // Document has been changed since we made the request, or we need to reconnect - // to the RPC sessions. Try again. - setUpdaterTick(t => t + 1) - reject('retry') - } - - let errorString = '' - if (typeof ex === 'string') { - errorString = ex - } else if (isRpcError(ex)) { - errorString = mapRpcError(ex).message - } else if (ex instanceof Error) { - errorString = ex.toString() - } else { - errorString = `Unrecognized error: ${JSON.stringify(ex)}` - } - - resolve({ - pos, - status: 'error', - messages: lspDiagsHere.map(lspDiagToInteractive), - proof: undefined, - goals: undefined, - termGoal: undefined, - error: `Error fetching goals: ${errorString}`, - userWidgets: [], - rpcSess - }) - } - ) - }), [updaterTick, pos.uri, pos.line, pos.character, rpcSess, serverIsProcessing, lspDiagsHere]) - - // We use a timeout to debounce info requests. Whenever a request is already scheduled - // but something happens that warrants a request for newer info, we cancel the old request - // and schedule just the new one. - const updaterTimeout = React.useRef() - const clearUpdaterTimeout = () => { - if (updaterTimeout.current) { - window.clearTimeout(updaterTimeout.current) - updaterTimeout.current = undefined - } - } - const triggerUpdate = React.useCallback(() => new Promise(resolve => { - clearUpdaterTimeout() - const tm = window.setTimeout(() => { - void triggerUpdateCore().then(resolve) - updaterTimeout.current = undefined - }, config.debounceTime) - // Hack: even if the request is cancelled, the promise should resolve so that no `await` - // is left waiting forever. We ensure this happens in a simple way. - window.setTimeout(resolve, config.debounceTime) - updaterTimeout.current = tm - }), [triggerUpdateCore, config.debounceTime]) - - const [displayProps, setDisplayProps] = React.useState({ - pos, - status: 'updating', - messages: [], - proof: undefined, - goals: undefined, - termGoal: undefined, - error: undefined, - userWidgets: [], - rpcSess, - triggerUpdate - }) - - // Propagates changes in the state of async info requests to the display props, - // and re-requests info if needed. - // This effect triggers new requests for info whenever need. It also propagates changes - // in the state of the `useAsyncWithTrigger` to the displayed props. - React.useEffect(() => { - if (state.state === 'notStarted') - void triggerUpdate() - else if (state.state === 'loading') { - setDisplayProps(dp => ({ ...dp, status: 'updating' })) - } - else if (state.state === 'resolved') { - // if (state.value.goals?.goals?.length) { - // hintContext.setHints(state.value.goals.goals[0].hints) - // } - setDisplayProps({ ...state.value, triggerUpdate }) - - // Update the game's proof state - console.info('updating proof from editor mode.') - setProof(state.value.proof) - - } else if (state.state === 'rejected' && state.error !== 'retry') { - // The code inside `useAsyncWithTrigger` may only ever reject with a `retry` exception. - console.warn('Unreachable code reached with error: ', state.error) - } - }, [state]) - - return -} diff --git a/client/src/components/infoview/infos.tsx b/client/src/components/infoview/infos.tsx deleted file mode 100644 index 54470092..00000000 --- a/client/src/components/infoview/infos.tsx +++ /dev/null @@ -1,135 +0,0 @@ -/* Mostly copied from https://github.com/leanprover/vscode-lean4/blob/master/lean4-infoview/src/infoview/infos.tsx */ - -import * as React from 'react'; -import { DidChangeTextDocumentParams, DidCloseTextDocumentParams, TextDocumentContentChangeEvent } from 'vscode-languageserver-protocol'; - -import { EditorContext } from '../../../../node_modules/vscode-lean4/lean4-infoview/src/infoview/contexts'; -import { DocumentPosition, Keyed, PositionHelpers, useClientNotificationEffect, useEvent, useEventResult } from '../../../../node_modules/vscode-lean4/lean4-infoview/src/infoview/util'; -import { Info, InfoProps } from './info'; -import { useTranslation } from 'react-i18next'; - -/** Manages and displays pinned infos, as well as info for the current location. */ -export function Infos() { - let { t } = useTranslation() - const ec = React.useContext(EditorContext); - - // Update pins when the document changes. In particular, when edits are made - // earlier in the text such that a pin has to move up or down. - const [pinnedPositions, setPinnedPositions] = React.useState>>([]) - useClientNotificationEffect( - 'textDocument/didChange', - (params: DidChangeTextDocumentParams) => { - setPinnedPositions((prevPins) => { - if (prevPins.length === 0) return prevPins; - - let changed: boolean = false; - const newPins = prevPins.map(pin => { - if (pin.uri !== params.textDocument.uri) return pin; - // NOTE(WN): It's important to make a clone here, otherwise this - // actually mutates the pin. React state updates must be pure. - // See https://github.com/facebook/react/issues/12856 - const newPin: Keyed = { ...pin }; - for (const chg of params.contentChanges) { - if (!TextDocumentContentChangeEvent.isIncremental(chg)) { - changed = true; - return null; - } - if (PositionHelpers.isLessThanOrEqual(newPin, chg.range.start)) continue; - // We can assume chg.range.start < pin - - // If the pinned position is replaced with new text, just delete the pin. - if (PositionHelpers.isLessThanOrEqual(newPin, chg.range.end)) { - changed = true; - return null; - } - - const oldPin = { ...newPin }; - - // How many lines before the pin position were added by the change. - // Can be negative when more lines are removed than added. - let additionalLines = 0; - let lastLineLen = chg.range.start.character; - for (const c of chg.text) - if (c === '\n') { - additionalLines++; - lastLineLen = 0; - } else lastLineLen++; - - // Subtract lines that were already present - additionalLines -= (chg.range.end.line - chg.range.start.line) - newPin.line += additionalLines; - - if (oldPin.line < chg.range.end.line) { - // Should never execute by the <= check above. - throw new Error('unreachable code reached') - } else if (oldPin.line === chg.range.end.line) { - newPin.character = lastLineLen + (oldPin.character - chg.range.end.character); - } - } - if (!DocumentPosition.isEqual(newPin, pin)) changed = true; - - // NOTE(WN): We maintain the `key` when a pin is moved around to maintain - // its component identity and minimise flickering. - return newPin; - }); - - if (changed) return newPins.filter(p => p !== null) as Keyed[]; - return prevPins; - }); - }, - [] - ); - - // Remove pins for closed documents - useClientNotificationEffect( - 'textDocument/didClose', - (params: DidCloseTextDocumentParams) => { - setPinnedPositions(pinnedPositions => pinnedPositions.filter(p => p.uri !== params.textDocument.uri)); - }, - [] - ); - - const curPos: DocumentPosition | undefined = - useEventResult(ec.events.changedCursorLocation, loc => loc ? { uri: loc.uri, ...loc.range.start } : undefined) - - // Update pins on UI actions - const pinKey = React.useRef(0); - const isPinned = (pinnedPositions: DocumentPosition[], pos: DocumentPosition) => { - return pinnedPositions.some(p => DocumentPosition.isEqual(p, pos)); - } - const pin = React.useCallback((pos: DocumentPosition) => { - setPinnedPositions(pinnedPositions => { - if (isPinned(pinnedPositions, pos)) return pinnedPositions; - pinKey.current += 1; - return [ ...pinnedPositions, { ...pos, key: pinKey.current.toString() } ]; - }); - }, []); - const unpin = React.useCallback((pos: DocumentPosition) => { - setPinnedPositions(pinnedPositions => { - if (!isPinned(pinnedPositions, pos)) return pinnedPositions; - return pinnedPositions.filter(p => !DocumentPosition.isEqual(p, pos)); - }); - }, []); - - // Toggle pin at current position when the editor requests it - useEvent(ec.events.requestedAction, act => { - if (act.kind !== 'togglePin') return - if (!curPos) return - setPinnedPositions(pinnedPositions => { - if (isPinned(pinnedPositions, curPos)) { - return pinnedPositions.filter(p => !DocumentPosition.isEqual(p, curPos)); - } else { - pinKey.current += 1; - return [ ...pinnedPositions, { ...curPos, key: pinKey.current.toString() } ]; - } - }); - }, [curPos?.uri, curPos?.line, curPos?.character]); - - const infoProps: Keyed[] = pinnedPositions.map(pos => ({ kind: 'pin', onPin: unpin, pos, key: pos.key })); - if (curPos) infoProps.push({ kind: 'cursor', onPin: pin, key: 'cursor' }); - - return
- {infoProps.map (ps => )} - {!curPos &&

{t("Click somewhere in the Lean file to enable the infoview.")}

} -
; -} diff --git a/client/src/components/infoview/main.tsx b/client/src/components/infoview/main.tsx deleted file mode 100644 index b5e9fff7..00000000 --- a/client/src/components/infoview/main.tsx +++ /dev/null @@ -1,686 +0,0 @@ -/* Partly copied from https://github.com/leanprover/vscode-lean4/blob/master/lean4-infoview/src/infoview/main.tsx */ - -import * as React from 'react'; -import type { DidCloseTextDocumentParams, DocumentUri } from 'vscode-languageserver-protocol'; - -import 'tachyons/css/tachyons.css'; -import '@vscode/codicons/dist/codicon.css'; -import '../../../../node_modules/vscode-lean4/lean4-infoview/src/infoview/index.css'; -import '../../css/infoview.css' -import "../../css/tab_bar.css" - -import { LeanFileProgressParams, LeanFileProgressProcessingInfo, defaultInfoviewConfig } from '@leanprover/infoview-api'; -import { useClientNotificationEffect, useEventResult, useServerNotificationEffect, useServerNotificationState } from '../../../../node_modules/vscode-lean4/lean4-infoview/src/infoview/util'; -import { EditorContext, ConfigContext, ProgressContext, VersionContext } from '../../../../node_modules/vscode-lean4/lean4-infoview/src/infoview/contexts'; -import { RpcContext, WithRpcSessions, useRpcSessionAtPos } from '../../../../node_modules/vscode-lean4/lean4-infoview/src/infoview/rpcSessions'; -import { ServerVersion } from '../../../../node_modules/vscode-lean4/lean4-infoview/src/infoview/serverVersion'; - -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' -import { faDeleteLeft, faHome, faArrowRight } from '@fortawesome/free-solid-svg-icons' -import * as monaco from 'monaco-editor/esm/vs/editor/editor.api.js' - -import { Markdown } from '../markdown'; - -import { Infos } from './infos'; -import { Errors, WithLspDiagnosticsContext } from './messages'; -import { Goal, isLastStepWithErrors, lastStepHasErrors, loadGoals } from './goals'; -import { MonacoEditorContext } from './context'; -import { Typewriter, getInteractiveDiagsAt, hasInteractiveErrors } from './typewriter'; -import { Button } from '../button'; -import { CircularProgress } from '@mui/material'; -import { GameHint, InteractiveGoalsWithHints, ProofState } from './rpc_api'; -import { Hint, Hints, MoreHelpButton, filterHints } from '../hints'; -import { DocumentPosition } from '../../../../node_modules/vscode-lean4/lean4-infoview/src/infoview/util'; -import { DiagnosticSeverity } from 'vscode-languageclient'; -import { useTranslation } from 'react-i18next'; -import path from 'path'; -import { useGameTranslation } from '../../utils/translation'; -import { useAtom } from 'jotai'; -import { gameIdAtom, levelIdAtom, worldIdAtom } from '../../store/location-atoms'; -import { completedAtom } from '../../store/progress-atoms'; -import { gameInfoAtom, levelInfoAtom } from '../../store/query-atoms'; -import { crashedAtom, interimDiagsAtom, lockEditorModeAtom, proofAtom, typewriterContentAtom, typewriterModeAtom } from '../../store/editor-atoms'; -import { inventoryAtom } from '../../store/inventory-atoms'; -import { mobileAtom } from '../../store/preferences-atoms'; -import { deletedChatAtom, helpAtom, selectedStepAtom } from '../../store/chat-atoms'; - -/** Wrapper for the two editors. It is important that the `div` with `codeViewRef` is - * always present, or the monaco editor cannot start. - */ -export function DualEditor({ codeviewRef } : { codeviewRef: any }) { - const [typewriterMode] = useAtom(typewriterModeAtom) - const ec = React.useContext(EditorContext) - const showTypewriter = Boolean(ec) && typewriterMode - return <> -
- -
-
- {ec ? - : - // TODO: Style this if relevant. - <> - } - -} - -/** The part of the two editors that needs the editor connection first */ -function DualEditorMain() { - const ec = React.useContext(EditorContext) - const [gameId] = useAtom(gameIdAtom) - const [worldId] = useAtom(worldIdAtom) - const [levelId] = useAtom(levelIdAtom) - const [{ data: gameInfo }] = useAtom(gameInfoAtom) - const [{ data: levelInfo }] = useAtom(levelInfoAtom) - - const [, setCompleted] = useAtom(completedAtom) - - const [, addToInventory] = useAtom(inventoryAtom) - const [typewriterMode, setTypewriterMode] = useAtom(typewriterModeAtom) - - const [proof] = useAtom(proofAtom) - - React.useEffect(() => { - if (proof?.completed) { - setCompleted(true) - - // On completion, add the names of all new items to the local storage - let newTiles = [ - ...levelInfo?.tactics ?? [], - ...levelInfo?.lemmas ?? [], - ...levelInfo?.definitions ?? [] - ].filter((tile) => tile.new).map((tile) => tile.name) - - // Add the proven statement to the local storage as well. - if (levelInfo?.statementName != null) { - newTiles.push(levelInfo?.statementName) - } - addToInventory(newTiles) - } - }, [proof, levelInfo]) - - /* Set up updates to the global infoview state on editor events. */ - const config = useEventResult(ec.events.changedInfoviewConfig) ?? defaultInfoviewConfig; - - const [allProgress, _1] = useServerNotificationState( - '$/lean/fileProgress', - new Map(), - async (params: LeanFileProgressParams) => (allProgress) => { - const newProgress = new Map(allProgress); - return newProgress.set(params.textDocument.uri, params.processing); - }, []) - const serverVersion = useEventResult(ec.events.serverRestarted, result => new ServerVersion(result.serverInfo?.version ?? '')) - - return <> - - - - - - {(typewriterMode) ? - - : -
- } - - - - - - -} - -/** The mathematical formulation of the statement, supporting e.g. Latex - * It takes three forms, depending on the precence of name and description: - * - Theorem xyz: description - * - Theorem xyz - * - Exercises: description - * - * If `showLeanStatement` is true, it will additionally display the lean code. - */ -function ExerciseStatement({ showLeanStatement = false }) { - const { t : gT } = useGameTranslation() - const { t } = useTranslation() - const [gameId] = useAtom(gameIdAtom) - const [{ data: levelInfo }] = useAtom(levelInfoAtom) - - if (!(levelInfo?.descrText || levelInfo?.descrFormat)) { return <> } - return <> -
- {levelInfo?.descrText ? - - {(levelInfo?.displayName ? `**${t("Theorem")}** \`${levelInfo?.displayName}\`: ` : '') + t(levelInfo?.descrText, {ns: gameId})} - : levelInfo?.displayName && - - {(levelInfo?.displayName ? `**${t("Theorem")}** \`${levelInfo?.displayName}\`: ` : '') + gT(levelInfo?.descrText ?? "")} - - } - {levelInfo?.descrFormat && showLeanStatement && -

{levelInfo?.descrFormat}

- } -
- -} - -// TODO: This is only used in `EditorInterface` -// while `TypewriterInterface` has this copy-pasted in. -export function Main() { - let { t } = useTranslation() - const { t: gT } = useGameTranslation() - const [lockEditorMode] = useAtom(lockEditorModeAtom) - const ec = React.useContext(EditorContext); - const [gameId] = useAtom(gameIdAtom) - const [worldId] = useAtom(worldIdAtom) - const [levelId] = useAtom(levelIdAtom) - const [{ data: gameInfo }] = useAtom(gameInfoAtom) - const [{ data: levelInfo }] = useAtom(levelInfoAtom) - const [help, setHelp] = useAtom(helpAtom) - - const [typewriterMode] = useAtom(typewriterModeAtom) - - const [proof, setProof] = useAtom(proofAtom) - const [, setCrashed] = useAtom(crashedAtom) - const [selectedStep, setSelectedStep] = useAtom(selectedStepAtom) - const editor = React.useContext(MonacoEditorContext) - const model = editor?.getModel() - const uri = model?.uri.toString() - const rpcSess = useRpcSessionAtPos({ uri: uri ?? '', line: 0, character: 0 }) - - React.useEffect(() => { - if (!uri || !worldId || !levelId) { - return - } - loadGoals(rpcSess, uri, worldId, levelId, setProof, setCrashed) - }, [rpcSess, uri, worldId, levelId, setProof, setCrashed]) - - function toggleSelection(line: number) { - return (ev: any) => { - console.debug('toggled selection') - if (selectedStep == line) { - setSelectedStep(undefined) - } else { - setSelectedStep(line) - } - } - } - //console.debug(`template: ${props.data?.template}`) - - // React.useEffect (() => { - // if (props.data.template) { - // let code: string = selectCode(gameId, worldId, levelId)(store.getState()) - // if (!code.length) { - // //models.push(monaco.editor.createModel(code, 'lean4', uri)) - // } - // } - // }, [props.data.template]) - - /* Set up updates to the global infoview state on editor events. */ - const config = useEventResult(ec.events.changedInfoviewConfig) ?? defaultInfoviewConfig; - - const [allProgress, _1] = useServerNotificationState( - '$/lean/fileProgress', - new Map(), - async (params: LeanFileProgressParams) => (allProgress) => { - const newProgress = new Map(allProgress); - return newProgress.set(params.textDocument.uri, params.processing); - }, - [] - ); - - const curUri = useEventResult(ec.events.changedCursorLocation, loc => loc?.uri); - - const curPos: DocumentPosition | undefined = - useEventResult(ec.events.changedCursorLocation, loc => loc ? { uri: loc.uri, ...loc.range.start } : undefined) - - React.useEffect(() => { - if (typewriterMode) { - return - } - if (!uri) { - return - } - loadGoals(rpcSess, uri, worldId!, levelId!, setProof, setCrashed) - }, [typewriterMode, lockEditorMode, uri, worldId, levelId, rpcSess, setProof, setCrashed]) - - useServerNotificationEffect('textDocument/publishDiagnostics', (params: any) => { - if (typewriterMode) { - return - } - if (!uri || params?.uri !== uri) { - return - } - loadGoals(rpcSess, uri, worldId!, levelId!, setProof, setCrashed) - }, [typewriterMode, lockEditorMode, uri, worldId, levelId, rpcSess, setProof, setCrashed]) - - const hintLine = (() => { - const isEditorMode = !(typewriterMode) - const curLine = Number.isFinite(curPos?.line) - ? curPos?.line - : (Number.isFinite((curPos as any)?._line) ? (curPos as any)._line : undefined) - const curChar = Number.isFinite(curPos?.character) - ? curPos.character - : (Number.isFinite((curPos as any)?._character) ? (curPos as any)._character : undefined) - if (isEditorMode && Number.isFinite(curLine)) { - const baseLine = curLine - return (curChar === 0 && baseLine > 0) ? baseLine - 1 : baseLine - } - if (Number.isFinite(selectedStep)) { - return selectedStep - } - if (!proof?.steps?.length) { - return 0 - } - const lastIndex = proof.steps.length - 1 - return lastStepHasErrors(proof) ? Math.max(0, lastIndex - 1) : lastIndex - })() - - const clampedHintLine = Math.max(0, Math.min(Number.isFinite(hintLine) ? hintLine : 0, (proof?.steps?.length ?? 1) - 1)) - const hintStepIndex = (() => { - if (Number.isFinite(selectedStep)) { - return selectedStep - } - if (!proof?.steps?.length) { - return 0 - } - const maxIndex = proof.steps.length - 1 - return Math.min(clampedHintLine + 1, maxIndex) - })() - const hintsToShow = (() => { - if (!proof?.steps?.length) { - return undefined - } - return proof.steps[hintStepIndex]?.goals?.[0]?.hints - })() - - // Effect when the cursor changes in the editor - React.useEffect(() => { - // TODO: this is a bit of a hack and will yield unexpected behaviour if lines - // are indented. - const newPos = curPos?.line + (curPos?.character == 0 ? 0 : 1) - - if (Number.isFinite(newPos)) { - // scroll the chat along - setSelectedStep(newPos) - } - }, [curPos]) - - useClientNotificationEffect( - 'textDocument/didClose', - (params: DidCloseTextDocumentParams) => { - if (ec.events.changedCursorLocation.current && - ec.events.changedCursorLocation.current.uri === params.textDocument.uri) { - ec.events.changedCursorLocation.fire(undefined) - } - }, - [] - ); - - const serverVersion = - useEventResult(ec.events.serverRestarted, result => new ServerVersion(result.serverInfo?.version ?? '')) - const serverStoppedResult = useEventResult(ec.events.serverStopped); - // NB: the cursor may temporarily become `undefined` when a file is closed. In this case - // it's important not to reconstruct the `WithBlah` wrappers below since they contain state - // that we want to persist. - let ret - if (serverStoppedResult) { - ret =

{serverStoppedResult.message}

{serverStoppedResult.reason}

- } else { - ret =
-
- {proof?.completedWithWarnings && -
- {proof?.completed ? t("Level completed! 🎉") : t("Level completed with warnings 🎭")} -
- } - -
- {hintsToShow && ( - - )} - -
- } - - return ret -} - -const goalFilter = { - reverse: false, - showType: true, - showInstance: true, - showHiddenAssumption: true, - showLetValue: true -} - -/** The display of a single entered lean command */ -function Command({ proof, i, deleteProof }: { proof: ProofState, i: number, deleteProof: any }) { - let {t} = useTranslation() - - // The first step will always have an empty command - if (!proof?.steps[i]?.command) { return <> } - - if (isLastStepWithErrors(proof, i)) { - // If the last step has errors, we display the command in a different style - // indicating that it will be removed on the next try. - return
- {t("Failed command")}: {proof?.steps[i].command} -
- } else { - return
-
{proof?.steps[i].command}
- -
- } -} - -/** The tabs of goals that lean ahs after the command of this step has been processed */ -function GoalsTabs({ proofStep, last, onClick, onGoalChange=(n)=>{}}: { proofStep: InteractiveGoalsWithHints, last : boolean, onClick? : any, onGoalChange?: (n?: number) => void }) { - let { t } = useTranslation() - const [mobile] = useAtom(mobileAtom) - const [selectedGoal, setSelectedGoal] = React.useState(0) - - if (proofStep.goals.length == 0) { - return <> - } - - return
-
- {proofStep.goals.map((goal, i) => ( - // TODO: Should not use index as key. -
{ onGoalChange(i); setSelectedGoal(i); ev.stopPropagation() }}> - {i ? t("Goal") + ` ${i + 1}` : t("Active Goal")} -
- ))} -
-
- -
-
-} - -// Splitting up Typewriter into two parts is a HACK -export function TypewriterInterfaceWrapper() { - const ec = React.useContext(EditorContext) - - useClientNotificationEffect( - 'textDocument/didClose', - (params: DidCloseTextDocumentParams) => { - if (ec.events.changedCursorLocation.current && - ec.events.changedCursorLocation.current.uri === params.textDocument.uri) { - ec.events.changedCursorLocation.fire(undefined) - } - }, [] - ) - - const serverVersion = - useEventResult(ec.events.serverRestarted, result => new ServerVersion(result.serverInfo?.version ?? '')) - const serverStoppedResult = useEventResult(ec.events.serverStopped); - // NB: the cursor may temporarily become `undefined` when a file is closed. In this case - // it's important not to reconstruct the `WithBlah` wrappers below since they contain state - // that we want to persist. - - if (serverStoppedResult) { - return
-

{serverStoppedResult.message}

-

{serverStoppedResult.reason}

-
- } - - return -} - -/** The interface in command line mode */ -export function TypewriterInterface() { - let { t } = useTranslation() - const ec = React.useContext(EditorContext) - const [gameId, navigateToGame] = useAtom(gameIdAtom) - const [worldId] = useAtom(worldIdAtom) - const [levelId, navigateToLevel] = useAtom(levelIdAtom) - const [{ data: gameInfo }] = useAtom(gameInfoAtom) - const [help, setHelp] = useAtom(helpAtom) - - const editor = React.useContext(MonacoEditorContext) - const model = editor?.getModel() - const uri = model?.uri.toString() ?? '' - - const worldSize = gameInfo?.worldSize?.[worldId ?? ""] ?? 0 - - const fallbackUri = `file:///${worldId}/${levelId}.lean` - const effectiveUri = uri || fallbackUri - let image: string | undefined = gameInfo?.worlds?.nodes[worldId!]?.image - - - const [disableInput, setDisableInput] = React.useState(false) - const [loadingProgress, setLoadingProgress] = React.useState(0) - const [, setDeletedChat] = useAtom(deletedChatAtom) - const [mobile] = useAtom(mobileAtom) - const [proof, setProof ] = useAtom(proofAtom) - const [crashed, setCrashed ] = useAtom(crashedAtom) - const [interimDiags ] = useAtom(interimDiagsAtom) - - const [, setTypewriter] = useAtom(typewriterContentAtom) - const [selectedStep, setSelectedStep] = useAtom(selectedStepAtom) - - const proofPanelRef = React.useRef(null) - // const config = useEventResult(ec.events.changedInfoviewConfig) ?? defaultInfoviewConfig; - // const curUri = useEventResult(ec.events.changedCursorLocation, loc => loc?.uri); - - const rpcSess = useRpcSessionAtPos({uri: effectiveUri, line: 0, character: 0}) - - React.useEffect(() => { - if (!effectiveUri) { - return - } - setCrashed(false) - loadGoals(rpcSess, effectiveUri, worldId!, levelId!, setProof, setCrashed) - }, [rpcSess, effectiveUri, worldId, levelId, setProof, setCrashed]) - - /** Delete all proof lines starting from a given line. - * Note that the first line (i.e. deleting everything) is `1`! - */ - function deleteProof(line: number) { - return (ev: any) => { - let deletedChat: Array = [] - proof?.steps.slice(line).map((step, i) => { - let filteredHints = filterHints(step.goals[0]?.hints, proof?.steps[i-1]?.goals[0]?.hints) - - // Only add these hidden hints to the deletion stack which were visible - deletedChat = [...deletedChat, ...filteredHints.filter(hint => (!hint.hidden || help.has(line + i)))] - }) - setDeletedChat(deletedChat) - - // delete showHelp for deleted steps - setHelp(new Set(Array.from(help).filter(i => i < line - 1))) - - editor.executeEdits("typewriter", [{ - range: monaco.Selection.fromPositions( - { lineNumber: line, column: 1 }, - editor.getModel()?.getFullModelRange().getEndPosition() - ), - text: '', - forceMoveMarkers: false - }]) - setSelectedStep(undefined) - setTypewriter(proof?.steps[line].command ?? "") - // Reload proof on deleting - loadGoals(rpcSess, uri, worldId!, levelId!, setProof, setCrashed) - ev.stopPropagation() - } - } - - function toggleSelectStep(line: number) { - return (ev: any) => { - if (mobile) {return} - if (selectedStep == line) { - setSelectedStep(undefined) - console.debug(`unselected step`) - } else { - setSelectedStep(line) - console.debug(`step ${line} selected`) - } - } - } - - // Scroll to the end of the proof if it is updated. - React.useEffect(() => { - if (proof?.steps?.length && proof?.steps?.length > 1) { - proofPanelRef.current?.lastElementChild?.scrollIntoView() //scrollTo(0,0) - } else { - proofPanelRef.current?.scrollTo(0,0) - } - // also reenable the commandline when the proof changes - - // BUG: If selecting 2nd goal on a intermediate proofstep and then delete proof to there, - // the commandline is not displaying disabled even though it should. - setDisableInput(false) - }, [proof]) - - // Scroll to element if selection changes - React.useEffect(() => { - if (typeof selectedStep !== 'undefined') { - Array.from(proofPanelRef.current?.getElementsByClassName(`step-${selectedStep}`)).map((elem) => { - elem.scrollIntoView({ block: "center" }) - }) - } - }, [selectedStep]) - - // TODO: superfluous, can be replaced with `withErr` from above - let lastStepErrors = proof?.steps.length ? hasInteractiveErrors(getInteractiveDiagsAt(proof, proof?.steps.length)) : false - - - useServerNotificationEffect("$/game/loading", (params : any) => { - if (params.kind == "loadConstants") { - setLoadingProgress(params.counter/100*50) - } else if (params.kind == "finalizeExtensions") { - setLoadingProgress(50 + params.counter/150*50) - } else { - console.error(`Unknown loading kind: ${params.kind}`) - } - }) - - let introText: Array = t(gameInfo?.introduction ?? "", {ns: gameId}).split(/\n(\s*\n)+/) - - return
- -
-
- {image && - - } - -
-
- {/*
- -
*/} -
-
- - {((crashed && (interimDiags.length > 0 || proof?.steps.length > 0))) ?
-

{t("Crashed! Go to editor mode and fix your proof! Last server response:")}

- {interimDiags.map((diag, index) => { - const severityClass = diag.severity ? { - [DiagnosticSeverity.Error]: 'error', - [DiagnosticSeverity.Warning]: 'warning', - [DiagnosticSeverity.Information]: 'information', - [DiagnosticSeverity.Hint]: 'hint', - }[diag.severity] : ''; - - return
-
-

{t("Line")} {diag.range.start.line}, {t("Character")} {diag.range.start.character}

-
-                  {diag.message}
-                
-
-
- })} - -
: proof?.steps.length ? - <> - {proof?.steps.map((step, i) => { - let filteredHints = filterHints(step.goals[0]?.hints, proof?.steps[i-1]?.goals[0]?.hints) - - // if (i == proof?.steps.length - 1 && hasInteractiveErrors(step.diags)) { - // // if the last command contains an error, we only display the errors but not the - // // entered command as it is still present in the command line. - // // TODO: Should not use index as key. - // return
- // - //
- // } else { - return
- - - {mobile && i == 0 && gameInfo?.introduction && - introText?.filter(it => it.trim()).map(((it, i) => - // Show the level's intro text as hints, too - - )) - } - {mobile && - - } - {/* setDisableInput(n > 0) : (n) => {}}/> */} - {!(isLastStepWithErrors(proof, i)) && - setDisableInput(n > 0) : (n) => {}}/> - } - {mobile && i == proof?.steps.length - 1 && - - } - - {/* Show a message that there are no goals left */} - {/* {!step.goals.length && ( -
- {proof?.completed ? -

Level completed! 🎉

: -

- no goals left
- This probably means you solved the level with warnings or Lean encountered a parsing error. -

- } -
- )} */} -
- } - //} - )} - {proof?.diagnostics.length > 0 && -
- -
- } - {mobile && proof?.completed && -
- {levelId! >= worldSize ? - - : - - } -
- } - : - - // - // note: since we don't know the total number of files, - // we use a function which strictly monotonely increases towards `100` as `x → ∞` - // The base is chosen at random s.t. we get roughly 91% for `x = 100`. - } -
-
- -
-
-} diff --git a/client/src/components/infoview/messages.tsx b/client/src/components/infoview/messages.tsx deleted file mode 100644 index 882cf22b..00000000 --- a/client/src/components/infoview/messages.tsx +++ /dev/null @@ -1,241 +0,0 @@ -import * as React from 'react' -import fastIsEqual from 'react-fast-compare' -import { Location, DocumentUri, Diagnostic, DiagnosticSeverity, PublishDiagnosticsParams } from 'vscode-languageserver-protocol' - -import { LeanDiagnostic, RpcErrorCode, getInteractiveDiagnostics, InteractiveDiagnostic, TaggedText_stripTags } from '@leanprover/infoview-api' - -import { basename, escapeHtml, usePausableState, useEvent, addUniqueKeys, DocumentPosition, useServerNotificationState, useEventResult } from '../../../../node_modules/vscode-lean4/lean4-infoview/src/infoview/util' -import { ConfigContext, EditorContext, LspDiagnosticsContext, VersionContext } from '../../../../node_modules/vscode-lean4/lean4-infoview/src/infoview/contexts' -import { Details } from '../../../../node_modules/vscode-lean4/lean4-infoview/src/infoview/collapsing' -import { InteractiveMessage } from '../../../../node_modules/vscode-lean4/lean4-infoview/src/infoview/traceExplorer' -import { RpcContext, useRpcSessionAtPos } from '../../../../node_modules/vscode-lean4/lean4-infoview/src/infoview/rpcSessions' - -import { useTranslation } from 'react-i18next' -import { useAtom } from 'jotai' -import { typewriterModeAtom } from '../../store/editor-atoms' - -interface MessageViewProps { - uri: DocumentUri; - diag: InteractiveDiagnostic; -} - -/** A list of messages (info/warning/error) that are produced after this command */ -function Error({error, typewriterMode} : {error : InteractiveDiagnostic, typewriterMode : boolean}) { - // The first step will always have an empty command - - const severityClass = error.severity ? { - [DiagnosticSeverity.Error]: 'error', - [DiagnosticSeverity.Warning]: 'warning', - [DiagnosticSeverity.Information]: 'information', - [DiagnosticSeverity.Hint]: 'hint', - }[error.severity] : ''; - - const {line, character} = error.range.start; - const title = `Line ${line+1}, Character ${character}`; - - // Hide "unsolved goals" messages - let message; - if ("append" in error.message && "text" in error.message.append[0] && - error.message?.append[0].text === "unsolved goals") { - message = error.message.append[0] - } else { - message = error.message - } - - return
- {!typewriterMode &&

{title}

} -
-      
-    
-
-} - -// TODO: Should not use index as key. -/** A list of messages (info/warning/error) that are produced after this command */ -export function Errors ({errors, typewriterMode} : {errors : InteractiveDiagnostic[], typewriterMode : boolean}) { - return
- {errors.map((err, i) => ())} -
-} - -const MessageView = React.memo(({uri, diag}: MessageViewProps) => { - const [typewriterMode, setTypewriterMode] = useAtom(typewriterModeAtom) - const ec = React.useContext(EditorContext); - const fname = escapeHtml(basename(uri)); - const {line, character} = diag.range.start; - const loc: Location = { uri, range: diag.range }; - const text = TaggedText_stripTags(diag.message); - const severityClass = diag.severity ? { - [DiagnosticSeverity.Error]: 'error', - [DiagnosticSeverity.Warning]: 'warning', - [DiagnosticSeverity.Information]: 'information', - [DiagnosticSeverity.Hint]: 'hint', - }[diag.severity] : ''; - const title = `Line ${line+1}, Character ${character}`; - - // Hide "unsolved goals" messages - let message; - if ("append" in diag.message && "text" in diag.message.append[0] && - diag.message?.append[0].text === "unsolved goals") { - message = diag.message.append[0] - } else { - message = diag.message - } - - return ( - //
- // {title} - // - // { e.preventDefault(); void ec.revealLocation(loc); }} - // title="reveal file location"> - // {e.preventDefault(); void ec.copyToComment(text)}} - // title="copy message to comment"> - // {e.preventDefault(); void ec.api.copyToClipboard(text)}} - // title="copy message to clipboard"> - // - // -
- {!(typewriterMode) &&

{title}

} -
-                
-            
-
- //
- ) -}, fastIsEqual) - -function mkMessageViewProps(uri: DocumentUri, messages: InteractiveDiagnostic[]): MessageViewProps[] { - const views: MessageViewProps[] = messages - .sort((msga, msgb) => { - const a = msga.fullRange?.end || msga.range.end; - const b = msgb.fullRange?.end || msgb.range.end; - return a.line === b.line ? a.character - b.character : a.line - b.line - }).map(m => { - return { uri, diag: m }; - }); - - return addUniqueKeys(views, v => DocumentPosition.toString({uri: v.uri, ...v.diag.range.start})); -} - -/** Shows the given messages assuming they are for the given file. */ -export const MessagesList = React.memo(({uri, messages}: {uri: DocumentUri, messages: InteractiveDiagnostic[]}) => { - const should_hide = messages.length === 0; - if (should_hide) { return <> } - - return ( -
- {mkMessageViewProps(uri, messages).map(m => )} -
- ); -}) - -function lazy(f: () => T): () => T { - let state: {t: T} | undefined - return () => { - if (!state) state = {t: f()} - return state.t - } -} - -/** Displays all messages for the specified file. Can be paused. */ -export function AllMessages() { - const ec = React.useContext(EditorContext); - const sv = React.useContext(VersionContext); - const curPos: DocumentPosition | undefined = - useEventResult(ec.events.changedCursorLocation, loc => loc ? { uri: loc.uri, ...loc.range.start } : undefined) - const rs0 = useRpcSessionAtPos({ uri: curPos.uri, line: 0, character: 0 }); - const dc = React.useContext(LspDiagnosticsContext); - const config = React.useContext(ConfigContext); - const diags0 = dc.get(curPos.uri) || []; - - const iDiags0 = React.useMemo(() => lazy(async () => { - try { - const diags = await getInteractiveDiagnostics(rs0); - if (diags.length > 0) { - return diags - } - } catch (err: any) { - if (err?.code === RpcErrorCode.ContentModified) { - // Document has been changed since we made the request. This can happen - // while typing quickly. When the server catches up on next edit, it will - // send new diagnostics to which the infoview responds by calling - // `getInteractiveDiagnostics` again. - } else { - console.log('getInteractiveDiagnostics error ', err) - } - } - return diags0.map(d => ({ ...(d as LeanDiagnostic), message: { text: d.message } })); - }), [sv, rs0, curPos.uri, diags0]); - const [{ isPaused, setPaused }, [uri, rs, diags, iDiags], _] = usePausableState(false, [curPos.uri, rs0, diags0, iDiags0]); - - // Fetch interactive diagnostics when we're entering the paused state - // (if they haven't already been fetched before) - React.useEffect(() => { if (isPaused) { void iDiags() } }, [iDiags, isPaused]); - - const setOpenRef = React.useRef>>(); - useEvent(ec.events.requestedAction, act => { - if (act.kind === 'toggleAllMessages' && setOpenRef.current !== undefined) { - setOpenRef.current(t => !t); - } - }); - - return ( - - {/*
- - All Messages ({diags.length}) - - { e.preventDefault(); setPaused(p => !p); }} - title={isPaused ? 'continue updating' : 'pause updating'}> - - - */} - - {/*
*/} -
- ) -} - -/** We factor out the body of {@link AllMessages} which lazily fetches its contents only when expanded. */ -function AllMessagesBody({uri, curPos, messages}: {uri: DocumentUri, curPos: DocumentPosition | undefined , messages: () => Promise}) { - let { t } = useTranslation() - const [msgs, setMsgs] = React.useState(undefined) - React.useEffect(() => { void messages().then( - msgs => setMsgs(msgs.filter((d) => { - const text = TaggedText_stripTags(d.message) - if (text.includes('unsolved goals')) { - return true - } - // Only show the messages from the line where the cursor is. - return d.range.start.line == curPos.line - })) - ) }, [messages, curPos]) - if (msgs === undefined) return
{t("Loading messages…")}
- else return -} - -/** - * Provides a `LspDiagnosticsContext` which stores the latest version of the - * diagnostics as sent by the publishDiagnostics notification. - */ -export function WithLspDiagnosticsContext({children}: React.PropsWithChildren<{}>) { - const [allDiags, _0] = useServerNotificationState( - 'textDocument/publishDiagnostics', - new Map(), - async (params: PublishDiagnosticsParams) => diags => - new Map(diags).set(params.uri, params.diagnostics), - [] - ) - - return {children} -} - -/** Embeds a non-interactive diagnostic into the type `InteractiveDiagnostic`. */ -export function lspDiagToInteractive(diag: Diagnostic): InteractiveDiagnostic { - return { ...(diag as LeanDiagnostic), message: { text: diag.message } }; -} diff --git a/client/src/components/infoview/rpc_api.ts b/client/src/components/infoview/rpc_api.ts deleted file mode 100644 index aebdd8be..00000000 --- a/client/src/components/infoview/rpc_api.ts +++ /dev/null @@ -1,83 +0,0 @@ -/** - * @fileOverview Defines the interface for the communication with the server. - * - * This file is based on `vscode-lean4/vscode-lean4/src/rpcApi.ts` - */ -import type { Range } from 'vscode-languageserver-protocol'; -import type { ContextInfo, FVarId, CodeWithInfos, MVarId } from '@leanprover/infoview-api'; -import { InteractiveDiagnostic, TermInfo } from '@leanprover/infoview/*'; - -export interface InteractiveHypothesisBundle { - /** The pretty names of the variables in the bundle. Anonymous names are rendered - * as `"[anonymous]"` whereas inaccessible ones have a `✝` appended at the end. - * Use `InteractiveHypothesisBundle_nonAnonymousNames` to filter anonymouse ones out. */ - names: string[]; - fvarIds?: FVarId[]; - type: CodeWithInfos; - val?: CodeWithInfos; - isInstance?: boolean; - isType?: boolean; - isInserted?: boolean; - isRemoved?: boolean; - isAssumption?: boolean; -} - -export interface InteractiveGoalCore { - hyps: InteractiveHypothesisBundle[]; - type: CodeWithInfos; - ctx?: ContextInfo; -} - -export interface InteractiveGoal extends InteractiveGoalCore { - userName?: string; - goalPrefix?: string; - mvarId?: MVarId; - isInserted?: boolean; - isRemoved?: boolean; -} - -export interface InteractiveGoals extends InteractiveGoalCore { - goals: InteractiveGoals[]; -} - -export interface InteractiveTermGoal extends InteractiveGoalCore { - range?: Range; - term?: TermInfo; -} - -export interface GameHint { - text: string; - hidden: boolean; - rawText: string; - varNames: string[][]; // in Lean: `Array (Name × Name)` -} - -export interface InteractiveGoalWithHints { - goal: InteractiveGoal; - hints: GameHint[]; -} - -export interface InteractiveGoalsWithHints { - goals: InteractiveGoalWithHints[]; - command: string; - diags: InteractiveDiagnostic[]; -} - -/** - * The proof state as it is received from the server. - * Per proof step of the tactic proof, there is one `InteractiveGoalWithHints[]`. - */ -export interface ProofState { - /** The proof steps. step 0 is the state at the beginning of the proof. step one - * contains the goal after the first line has been evaluated. - * - * In particular `step[i]` is the proof step at the beginning of line `i` in vscode. - */ - steps: InteractiveGoalsWithHints[]; - /** The remaining diagnostics that are not in the steps. Usually this should only - * be the "unsolved goals" message, I believe. - */ - diagnostics : InteractiveDiagnostic[]; - completed : boolean; - completedWithWarnings : boolean; -} diff --git a/client/src/components/infoview/types.ts b/client/src/components/infoview/types.ts new file mode 100644 index 00000000..f4629413 --- /dev/null +++ b/client/src/components/infoview/types.ts @@ -0,0 +1,17 @@ +export interface GameHint { + text: string; + hidden: boolean; + rawText: string; + varNames: string[][]; // in Lean: `Array (Name × Name)` +} + +export interface InteractiveGoalWithHints { + // goal: InteractiveGoal; // FIXME + hints: GameHint[]; +} + +export interface InteractiveGoalsWithHints { + goals: InteractiveGoalWithHints[]; + command: string; + // diags: InteractiveDiagnostic[]; // FIXME +} diff --git a/client/src/components/infoview/typewriter.tsx b/client/src/components/infoview/typewriter.tsx index 64db45fb..35ca2ccd 100644 --- a/client/src/components/infoview/typewriter.tsx +++ b/client/src/components/infoview/typewriter.tsx @@ -1,301 +1,7 @@ -import * as React from 'react' -import { useRef, useState, useEffect } from 'react' -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' -import { faWandMagicSparkles } from '@fortawesome/free-solid-svg-icons' -import * as monaco from 'monaco-editor/esm/vs/editor/editor.api.js' -import { DiagnosticSeverity, PublishDiagnosticsParams, DocumentUri } from 'vscode-languageserver-protocol'; -import { useServerNotificationEffect } from '../../../../node_modules/vscode-lean4/lean4-infoview/src/infoview/util'; -import { InteractiveDiagnostic } from '@leanprover/infoview-api'; -import { Diagnostic } from 'vscode-languageserver-types'; -import { RpcContext } from '../../../../node_modules/vscode-lean4/lean4-infoview/src/infoview/rpcSessions'; -import { MonacoEditorContext } from './context' -import { lastStepHasErrors, loadGoals } from './goals' -import { ProofState } from './rpc_api' -import { useTranslation } from 'react-i18next' -import { useAtom } from 'jotai' -import { levelIdAtom, worldIdAtom } from '../../store/location-atoms' -import { preferencesAtom } from '../../store/preferences-atoms' -import { crashedAtom, interimDiagsAtom, proofAtom, typewriterContentAtom } from '../../store/editor-atoms' -import { deletedChatAtom } from '../../store/chat-atoms' +import React from "react"; -export interface GameDiagnosticsParams { - uri: DocumentUri; - diagnostics: Diagnostic[]; -} - -/** The input field */ -export function Typewriter({disabled}: {disabled?: boolean}) { - let { t } = useTranslation() - - /** Reference to the hidden multi-line editor */ - const editor = React.useContext(MonacoEditorContext) - const model = editor?.getModel() - const uri = model?.uri.toString() ?? '' - const hasEditor = Boolean(editor && model) - - const [worldId] = useAtom(worldIdAtom) - const [levelId] = useAtom(levelIdAtom) - - const [oneLineEditor, setOneLineEditor] = useState() - const oneLineEditorRef = useRef(null) - const [processing, setProcessing] = useState(false) - - const [typewriter, setTypewriter] = useAtom(typewriterContentAtom) - - const inputRef = useRef() - - const [proof, setProof] = useAtom(proofAtom) - const [interimDiags, setInterimDiags] = useAtom(interimDiagsAtom) - const [, setCrashed] = useAtom(crashedAtom) - - // state to store the last batch of deleted messages - const [, setDeletedChat] = useAtom(deletedChatAtom) - - const rpcSess = React.useContext(RpcContext) - - // Run the command - const runCommand = React.useCallback(() => { - if (processing || !hasEditor) {return} - - // TODO: Desired logic is to only reset this after a new *error-free* command has been entered - setDeletedChat([]) - - const pos = editor.getPosition() - if (typewriter) { - setProcessing(true) - editor.executeEdits("typewriter", [{ - range: monaco.Selection.fromPositions( - pos, - editor.getModel()?.getFullModelRange().getEndPosition() - ), - text: typewriter.trim() + "\n", - forceMoveMarkers: false - }]) - setTypewriter('') - // Load proof after executing edits - loadGoals(rpcSess, uri, worldId!, levelId!, setProof, setCrashed) - } - - editor.setPosition(pos) - }, [typewriter, editor]) - - const [{ isSuggestionsMobileMode }] = useAtom(preferencesAtom) - - useEffect(() => { - if (oneLineEditor && oneLineEditor.getValue() !== typewriter) { - oneLineEditor.setValue(typewriter) - oneLineEditor.setPosition({ column: typewriter.length + 1, lineNumber: 1 }) - isSuggestionsMobileMode || oneLineEditor.focus() - } - }, [typewriter]) - - useEffect(() => { - if (oneLineEditor && hasEditor) { - oneLineEditor.setPosition({ column: editor.getValue().length + 1, lineNumber: 1 }) - isSuggestionsMobileMode || oneLineEditor.focus() - } - }, [oneLineEditor, hasEditor, isSuggestionsMobileMode, editor]) - - /** If the last step has an error, add the command to the typewriter. */ - useEffect(() => { - if (lastStepHasErrors(proof)) { - setTypewriter(proof?.steps[proof?.steps.length - 1].command) - } - }, [proof]) - - // React when answer from the server comes back - useServerNotificationEffect('textDocument/publishDiagnostics', (params: PublishDiagnosticsParams) => { - if (!hasEditor) { - return - } - if (params.uri == uri) { - setProcessing(false) - - const seriousDiags = params.diagnostics.filter(diag => - diag.severity === DiagnosticSeverity.Error || diag.severity === DiagnosticSeverity.Warning - ) - setInterimDiags(seriousDiags) - // loadGoals(rpcSess, uri, worldId, levelId, setProof, setCrashed) - - // TODO: loadAllGoals() - if (!hasErrors(params.diagnostics)) { - //setTypewriterInput("") - editor.setPosition(editor.getModel().getFullModelRange().getEndPosition()) - } - } else { - // console.debug(`expected uri: ${uri}, got: ${params.uri}`) - // console.debug(params) - } - // TODO: This is the wrong place apparently. Where do wee need to load them? - // TODO: instead of loading all goals every time, we could only load the last one - // loadAllGoals() - }, [uri, hasEditor, editor]); - - // // React when answer from the server comes back - // useServerNotificationEffect('$/game/publishDiagnostics', (params: GameDiagnosticsParams) => { - // console.log('Received game diagnostics') - // console.log(`diag. uri : ${params.uri}`) - // console.log(params.diagnostics) - - // }, [uri]); - - - useEffect(() => { - if (oneLineEditorRef.current) { - return - } - const myEditor = monaco.editor.create(inputRef.current!, { - value: typewriter, - language: "lean4", - quickSuggestions: false, - // lightbulb: { - // enabled: true - // }, - unicodeHighlight: { - ambiguousCharacters: false, - }, - automaticLayout: true, - minimap: { - enabled: false - }, - lineNumbers: 'off', - tabSize: 2, - wordWrap: 'on', - glyphMargin: false, - folding: false, - lineDecorationsWidth: 0, - lineNumbersMinChars: 0, - 'semanticHighlighting.enabled': true, - overviewRulerLanes: 0, - hideCursorInOverviewRuler: true, - padding: { - top: 0, - bottom: 0, - }, - scrollbar: { - verticalScrollbarSize: 3 - }, - scrollBeyondLastLine: false, - overviewRulerBorder: false, - theme: 'vs-code-theme-converted', - contextmenu: false - }) - - const layoutInput = () => { - const lineHeight = myEditor.getOption(monaco.editor.EditorOption.lineHeight) - const height = Math.min(myEditor.getContentHeight(), lineHeight + 2, window.innerHeight / 3) - inputRef.current.style.height = `${height}px` - myEditor.layout({ - width: inputRef.current.clientWidth, - height - }) - } - myEditor.onDidContentSizeChange(layoutInput) - layoutInput() - - oneLineEditorRef.current = myEditor - setOneLineEditor(myEditor) - - // const abbrevRewriter = new AbbreviationRewriter(new AbbreviationProvider(), myEditor.getModel(), myEditor) - - return () => { - // abbrevRewriter.dispose() - myEditor.dispose() - oneLineEditorRef.current = null - setOneLineEditor(undefined) - } - }, []) - - useEffect(() => { - if (!oneLineEditor) return - // Ensure that our one-line editor can only have a single line - const l = oneLineEditor.getModel()?.onDidChangeContent((e) => { - const value = oneLineEditor.getValue() - setTypewriter(value) - const newValue = value.replace(/[\n\r]/g, '') - if (value != newValue) { - oneLineEditor.setValue(newValue) - } - }) - return () => { - if (typeof (l as any)?.dispose === 'function') { - l.dispose() - } else if (typeof l === 'function') { - l() - } - } - }, [oneLineEditor, setTypewriter]) - - useEffect(() => { - if (!oneLineEditor) return - // Run command when pressing enter (and block newline insertion) - const l = oneLineEditor.onKeyDown((ev) => { - if (ev.code === "Enter" || ev.code === "NumpadEnter") { - ev.preventDefault() - runCommand() - } - }) - return () => { - if (typeof (l as any)?.dispose === 'function') { - l.dispose() - } else if (typeof l === 'function') { - l() - } - } - }, [oneLineEditor, runCommand]) - - // // BUG: Causes `file closed` error - // //TODO: Intention is to run once when loading, does that work? - // useEffect(() => { - // console.debug(`time to update: ${uri} \n ${rpcSess}`) - // console.debug(rpcSess) - // // console.debug('LOAD ALL GOALS') - // // TODO: loadAllGoals() - // }, [rpcSess]) - - /** Process the entered command */ - const handleSubmit : React.FormEventHandler = (ev) => { - ev.preventDefault() - runCommand() - } - - // do not display if the proof is completed (with potential warnings still present) - return
-
-
-
-
- - -
-} - -/** Checks whether the diagnostics contain any errors or warnings to check whether the level has - been completed.*/ -export function hasErrors(diags: Diagnostic[]) { - return diags.some( - (d) => - !d.message.startsWith("unsolved goals") && - (d.severity == DiagnosticSeverity.Error ) // || d.severity == DiagnosticSeverity.Warning - ) -} - -// TODO: Didn't manage to unify this with the one above -export function hasInteractiveErrors (diags: InteractiveDiagnostic[]) { - return (typeof diags !== 'undefined') && diags.some( - (d) => (d.severity == DiagnosticSeverity.Error ) // || d.severity == DiagnosticSeverity.Warning - ) -} +import "../../css/typewriter.css" -export function getInteractiveDiagsAt (proof: ProofState, k : number) { - if (k == 0) { - return [] - } else if (k >= proof?.steps.length-1) { - // TODO: Do we need that? - return proof?.diagnostics.filter(msg => msg.range.start.line >= proof?.steps.length-1) - } else { - return proof?.diagnostics.filter(msg => msg.range.start.line == k-1) - } +export function Typewriter() { + return
} diff --git a/client/src/components/inventory/inventory_item.tsx b/client/src/components/inventory/inventory_item.tsx index 7c7f8c4c..a0b2f155 100644 --- a/client/src/components/inventory/inventory_item.tsx +++ b/client/src/components/inventory/inventory_item.tsx @@ -2,7 +2,6 @@ import { faBan, faCheck, faClipboard, faLock, faReply } from "@fortawesome/free- import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" import React, { useState } from "react" import { useTranslation } from "react-i18next" -import { useAppendTypewriterInput } from "../infoview/context" import { InventoryTile } from "../../store/api" import { useAtom } from "jotai" import { selectedDocTileAtom } from "../../store/inventory-atoms" @@ -30,7 +29,12 @@ export function InventoryItem({tile, isTheorem, recent=false, enableAll=false} : const [inserted, setInserted] = useState(false) - const appendTypewriterInput = useAppendTypewriterInput() + // const appendTypewriterInput = useAppendTypewriterInput() + + // FIXME: implement + function appendTypewriterInput(a: any, b: any, c: any, d: any) {} + + const handleClick = (ev: any) => {setDoc(tile)} const insertItemName = (ev: any) => { diff --git a/client/src/components/inventory/inventory_panel.tsx b/client/src/components/inventory/inventory_panel.tsx index 2095362e..ed6c5e6d 100644 --- a/client/src/components/inventory/inventory_panel.tsx +++ b/client/src/components/inventory/inventory_panel.tsx @@ -36,16 +36,11 @@ export function InventoryPanel({levelInfo, visible = true} : { } }, [levelInfo]) - return
- {doc ? - - : -
- - - -
- } + return
+ + + + {doc && }
} diff --git a/client/src/components/level.tsx b/client/src/components/level.tsx index 961a77fc..0f3bf2f7 100644 --- a/client/src/components/level.tsx +++ b/client/src/components/level.tsx @@ -7,14 +7,8 @@ import { CircularProgress } from '@mui/material' import type { Location } from 'vscode-languageserver-protocol' import { LeanMonaco, LeanMonacoEditor, LeanMonacoOptions } from 'lean4monaco' import { setupMonacoClient } from 'lean4monaco/dist/monacoleanclient' -import type { EditorApi, InfoviewApi } from '@leanprover/infoview-api' -import { EditorContext } from '../../../node_modules/vscode-lean4/lean4-infoview/src/infoview/contexts' -import { EditorConnection, EditorEvents } from '../../../node_modules/vscode-lean4/lean4-infoview/src/infoview/editorConnection' -import { EventEmitter } from '../../../node_modules/vscode-lean4/lean4-infoview/src/infoview/event' import { Button } from './button' import { Markdown } from './markdown' -import { MonacoEditorContext } from './infoview/context' -import { DualEditor } from './infoview/main' import { DeletedHints, Hint, Hints, MoreHelpButton, filterHints } from './hints' import path from 'path'; @@ -24,7 +18,6 @@ import '@fontsource/roboto/500.css' import '@fontsource/roboto/700.css' import '../css/level.css' import { LevelAppBar } from './app_bar' -import { isLastStepWithErrors, lastStepHasErrors } from './infoview/goals' import { useTranslation } from 'react-i18next' import { useGameTranslation } from '../utils/translation' import { InventoryPanel } from './inventory/inventory_panel' @@ -35,6 +28,7 @@ import { gameInfoAtom, levelInfoAtom } from '../store/query-atoms' import { deletedChatAtom, helpAtom, selectedStepAtom } from '../store/chat-atoms' import { inventoryOverviewAtom } from '../store/inventory-atoms' import { mobileAtom } from '../store/preferences-atoms' +import { GameEditor } from './infoview/GameEditor' const reconfigureLeanMonacoClient = async (leanMonaco: LeanMonaco, options: LeanMonacoOptions) => { const maybeLeanMonaco = leanMonaco as unknown as { @@ -62,6 +56,13 @@ const reconfigureLeanMonacoClient = async (leanMonaco: LeanMonaco, options: Lean } } +//FIXME: implement +function lastStepHasErrors(x: any) {return false} + + +//FIXME: implement +function isLastStepWithErrors(x: any, y: any) {return false} + function Level() { const { t: gT, i18n } = useGameTranslation() const [gameId] = useAtom(gameIdAtom) @@ -199,12 +200,9 @@ function ChatPanel({lastLevel, visible = true}: {lastLevel: boolean, visible: bo } -function ExercisePanel({codeviewRef, infoviewRef, visible=true}: {codeviewRef: React.MutableRefObject, infoviewRef: React.MutableRefObject, visible?: boolean}) { - return
-
-
- -
+function ExercisePanel({codeviewRef, visible=true}: {codeviewRef: React.MutableRefObject, visible?: boolean}) { + return
+
} @@ -212,7 +210,6 @@ function PlayableLevel() { let { t } = useTranslation() const { t : gT } = useGameTranslation() const codeviewRef = useRef(null) - const infoviewRef = useRef(null) const [leanMonaco] = useAtom(leanMonacoAtom) const [gameId] = useAtom(gameIdAtom) const [worldId] = useAtom(worldIdAtom) @@ -236,7 +233,6 @@ function PlayableLevel() { function closeInventoryDoc () {setInventoryDoc(null)} const [leanMonacoEditor, setLeanMonacoEditor] = useState(null) - const [editorConnection, setEditorConnection] = useState(null) // Start the editor useEffect(() => { @@ -250,108 +246,6 @@ function PlayableLevel() { await leanMonacoEditor.start(codeviewRef.current!, uriStr, code ?? "") console.debug('[demo]: editor started') setLeanMonacoEditor(leanMonacoEditor) - - // TODO: old stuff from here on - - const infoProvider = leanMonaco.infoProvider as { - editorApi: EditorApi - webviewPanel?: { api: InfoviewApi, visible: boolean } - sendConfig?: () => Promise - sendPosition?: () => Promise - } | undefined - - if (!infoProvider?.editorApi) { - console.warn('Lean infoview is not ready yet.') - return - } - - const editorEvents: EditorEvents = { - initialize: new EventEmitter(), - gotServerNotification: new EventEmitter(), - sentClientNotification: new EventEmitter(), - serverRestarted: new EventEmitter(), - serverStopped: new EventEmitter(), - changedCursorLocation: new EventEmitter(), - changedInfoviewConfig: new EventEmitter(), - runTestScript: new EventEmitter(), - requestedAction: new EventEmitter(), - } - - const infoviewApi: InfoviewApi = { - initialize: async l => editorEvents.initialize.fire(l), - gotServerNotification: async (method, params) => { - editorEvents.gotServerNotification.fire([method, params]) - }, - sentClientNotification: async (method, params) => { - editorEvents.sentClientNotification.fire([method, params]) - }, - serverRestarted: async r => editorEvents.serverRestarted.fire(r), - serverStopped: async serverStoppedReason => { - editorEvents.serverStopped.fire(serverStoppedReason) - }, - changedCursorLocation: async loc => editorEvents.changedCursorLocation.fire(loc), - changedInfoviewConfig: async conf => editorEvents.changedInfoviewConfig.fire(conf), - requestedAction: async action => editorEvents.requestedAction.fire(action), - runTestScript: async script => new Function(script)(), - getInfoviewHtml: async () => document.body.innerHTML, - } - - infoProvider.webviewPanel = { - api: infoviewApi, - visible: true, - } - - const editorConnection = new EditorConnection(infoProvider.editorApi, editorEvents) - setEditorConnection(editorConnection) - - const model = leanMonacoEditor.editor?.getModel() - const fireCursorLocation = () => { - const selection = leanMonacoEditor.editor?.getSelection() - const position = leanMonacoEditor.editor?.getPosition() - if (!selection || !model) { - if (!position || !model) { - return - } - const loc: Location = { - uri: model.uri.toString(), - range: { - start: { - line: position.lineNumber - 1, - character: position.column - 1, - }, - end: { - line: position.lineNumber - 1, - character: position.column - 1, - }, - }, - } - editorEvents.changedCursorLocation.fire(loc) - return - } - const loc: Location = { - uri: model.uri.toString(), - range: { - start: { - line: selection.startLineNumber - 1, - character: selection.startColumn - 1, - }, - end: { - line: selection.endLineNumber - 1, - character: selection.endColumn - 1, - }, - }, - } - editorEvents.changedCursorLocation.fire(loc) - } - - fireCursorLocation() - leanMonacoEditor.editor?.onDidChangeCursorSelection(() => { - fireCursorLocation() - }) - - await infoProvider.sendConfig?.() - await infoProvider.sendPosition?.() - })() return () => { @@ -360,37 +254,11 @@ function PlayableLevel() { } }, [leanMonaco, worldId, levelId]) - // /** Unused. Was implementing an undo button, which has been replaced by `deleteProof` inside - // * `TypewriterInterface`. - // */ - // const handleUndo = () => { - // const endPos = leanMonacoEditor?.editor.getModel().getFullModelRange().getEndPosition() - // let range - // console.log(endPos?.column) - // if (endPos?.column === 1) { - // range = monaco.Selection.fromPositions( - // new monaco.Position(endPos.lineNumber - 1, 1), - // endPos - // ) - // } else { - // range = monaco.Selection.fromPositions( - // new monaco.Position(endPos.lineNumber, 1), - // endPos - // ) - // } - // leanMonacoEditor?.editor.executeEdits("undo-button", [{ - // range, - // text: "", - // forceMoveMarkers: false - // }]); - // } - // Select and highlight proof steps and corresponding hints // TODO: with the new design, there is no difference between the introduction and // a hint at the beginning of the proof... const [selectedStep, setSelectedStep] = useState() - useEffect (() => { // Lock editor mode if (levelInfo?.template) { @@ -399,13 +267,6 @@ function PlayableLevel() { if (model) { let code = model.getLinesContent() - // console.log(`insert. code: ${code}`) - // console.log(`insert. join: ${code.join('')}`) - // console.log(`insert. trim: ${code.join('').trim()}`) - // console.log(`insert. length: ${code.join('').trim().length}`) - // console.log(`insert. range: ${editor.getModel().getFullModelRange()}`) - - // TODO: It does seem that the template is always indented by spaces. // This is a hack, assuming there are exactly two. if (!code.join("").trim().length) { @@ -492,37 +353,28 @@ function PlayableLevel() { }, [leanMonacoEditor, leanMonacoEditor?.editor, typewriterMode, lockEditorMode]) return <> -
- - - - {mobile? - // TODO: This is copied from the `Split` component below... - <> -
- - -
- - : - - - - - - } -
-
+ { levelInfoIsLoading &&
} + + {mobile? + // TODO: This is copied from the `Split` component below... + <> +
+ + +
+ + : + + + + + + } } diff --git a/client/src/components/welcome.tsx b/client/src/components/welcome.tsx index b244aff1..a163216c 100644 --- a/client/src/components/welcome.tsx +++ b/client/src/components/welcome.tsx @@ -38,7 +38,7 @@ function IntroductionPanel({setPageNumber}: {setPageNumber: (val: number) => voi // let text: Array = introduction.split(/\n(\s*\n)+/) let text: Array = gameInfo?.introduction ? [gT(gameInfo.introduction)] : [] - return
+ return
{text?.map(((t, i) => t.trim() ? diff --git a/client/src/components/world_tree.tsx b/client/src/components/world_tree.tsx index 445c0e36..99bda4b7 100644 --- a/client/src/components/world_tree.tsx +++ b/client/src/components/world_tree.tsx @@ -332,13 +332,17 @@ export function WorldTreePanel() { let dx = bounds ? s*(bounds.x2 - bounds.x1) + 2*padding : 100 - return
+ return ( +
- - {svgElements} - -
+
+ + {svgElements} + +
+
+ ) } diff --git a/client/src/css/app.css b/client/src/css/app.css index cb07ab61..a3480d93 100644 --- a/client/src/css/app.css +++ b/client/src/css/app.css @@ -13,7 +13,6 @@ html { --ff-primary: Roboto; } - /* General styling */ body { @@ -92,22 +91,8 @@ em { /* App Bar */ -#root { - height: 100%; -} - -.app { - height: 100%; - display: flex; - flex-direction: column; -} - .app-bar { - flex: 0; background: var(--clr-primary); - display: flex; - position: relative; - flex-direction: row; justify-content: space-between; align-items: center; padding: 1.1em; @@ -130,14 +115,6 @@ em { /* margin: 0 1em; */ } -.app-content { - height: 100%; - flex: 1; - min-height: 0; - display: flex; -} - - .markdown li ul, .markdown li ol { margin:0 1.5em; } diff --git a/client/src/css/infoview.css b/client/src/css/infoview.css index 5453b3a3..7af31094 100644 --- a/client/src/css/infoview.css +++ b/client/src/css/infoview.css @@ -126,14 +126,12 @@ .typewriter .typewriter-input { flex: 0 0 auto; - width: 100%; align-self: flex-start; margin-top: 0; } .typewriter-input .monaco-editor, .typewriter-input .monaco-editor-background { - height: 100%; margin-top: 0; } @@ -293,3 +291,11 @@ stroke: var(--clr-dark-gray); stroke-width: 2; } + +.exercise-statement { + padding-left: .5em; + padding-right: .5em; + padding-top: 1em; + padding-bottom: 1em; + margin: 0; +} diff --git a/client/src/css/inventory.css b/client/src/css/inventory.css index 4772df66..b1494951 100644 --- a/client/src/css/inventory.css +++ b/client/src/css/inventory.css @@ -1,14 +1,9 @@ -.inventory-panel { - position: relative; -} .inventory-panel .documentation { position: absolute; display: block; top: 0; left: 0; - width: 100%; - height: 100%; z-index: 10; background-color: white; } @@ -26,8 +21,8 @@ font-size: .7rem; } -.inventory, .inventory-panel .documentation { - padding: 0 1em 1em 1em; +.inventory-list { + padding: 0 1em; } .inventory h2, .inventory-panel .documentation h2, .inventory-panel .documentation h1 { @@ -45,12 +40,6 @@ transition: display 2s; } -.inventory-list { - display: flex; - flex-direction: column; - gap: 0; - flex-wrap : wrap; -} .inventory .item { background: #fff; diff --git a/client/src/css/layout.css b/client/src/css/layout.css new file mode 100644 index 00000000..cd1f7c48 --- /dev/null +++ b/client/src/css/layout.css @@ -0,0 +1,92 @@ +html, body { + height: 100%; + margin: 0; +} + +#root { + height: 100vh; + width: 100vw; + box-sizing: border-box; + display: flex; + flex-direction: column; +} + +.app-bar { + flex-shrink: 0; + background: var(--clr-primary); + display: flex; + position: relative; + flex-direction: row; + justify-content: space-between; + align-items: center; + padding: 1.1em; + filter: drop-shadow(0 0 5px rgba(0,0,0,0.5)); + z-index: 20; +} + +.app-content { + flex: 1; + min-height: 0; + position: relative; +} + +.level, .welcome { + display: flex; + height: 100%; +} + +.inventory-panel, .exercise-panel, .doc-panel, .introduction-panel, .chat-panel, .panel { + display: flex; + min-height: 0; + flex-direction: column; + position: relative; + overflow-y: hidden; +} + +/* World Tree Panel */ + +.world-tree { + flex: 1; + min-height: 0; + overflow-y: auto; +} + +.world-selection { + max-width: 100%; + display: block; + margin: auto; +} + +/* Exercise Panel */ +.exercise { + gap: 8px; +} + +.codeview { + flex: 1; +} + +.infoview { + flex: 1; + display: flex; + border: 1px solid green; + padding-top: 1em; + padding-bottom: 0; + padding-top: 0em; +} + +.infoview iframe { + width: 100%; + height: 100%; +} + +/* Inventory Panel */ + +.tab-bar { + flex-shrink: 0; +} + +.inventory-list { + flex: 1; + overflow-y: auto; +} diff --git a/client/src/css/level.css b/client/src/css/level.css index b084fa7d..a86b5923 100644 --- a/client/src/css/level.css +++ b/client/src/css/level.css @@ -1,5 +1,4 @@ .level-mobile { - height: 100%; flex: 1; min-height: 0; /* display: flex; */ @@ -24,32 +23,6 @@ background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAeCAYAAADkftS9AAAAIklEQVQoU2M4c+bMfxAGAgYYmwGrIIiDjrELjpo5aiZeMwF+yNnOs5KSvgAAAABJRU5ErkJggg=='); } -.inventory-panel, .exercise-panel, .doc-panel, .introduction-panel { - height: 100%; - width: 100%; - overflow: auto; - position: relative; -} - -.infoview { - padding-top: 1em; - padding-bottom: 0; -} - -.infoview, .exercise-statement { - padding-left: .5em; - padding-right: .5em; -} - -.exercise-statement { - padding-top: 1em; - padding-bottom: 1em; -} - -.exercise-statement p { - margin: 0; -} - .exercise-statement .lean-code { color: rgba(0, 32, 90, 0.87); font-size: 12px; /* TODO: is the monaco font-size hardcoded? */ @@ -59,22 +32,6 @@ padding: 1em; } -.infoview { - padding-top: 0em; -} - -.exercise { - flex: 1 1 auto; - display: flex; - flex-flow: column; -} - -.codeview { - flex: 1 1 auto; - min-height: 192px; - -} - .exercise h4 { margin-top: 0; margin-bottom: 0; @@ -192,7 +149,6 @@ td code { } */ .exercise { - height: 100%; } .chat { @@ -208,7 +164,6 @@ td code { overflow: hidden; display: flex; flex-direction: column; - } .chat-panel .button-row { @@ -217,6 +172,7 @@ td code { margin-right: .5em; min-height: 2.5em; border-top: 0.1em solid #aaa; + flex-shrink: 0; } .chat-panel .btn { @@ -249,7 +205,6 @@ td code { .typewriter-interface { display: flex; flex-flow: column; - height: 100%; } .typewriter { diff --git a/client/src/css/typewriter.css b/client/src/css/typewriter.css new file mode 100644 index 00000000..85b3164f --- /dev/null +++ b/client/src/css/typewriter.css @@ -0,0 +1,9 @@ + + +.typewriter-canvas { + position: absolute; + /* width: 100%; + height: 100%; */ + background-color: white; + border: 1px solid red; +} diff --git a/client/src/css/welcome.css b/client/src/css/welcome.css index cf87e69c..056adc05 100644 --- a/client/src/css/welcome.css +++ b/client/src/css/welcome.css @@ -3,25 +3,7 @@ fill: var(--clr-primary) } */ -.welcome { - height: 100%; - flex: 1; - min-height: 0; - display: flex; -} - -.welcome.mobile .column { - width: 100%; -} - -.app-content { - height: 100% -} - -.welcome .column { - height: 100%; - overflow: auto; - position: relative; +.panel { scroll-behavior: smooth; } diff --git a/client/src/css/world_tree.css b/client/src/css/world_tree.css index ab13154a..91ae99dd 100644 --- a/client/src/css/world_tree.css +++ b/client/src/css/world_tree.css @@ -44,14 +44,6 @@ svg .disabled { cursor: default; } -.world-selection { - display: block; - margin-left: auto; - margin-right: auto; - margin-top: 2em; - max-width: 100%; -} - .world-selection-menu { right: 1em; top: 1em; diff --git a/client/src/store/chat-atoms.ts b/client/src/store/chat-atoms.ts index 502b1cfe..1262a983 100644 --- a/client/src/store/chat-atoms.ts +++ b/client/src/store/chat-atoms.ts @@ -1,6 +1,6 @@ import { atom } from "jotai" import { levelProgressAtom, progressAtom } from "./progress-atoms" -import { GameHint } from "../components/infoview/rpc_api" +import { GameHint } from "../../../infoview/rpc_api" /** User read the game introduction. Only relevant on mobile */ export const readGameIntroAtom = atom( diff --git a/client/src/store/editor-atoms.ts b/client/src/store/editor-atoms.ts index badbea39..58a33dc0 100644 --- a/client/src/store/editor-atoms.ts +++ b/client/src/store/editor-atoms.ts @@ -4,7 +4,7 @@ import { gameIdAtom } from "./location-atoms"; import { levelProgressAtom, progressAtom } from "./progress-atoms"; import { Selection } from "./progress-types"; import { levelInfoAtom } from "./query-atoms"; -import { ProofState } from "../components/infoview/rpc_api"; +import { ProofState } from "../../../infoview/rpc_api"; import { Diagnostic } from 'vscode-languageserver-types' /** Options for the LeanMonaco instance */ diff --git a/client/tsconfig.json b/client/tsconfig.json index 5f4853f4..bf580571 100644 --- a/client/tsconfig.json +++ b/client/tsconfig.json @@ -20,6 +20,6 @@ }, "include": [ "./src/", - "./src/config.json", + "./src/config.json", "../infoview", "../infoview", "../infoview", ] } diff --git a/package-lock.json b/package-lock.json index 200ebd1c..a6d96fdd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,7 +29,6 @@ "@fortawesome/free-regular-svg-icons": "^7.2.0", "@fortawesome/free-solid-svg-icons": "^7.2.0", "@fortawesome/react-fontawesome": "^3.2.0", - "@leanprover/infoview": "^0.4.3", "@mui/material": "^5.11.1", "@reduxjs/toolkit": "^1.9.1", "@tanstack/query-core": "^5.90.20", @@ -52,8 +51,7 @@ "react-split": "^2.0.14", "rehype-katex": "^7.0.1", "remark-gfm": "^4.0.1", - "remark-math": "^6.0.0", - "vscode-lean4": "github:leanprover/vscode-lean4#de0062c" + "remark-math": "^6.0.0" }, "devDependencies": { "@codingame/esbuild-import-meta-url-plugin": "^1.0.3", @@ -1675,25 +1673,6 @@ "tslib": "2" } }, - "node_modules/@leanprover/infoview": { - "version": "0.4.4", - "resolved": "https://registry.npmjs.org/@leanprover/infoview/-/infoview-0.4.4.tgz", - "integrity": "sha512-OxHffFaHcEudLyBEWpicOl7TfXuTYxW5Sz1RkHdUINWJpQsQn60YDF5fNRKmSb0d/fm7p+LVeBvM273jvfR5wQ==", - "dependencies": { - "@leanprover/infoview-api": "~0.2.1", - "@vscode/codicons": "^0.0.32", - "es-module-shims": "^1.6.2", - "marked": "^4.2.2", - "react-fast-compare": "^3.2.0", - "tachyons": "^4.12.0", - "vscode-languageserver-protocol": "^3.17.2" - } - }, - "node_modules/@leanprover/infoview-api": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/@leanprover/infoview-api/-/infoview-api-0.2.1.tgz", - "integrity": "sha512-4sYdwOhUsa5wfvo/ZsCbcm8fBWcrATciZq0sWfmi5NRbIyZ+c2QjTm6D9CeYPCNvz9yvD1KBp/2+hKEZ8SOHkA==" - }, "node_modules/@leanprover/unicode-input": { "version": "0.1.9", "resolved": "https://registry.npmjs.org/@leanprover/unicode-input/-/unicode-input-0.1.9.tgz", @@ -8141,17 +8120,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/marked": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/marked/-/marked-4.3.0.tgz", - "integrity": "sha512-PRsaiG84bK+AMvxziE/lCFss8juXjNaWzVbN5tXAm4XjeaS9NAHhop+PjQxz2A9h8Q4M/xGmzP8vqNwy6JeK0A==", - "bin": { - "marked": "bin/marked.js" - }, - "engines": { - "node": ">= 12" - } - }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -12540,10 +12508,6 @@ "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz", "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==" }, - "node_modules/vscode-lean4": { - "name": "@leanprover/vscode-lean4-repo", - "resolved": "git+ssh://git@github.com/leanprover/vscode-lean4.git#de0062c9e108270c8dbf649a77538f433721154e" - }, "node_modules/vscode-oniguruma": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/vscode-oniguruma/-/vscode-oniguruma-1.7.0.tgz", From c052ee03e011f7a26933cab56a2aad7da06a7531 Mon Sep 17 00:00:00 2001 From: Jon Eugster Date: Fri, 3 Apr 2026 22:58:03 +0200 Subject: [PATCH 02/36] wip --- client/src/components/infoview/ProofStep.tsx | 6 + client/src/components/infoview/typewriter.tsx | 61 +- client/src/css/infoview.css | 4 - client/src/css/level.css | 35 - client/src/css/typewriter.css | 50 +- infoview/context.ts | 131 ++++ infoview/goals.tsx | 413 +++++++++++ infoview/info.tsx | 461 ++++++++++++ infoview/main.tsx | 654 ++++++++++++++++++ infoview/messages.tsx | 241 +++++++ infoview/rpc_api.ts | 83 +++ infoview/typewriter.tsx | 235 +++++++ 12 files changed, 2330 insertions(+), 44 deletions(-) create mode 100644 client/src/components/infoview/ProofStep.tsx create mode 100644 infoview/context.ts create mode 100644 infoview/goals.tsx create mode 100644 infoview/info.tsx create mode 100644 infoview/main.tsx create mode 100644 infoview/messages.tsx create mode 100644 infoview/rpc_api.ts create mode 100644 infoview/typewriter.tsx diff --git a/client/src/components/infoview/ProofStep.tsx b/client/src/components/infoview/ProofStep.tsx new file mode 100644 index 00000000..d8a37603 --- /dev/null +++ b/client/src/components/infoview/ProofStep.tsx @@ -0,0 +1,6 @@ +import React from "react"; +import { InteractiveGoalsWithHints } from "./types"; + +export function ProofStep({step, idx}: {step:InteractiveGoalsWithHints, idx: number}) { + return
Step {idx}
+} diff --git a/client/src/components/infoview/typewriter.tsx b/client/src/components/infoview/typewriter.tsx index 35ca2ccd..d32eb42b 100644 --- a/client/src/components/infoview/typewriter.tsx +++ b/client/src/components/infoview/typewriter.tsx @@ -1,7 +1,64 @@ -import React from "react"; +import React, { useEffect, useRef, useState } from "react"; import "../../css/typewriter.css" +import { useTranslation } from "react-i18next"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faWandMagicSparkles } from "@fortawesome/free-solid-svg-icons"; +import { useAtom } from "jotai"; +import { gameInfoAtom } from "../../store/query-atoms"; +import { gameIdAtom, worldIdAtom } from "../../store/location-atoms"; +import path from "node:path"; +import { proofAtom } from "../../store/editor-atoms"; +import { ExerciseStatement } from "./ExerciseStatement"; +import { ProofStep } from "./ProofStep"; +/** + * Der Typewriter bestehend aus Eingabezeile, Beweisschritten, Aufgabenstellung und Hintergrundbild + */ export function Typewriter() { - return
+ const [{ data: gameInfo }] = useAtom(gameInfoAtom) + const [gameId, navigateToGame] = useAtom(gameIdAtom) + const [worldId] = useAtom(worldIdAtom) + const [proof, setProof ] = useAtom(proofAtom) + + const proofPanelRef = useRef(null) + + let image = gameInfo?.worlds?.nodes[worldId!]?.image + + return
+
+ {image && } +
+
+ + {proof?.steps.map((step, i) => )} +
+
+ +
+} + +/** The input field */ +export function TypewriterCommandLine() { + let { t } = useTranslation() + const oneLineEditorRef = useRef(null) + const [typewriterContent, setTypewriterContent] = useState("") + + /** Process the entered command */ + const handleSubmit : React.FormEventHandler = (ev) => { + ev.preventDefault() + // runCommand() + } + + // do not display if the proof is completed (with potential warnings still present) + return
+
+
+
+
+ + +
} diff --git a/client/src/css/infoview.css b/client/src/css/infoview.css index 7af31094..784c87d2 100644 --- a/client/src/css/infoview.css +++ b/client/src/css/infoview.css @@ -209,10 +209,6 @@ scroll-behavior: smooth; } -.typewriter-interface .content .tmp-pusher { - flex: 1; -} - .exercise .command { background-color: #bbb; padding: .5em; diff --git a/client/src/css/level.css b/client/src/css/level.css index a86b5923..6e197035 100644 --- a/client/src/css/level.css +++ b/client/src/css/level.css @@ -295,41 +295,6 @@ td code { width: 1.8rem; } -.tmp-pusher { - align-items: center; - display: flex; - justify-content: center; -} - -.typewriter-interface .content, .world-image-container.empty { - background-color: #eee; -} - -.world-image-container { - display: flex; - flex-direction: column; - min-height: 0px; /* somehow this has a desired affect, but why? */ - overflow: hidden; -} - -.world-image-container img.contain { - object-fit: contain; -} - -.world-image-container.center { - justify-content: center; -} - - -.world-image-container img.cover { - height: 100%; - object-fit: cover; -} - -.typewriter-interface .proof { - background-color: #fff; -} - .toggle-width { min-width: 40px; text-align: center; diff --git a/client/src/css/typewriter.css b/client/src/css/typewriter.css index 85b3164f..26ca6612 100644 --- a/client/src/css/typewriter.css +++ b/client/src/css/typewriter.css @@ -2,8 +2,52 @@ .typewriter-canvas { position: absolute; - /* width: 100%; - height: 100%; */ + width: 100%; + height: 100%; + display: flex; + flex-direction: column; background-color: white; - border: 1px solid red; +} + +.typewriter-info { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; + position: relative; + overflow-y: auto; +} + +.pusher { + flex: 1; + min-height: 0; + display: block; +} + +.typewriter-canvas .typewriter-info { + background-color: #eee; +} + +.world-image { + position: absolute; + max-height: 100%; + width: 100%; +} + +.world-image.contain { + object-fit: contain; +} + +.world-image.cover { + height: 100%; + object-fit: cover; +} + +.typewriter-info .proof { + background-color: #fff; + border-radius: 8px 8px 0 0; +} + +.proof { + z-index: 1; } diff --git a/infoview/context.ts b/infoview/context.ts new file mode 100644 index 00000000..83324cd9 --- /dev/null +++ b/infoview/context.ts @@ -0,0 +1,131 @@ +/** + * @fileOverview This file contains the the react contexts used in the project. + */ +import * as React from 'react'; +import * as monaco from 'monaco-editor/esm/vs/editor/editor.api.js' +import { InteractiveDiagnostic } from '@leanprover/infoview-api'; +import { InteractiveTermGoal,InteractiveGoalsWithHints } from './rpc_api'; +import { Preferences, preferencesAtom } from '../client/src/store/preferences-atoms'; +import { useAtom } from 'jotai'; +import { typewriterContentAtom } from '../client/src/store/editor-atoms'; + +export const MonacoEditorContext = React.createContext( + null as any) + +export type InfoStatus = 'updating' | 'error' | 'ready'; + +// /** One step of the proof */ +// export type ProofStep = { +// /** The command in this step */ +// command : string +// /** List of goals *after* this command */ +// goals: InteractiveGoal[] // TODO: Add correct type +// /** Story relevant messages */ +// hints: GameHint[] // TODO: Add correct type +// /** Errors and warnings */ +// errors: InteractiveDiagnostic[] // TODO: Add correct type +// } + + +// TODO: Do we still need that? +export interface ProofStateProps { + // pos: DocumentPosition; + status: InfoStatus; + messages: InteractiveDiagnostic[]; + goals?: InteractiveGoalsWithHints; + termGoal?: InteractiveTermGoal; + error?: string; + // userWidgets: UserWidgetInstance[]; + // rpcSess: RpcSessionAtPos; + // triggerUpdate: () => Promise; +} + +// export const ProofStateContext = React.createContext<{ +// proofState : ProofStateProps, +// setProofState: React.Dispatch> +// }>({ +// proofState : { +// status: 'updating', +// messages: [], +// goals: undefined, +// termGoal: undefined, +// error: undefined}, +// setProofState: () => {}, +// }) + +export interface IPreferencesContext extends Preferences{ + mobile: boolean, // The variables that actually control the page 'layout' can only be changed through layout. + setLayout: React.Dispatch>; + setIsSavePreferences: React.Dispatch>; + setLanguage: React.Dispatch>; +} + + +const SUFFIX_OVERRIDES: Record = { + "induction": "generalizing", + "left": ".left", + "rewrite": "←", + "right": ".right", + "rw": "←", +} +const PREFIX_OVERRIDES: Record = { + "by_cases": "by_cases this :", + "contrapose": "contrapose!", + "have": "have :", + "obtain": "obtain ⟨⟩ :=", + "rewrite": "rw []", + "rw": "rw []", + "simp": "simp only []", +} +export function useAppendTypewriterInput() { + const [typewriter, setTypewriter] = useAtom(typewriterContentAtom) + const [{ isSuggestionsMobileMode }] = useAtom(preferencesAtom) + return (shiftKey: boolean, suffix: string, isTheorem: boolean, isAssumption: boolean) => { + if (!isSuggestionsMobileMode && !shiftKey) { + return false + } + // Automagically detect and adjust punctuation for mobile keyboardless usage + let _typewriter = typewriter.trim() + if (!typewriter.length) { + _typewriter = Object.hasOwn(PREFIX_OVERRIDES, suffix) ? PREFIX_OVERRIDES[suffix] : isTheorem ? `rw [${suffix}]` : suffix + setTypewriter(_typewriter + " ") + return true + } + suffix = !isAssumption && Object.hasOwn(SUFFIX_OVERRIDES, suffix) ? SUFFIX_OVERRIDES[suffix] : suffix + if (suffix === "ℕ") { + if (/ \d$/.test(_typewriter)) { + suffix = ((+_typewriter.slice(-2) + 1) % 10).toString() + _typewriter = _typewriter.slice(0, -2) + } else { + suffix = "0" + } + suffix = " " + suffix + } else if (suffix === "∈" && _typewriter.endsWith("∈")) { + suffix = " {} " + } else if (isAssumption && /^apply |^symm|^push_neg/.test(_typewriter)) { + suffix = " at " + suffix + } else if (suffix === "have") { + suffix = _typewriter === "have :" ? "=" : " :=" + } else if (/[\]}]$/.test(_typewriter)) { + if (isAssumption) { + suffix = " at " + suffix + } else { + const closing = _typewriter.slice(-1) + _typewriter = _typewriter.slice(0, -1) + if (suffix === "←") { + const imbalance = (_typewriter.match(/\(/)?.length ?? 0) - (_typewriter.match(/\)/)?.length ?? 0) + suffix = /[[,({]$/.test(_typewriter) ? "←" : /\([^)]*$/.test(_typewriter) ? ")" : " (" + } else { + if (!/[[,({]$/.test(_typewriter)) { + suffix = (isTheorem && !_typewriter.endsWith("←") && closing === "]" ? ", " : /^[ᶜ.]/.test(suffix) ? "" : " ") + suffix + } + } + suffix = suffix + closing + } + } else if (!/^[ᶜ.]/.test(suffix)) { + suffix = " " + suffix + } + setTypewriter(`${_typewriter}${suffix} `.trimStart()) + return true + } +} diff --git a/infoview/goals.tsx b/infoview/goals.tsx new file mode 100644 index 00000000..c1023e82 --- /dev/null +++ b/infoview/goals.tsx @@ -0,0 +1,413 @@ +/** + * @fileOverview + * + * Mostly copied from https://github.com/leanprover/vscode-lean4/blob/master/lean4-infoview/src/infoview/goals.tsx + */ +import * as React from 'react' +import { InteractiveHypothesisBundle_nonAnonymousNames, MVarId, TaggedText_stripTags } from '@leanprover/infoview-api' +import { EditorContext } from '../../../../node_modules/vscode-lean4/lean4-infoview/src/infoview/contexts'; +import { Locations, LocationsContext, SelectableLocation } from '../../../../node_modules/vscode-lean4/lean4-infoview/src/infoview/goalLocation'; +import { InteractiveCode } from '../../../../node_modules/vscode-lean4/lean4-infoview/src/infoview/interactiveCode' +import { WithTooltipOnHover } from '../../../../node_modules/vscode-lean4/lean4-infoview/src/infoview/tooltips'; +import { useAppendTypewriterInput } from './context'; +import { InteractiveGoal, InteractiveGoals, InteractiveGoalsWithHints, InteractiveHypothesisBundle, ProofState } from './rpc_api'; +import { RpcSessionAtPos } from '@leanprover/infoview/*'; +import { DocumentPosition } from '../../../../node_modules/vscode-lean4/lean4-infoview/src/infoview/util'; +import { DiagnosticSeverity } from 'vscode-languageserver-protocol'; +import { useTranslation } from 'react-i18next'; +import { useAtom } from 'jotai'; +import { gameIdAtom } from '../client/src/store/location-atoms'; +import { typewriterModeAtom } from '../client/src/store/editor-atoms'; +import { gameInfoAtom } from '../client/src/store/query-atoms'; +import { mobileAtom } from '../client/src/store/preferences-atoms'; + +/** Returns true if `h` is inaccessible according to Lean's default name rendering. */ +function isInaccessibleName(h: string): boolean { + return h.indexOf('✝') >= 0; +} + +function goalToString(g: InteractiveGoal): string { + let ret = '' + + if (g.userName) { + ret += `case ${g.userName}\n` + } + + for (const h of g.hyps) { + const names = InteractiveHypothesisBundle_nonAnonymousNames(h).join(' ') + ret += `${names} : ${TaggedText_stripTags(h.type)}` + if (h.val) { + ret += ` := ${TaggedText_stripTags(h.val)}` + } + ret += '\n' + } + + ret += `⊢ ${TaggedText_stripTags(g.type)}` + + return ret +} + +export function goalsToString(goals: InteractiveGoals): string { + return goals.goals.map(g => goalToString(g)).join('\n\n') +} + +export function goalsWithHintsToString(goals: InteractiveGoalsWithHints): string { + return goals.goals.map(g => goalToString(g.goal)).join('\n\n') +} + +interface GoalFilterState { + /** If true reverse the list of hypotheses, if false present the order received from LSP. */ + reverse: boolean, + /** If true show hypotheses that have isType=True, otherwise hide them. */ + showType: boolean, + /** If true show hypotheses that have isInstance=True, otherwise hide them. */ + showInstance: boolean, + /** If true show hypotheses that contain a dagger in the name, otherwise hide them. */ + showHiddenAssumption: boolean + /** If true show the bodies of let-values, otherwise hide them. */ + showLetValue: boolean; +} + +function getFilteredHypotheses(hyps: InteractiveHypothesisBundle[], filter: GoalFilterState): InteractiveHypothesisBundle[] { + return hyps.reduce((acc: InteractiveHypothesisBundle[], h) => { + if (h.isInstance && !filter.showInstance) return acc + if (h.isType && !filter.showType) return acc + const names = filter.showHiddenAssumption ? h.names : h.names.filter(n => !isInaccessibleName(n)) + const hNew: InteractiveHypothesisBundle = filter.showLetValue ? { ...h, names } : { ...h, names, val: undefined } + if (names.length !== 0) acc.push(hNew) + return acc + }, []) +} + +interface HypProps { + hyp: InteractiveHypothesisBundle + mvarId?: MVarId +} + +function Hyp({ hyp: h, mvarId }: HypProps) { + const locs = React.useContext(LocationsContext) + + const namecls: string = 'mr1 hyp-name ' + + (h.isInserted ? 'inserted-text ' : '') + + (h.isRemoved ? 'removed-text ' : '') + + const appendTypewriterInput = useAppendTypewriterInput() + const names = InteractiveHypothesisBundle_nonAnonymousNames(h).map((n, i) => + { + appendTypewriterInput(ev.shiftKey, n, h.isAssumption ?? false, h.isAssumption ?? false) + ev.stopPropagation() + } } + > + i ? + { mvarId, loc: { hyp: h.fvarIds[i] }} : + undefined + } + alwaysHighlight={false} + >{n} + ) + + const typeLocs: Locations | undefined = React.useMemo(() => + locs && mvarId && h.fvarIds && h.fvarIds.length > 0 ? + { ...locs, subexprTemplate: { mvarId, loc: { hypType: [h.fvarIds[0], ''] }}} : + undefined, + [locs, mvarId, h.fvarIds]) + + const valLocs: Locations | undefined = React.useMemo(() => + h.val && locs && mvarId && h.fvarIds && h.fvarIds.length > 0 ? + { ...locs, subexprTemplate: { mvarId, loc: { hypValue: [h.fvarIds[0], ''] }}} : + undefined, + [h.val, locs, mvarId, h.fvarIds]) + + return
+ {names} + :  + + + + {h.val && + +  :=  + } +
+} + +interface GoalProps2 { + goals: InteractiveGoal[] + filter: GoalFilterState +} + +interface GoalProps { + goal: InteractiveGoal + filter: GoalFilterState + showHints?: boolean + typewriter: boolean +} + +/** + * Displays the hypotheses, target type and optional case label of a goal according to the + * provided `filter`. */ +export const Goal = React.memo((props: GoalProps) => { + const [gameId] = useAtom(gameIdAtom) + const [typewriterMode] = useAtom(typewriterModeAtom) + const [{ data: gameInfo }] = useAtom(gameInfoAtom) + const [mobile] = useAtom(mobileAtom) + const { goal, filter, showHints, typewriter } = props + let { t } = useTranslation() + + // TODO: Apparently `goal` can be `undefined` + if (!goal) {return <>} + + const filteredList = getFilteredHypotheses(goal.hyps, filter); + const hyps = filter.reverse ? filteredList.slice().reverse() : filteredList; + const locs = React.useContext(LocationsContext) + const goalLocs = React.useMemo(() => + locs && goal.mvarId ? + { ...locs, subexprTemplate: { mvarId: goal.mvarId, loc: { target: '' }}} : + undefined, + [locs, goal.mvarId]) + const goalLi =
+ {(mobile || !typewriterMode) &&
{t("Goal")}:
} + + + +
+ + // let cn = 'font-code tl pre-wrap bl bw1 pl1 b--transparent ' + // if (props.goal.isInserted) cn += 'b--inserted ' + // if (props.goal.isRemoved) cn += 'b--removed ' + + function unbundleHyps (hyps: InteractiveHypothesisBundle[]) : InteractiveHypothesisBundle[] { + return hyps.flatMap(hyp => ( + gameInfo?.settings?.unbundleHyps ? hyp.names.map(name => {return {...hyp, names: [name]}}) : [hyp] + )) + } + + // const hints = + const objectHyps = unbundleHyps(hyps.filter(hyp => !hyp.isAssumption)) + const assumptionHyps = unbundleHyps(hyps.filter(hyp => hyp.isAssumption)) + + return <> + {/* {goal.userName &&
case {goal.userName}
} */} + {filter.reverse && goalLi} +
+ {! typewriter && objectHyps.length > 0 && +
{t("Objects")}:
+ {objectHyps.map((h, i) => )}
} + {!typewriter && assumptionHyps.length > 0 && +
{t("Assumptions")}:
+ {assumptionHyps.map((h, i) => { + h = {...h, val: undefined} // JE: never show value of assumptions (proof irrelevance) + return })}
} +
+ {!filter.reverse && <> + { (!mobile && typewriterMode) && +
+ + + + +
+ } + {goalLi} + } + {/* {showHints && hints} */} + +}) + +export const MainAssumptions = React.memo((props: GoalProps2) => { + let { t } = useTranslation() + const { goals, filter } = props + + const goal = goals[0] + const filteredList = getFilteredHypotheses(goal.hyps, filter); + const hyps = filter.reverse ? filteredList.slice().reverse() : filteredList; + const locs = React.useContext(LocationsContext) + + const goalLocs = React.useMemo(() => + locs && goal.mvarId ? + { ...locs, subexprTemplate: { mvarId: goal.mvarId, loc: { target: '' }}} : + undefined, + [locs, goal.mvarId]) + + const goalLi =
+
{t("Goal") + ":"}
+ + + +
+ + const objectHyps = hyps.filter(hyp => !hyp.isAssumption) + const assumptionHyps = hyps.filter(hyp => hyp.isAssumption) + + return
+
{t("Current Goal")}
+ {filter.reverse && goalLi} + { objectHyps.length > 0 && +
{t("Objects") + ":"}
+ {objectHyps.map((h, i) => )}
} + { assumptionHyps.length > 0 && +
+
{t("Assumptions") + ":"}
+ {assumptionHyps.map((h, i) => )} +
} +
+}) + +export const OtherGoals = React.memo((props: GoalProps2) => { + let { t } = useTranslation() + const { goals, filter } = props + return <> + {goals && goals.length > 1 && +
+
{t("Further Goals")}
+ {goals.slice(1).map((goal, i) => +
+ + + + +
)} +
} + +}) + +interface GoalsProps { + goals: InteractiveGoalsWithHints + filter: GoalFilterState +} + +export function Goals({ goals, filter }: GoalsProps) { + if (goals.goals.length === 0) { + return <> + } else { + return <> + {goals.goals.map((g, i) => )} + + } +} + +interface FilteredGoalsProps { + /** Components to render in the header. */ + headerChildren: React.ReactNode + /** + * When this is `undefined`, the component will not appear at all but will remember its state + * by virtue of still being mounted in the React tree. When it does appear again, the filter + * settings and collapsed state will be as before. */ + goals?: InteractiveGoalsWithHints +} + +/** + * Display goals together with a header containing the provided children as well as buttons + * to control how the goals are displayed. + */ +export const FilteredGoals = React.memo(({ headerChildren, goals }: FilteredGoalsProps) => { + const ec = React.useContext(EditorContext) + + const copyToCommentButton = + { + e.preventDefault(); + if (goals) void ec.copyToComment(goalsWithHintsToString(goals)) + }} + title="copy state to comment" /> + + const [goalFilters, setGoalFilters] = React.useState( + { reverse: false, showType: true, showInstance: true, showHiddenAssumption: true, showLetValue: true }); + + const sortClasses = 'link pointer mh2 dim codicon ' + (goalFilters.reverse ? 'codicon-arrow-up ' : 'codicon-arrow-down '); + const sortButton = + setGoalFilters(s => ({ ...s, reverse: !s.reverse }))} /> + + const mkFilterButton = (filterFn: React.SetStateAction, filledFn: (_: GoalFilterState) => boolean, name: string) => + { setGoalFilters(filterFn) }}> +   + {name} + + const filterMenu = + {mkFilterButton(s => ({ ...s, showType: !s.showType }), gf => gf.showType, 'types')} +
+ {mkFilterButton(s => ({ ...s, showInstance: !s.showInstance }), gf => gf.showInstance, 'instances')} +
+ {mkFilterButton(s => ({ ...s, showHiddenAssumption: !s.showHiddenAssumption }), gf => gf.showHiddenAssumption, 'hidden assumptions')} +
+ {mkFilterButton(s => ({ ...s, showLetValue: !s.showLetValue }), gf => gf.showLetValue, 'let-values')} +
+ + const isFiltered = !goalFilters.showInstance || !goalFilters.showType || !goalFilters.showHiddenAssumption || !goalFilters.showLetValue + const filterButton = + filterMenu}> + + + + return
+
+ + {headerChildren} + {copyToCommentButton}{sortButton}{filterButton} + +
+ {goals && } +
+
+
+}) + +export function loadGoals( + rpcSess: RpcSessionAtPos, + uri: string, + worldId: string, + levelId: number, + setProof: React.Dispatch>, + setCrashed: React.Dispatch>) { +console.info('sending rpc request to load the proof state') + +rpcSess.call('Game.getProofState', + { + ...DocumentPosition.toTdpp({line: 0, character: 0, uri: uri}), + worldId, levelId + } +).then( + (proof) => { + if (typeof proof !== 'undefined') { + console.info(`received a proof state!`) + console.log(proof) + setProof(proof as ProofState) + setCrashed(false) + } else { + console.warn('received undefined proof state!') + // Avoid transient crash state while the server warms up. + } + } +).catch((error) => { + if (error === 'No connection to Lean') { + console.warn(error) + return + } + setCrashed(true) + console.warn(error) +}) +} + + +export function lastStepHasErrors (proof : ProofState): boolean { + if (!proof?.steps.length) {return false} + + let diags = [...proof.steps[proof.steps.length - 1].diags, ...proof.diagnostics] + + return diags.some( + (d) => (d.severity == DiagnosticSeverity.Error ) // || d.severity == DiagnosticSeverity.Warning + ) +} + +export function isLastStepWithErrors (proof : ProofState, i: number): boolean { + if (!proof?.steps.length) {return false} + return (i == proof.steps.length - 1) && lastStepHasErrors(proof) +} diff --git a/infoview/info.tsx b/infoview/info.tsx new file mode 100644 index 00000000..32158a63 --- /dev/null +++ b/infoview/info.tsx @@ -0,0 +1,461 @@ +/* Mostly copied from https://github.com/leanprover/vscode-lean4/blob/master/lean4-infoview/src/infoview/info.tsx */ + +import * as React from 'react' +import { CircularProgress } from '@mui/material' +import type { Location, Diagnostic } from 'vscode-languageserver-protocol' +import { getInteractiveTermGoal, InteractiveDiagnostic, UserWidgetInstance, Widget_getWidgets, RpcSessionAtPos, isRpcError, + RpcErrorCode, getInteractiveDiagnostics } from '@leanprover/infoview-api' +import { basename, DocumentPosition, RangeHelpers, useEvent, usePausableState, discardMethodNotFound, + mapRpcError, useAsyncWithTrigger, PausableProps } from '../../../../node_modules/vscode-lean4/lean4-infoview/src/infoview/util' +import { ConfigContext, EditorContext, LspDiagnosticsContext, ProgressContext } from '../../../../node_modules/vscode-lean4/lean4-infoview/src/infoview/contexts' +import { PanelWidgetDisplay } from '../../../../node_modules/vscode-lean4/lean4-infoview/src/infoview/userWidget' +import { RpcContext, useRpcSessionAtPos } from '../../../../node_modules/vscode-lean4/lean4-infoview/src/infoview/rpcSessions' +import { GoalsLocation, Locations, LocationsContext } from '../../../../node_modules/vscode-lean4/lean4-infoview/src/infoview/goalLocation' + +import { AllMessages, lspDiagToInteractive } from './messages' +import { goalsToString, Goal, MainAssumptions, OtherGoals } from './goals' +import { InteractiveTermGoal, InteractiveGoals, ProofState } from './rpc_api' +import { MonacoEditorContext, InfoStatus } from './context' +import { useTranslation } from 'react-i18next' +import { useAtom } from 'jotai' +import { levelIdAtom, worldIdAtom } from '../client/src/store/location-atoms' +import { proofAtom, typewriterModeAtom } from '../client/src/store/editor-atoms' + +// TODO: All about pinning could probably be removed +type InfoKind = 'cursor' | 'pin' + +interface InfoPinnable { + kind: InfoKind + /** Takes an argument for caching reasons, but should only ever (un)pin itself. */ + onPin: (pos: DocumentPosition) => void +} + +interface InfoStatusBarProps extends InfoPinnable, PausableProps { + pos: DocumentPosition + status: InfoStatus + triggerUpdate: () => Promise +} + +const InfoStatusBar = React.memo((props: InfoStatusBarProps) => { + const { kind, onPin, status, pos, isPaused, setPaused, triggerUpdate } = props + + const ec = React.useContext(EditorContext) + + const statusColTable: {[T in InfoStatus]: string} = { + 'updating': 'gold ', + 'error': 'dark-red ', + 'ready': '', + } + const statusColor = statusColTable[status] + const locationString = `${basename(pos.uri)}:${pos.line+1}:${pos.character}` + const isPinned = kind === 'pin' + + return ( +
+ {locationString} + {isPinned && !isPaused && ' (pinned)'} + {!isPinned && isPaused && ' (paused)'} + {isPinned && isPaused && ' (pinned and paused)'} + + {isPinned && + { e.preventDefault(); void ec.revealPosition(pos); }} + title='reveal file location' />} + { e.preventDefault(); onPin(pos); }} + title={isPinned ? 'unpin' : 'pin'} /> + { e.preventDefault(); setPaused(!isPaused) }} + title={isPaused ? 'continue updating' : 'pause updating'} /> + { e.preventDefault(); void triggerUpdate() }} + title='update'/> + + + ) +}) + +interface InfoDisplayContentProps extends PausableProps { + pos: DocumentPosition + messages: InteractiveDiagnostic[] + goals?: InteractiveGoals + termGoal?: InteractiveTermGoal + error?: string + userWidgets: UserWidgetInstance[] + triggerUpdate: () => Promise + proofString? : string +} + +const InfoDisplayContent = React.memo((props: InfoDisplayContentProps) => { + let { t } = useTranslation() + const [typewriterMode, setTypewriterMode] = useAtom(typewriterModeAtom) + const {pos, messages, goals, termGoal, error, userWidgets, triggerUpdate, isPaused, setPaused, proofString} = props + + const hasWidget = userWidgets.length > 0 + const hasError = !!error + const hasMessages = messages.length !== 0 + + const nothingToShow = !hasError && !goals && !termGoal && !hasMessages && !hasWidget + + const [selectedLocs, setSelectedLocs] = React.useState([]) + React.useEffect(() => setSelectedLocs([]), [pos.uri, pos.line, pos.character]) + + const locs: Locations = React.useMemo(() => ({ + isSelected: (l: GoalsLocation) => selectedLocs.some(v => GoalsLocation.isEqual(v, l)), + setSelected: (l, act) => setSelectedLocs(ls => { + // We ensure that `selectedLocs` maintains its reference identity if the selection + // status of `l` didn't change. + const newLocs = ls.filter(v => !GoalsLocation.isEqual(v, l)) + const wasSelected = newLocs.length !== ls.length + const isSelected = typeof act === 'function' ? act(wasSelected) : act + if (isSelected) newLocs.push(l) + return wasSelected === isSelected ? ls : newLocs + }), + subexprTemplate: undefined + }), [selectedLocs]) + + const goalFilter = { reverse: false, showType: true, showInstance: true, showHiddenAssumption: true, showLetValue: true } + /* Adding {' '} to manage string literals properly: https://reactjs.org/docs/jsx-in-depth.html#string-literals-1 */ + + return <> + {hasError && + } + +
+ { goals && goals.goals.length > 0 && <> + + + } +
+
+ { goals && (goals.goals.length > 0 + ? + :
{t("No Goals")}
+ )} +
+
+ {goals && goals.goals.length > 0 && !(typewriterMode) && ( +
+
unsolved goals
+
{goalsToString(goals)}
+
+ )} + {userWidgets.map(widget => +
+ {widget.name} + +
+ )} + {nothingToShow && ( + isPaused ? + /* Adding {' '} to manage string literals properly: https://reactjs.org/docs/jsx-in-depth.html#string-literals-1 */ + Updating is paused.{' '} + { e.preventDefault(); void triggerUpdate() }}>Refresh + {' '}or { e.preventDefault(); setPaused(false) }}>resume updating + {' '}to see information. + : + <>
{t("Loading goal…")}
)} + + {/* + {goals && goals.goals.length > 1 &&
+
Weitere Goals
+ + {goals.goals.slice(1).map((goal, i) => +
)} +
} +
*/} + +}) + +interface InfoDisplayProps { + pos: DocumentPosition, + status: InfoStatus, + messages: InteractiveDiagnostic[], + proof?: ProofState, + goals?: InteractiveGoals, + termGoal?: InteractiveTermGoal, + error?: string, + userWidgets: UserWidgetInstance[], + rpcSess: RpcSessionAtPos, + triggerUpdate: () => Promise, +} + +/** Displays goal state and messages. Can be paused. */ +function InfoDisplay(props0: InfoDisplayProps & InfoPinnable) { + // Used to update the paused state *just once* if it is paused, + // but a display update is triggered + const [shouldRefresh, setShouldRefresh] = React.useState(false) + const [{ isPaused, setPaused }, props, propsRef] = usePausableState(false, props0) + if (shouldRefresh) { + propsRef.current = props0 + setShouldRefresh(false) + } + const triggerDisplayUpdate = async () => { + await props0.triggerUpdate() + setShouldRefresh(true) + } + + const {kind, goals, rpcSess} = props + + const ec = React.useContext(EditorContext) + + // If we are the cursor infoview, then we should subscribe to + // some commands from the editor extension + const isCursor = kind === 'cursor' + useEvent(ec.events.requestedAction, act => { + if (!isCursor) return + if (act.kind !== 'copyToComment') return + if (goals) void ec.copyToComment(goalsToString(goals)) + }, [goals]) + useEvent(ec.events.requestedAction, act => { + if (!isCursor) return + if (act.kind !== 'togglePaused') return + setPaused(isPaused => !isPaused) + }) + + const editor = React.useContext(MonacoEditorContext) + + return ( + + {/*
*/} + {/* */} +
+ +
+ {/*
*/} +
+ ) +} + +/** + * Note: in the cursor view, we have to keep the cursor position as part of the component state + * to avoid flickering when the cursor moved. Otherwise, the component is re-initialised and the + * goal states reset to `undefined` on cursor moves. + */ +export type InfoProps = InfoPinnable & { pos?: DocumentPosition } + +/** Fetches info from the server and renders an {@link InfoDisplay}. */ +export function Info(props: InfoProps) { + if (props.kind === 'cursor') return + else return +} + +function InfoAtCursor(props: InfoProps) { + const ec = React.useContext(EditorContext) + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const [curLoc, setCurLoc] = React.useState(ec.events.changedCursorLocation.current!) + useEvent(ec.events.changedCursorLocation, loc => loc && setCurLoc(loc), []) + const start = curLoc.range.start ?? { line: 0, character: 0 } + const pos = { + uri: curLoc.uri, + line: start.line ?? 0, + character: start.character ?? 0, + } + return +} + +function useIsProcessingAt(p: DocumentPosition): boolean { + const allProgress = React.useContext(ProgressContext) + const processing = allProgress.get(p.uri) + if (!processing) return false + return processing.some(i => RangeHelpers.contains(i.range, p)) +} + +function InfoAux(props: InfoProps) { + + const [, setProof] = useAtom(proofAtom) + + const config = React.useContext(ConfigContext) + + const [worldId] = useAtom(worldIdAtom) + const [levelId] = useAtom(levelIdAtom) + + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const pos = props.pos! + const rpcSess = useRpcSessionAtPos(pos) + + // Compute the LSP diagnostics at this info's position. We try to ensure that if these remain + // the same, then so does the identity of `lspDiagsHere` so that it can be used as a dep. + const lspDiags = React.useContext(LspDiagnosticsContext) + const [lspDiagsHere, setLspDiagsHere] = React.useState([]) + React.useEffect(() => { + // Note: the curly braces are important. https://medium.com/geekculture/react-uncaught-typeerror-destroy-is-not-a-function-192738a6e79b + setLspDiagsHere(diags0 => { + const diagPred = (d: Diagnostic) => + RangeHelpers.contains(d.range, pos, config.allErrorsOnLine) + const newDiags = (lspDiags.get(pos.uri) || []).filter(diagPred) + if (newDiags.length === diags0.length && newDiags.every((d, i) => d === diags0[i])) return diags0 + return newDiags + }) + }, [lspDiags, pos.uri, pos.line, pos.character, config.allErrorsOnLine]) + + const serverIsProcessing = useIsProcessingAt(pos) + + // This is a virtual dep of the info-requesting function. It is bumped whenever the Lean server + // indicates that another request should be made. Bumping it dirties the dep state of + // `useAsyncWithTrigger` below, causing the `useEffect` lower down in this component to + // make the request. We cannot simply call `triggerUpdateCore` because `useAsyncWithTrigger` + // does not support reentrancy like that. + const [updaterTick, setUpdaterTick] = React.useState(0) + + // For atomicity, we use a single update function that fetches all the info at `pos` at once. + // Besides what the server replies with, we also include the inputs (deps) in this type so + // that the displayed state cannot ever get out of sync by showing an old reply together + // with e.g. a new `pos`. + type InfoRequestResult = Omit + const [state, triggerUpdateCore] = useAsyncWithTrigger(() => new Promise((resolve, reject) => { + + const proofReq = rpcSess.call('Game.getProofState', { + ...DocumentPosition.toTdpp(pos), + worldId, levelId + }).catch((error) => { + console.warn(error) + }) + const goalsReq = rpcSess.call('Game.getInteractiveGoals', DocumentPosition.toTdpp(pos)) + const termGoalReq = getInteractiveTermGoal(rpcSess, DocumentPosition.toTdpp(pos)) + const widgetsReq = Widget_getWidgets(rpcSess, pos).catch(discardMethodNotFound) + const messagesReq = getInteractiveDiagnostics(rpcSess, {start: pos.line, end: pos.line+1}) + // fall back to non-interactive diagnostics when lake fails + // (see https://github.com/leanprover/vscode-lean4/issues/90) + .then(diags => diags.length === 0 ? lspDiagsHere.map(lspDiagToInteractive) : diags) + + // While `lake print-paths` is running, the output of Lake is shown as + // info diagnostics on line 1. However, all RPC requests block until + // Lake is finished, so we don't see these diagnostics while Lake is + // building. Therefore we show the LSP diagnostics on line 1 if the + // server does not respond within half a second. + if (pos.line === 0 && lspDiagsHere.length) { + setTimeout(() => resolve({ + pos, + status: 'updating', + messages: lspDiagsHere.map(lspDiagToInteractive), + proof: undefined, + goals: undefined, + termGoal: undefined, + error: undefined, + userWidgets: [], + rpcSess + }), 500) + } + + // NB: it is important to await await reqs at once, otherwise + // if both throw then one exception becomes unhandled. + Promise.all([proofReq, goalsReq, termGoalReq, widgetsReq, messagesReq]).then( + ([proof, goals, termGoal, userWidgets, messages]) => resolve({ + pos, + status: 'ready', + messages, + proof : proof as any, + goals: goals as any, + termGoal, + error: undefined, + userWidgets: userWidgets?.widgets ?? [], + rpcSess + }), + ex => { + if (ex?.code === RpcErrorCode.ContentModified || + ex?.code === RpcErrorCode.RpcNeedsReconnect) { + // Document has been changed since we made the request, or we need to reconnect + // to the RPC sessions. Try again. + setUpdaterTick(t => t + 1) + reject('retry') + } + + let errorString = '' + if (typeof ex === 'string') { + errorString = ex + } else if (isRpcError(ex)) { + errorString = mapRpcError(ex).message + } else if (ex instanceof Error) { + errorString = ex.toString() + } else { + errorString = `Unrecognized error: ${JSON.stringify(ex)}` + } + + resolve({ + pos, + status: 'error', + messages: lspDiagsHere.map(lspDiagToInteractive), + proof: undefined, + goals: undefined, + termGoal: undefined, + error: `Error fetching goals: ${errorString}`, + userWidgets: [], + rpcSess + }) + } + ) + }), [updaterTick, pos.uri, pos.line, pos.character, rpcSess, serverIsProcessing, lspDiagsHere]) + + // We use a timeout to debounce info requests. Whenever a request is already scheduled + // but something happens that warrants a request for newer info, we cancel the old request + // and schedule just the new one. + const updaterTimeout = React.useRef() + const clearUpdaterTimeout = () => { + if (updaterTimeout.current) { + window.clearTimeout(updaterTimeout.current) + updaterTimeout.current = undefined + } + } + const triggerUpdate = React.useCallback(() => new Promise(resolve => { + clearUpdaterTimeout() + const tm = window.setTimeout(() => { + void triggerUpdateCore().then(resolve) + updaterTimeout.current = undefined + }, config.debounceTime) + // Hack: even if the request is cancelled, the promise should resolve so that no `await` + // is left waiting forever. We ensure this happens in a simple way. + window.setTimeout(resolve, config.debounceTime) + updaterTimeout.current = tm + }), [triggerUpdateCore, config.debounceTime]) + + const [displayProps, setDisplayProps] = React.useState({ + pos, + status: 'updating', + messages: [], + proof: undefined, + goals: undefined, + termGoal: undefined, + error: undefined, + userWidgets: [], + rpcSess, + triggerUpdate + }) + + // Propagates changes in the state of async info requests to the display props, + // and re-requests info if needed. + // This effect triggers new requests for info whenever need. It also propagates changes + // in the state of the `useAsyncWithTrigger` to the displayed props. + React.useEffect(() => { + if (state.state === 'notStarted') + void triggerUpdate() + else if (state.state === 'loading') { + setDisplayProps(dp => ({ ...dp, status: 'updating' })) + } + else if (state.state === 'resolved') { + // if (state.value.goals?.goals?.length) { + // hintContext.setHints(state.value.goals.goals[0].hints) + // } + setDisplayProps({ ...state.value, triggerUpdate }) + + // Update the game's proof state + console.info('updating proof from editor mode.') + setProof(state.value.proof) + + } else if (state.state === 'rejected' && state.error !== 'retry') { + // The code inside `useAsyncWithTrigger` may only ever reject with a `retry` exception. + console.warn('Unreachable code reached with error: ', state.error) + } + }, [state]) + + return +} diff --git a/infoview/main.tsx b/infoview/main.tsx new file mode 100644 index 00000000..6b7c34e5 --- /dev/null +++ b/infoview/main.tsx @@ -0,0 +1,654 @@ +/* Partly copied from https://github.com/leanprover/vscode-lean4/blob/master/lean4-infoview/src/infoview/main.tsx */ + +import * as React from 'react'; +import type { DidCloseTextDocumentParams, DocumentUri } from 'vscode-languageserver-protocol'; + +import 'tachyons/css/tachyons.css'; +import '@vscode/codicons/dist/codicon.css'; +import '../../../../node_modules/vscode-lean4/lean4-infoview/src/infoview/index.css'; +import '../../css/infoview.css' +import "../../css/tab_bar.css" + +import { LeanFileProgressParams, LeanFileProgressProcessingInfo, defaultInfoviewConfig } from '@leanprover/infoview-api'; +import { useClientNotificationEffect, useEventResult, useServerNotificationEffect, useServerNotificationState } from '../../../../node_modules/vscode-lean4/lean4-infoview/src/infoview/util'; +import { EditorContext, ConfigContext, ProgressContext, VersionContext } from '../../../../node_modules/vscode-lean4/lean4-infoview/src/infoview/contexts'; +import { RpcContext, WithRpcSessions, useRpcSessionAtPos } from '../../../../node_modules/vscode-lean4/lean4-infoview/src/infoview/rpcSessions'; +import { ServerVersion } from '../../../../node_modules/vscode-lean4/lean4-infoview/src/infoview/serverVersion'; + +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { faDeleteLeft, faHome, faArrowRight } from '@fortawesome/free-solid-svg-icons' +import * as monaco from 'monaco-editor/esm/vs/editor/editor.api.js' + +import { Markdown } from '../client/src/components/markdown'; + +import { Infos } from './infos'; +import { Errors, WithLspDiagnosticsContext } from './messages'; +import { Goal, isLastStepWithErrors, lastStepHasErrors, loadGoals } from './goals'; +import { MonacoEditorContext } from './context'; +import { Typewriter, getInteractiveDiagsAt, hasInteractiveErrors } from './typewriter'; +import { Button } from '../client/src/components/button'; +import { CircularProgress } from '@mui/material'; +import { GameHint, InteractiveGoalsWithHints, ProofState } from './rpc_api'; +import { Hint, Hints, MoreHelpButton, filterHints } from '../client/src/components/hints'; +import { DocumentPosition } from '../../../../node_modules/vscode-lean4/lean4-infoview/src/infoview/util'; +import { DiagnosticSeverity } from 'vscode-languageclient'; +import { useTranslation } from 'react-i18next'; +import path from 'path'; +import { useGameTranslation } from '../client/src/utils/translation'; +import { useAtom } from 'jotai'; +import { gameIdAtom, levelIdAtom, worldIdAtom } from '../client/src/store/location-atoms'; +import { completedAtom } from '../client/src/store/progress-atoms'; +import { gameInfoAtom, levelInfoAtom } from '../client/src/store/query-atoms'; +import { crashedAtom, interimDiagsAtom, lockEditorModeAtom, proofAtom, typewriterContentAtom, typewriterModeAtom } from '../client/src/store/editor-atoms'; +import { inventoryAtom } from '../client/src/store/inventory-atoms'; +import { mobileAtom } from '../client/src/store/preferences-atoms'; +import { deletedChatAtom, helpAtom, selectedStepAtom } from '../client/src/store/chat-atoms'; + +/** Wrapper for the two editors. It is important that the `div` with `codeViewRef` is + * always present, or the monaco editor cannot start. + */ +export function DualEditor({ codeviewRef } : { codeviewRef: any }) { + const [typewriterMode] = useAtom(typewriterModeAtom) + const ec = React.useContext(EditorContext) + const showTypewriter = Boolean(ec) && typewriterMode + return <> +
+ +
+
+ {ec ? + : + // TODO: Style this if relevant. + <> + } + +} + +/** The part of the two editors that needs the editor connection first */ +function DualEditorMain() { + const ec = React.useContext(EditorContext) + const [gameId] = useAtom(gameIdAtom) + const [worldId] = useAtom(worldIdAtom) + const [levelId] = useAtom(levelIdAtom) + const [{ data: gameInfo }] = useAtom(gameInfoAtom) + const [{ data: levelInfo }] = useAtom(levelInfoAtom) + + const [, setCompleted] = useAtom(completedAtom) + + const [, addToInventory] = useAtom(inventoryAtom) + const [typewriterMode, setTypewriterMode] = useAtom(typewriterModeAtom) + + const [proof] = useAtom(proofAtom) + + React.useEffect(() => { + if (proof?.completed) { + setCompleted(true) + + // On completion, add the names of all new items to the local storage + let newTiles = [ + ...levelInfo?.tactics ?? [], + ...levelInfo?.lemmas ?? [], + ...levelInfo?.definitions ?? [] + ].filter((tile) => tile.new).map((tile) => tile.name) + + // Add the proven statement to the local storage as well. + if (levelInfo?.statementName != null) { + newTiles.push(levelInfo?.statementName) + } + addToInventory(newTiles) + } + }, [proof, levelInfo]) + + /* Set up updates to the global infoview state on editor events. */ + const config = useEventResult(ec.events.changedInfoviewConfig) ?? defaultInfoviewConfig; + + const [allProgress, _1] = useServerNotificationState( + '$/lean/fileProgress', + new Map(), + async (params: LeanFileProgressParams) => (allProgress) => { + const newProgress = new Map(allProgress); + return newProgress.set(params.textDocument.uri, params.processing); + }, []) + const serverVersion = useEventResult(ec.events.serverRestarted, result => new ServerVersion(result.serverInfo?.version ?? '')) + + return <> + + + + + + {(typewriterMode) ? + + : +
+ } + + + + + + +} + +// TODO: This is only used in `EditorInterface` +// while `TypewriterInterface` has this copy-pasted in. +export function Main() { + let { t } = useTranslation() + const { t: gT } = useGameTranslation() + const [lockEditorMode] = useAtom(lockEditorModeAtom) + const ec = React.useContext(EditorContext); + const [gameId] = useAtom(gameIdAtom) + const [worldId] = useAtom(worldIdAtom) + const [levelId] = useAtom(levelIdAtom) + const [{ data: gameInfo }] = useAtom(gameInfoAtom) + const [{ data: levelInfo }] = useAtom(levelInfoAtom) + const [help, setHelp] = useAtom(helpAtom) + + const [typewriterMode] = useAtom(typewriterModeAtom) + + const [proof, setProof] = useAtom(proofAtom) + const [, setCrashed] = useAtom(crashedAtom) + const [selectedStep, setSelectedStep] = useAtom(selectedStepAtom) + const editor = React.useContext(MonacoEditorContext) + const model = editor?.getModel() + const uri = model?.uri.toString() + const rpcSess = useRpcSessionAtPos({ uri: uri ?? '', line: 0, character: 0 }) + + React.useEffect(() => { + if (!uri || !worldId || !levelId) { + return + } + loadGoals(rpcSess, uri, worldId, levelId, setProof, setCrashed) + }, [rpcSess, uri, worldId, levelId, setProof, setCrashed]) + + function toggleSelection(line: number) { + return (ev: any) => { + console.debug('toggled selection') + if (selectedStep == line) { + setSelectedStep(undefined) + } else { + setSelectedStep(line) + } + } + } + //console.debug(`template: ${props.data?.template}`) + + // React.useEffect (() => { + // if (props.data.template) { + // let code: string = selectCode(gameId, worldId, levelId)(store.getState()) + // if (!code.length) { + // //models.push(monaco.editor.createModel(code, 'lean4', uri)) + // } + // } + // }, [props.data.template]) + + /* Set up updates to the global infoview state on editor events. */ + const config = useEventResult(ec.events.changedInfoviewConfig) ?? defaultInfoviewConfig; + + const [allProgress, _1] = useServerNotificationState( + '$/lean/fileProgress', + new Map(), + async (params: LeanFileProgressParams) => (allProgress) => { + const newProgress = new Map(allProgress); + return newProgress.set(params.textDocument.uri, params.processing); + }, + [] + ); + + const curUri = useEventResult(ec.events.changedCursorLocation, loc => loc?.uri); + + const curPos: DocumentPosition | undefined = + useEventResult(ec.events.changedCursorLocation, loc => loc ? { uri: loc.uri, ...loc.range.start } : undefined) + + React.useEffect(() => { + if (typewriterMode) { + return + } + if (!uri) { + return + } + loadGoals(rpcSess, uri, worldId!, levelId!, setProof, setCrashed) + }, [typewriterMode, lockEditorMode, uri, worldId, levelId, rpcSess, setProof, setCrashed]) + + useServerNotificationEffect('textDocument/publishDiagnostics', (params: any) => { + if (typewriterMode) { + return + } + if (!uri || params?.uri !== uri) { + return + } + loadGoals(rpcSess, uri, worldId!, levelId!, setProof, setCrashed) + }, [typewriterMode, lockEditorMode, uri, worldId, levelId, rpcSess, setProof, setCrashed]) + + const hintLine = (() => { + const isEditorMode = !(typewriterMode) + const curLine = Number.isFinite(curPos?.line) + ? curPos?.line + : (Number.isFinite((curPos as any)?._line) ? (curPos as any)._line : undefined) + const curChar = Number.isFinite(curPos?.character) + ? curPos.character + : (Number.isFinite((curPos as any)?._character) ? (curPos as any)._character : undefined) + if (isEditorMode && Number.isFinite(curLine)) { + const baseLine = curLine + return (curChar === 0 && baseLine > 0) ? baseLine - 1 : baseLine + } + if (Number.isFinite(selectedStep)) { + return selectedStep + } + if (!proof?.steps?.length) { + return 0 + } + const lastIndex = proof.steps.length - 1 + return lastStepHasErrors(proof) ? Math.max(0, lastIndex - 1) : lastIndex + })() + + const clampedHintLine = Math.max(0, Math.min(Number.isFinite(hintLine) ? hintLine : 0, (proof?.steps?.length ?? 1) - 1)) + const hintStepIndex = (() => { + if (Number.isFinite(selectedStep)) { + return selectedStep + } + if (!proof?.steps?.length) { + return 0 + } + const maxIndex = proof.steps.length - 1 + return Math.min(clampedHintLine + 1, maxIndex) + })() + const hintsToShow = (() => { + if (!proof?.steps?.length) { + return undefined + } + return proof.steps[hintStepIndex]?.goals?.[0]?.hints + })() + + // Effect when the cursor changes in the editor + React.useEffect(() => { + // TODO: this is a bit of a hack and will yield unexpected behaviour if lines + // are indented. + const newPos = curPos?.line + (curPos?.character == 0 ? 0 : 1) + + if (Number.isFinite(newPos)) { + // scroll the chat along + setSelectedStep(newPos) + } + }, [curPos]) + + useClientNotificationEffect( + 'textDocument/didClose', + (params: DidCloseTextDocumentParams) => { + if (ec.events.changedCursorLocation.current && + ec.events.changedCursorLocation.current.uri === params.textDocument.uri) { + ec.events.changedCursorLocation.fire(undefined) + } + }, + [] + ); + + const serverVersion = + useEventResult(ec.events.serverRestarted, result => new ServerVersion(result.serverInfo?.version ?? '')) + const serverStoppedResult = useEventResult(ec.events.serverStopped); + // NB: the cursor may temporarily become `undefined` when a file is closed. In this case + // it's important not to reconstruct the `WithBlah` wrappers below since they contain state + // that we want to persist. + let ret + if (serverStoppedResult) { + ret =

{serverStoppedResult.message}

{serverStoppedResult.reason}

+ } else { + ret =
+
+ {proof?.completedWithWarnings && +
+ {proof?.completed ? t("Level completed! 🎉") : t("Level completed with warnings 🎭")} +
+ } + +
+ {hintsToShow && ( + + )} + +
+ } + + return ret +} + +const goalFilter = { + reverse: false, + showType: true, + showInstance: true, + showHiddenAssumption: true, + showLetValue: true +} + +/** The display of a single entered lean command */ +function Command({ proof, i, deleteProof }: { proof: ProofState, i: number, deleteProof: any }) { + let {t} = useTranslation() + + // The first step will always have an empty command + if (!proof?.steps[i]?.command) { return <> } + + if (isLastStepWithErrors(proof, i)) { + // If the last step has errors, we display the command in a different style + // indicating that it will be removed on the next try. + return
+ {t("Failed command")}: {proof?.steps[i].command} +
+ } else { + return
+
{proof?.steps[i].command}
+ +
+ } +} + +/** The tabs of goals that lean ahs after the command of this step has been processed */ +function GoalsTabs({ proofStep, last, onClick, onGoalChange=(n)=>{}}: { proofStep: InteractiveGoalsWithHints, last : boolean, onClick? : any, onGoalChange?: (n?: number) => void }) { + let { t } = useTranslation() + const [mobile] = useAtom(mobileAtom) + const [selectedGoal, setSelectedGoal] = React.useState(0) + + if (proofStep.goals.length == 0) { + return <> + } + + return
+
+ {proofStep.goals.map((goal, i) => ( + // TODO: Should not use index as key. +
{ onGoalChange(i); setSelectedGoal(i); ev.stopPropagation() }}> + {i ? t("Goal") + ` ${i + 1}` : t("Active Goal")} +
+ ))} +
+
+ +
+
+} + +// Splitting up Typewriter into two parts is a HACK +export function TypewriterInterfaceWrapper() { + const ec = React.useContext(EditorContext) + + useClientNotificationEffect( + 'textDocument/didClose', + (params: DidCloseTextDocumentParams) => { + if (ec.events.changedCursorLocation.current && + ec.events.changedCursorLocation.current.uri === params.textDocument.uri) { + ec.events.changedCursorLocation.fire(undefined) + } + }, [] + ) + + const serverVersion = + useEventResult(ec.events.serverRestarted, result => new ServerVersion(result.serverInfo?.version ?? '')) + const serverStoppedResult = useEventResult(ec.events.serverStopped); + // NB: the cursor may temporarily become `undefined` when a file is closed. In this case + // it's important not to reconstruct the `WithBlah` wrappers below since they contain state + // that we want to persist. + + if (serverStoppedResult) { + return
+

{serverStoppedResult.message}

+

{serverStoppedResult.reason}

+
+ } + + return +} + +/** The interface in command line mode */ +export function TypewriterInterface() { + let { t } = useTranslation() + const ec = React.useContext(EditorContext) + const [gameId, navigateToGame] = useAtom(gameIdAtom) + const [worldId] = useAtom(worldIdAtom) + const [levelId, navigateToLevel] = useAtom(levelIdAtom) + const [{ data: gameInfo }] = useAtom(gameInfoAtom) + const [help, setHelp] = useAtom(helpAtom) + + const editor = React.useContext(MonacoEditorContext) + const model = editor?.getModel() + const uri = model?.uri.toString() ?? '' + + const worldSize = gameInfo?.worldSize?.[worldId ?? ""] ?? 0 + + const fallbackUri = `file:///${worldId}/${levelId}.lean` + const effectiveUri = uri || fallbackUri + let image: string | undefined = gameInfo?.worlds?.nodes[worldId!]?.image + + + const [disableInput, setDisableInput] = React.useState(false) + const [loadingProgress, setLoadingProgress] = React.useState(0) + const [, setDeletedChat] = useAtom(deletedChatAtom) + const [mobile] = useAtom(mobileAtom) + const [proof, setProof ] = useAtom(proofAtom) + const [crashed, setCrashed ] = useAtom(crashedAtom) + const [interimDiags ] = useAtom(interimDiagsAtom) + + const [, setTypewriter] = useAtom(typewriterContentAtom) + const [selectedStep, setSelectedStep] = useAtom(selectedStepAtom) + + const proofPanelRef = React.useRef(null) + // const config = useEventResult(ec.events.changedInfoviewConfig) ?? defaultInfoviewConfig; + // const curUri = useEventResult(ec.events.changedCursorLocation, loc => loc?.uri); + + const rpcSess = useRpcSessionAtPos({uri: effectiveUri, line: 0, character: 0}) + + React.useEffect(() => { + if (!effectiveUri) { + return + } + setCrashed(false) + loadGoals(rpcSess, effectiveUri, worldId!, levelId!, setProof, setCrashed) + }, [rpcSess, effectiveUri, worldId, levelId, setProof, setCrashed]) + + /** Delete all proof lines starting from a given line. + * Note that the first line (i.e. deleting everything) is `1`! + */ + function deleteProof(line: number) { + return (ev: any) => { + let deletedChat: Array = [] + proof?.steps.slice(line).map((step, i) => { + let filteredHints = filterHints(step.goals[0]?.hints, proof?.steps[i-1]?.goals[0]?.hints) + + // Only add these hidden hints to the deletion stack which were visible + deletedChat = [...deletedChat, ...filteredHints.filter(hint => (!hint.hidden || help.has(line + i)))] + }) + setDeletedChat(deletedChat) + + // delete showHelp for deleted steps + setHelp(new Set(Array.from(help).filter(i => i < line - 1))) + + editor.executeEdits("typewriter", [{ + range: monaco.Selection.fromPositions( + { lineNumber: line, column: 1 }, + editor.getModel()?.getFullModelRange().getEndPosition() + ), + text: '', + forceMoveMarkers: false + }]) + setSelectedStep(undefined) + setTypewriter(proof?.steps[line].command ?? "") + // Reload proof on deleting + loadGoals(rpcSess, uri, worldId!, levelId!, setProof, setCrashed) + ev.stopPropagation() + } + } + + function toggleSelectStep(line: number) { + return (ev: any) => { + if (mobile) {return} + if (selectedStep == line) { + setSelectedStep(undefined) + console.debug(`unselected step`) + } else { + setSelectedStep(line) + console.debug(`step ${line} selected`) + } + } + } + + // Scroll to the end of the proof if it is updated. + React.useEffect(() => { + if (proof?.steps?.length && proof?.steps?.length > 1) { + proofPanelRef.current?.lastElementChild?.scrollIntoView() //scrollTo(0,0) + } else { + proofPanelRef.current?.scrollTo(0,0) + } + // also reenable the commandline when the proof changes + + // BUG: If selecting 2nd goal on a intermediate proofstep and then delete proof to there, + // the commandline is not displaying disabled even though it should. + setDisableInput(false) + }, [proof]) + + // Scroll to element if selection changes + React.useEffect(() => { + if (typeof selectedStep !== 'undefined') { + Array.from(proofPanelRef.current?.getElementsByClassName(`step-${selectedStep}`)).map((elem) => { + elem.scrollIntoView({ block: "center" }) + }) + } + }, [selectedStep]) + + // TODO: superfluous, can be replaced with `withErr` from above + let lastStepErrors = proof?.steps.length ? hasInteractiveErrors(getInteractiveDiagsAt(proof, proof?.steps.length)) : false + + + useServerNotificationEffect("$/game/loading", (params : any) => { + if (params.kind == "loadConstants") { + setLoadingProgress(params.counter/100*50) + } else if (params.kind == "finalizeExtensions") { + setLoadingProgress(50 + params.counter/150*50) + } else { + console.error(`Unknown loading kind: ${params.kind}`) + } + }) + + let introText: Array = t(gameInfo?.introduction ?? "", {ns: gameId}).split(/\n(\s*\n)+/) + + return
+ +
+
+ {image && + + } + +
+
+ {/*
+ +
*/} +
+
+ + {((crashed && (interimDiags.length > 0 || proof?.steps.length > 0))) ?
+

{t("Crashed! Go to editor mode and fix your proof! Last server response:")}

+ {interimDiags.map((diag, index) => { + const severityClass = diag.severity ? { + [DiagnosticSeverity.Error]: 'error', + [DiagnosticSeverity.Warning]: 'warning', + [DiagnosticSeverity.Information]: 'information', + [DiagnosticSeverity.Hint]: 'hint', + }[diag.severity] : ''; + + return
+
+

{t("Line")} {diag.range.start.line}, {t("Character")} {diag.range.start.character}

+
+                  {diag.message}
+                
+
+
+ })} + +
: proof?.steps.length ? + <> + {proof?.steps.map((step, i) => { + let filteredHints = filterHints(step.goals[0]?.hints, proof?.steps[i-1]?.goals[0]?.hints) + + // if (i == proof?.steps.length - 1 && hasInteractiveErrors(step.diags)) { + // // if the last command contains an error, we only display the errors but not the + // // entered command as it is still present in the command line. + // // TODO: Should not use index as key. + // return
+ // + //
+ // } else { + return
+ + + {mobile && i == 0 && gameInfo?.introduction && + introText?.filter(it => it.trim()).map(((it, i) => + // Show the level's intro text as hints, too + + )) + } + {mobile && + + } + {/* setDisableInput(n > 0) : (n) => {}}/> */} + {!(isLastStepWithErrors(proof, i)) && + setDisableInput(n > 0) : (n) => {}}/> + } + {mobile && i == proof?.steps.length - 1 && + + } + + {/* Show a message that there are no goals left */} + {/* {!step.goals.length && ( +
+ {proof?.completed ? +

Level completed! 🎉

: +

+ no goals left
+ This probably means you solved the level with warnings or Lean encountered a parsing error. +

+ } +
+ )} */} +
+ } + //} + )} + {proof?.diagnostics.length > 0 && +
+ +
+ } + {mobile && proof?.completed && +
+ {levelId! >= worldSize ? + + : + + } +
+ } + : + + // + // note: since we don't know the total number of files, + // we use a function which strictly monotonely increases towards `100` as `x → ∞` + // The base is chosen at random s.t. we get roughly 91% for `x = 100`. + } +
+
+ +
+
+} diff --git a/infoview/messages.tsx b/infoview/messages.tsx new file mode 100644 index 00000000..f384dcd5 --- /dev/null +++ b/infoview/messages.tsx @@ -0,0 +1,241 @@ +import * as React from 'react' +import fastIsEqual from 'react-fast-compare' +import { Location, DocumentUri, Diagnostic, DiagnosticSeverity, PublishDiagnosticsParams } from 'vscode-languageserver-protocol' + +import { LeanDiagnostic, RpcErrorCode, getInteractiveDiagnostics, InteractiveDiagnostic, TaggedText_stripTags } from '@leanprover/infoview-api' + +import { basename, escapeHtml, usePausableState, useEvent, addUniqueKeys, DocumentPosition, useServerNotificationState, useEventResult } from '../../../../node_modules/vscode-lean4/lean4-infoview/src/infoview/util' +import { ConfigContext, EditorContext, LspDiagnosticsContext, VersionContext } from '../../../../node_modules/vscode-lean4/lean4-infoview/src/infoview/contexts' +import { Details } from '../../../../node_modules/vscode-lean4/lean4-infoview/src/infoview/collapsing' +import { InteractiveMessage } from '../../../../node_modules/vscode-lean4/lean4-infoview/src/infoview/traceExplorer' +import { RpcContext, useRpcSessionAtPos } from '../../../../node_modules/vscode-lean4/lean4-infoview/src/infoview/rpcSessions' + +import { useTranslation } from 'react-i18next' +import { useAtom } from 'jotai' +import { typewriterModeAtom } from '../client/src/store/editor-atoms' + +interface MessageViewProps { + uri: DocumentUri; + diag: InteractiveDiagnostic; +} + +/** A list of messages (info/warning/error) that are produced after this command */ +function Error({error, typewriterMode} : {error : InteractiveDiagnostic, typewriterMode : boolean}) { + // The first step will always have an empty command + + const severityClass = error.severity ? { + [DiagnosticSeverity.Error]: 'error', + [DiagnosticSeverity.Warning]: 'warning', + [DiagnosticSeverity.Information]: 'information', + [DiagnosticSeverity.Hint]: 'hint', + }[error.severity] : ''; + + const {line, character} = error.range.start; + const title = `Line ${line+1}, Character ${character}`; + + // Hide "unsolved goals" messages + let message; + if ("append" in error.message && "text" in error.message.append[0] && + error.message?.append[0].text === "unsolved goals") { + message = error.message.append[0] + } else { + message = error.message + } + + return
+ {!typewriterMode &&

{title}

} +
+      
+    
+
+} + +// TODO: Should not use index as key. +/** A list of messages (info/warning/error) that are produced after this command */ +export function Errors ({errors, typewriterMode} : {errors : InteractiveDiagnostic[], typewriterMode : boolean}) { + return
+ {errors.map((err, i) => ())} +
+} + +const MessageView = React.memo(({uri, diag}: MessageViewProps) => { + const [typewriterMode, setTypewriterMode] = useAtom(typewriterModeAtom) + const ec = React.useContext(EditorContext); + const fname = escapeHtml(basename(uri)); + const {line, character} = diag.range.start; + const loc: Location = { uri, range: diag.range }; + const text = TaggedText_stripTags(diag.message); + const severityClass = diag.severity ? { + [DiagnosticSeverity.Error]: 'error', + [DiagnosticSeverity.Warning]: 'warning', + [DiagnosticSeverity.Information]: 'information', + [DiagnosticSeverity.Hint]: 'hint', + }[diag.severity] : ''; + const title = `Line ${line+1}, Character ${character}`; + + // Hide "unsolved goals" messages + let message; + if ("append" in diag.message && "text" in diag.message.append[0] && + diag.message?.append[0].text === "unsolved goals") { + message = diag.message.append[0] + } else { + message = diag.message + } + + return ( + //
+ // {title} + // + // { e.preventDefault(); void ec.revealLocation(loc); }} + // title="reveal file location"> + // {e.preventDefault(); void ec.copyToComment(text)}} + // title="copy message to comment"> + // {e.preventDefault(); void ec.api.copyToClipboard(text)}} + // title="copy message to clipboard"> + // + // +
+ {!(typewriterMode) &&

{title}

} +
+                
+            
+
+ //
+ ) +}, fastIsEqual) + +function mkMessageViewProps(uri: DocumentUri, messages: InteractiveDiagnostic[]): MessageViewProps[] { + const views: MessageViewProps[] = messages + .sort((msga, msgb) => { + const a = msga.fullRange?.end || msga.range.end; + const b = msgb.fullRange?.end || msgb.range.end; + return a.line === b.line ? a.character - b.character : a.line - b.line + }).map(m => { + return { uri, diag: m }; + }); + + return addUniqueKeys(views, v => DocumentPosition.toString({uri: v.uri, ...v.diag.range.start})); +} + +/** Shows the given messages assuming they are for the given file. */ +export const MessagesList = React.memo(({uri, messages}: {uri: DocumentUri, messages: InteractiveDiagnostic[]}) => { + const should_hide = messages.length === 0; + if (should_hide) { return <> } + + return ( +
+ {mkMessageViewProps(uri, messages).map(m => )} +
+ ); +}) + +function lazy(f: () => T): () => T { + let state: {t: T} | undefined + return () => { + if (!state) state = {t: f()} + return state.t + } +} + +/** Displays all messages for the specified file. Can be paused. */ +export function AllMessages() { + const ec = React.useContext(EditorContext); + const sv = React.useContext(VersionContext); + const curPos: DocumentPosition | undefined = + useEventResult(ec.events.changedCursorLocation, loc => loc ? { uri: loc.uri, ...loc.range.start } : undefined) + const rs0 = useRpcSessionAtPos({ uri: curPos.uri, line: 0, character: 0 }); + const dc = React.useContext(LspDiagnosticsContext); + const config = React.useContext(ConfigContext); + const diags0 = dc.get(curPos.uri) || []; + + const iDiags0 = React.useMemo(() => lazy(async () => { + try { + const diags = await getInteractiveDiagnostics(rs0); + if (diags.length > 0) { + return diags + } + } catch (err: any) { + if (err?.code === RpcErrorCode.ContentModified) { + // Document has been changed since we made the request. This can happen + // while typing quickly. When the server catches up on next edit, it will + // send new diagnostics to which the infoview responds by calling + // `getInteractiveDiagnostics` again. + } else { + console.log('getInteractiveDiagnostics error ', err) + } + } + return diags0.map(d => ({ ...(d as LeanDiagnostic), message: { text: d.message } })); + }), [sv, rs0, curPos.uri, diags0]); + const [{ isPaused, setPaused }, [uri, rs, diags, iDiags], _] = usePausableState(false, [curPos.uri, rs0, diags0, iDiags0]); + + // Fetch interactive diagnostics when we're entering the paused state + // (if they haven't already been fetched before) + React.useEffect(() => { if (isPaused) { void iDiags() } }, [iDiags, isPaused]); + + const setOpenRef = React.useRef>>(); + useEvent(ec.events.requestedAction, act => { + if (act.kind === 'toggleAllMessages' && setOpenRef.current !== undefined) { + setOpenRef.current(t => !t); + } + }); + + return ( + + {/*
+ + All Messages ({diags.length}) + + { e.preventDefault(); setPaused(p => !p); }} + title={isPaused ? 'continue updating' : 'pause updating'}> + + + */} + + {/*
*/} +
+ ) +} + +/** We factor out the body of {@link AllMessages} which lazily fetches its contents only when expanded. */ +function AllMessagesBody({uri, curPos, messages}: {uri: DocumentUri, curPos: DocumentPosition | undefined , messages: () => Promise}) { + let { t } = useTranslation() + const [msgs, setMsgs] = React.useState(undefined) + React.useEffect(() => { void messages().then( + msgs => setMsgs(msgs.filter((d) => { + const text = TaggedText_stripTags(d.message) + if (text.includes('unsolved goals')) { + return true + } + // Only show the messages from the line where the cursor is. + return d.range.start.line == curPos.line + })) + ) }, [messages, curPos]) + if (msgs === undefined) return
{t("Loading messages…")}
+ else return +} + +/** + * Provides a `LspDiagnosticsContext` which stores the latest version of the + * diagnostics as sent by the publishDiagnostics notification. + */ +export function WithLspDiagnosticsContext({children}: React.PropsWithChildren<{}>) { + const [allDiags, _0] = useServerNotificationState( + 'textDocument/publishDiagnostics', + new Map(), + async (params: PublishDiagnosticsParams) => diags => + new Map(diags).set(params.uri, params.diagnostics), + [] + ) + + return {children} +} + +/** Embeds a non-interactive diagnostic into the type `InteractiveDiagnostic`. */ +export function lspDiagToInteractive(diag: Diagnostic): InteractiveDiagnostic { + return { ...(diag as LeanDiagnostic), message: { text: diag.message } }; +} diff --git a/infoview/rpc_api.ts b/infoview/rpc_api.ts new file mode 100644 index 00000000..aebdd8be --- /dev/null +++ b/infoview/rpc_api.ts @@ -0,0 +1,83 @@ +/** + * @fileOverview Defines the interface for the communication with the server. + * + * This file is based on `vscode-lean4/vscode-lean4/src/rpcApi.ts` + */ +import type { Range } from 'vscode-languageserver-protocol'; +import type { ContextInfo, FVarId, CodeWithInfos, MVarId } from '@leanprover/infoview-api'; +import { InteractiveDiagnostic, TermInfo } from '@leanprover/infoview/*'; + +export interface InteractiveHypothesisBundle { + /** The pretty names of the variables in the bundle. Anonymous names are rendered + * as `"[anonymous]"` whereas inaccessible ones have a `✝` appended at the end. + * Use `InteractiveHypothesisBundle_nonAnonymousNames` to filter anonymouse ones out. */ + names: string[]; + fvarIds?: FVarId[]; + type: CodeWithInfos; + val?: CodeWithInfos; + isInstance?: boolean; + isType?: boolean; + isInserted?: boolean; + isRemoved?: boolean; + isAssumption?: boolean; +} + +export interface InteractiveGoalCore { + hyps: InteractiveHypothesisBundle[]; + type: CodeWithInfos; + ctx?: ContextInfo; +} + +export interface InteractiveGoal extends InteractiveGoalCore { + userName?: string; + goalPrefix?: string; + mvarId?: MVarId; + isInserted?: boolean; + isRemoved?: boolean; +} + +export interface InteractiveGoals extends InteractiveGoalCore { + goals: InteractiveGoals[]; +} + +export interface InteractiveTermGoal extends InteractiveGoalCore { + range?: Range; + term?: TermInfo; +} + +export interface GameHint { + text: string; + hidden: boolean; + rawText: string; + varNames: string[][]; // in Lean: `Array (Name × Name)` +} + +export interface InteractiveGoalWithHints { + goal: InteractiveGoal; + hints: GameHint[]; +} + +export interface InteractiveGoalsWithHints { + goals: InteractiveGoalWithHints[]; + command: string; + diags: InteractiveDiagnostic[]; +} + +/** + * The proof state as it is received from the server. + * Per proof step of the tactic proof, there is one `InteractiveGoalWithHints[]`. + */ +export interface ProofState { + /** The proof steps. step 0 is the state at the beginning of the proof. step one + * contains the goal after the first line has been evaluated. + * + * In particular `step[i]` is the proof step at the beginning of line `i` in vscode. + */ + steps: InteractiveGoalsWithHints[]; + /** The remaining diagnostics that are not in the steps. Usually this should only + * be the "unsolved goals" message, I believe. + */ + diagnostics : InteractiveDiagnostic[]; + completed : boolean; + completedWithWarnings : boolean; +} diff --git a/infoview/typewriter.tsx b/infoview/typewriter.tsx new file mode 100644 index 00000000..6438833a --- /dev/null +++ b/infoview/typewriter.tsx @@ -0,0 +1,235 @@ +import * as React from 'react' +import { useRef, useState, useEffect } from 'react' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { faWandMagicSparkles } from '@fortawesome/free-solid-svg-icons' +import * as monaco from 'monaco-editor/esm/vs/editor/editor.api.js' +import { DiagnosticSeverity, PublishDiagnosticsParams, DocumentUri } from 'vscode-languageserver-protocol'; +import { useServerNotificationEffect } from '../../../../node_modules/vscode-lean4/lean4-infoview/src/infoview/util'; +import { InteractiveDiagnostic } from '@leanprover/infoview-api'; +import { Diagnostic } from 'vscode-languageserver-types'; +import { RpcContext } from '../../../../node_modules/vscode-lean4/lean4-infoview/src/infoview/rpcSessions'; +import { MonacoEditorContext } from './context' +import { lastStepHasErrors, loadGoals } from './goals' +import { ProofState } from './rpc_api' +import { useTranslation } from 'react-i18next' +import { useAtom } from 'jotai' +import { levelIdAtom, worldIdAtom } from '../client/src/store/location-atoms' +import { preferencesAtom } from '../client/src/store/preferences-atoms' +import { crashedAtom, interimDiagsAtom, proofAtom, typewriterContentAtom } from '../client/src/store/editor-atoms' +import { deletedChatAtom } from '../client/src/store/chat-atoms' + +export interface GameDiagnosticsParams { + uri: DocumentUri; + diagnostics: Diagnostic[]; +} + +/** The input field */ +export function Typewriter({disabled}: {disabled?: boolean}) { + let { t } = useTranslation() + + /** Reference to the hidden multi-line editor */ + const editor = React.useContext(MonacoEditorContext) + const model = editor?.getModel() + const uri = model?.uri.toString() ?? '' + const hasEditor = Boolean(editor && model) + + const [worldId] = useAtom(worldIdAtom) + const [levelId] = useAtom(levelIdAtom) + + const [oneLineEditor, setOneLineEditor] = useState() + const oneLineEditorRef = useRef(null) + const [processing, setProcessing] = useState(false) + + const [typewriter, setTypewriter] = useAtom(typewriterContentAtom) + + const inputRef = useRef() + + const [proof, setProof] = useAtom(proofAtom) + const [interimDiags, setInterimDiags] = useAtom(interimDiagsAtom) + const [, setCrashed] = useAtom(crashedAtom) + + // state to store the last batch of deleted messages + const [, setDeletedChat] = useAtom(deletedChatAtom) + + const rpcSess = React.useContext(RpcContext) + + // Run the command + const runCommand = React.useCallback(() => { + if (processing || !hasEditor) {return} + + // TODO: Desired logic is to only reset this after a new *error-free* command has been entered + setDeletedChat([]) + + const pos = editor.getPosition() + if (typewriter) { + setProcessing(true) + editor.executeEdits("typewriter", [{ + range: monaco.Selection.fromPositions( + pos, + editor.getModel()?.getFullModelRange().getEndPosition() + ), + text: typewriter.trim() + "\n", + forceMoveMarkers: false + }]) + setTypewriter('') + // Load proof after executing edits + loadGoals(rpcSess, uri, worldId!, levelId!, setProof, setCrashed) + } + + editor.setPosition(pos) + }, [typewriter, editor]) + + const [{ isSuggestionsMobileMode }] = useAtom(preferencesAtom) + + useEffect(() => { + if (oneLineEditor && oneLineEditor.getValue() !== typewriter) { + oneLineEditor.setValue(typewriter) + oneLineEditor.setPosition({ column: typewriter.length + 1, lineNumber: 1 }) + isSuggestionsMobileMode || oneLineEditor.focus() + } + }, [typewriter]) + + useEffect(() => { + if (oneLineEditor && hasEditor) { + oneLineEditor.setPosition({ column: editor.getValue().length + 1, lineNumber: 1 }) + isSuggestionsMobileMode || oneLineEditor.focus() + } + }, [oneLineEditor, hasEditor, isSuggestionsMobileMode, editor]) + + /** If the last step has an error, add the command to the typewriter. */ + useEffect(() => { + if (lastStepHasErrors(proof)) { + setTypewriter(proof?.steps[proof?.steps.length - 1].command) + } + }, [proof]) + + // React when answer from the server comes back + useServerNotificationEffect('textDocument/publishDiagnostics', (params: PublishDiagnosticsParams) => { + if (!hasEditor) { + return + } + if (params.uri == uri) { + setProcessing(false) + + const seriousDiags = params.diagnostics.filter(diag => + diag.severity === DiagnosticSeverity.Error || diag.severity === DiagnosticSeverity.Warning + ) + setInterimDiags(seriousDiags) + // loadGoals(rpcSess, uri, worldId, levelId, setProof, setCrashed) + + // TODO: loadAllGoals() + if (!hasErrors(params.diagnostics)) { + //setTypewriterInput("") + editor.setPosition(editor.getModel().getFullModelRange().getEndPosition()) + } + } else { + // console.debug(`expected uri: ${uri}, got: ${params.uri}`) + // console.debug(params) + } + // TODO: This is the wrong place apparently. Where do wee need to load them? + // TODO: instead of loading all goals every time, we could only load the last one + // loadAllGoals() + }, [uri, hasEditor, editor]); + + // // React when answer from the server comes back + // useServerNotificationEffect('$/game/publishDiagnostics', (params: GameDiagnosticsParams) => { + // console.log('Received game diagnostics') + // console.log(`diag. uri : ${params.uri}`) + // console.log(params.diagnostics) + + // }, [uri]); + + + useEffect(() => { + if (!oneLineEditor) return + // Ensure that our one-line editor can only have a single line + const l = oneLineEditor.getModel()?.onDidChangeContent((e) => { + const value = oneLineEditor.getValue() + setTypewriter(value) + const newValue = value.replace(/[\n\r]/g, '') + if (value != newValue) { + oneLineEditor.setValue(newValue) + } + }) + return () => { + if (typeof (l as any)?.dispose === 'function') { + l.dispose() + } else if (typeof l === 'function') { + l() + } + } + }, [oneLineEditor, setTypewriter]) + + useEffect(() => { + if (!oneLineEditor) return + // Run command when pressing enter (and block newline insertion) + const l = oneLineEditor.onKeyDown((ev) => { + if (ev.code === "Enter" || ev.code === "NumpadEnter") { + ev.preventDefault() + runCommand() + } + }) + return () => { + if (typeof (l as any)?.dispose === 'function') { + l.dispose() + } else if (typeof l === 'function') { + l() + } + } + }, [oneLineEditor, runCommand]) + + // // BUG: Causes `file closed` error + // //TODO: Intention is to run once when loading, does that work? + // useEffect(() => { + // console.debug(`time to update: ${uri} \n ${rpcSess}`) + // console.debug(rpcSess) + // // console.debug('LOAD ALL GOALS') + // // TODO: loadAllGoals() + // }, [rpcSess]) + + /** Process the entered command */ + const handleSubmit : React.FormEventHandler = (ev) => { + ev.preventDefault() + runCommand() + } + + // do not display if the proof is completed (with potential warnings still present) + return
+
+
+
+
+ + +
+} + +/** Checks whether the diagnostics contain any errors or warnings to check whether the level has + been completed.*/ +export function hasErrors(diags: Diagnostic[]) { + return diags.some( + (d) => + !d.message.startsWith("unsolved goals") && + (d.severity == DiagnosticSeverity.Error ) // || d.severity == DiagnosticSeverity.Warning + ) +} + +// TODO: Didn't manage to unify this with the one above +export function hasInteractiveErrors (diags: InteractiveDiagnostic[]) { + return (typeof diags !== 'undefined') && diags.some( + (d) => (d.severity == DiagnosticSeverity.Error ) // || d.severity == DiagnosticSeverity.Warning + ) +} + +export function getInteractiveDiagsAt (proof: ProofState, k : number) { + if (k == 0) { + return [] + } else if (k >= proof?.steps.length-1) { + // TODO: Do we need that? + return proof?.diagnostics.filter(msg => msg.range.start.line >= proof?.steps.length-1) + } else { + return proof?.diagnostics.filter(msg => msg.range.start.line == k-1) + } +} From 889382edced2ec4b88a7aa0dd259fff844f2e647 Mon Sep 17 00:00:00 2001 From: matlorr Date: Fri, 10 Apr 2026 10:09:33 +0200 Subject: [PATCH 03/36] Fix typo in import statement --- client/src/components/infoview/GameEditor.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/components/infoview/GameEditor.tsx b/client/src/components/infoview/GameEditor.tsx index 6a85b2f4..a6986f01 100644 --- a/client/src/components/infoview/GameEditor.tsx +++ b/client/src/components/infoview/GameEditor.tsx @@ -2,7 +2,7 @@ import { useAtom } from "jotai" import { leanMonacoAtom, typewriterModeAtom } from "../../store/editor-atoms" import React, { useEffect, useLayoutEffect, useRef } from "react" import { ExerciseStatement } from "./ExerciseStatement" -import { Typewriter } from "./Typewriter" +import { Typewriter } from "./typewriter" /** * Note: It is important that the `div` with `codeViewRef` is From cc7e45076efa5c4307d54a8785128720fc02ef17 Mon Sep 17 00:00:00 2001 From: matlorr Date: Fri, 10 Apr 2026 17:38:01 +0200 Subject: [PATCH 04/36] Non-dynamic textbox in typewriter mode --- client/src/components/infoview/typewriter.tsx | 99 ++++++++++++++- client/src/store/editor-atoms.ts | 119 +++++++++++++++++- package-lock.json | 70 +++-------- 3 files changed, 229 insertions(+), 59 deletions(-) diff --git a/client/src/components/infoview/typewriter.tsx b/client/src/components/infoview/typewriter.tsx index d32eb42b..37f46adf 100644 --- a/client/src/components/infoview/typewriter.tsx +++ b/client/src/components/infoview/typewriter.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef, useState } from "react"; +import React, { MutableRefObject, useEffect, useRef, useState } from "react"; import "../../css/typewriter.css" import { useTranslation } from "react-i18next"; @@ -6,7 +6,14 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faWandMagicSparkles } from "@fortawesome/free-solid-svg-icons"; import { useAtom } from "jotai"; import { gameInfoAtom } from "../../store/query-atoms"; -import { gameIdAtom, worldIdAtom } from "../../store/location-atoms"; + +import { DiagnosticSeverity, PublishDiagnosticsParams, DocumentUri } from 'vscode-languageserver-protocol'; +import * as monaco from 'monaco-editor' +import { levelIdAtom, gameIdAtom, worldIdAtom } from "../../store/location-atoms"; +import { leanMonacoEditorAtom, typewriterContentAtom, interimDiagsAtom, crashedAtom, leanMonacoEditorModelAtom, leanMonacoEditorUriAtom, hasLeanMonacoEditorAtom, lastProofStepErrorCommandAtom, restoreErrorCommandEffect, oneLineEditorAtom, syncTypewriterToEditorEffect, syncEditorPositionEffect } from "../../store/editor-atoms"; +import { deletedChatAtom } from '../../store/chat-atoms' +import { preferencesAtom } from '../../store/preferences-atoms' + import path from "node:path"; import { proofAtom } from "../../store/editor-atoms"; import { ExerciseStatement } from "./ExerciseStatement"; @@ -50,11 +57,13 @@ export function TypewriterCommandLine() { // runCommand() } + let inputRef = getInputRef() + // do not display if the proof is completed (with potential warnings still present) return
-
+
} + +export function getInputRef() { + const [typewriter, setTypewriter] = useAtom(typewriterContentAtom) + const [oneLineEditor, setOneLineEditor] = useAtom(oneLineEditorAtom) + // added mutability so oneLineEditorRef.current can be reassigned + const oneLineEditorRef = useRef(null) as MutableRefObject + const inputRef = useRef() + + useAtom(syncTypewriterToEditorEffect) + useAtom(syncEditorPositionEffect) + + useEffect(() => { + console.log("getInputRef useEffect") + if (oneLineEditorRef.current) { + console.log("oneLineEditorRef.current is") + return + } + + const editorConfig: monaco.editor.IStandaloneEditorConstructionOptions = { + value: typewriter, + language: "lean4", + quickSuggestions: false, + // lightbulb: { + // enabled: true + // }, + unicodeHighlight: { + ambiguousCharacters: false, + }, + automaticLayout: true, + minimap: { + enabled: false + }, + lineNumbers: 'off', + tabSize: 2, + wordWrap: 'on', + glyphMargin: false, + folding: false, + lineDecorationsWidth: 0, + lineNumbersMinChars: 0, + 'semanticHighlighting.enabled': true, + overviewRulerLanes: 0, + hideCursorInOverviewRuler: true, + padding: { + top: 0, + bottom: 0, + }, + scrollbar: { + verticalScrollbarSize: 3 + }, + scrollBeyondLastLine: false, + overviewRulerBorder: false, + theme: 'vs-code-theme-converted', + contextmenu: false + }; + + const myEditor = monaco.editor.create(inputRef.current!, editorConfig) + + const layoutInput = () => { + const lineHeight = myEditor.getOption(monaco.editor.EditorOption.lineHeight) + const height = Math.min(myEditor.getContentHeight(), lineHeight + 2, window.innerHeight / 3) + inputRef.current!.style.height = `${height}px` + console.log(`width of single line editor: ${inputRef.current!.clientWidth}`) + myEditor.layout({ + width: inputRef.current!.clientWidth, + height + }) + } + + myEditor.onDidContentSizeChange(layoutInput) + layoutInput() + + oneLineEditorRef.current = myEditor + setOneLineEditor(myEditor) + + return () => { + // abbrevRewriter.dispose() + myEditor.dispose() + oneLineEditorRef.current = null + setOneLineEditor(null) + } + }, []) + + return inputRef +} diff --git a/client/src/store/editor-atoms.ts b/client/src/store/editor-atoms.ts index 58a33dc0..adf8ac44 100644 --- a/client/src/store/editor-atoms.ts +++ b/client/src/store/editor-atoms.ts @@ -1,11 +1,15 @@ +import { editor } from 'monaco-editor' + import { atom } from "jotai"; -import { LeanMonaco, LeanMonacoOptions } from 'lean4monaco' +import { atomEffect } from 'jotai-effect'; +import { LeanMonaco, LeanMonacoOptions, LeanMonacoEditor } from 'lean4monaco' import { gameIdAtom } from "./location-atoms"; import { levelProgressAtom, progressAtom } from "./progress-atoms"; import { Selection } from "./progress-types"; import { levelInfoAtom } from "./query-atoms"; import { ProofState } from "../../../infoview/rpc_api"; -import { Diagnostic } from 'vscode-languageserver-types' +import { Diagnostic, DiagnosticSeverity } from 'vscode-languageserver-types' +import { preferencesAtom } from './preferences-atoms'; /** Options for the LeanMonaco instance */ export const leanMonacoOptionsAtom = atom(get => { @@ -23,6 +27,31 @@ export const leanMonacoOptionsAtom = atom(get => { } }}) +export const rpcSessionAtom = atom + +export const leanMonacoEditorAtom = atom(null as any) + +export const leanMonacoEditorModelAtom = atom( + (get) => { + const editor = get(leanMonacoEditorAtom) + return editor?.getModel() + } +) + +export const leanMonacoEditorUriAtom = atom( + (get) => { + const model = get(leanMonacoEditorModelAtom) + return model?.uri.toString() ?? '' + } +) + +export const hasLeanMonacoEditorAtom = atom( + (get) => { + const editor = get(leanMonacoEditorAtom) + const model = get(leanMonacoEditorModelAtom) + return Boolean(editor && model) + }) + /** The unique leanMonaco instance for the entire application */ export const leanMonacoAtom = atom(null) @@ -84,8 +113,94 @@ export const typewriterModeAtom = atom( */ export const proofAtom = atom() +export const restoreErrorCommandEffect = atomEffect( + (get, set) => { + const errorCommand = get(lastProofStepErrorCommandAtom) + + if (errorCommand) { + set(typewriterContentAtom, errorCommand) + } + } +) + +export const lastProofStepHasErrorsAtom = atom( + (get) => { + const proof = get(proofAtom) + if (!proof?.steps.length) { + return false + } + + let diags = [...proof.steps[proof.steps.length - 1].diags, ...proof.diagnostics] + + return diags.some( + (d) => (d.severity == DiagnosticSeverity.Error) + ) + } +) + +export const lastProofStepErrorCommandAtom = atom( + (get) => { + const proof = get(proofAtom) + const hasErrors = get(lastProofStepHasErrorsAtom) + + if (!hasErrors || !proof?.steps) { + return null + } + + return proof.steps[proof.steps.length - 1].command + } +) + /** TODO: Workaround to capture a crash of the gameserver. */ export const interimDiagsAtom = atom>([]) /** TODO: Workaround to capture a crash of the gameserver. */ export const crashedAtom = atom(false) + +export const syncTypewriterToEditorEffect = atomEffect( + (get, set) => { + const oneLineEditor = get(oneLineEditorAtom) + const typewriter = get(typewriterContentAtom) + const { isSuggestionsMobileMode } = get(preferencesAtom) + + if (!oneLineEditor) { + console.log("oneLineEditor is False") + return + } + + if (oneLineEditor.getValue() !== typewriter) { + console.log(`Set value in oneLineEditor to ${typewriter}`) + oneLineEditor.setValue(typewriter) + oneLineEditor.setPosition({ + column: typewriter.length + 1, + lineNumber: 1 + }) + } + + if (!isSuggestionsMobileMode) { + oneLineEditor.focus() + } + } +) + +export const oneLineEditorAtom = atom(null) + +export const syncEditorPositionEffect = atomEffect( + (get, set) => { + const oneLineEditor = get(oneLineEditorAtom) + const editor = get(leanMonacoEditorAtom) + const hasEditor = get(hasLeanMonacoEditorAtom) + const { isSuggestionsMobileMode } = get(preferencesAtom) + + if (!oneLineEditor || !hasEditor || !editor) return + + oneLineEditor.setPosition({ + column: editor.getValue().length + 1, + lineNumber: 1 + }) + + if (!isSuggestionsMobileMode) { + oneLineEditor.focus() + } + } +) diff --git a/package-lock.json b/package-lock.json index a6d96fdd..bdb23ebb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39,6 +39,7 @@ "jotai": "^2.13.1", "jotai-location": "^0.6.2", "jotai-tanstack-query": "^0.11.0", + "jotai-effect": "^2.2.3", "lean4monaco": "^1.1.9", "monaco-editor-wrapper": "^5.3.1", "react": "^18.2.0", @@ -2486,9 +2487,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2503,9 +2501,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2520,9 +2515,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2537,9 +2529,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2554,9 +2543,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2571,9 +2557,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2588,9 +2571,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2605,9 +2585,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2622,9 +2599,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2639,9 +2613,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2656,9 +2627,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2673,9 +2641,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2690,9 +2655,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3164,9 +3126,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -3184,9 +3143,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -3204,9 +3160,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -3224,9 +3177,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -7552,9 +7502,9 @@ } }, "node_modules/jotai": { - "version": "2.13.1", - "resolved": "https://registry.npmjs.org/jotai/-/jotai-2.13.1.tgz", - "integrity": "sha512-cRsw6kFeGC9Z/D3egVKrTXRweycZ4z/k7i2MrfCzPYsL9SIWcPXTyqv258/+Ay8VUEcihNiE/coBLE6Kic6b8A==", + "version": "2.19.1", + "resolved": "https://registry.npmjs.org/jotai/-/jotai-2.19.1.tgz", + "integrity": "sha512-sqm9lVZiqBHZH8aSRk32DSiZDHY3yUIlulXYn9GQj7/LvoUdYXSMti7ZPJGo+6zjzKFt5a25k/I6iBCi43PJcw==", "license": "MIT", "engines": { "node": ">=12.20.0" @@ -7580,6 +7530,18 @@ } } }, + "node_modules/jotai-effect": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/jotai-effect/-/jotai-effect-2.2.3.tgz", + "integrity": "sha512-MKw5eUydU6aL+fGtrMbdWLB+KFHc4R1AfQcCnXYpvc2AN+tiE/qS0yZwuL2HEBQoEm6quGPb5M1Uib6viK2Eeg==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "jotai": ">=2.16.0" + } + }, "node_modules/jotai-location": { "version": "0.6.2", "resolved": "https://registry.npmjs.org/jotai-location/-/jotai-location-0.6.2.tgz", From 9aec9f3a095bb1fe014fbd50720d2cecffab3269 Mon Sep 17 00:00:00 2001 From: matlorr Date: Fri, 17 Apr 2026 09:36:53 +0200 Subject: [PATCH 05/36] Adjust css for typewriter input-field such that typed inputs are fully visible --- client/src/css/typewriter.css | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/client/src/css/typewriter.css b/client/src/css/typewriter.css index 26ca6612..b0fff778 100644 --- a/client/src/css/typewriter.css +++ b/client/src/css/typewriter.css @@ -18,6 +18,14 @@ overflow-y: auto; } +.typewriter-input { + width: 100%; + flex: 1; + border: 1px solid; + align-self: flex-start; + margin-top: 0; +} + .pusher { flex: 1; min-height: 0; From 7cdc8538f38bb79b365ded7b86fe87e42e70e797 Mon Sep 17 00:00:00 2001 From: matlorr Date: Fri, 17 Apr 2026 17:33:49 +0200 Subject: [PATCH 06/36] Add monaco editor creation logic to TypewriterCommandline and fix runCommandAtom --- client/src/components/infoview/typewriter.tsx | 130 +++++++++++++++++- client/src/store/editor-atoms.ts | 130 +++++++++++++++--- 2 files changed, 232 insertions(+), 28 deletions(-) diff --git a/client/src/components/infoview/typewriter.tsx b/client/src/components/infoview/typewriter.tsx index 37f46adf..11ce4ff8 100644 --- a/client/src/components/infoview/typewriter.tsx +++ b/client/src/components/infoview/typewriter.tsx @@ -4,13 +4,13 @@ import "../../css/typewriter.css" import { useTranslation } from "react-i18next"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faWandMagicSparkles } from "@fortawesome/free-solid-svg-icons"; -import { useAtom } from "jotai"; +import { useAtom, useSetAtom } from "jotai"; import { gameInfoAtom } from "../../store/query-atoms"; import { DiagnosticSeverity, PublishDiagnosticsParams, DocumentUri } from 'vscode-languageserver-protocol'; import * as monaco from 'monaco-editor' import { levelIdAtom, gameIdAtom, worldIdAtom } from "../../store/location-atoms"; -import { leanMonacoEditorAtom, typewriterContentAtom, interimDiagsAtom, crashedAtom, leanMonacoEditorModelAtom, leanMonacoEditorUriAtom, hasLeanMonacoEditorAtom, lastProofStepErrorCommandAtom, restoreErrorCommandEffect, oneLineEditorAtom, syncTypewriterToEditorEffect, syncEditorPositionEffect } from "../../store/editor-atoms"; +import { leanMonacoEditorAtom, typewriterContentAtom, interimDiagsAtom, crashedAtom, leanMonacoEditorModelAtom, leanMonacoEditorUriAtom, hasLeanMonacoEditorAtom, lastProofStepErrorCommandAtom, restoreErrorCommandEffect, oneLineEditorAtom, syncTypewriterToEditorEffect, syncEditorPositionEffect, isProcessingAtom, runCommandAtom } from "../../store/editor-atoms"; import { deletedChatAtom } from '../../store/chat-atoms' import { preferencesAtom } from '../../store/preferences-atoms' @@ -48,17 +48,133 @@ export function Typewriter() { /** The input field */ export function TypewriterCommandLine() { let { t } = useTranslation() - const oneLineEditorRef = useRef(null) - const [typewriterContent, setTypewriterContent] = useState("") + + const [typewriter, setTypewriter] = useAtom(typewriterContentAtom) + const [oneLineEditor, setOneLineEditor] = useAtom(oneLineEditorAtom) + const [proof] = useAtom(proofAtom) + const [isProcessing] = useAtom(isProcessingAtom) + const runCommand = useSetAtom(runCommandAtom) + + useAtom(syncTypewriterToEditorEffect) + useAtom(syncEditorPositionEffect) + useAtom(restoreErrorCommandEffect) + + const oneLineEditorRef = useRef(null) + const inputRef = useRef(null) + + useEffect(() => { + console.log("TypewriterCommandLine: Editor initialization useEffect") + + // Guard: only create once + if (oneLineEditorRef.current) { + console.log("Editor already exists, skipping initialization") + return + } + + // Guard: wait for DOM element + if (!inputRef.current) { + console.log("inputRef.current is not ready yet") + return + } + + const editorConfig: monaco.editor.IStandaloneEditorConstructionOptions = { + value: typewriter, + language: "lean4", + quickSuggestions: false, + unicodeHighlight: { + ambiguousCharacters: false, + }, + automaticLayout: true, + minimap: { + enabled: false + }, + lineNumbers: 'off', + tabSize: 2, + wordWrap: 'on', + glyphMargin: false, + folding: false, + lineDecorationsWidth: 0, + lineNumbersMinChars: 0, + 'semanticHighlighting.enabled': true, + overviewRulerLanes: 0, + hideCursorInOverviewRuler: true, + padding: { + top: 0, + bottom: 0, + }, + scrollbar: { + verticalScrollbarSize: 3 + }, + scrollBeyondLastLine: false, + overviewRulerBorder: false, + theme: 'vs-code-theme-converted', + contextmenu: false + } + + console.log("Creating Monaco editor instance...") + const myEditor = monaco.editor.create(inputRef.current, editorConfig) + + const layoutInput = () => { + const lineHeight = myEditor.getOption(monaco.editor.EditorOption.lineHeight) + const height = Math.min(myEditor.getContentHeight(), lineHeight + 2, window.innerHeight / 3) + if (inputRef.current) { + inputRef.current.style.height = `${height}px` + console.log(`Single-line editor width: ${inputRef.current.clientWidth}`) + myEditor.layout({ + width: inputRef.current.clientWidth, + height + }) + } + } + + myEditor.onDidContentSizeChange(layoutInput) + layoutInput() + + const changeDisposable = myEditor.getModel()?.onDidChangeContent(() => { + const value = myEditor.getValue() + console.log(`Editor content changed to: "${value}"`) + setTypewriter(value) + + // Prevent newlines (single-line behavior) + const newValue = value.replace(/[\n\r]/g, '') + if (value !== newValue) { + myEditor.setValue(newValue) + } + }) + + const keyDownDisposable = myEditor.onKeyDown((ev) => { + if (ev.code === "Enter" || ev.code === "NumpadEnter") { + ev.preventDefault() + console.log("Enter pressed, running command...") + runCommand() + } + }) + + oneLineEditorRef.current = myEditor + setOneLineEditor(myEditor) + console.log("Editor initialized successfully") + + return () => { + console.log("Cleaning up editor...") + changeDisposable?.dispose() + keyDownDisposable?.dispose() + myEditor.dispose() + oneLineEditorRef.current = null + setOneLineEditor(null) + } + }, [typewriter, setTypewriter, setOneLineEditor, runCommand]) + + //const oneLineEditorRef = useRef(null) + //const [typewriterContent, setTypewriterContent] = useState("") + //const leanMonacoEditor = useAtom(leanMonacoEditorAtom) + //let inputRef = getInputRef() /** Process the entered command */ const handleSubmit : React.FormEventHandler = (ev) => { ev.preventDefault() - // runCommand() + runCommand() } - let inputRef = getInputRef() - // do not display if the proof is completed (with potential warnings still present) return
diff --git a/client/src/store/editor-atoms.ts b/client/src/store/editor-atoms.ts index adf8ac44..5e23edb1 100644 --- a/client/src/store/editor-atoms.ts +++ b/client/src/store/editor-atoms.ts @@ -1,15 +1,30 @@ -import { editor } from 'monaco-editor' - +import { editor, Selection as selector, } from 'monaco-editor' import { atom } from "jotai"; import { atomEffect } from 'jotai-effect'; -import { LeanMonaco, LeanMonacoOptions, LeanMonacoEditor } from 'lean4monaco' -import { gameIdAtom } from "./location-atoms"; +import { LeanMonaco, LeanMonacoOptions, LeanMonacoEditor, RpcSessionAtPos} from 'lean4monaco' +import { gameIdAtom, levelIdAtom, worldIdAtom } from "./location-atoms"; import { levelProgressAtom, progressAtom } from "./progress-atoms"; import { Selection } from "./progress-types"; import { levelInfoAtom } from "./query-atoms"; import { ProofState } from "../../../infoview/rpc_api"; import { Diagnostic, DiagnosticSeverity } from 'vscode-languageserver-types' import { preferencesAtom } from './preferences-atoms'; +import { deletedChatAtom } from './chat-atoms'; +import { DocumentPosition } from '../components/infoview/types'; + +/** The unique leanMonaco instance for the entire application */ +export const leanMonacoAtom = atom(null) +export const leanMonacoEditorAtom = atom(null) +export const oneLineEditorAtom = atom(null) +/** The proof consists of multiple steps that are processed one after the other. + * In particular multi-line terms like `match`-statements will not be supported. + * + * Note that the first step will always have "" as command + */ +export const proofAtom = atom() +export const rpcSessionAtom = atom(null) +export const isProcessingAtom = atom(false) +export const typewriterContentAtom = atom("") /** Options for the LeanMonaco instance */ export const leanMonacoOptionsAtom = atom(get => { @@ -27,10 +42,6 @@ export const leanMonacoOptionsAtom = atom(get => { } }}) -export const rpcSessionAtom = atom - -export const leanMonacoEditorAtom = atom(null as any) - export const leanMonacoEditorModelAtom = atom( (get) => { const editor = get(leanMonacoEditorAtom) @@ -52,9 +63,6 @@ export const hasLeanMonacoEditorAtom = atom( return Boolean(editor && model) }) -/** The unique leanMonaco instance for the entire application */ -export const leanMonacoAtom = atom(null) - export const codeAtom = atom( get => { const levelProgress = get(levelProgressAtom) @@ -67,8 +75,6 @@ export const codeAtom = atom( } ) -export const typewriterContentAtom = atom("") - export const selectionsAtom = atom( get => { const levelProgress = get(levelProgressAtom) @@ -106,13 +112,6 @@ export const typewriterModeAtom = atom( } ) -/** The proof consists of multiple steps that are processed one after the other. - * In particular multi-line terms like `match`-statements will not be supported. - * - * Note that the first step will always have "" as command - */ -export const proofAtom = atom() - export const restoreErrorCommandEffect = atomEffect( (get, set) => { const errorCommand = get(lastProofStepErrorCommandAtom) @@ -183,7 +182,6 @@ export const syncTypewriterToEditorEffect = atomEffect( } ) -export const oneLineEditorAtom = atom(null) export const syncEditorPositionEffect = atomEffect( (get, set) => { @@ -204,3 +202,93 @@ export const syncEditorPositionEffect = atomEffect( } } ) + +export const runCommandAtom = atom( + null, + (get, set) => { + console.log("start running command") + const processing = get(isProcessingAtom) + const editor = get(oneLineEditorAtom) + + if (!editor){ + console.log("oneLineEditor not initialized") + return + } + + const hasEditor = get(hasLeanMonacoEditorAtom) + const typewriter = get(typewriterContentAtom) + + if (processing || !hasEditor) { + console.log("Editor not available or a process is currently running.") + console.log(`Currently running a process: ${processing}`) + console.log(`Does Editor exist: ${hasEditor}`) + return + } + + // TODO: Desired logic is to only reset this after a new *error-free* command has been entered + set(deletedChatAtom, []) + + const pos = editor.getPosition() + + if (typewriter) { + set(isProcessingAtom, true) + + editor.executeEdits("typewriter", [{ + range: selector.fromPositions( + pos!, + editor.getModel()?.getFullModelRange().getEndPosition() + ), + text: typewriter.trim() + "\n", + forceMoveMarkers: false + }]) + set(typewriterContentAtom, '') + // Load proof after executing edits + set(loadGoalsAtom) + } + + editor.setPosition(pos!) + } +) + +export const loadGoalsAtom = atom( + null, + (get, set) => { + const rpcSess = get(rpcSessionAtom) + + if (!rpcSess) { + console.error("RpcSession is not available"); + return; + } + + const uri = get(leanMonacoEditorUriAtom) + const worldId = get(worldIdAtom) + const levelId = get(levelIdAtom) + + console.info('sending rpc request to load the proof state') + rpcSess.call('Game.getProofState', + { + ...DocumentPosition.toTdpp({line: 0, character: 0, uri: uri}), + worldId, levelId + } + ).then( + (proof: ProofState) => { + if (typeof proof !== 'undefined') { + console.info(`received a proof state!`) + console.log(proof) + set(proofAtom, proof) + set(crashedAtom, false) + } else { + console.warn('received undefined proof state!') + // Avoid transient crash state while the server warms up. + } + } + ).catch((error: string) => { + if (error === 'No connection to Lean') { + console.warn(error) + return + } + set(crashedAtom, true) + console.warn(error) + }) + } +) From 65e05d572e93dfa3be0d5958695261b87ae8c77b Mon Sep 17 00:00:00 2001 From: Jon Eugster Date: Fri, 24 Apr 2026 00:38:02 +0200 Subject: [PATCH 07/36] npm audit fix --- package-lock.json | 131 ++++++++++++++++++++++------------------------ 1 file changed, 64 insertions(+), 67 deletions(-) diff --git a/package-lock.json b/package-lock.json index bdb23ebb..c69e3102 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39,7 +39,6 @@ "jotai": "^2.13.1", "jotai-location": "^0.6.2", "jotai-tanstack-query": "^0.11.0", - "jotai-effect": "^2.2.3", "lean4monaco": "^1.1.9", "monaco-editor-wrapper": "^5.3.1", "react": "^18.2.0", @@ -2383,9 +2382,9 @@ } }, "node_modules/@rollup/pluginutils/node_modules/picomatch": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", - "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -3912,15 +3911,15 @@ "dev": true }, "node_modules/axios": { - "version": "1.13.6", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", - "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", + "version": "1.15.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.2.tgz", + "integrity": "sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A==", "dev": true, "license": "MIT", "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", - "proxy-from-env": "^1.1.0" + "proxy-from-env": "^2.1.0" } }, "node_modules/babel-plugin-macros": { @@ -4067,9 +4066,9 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", - "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", "dev": true, "license": "MIT", "dependencies": { @@ -5012,9 +5011,9 @@ } }, "node_modules/cosmiconfig/node_modules/yaml": { - "version": "1.10.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", - "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", + "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", "license": "ISC", "engines": { "node": ">= 6" @@ -5093,11 +5092,12 @@ } }, "node_modules/cross-fetch": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.0.0.tgz", - "integrity": "sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.1.0.tgz", + "integrity": "sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw==", + "license": "MIT", "dependencies": { - "node-fetch": "^2.6.12" + "node-fetch": "^2.7.0" } }, "node_modules/cross-spawn": { @@ -5148,9 +5148,9 @@ "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" }, "node_modules/cypress": { - "version": "15.12.0", - "resolved": "https://registry.npmjs.org/cypress/-/cypress-15.12.0.tgz", - "integrity": "sha512-B2BRcudLfA4NZZP5QpA45J70bu1heCH59V1yKRLHAtiC49r7RV03X5ifUh7Nfbk8QNg93RAsc6oAmodm/+j0pA==", + "version": "15.14.1", + "resolved": "https://registry.npmjs.org/cypress/-/cypress-15.14.1.tgz", + "integrity": "sha512-AkuiHNSnmm0a+h/horcvbjmY6dWpCe1Ebp1R0LjMP5I6pjMaNA50Mw1YP/d07pLHJ/sV8FZoGecUWFCJ/Nifpw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -6205,9 +6205,9 @@ } }, "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", "dev": true, "funding": [ { @@ -6954,12 +6954,12 @@ } }, "node_modules/i18next-http-backend": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-3.0.2.tgz", - "integrity": "sha512-PdlvPnvIp4E1sYi46Ik4tBYh/v/NbYfFFgTjkwFl0is8A18s7/bx9aXqsrOax9WUbeNS6mD2oix7Z0yGGf6m5g==", + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-3.0.6.tgz", + "integrity": "sha512-mBOqy8993jtqAoj6XaI1XeC/8/9v6EPS+681ziegrPvTB0DoaCY7PpTS0SpY56qLMoS4OI1TZEM2Zf59zNh05w==", "license": "MIT", "dependencies": { - "cross-fetch": "4.0.0" + "cross-fetch": "4.1.0" } }, "node_modules/i18next-scanner": { @@ -7530,18 +7530,6 @@ } } }, - "node_modules/jotai-effect": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/jotai-effect/-/jotai-effect-2.2.3.tgz", - "integrity": "sha512-MKw5eUydU6aL+fGtrMbdWLB+KFHc4R1AfQcCnXYpvc2AN+tiE/qS0yZwuL2HEBQoEm6quGPb5M1Uib6viK2Eeg==", - "license": "MIT", - "engines": { - "node": ">=12.20.0" - }, - "peerDependencies": { - "jotai": ">=2.16.0" - } - }, "node_modules/jotai-location": { "version": "0.6.2", "resolved": "https://registry.npmjs.org/jotai-location/-/jotai-location-0.6.2.tgz", @@ -7871,9 +7859,9 @@ } }, "node_modules/lodash": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "license": "MIT" }, "node_modules/lodash.once": { @@ -9274,6 +9262,7 @@ "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", "dependencies": { "whatwg-url": "^5.0.0" }, @@ -9803,9 +9792,9 @@ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" }, "node_modules/path-to-regexp": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", - "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", "license": "MIT", "funding": { "type": "opencollective", @@ -9857,10 +9846,11 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, + "license": "MIT", "engines": { "node": ">=8.6" }, @@ -9972,10 +9962,14 @@ } }, "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "dev": true + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } }, "node_modules/pstree.remy": { "version": "1.1.8", @@ -11464,9 +11458,9 @@ } }, "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -11589,7 +11583,8 @@ "node_modules/tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" }, "node_modules/tree-dump": { "version": "1.0.3", @@ -12199,9 +12194,9 @@ } }, "node_modules/vite": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", - "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", + "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", "dev": true, "license": "MIT", "dependencies": { @@ -12347,9 +12342,9 @@ } }, "node_modules/vite/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -12416,9 +12411,9 @@ } }, "node_modules/vscode-languageclient/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -12537,12 +12532,14 @@ "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" }, "node_modules/whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" From 9a2659c6493fd8ec1d2ff2455b38ad4aa52305c1 Mon Sep 17 00:00:00 2001 From: Jon Eugster Date: Fri, 24 Apr 2026 00:41:19 +0200 Subject: [PATCH 08/36] add missing jotai-effect --- cypress/TestGame/.i18n/en/Game.pot | 2 +- package-lock.json | 15 +++++++++++++++ package.json | 3 +++ 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/cypress/TestGame/.i18n/en/Game.pot b/cypress/TestGame/.i18n/en/Game.pot index 1eebca96..28928425 100644 --- a/cypress/TestGame/.i18n/en/Game.pot +++ b/cypress/TestGame/.i18n/en/Game.pot @@ -1,7 +1,7 @@ msgid "" msgstr "Project-Id-Version: Game v4.29.1\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-04-23\n" +"POT-Creation-Date: 2026-04-24\n" "Last-Translator: \n" "Language-Team: none\n" "Language: en\n" diff --git a/package-lock.json b/package-lock.json index c69e3102..f4f55e15 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,9 @@ "client", "relay" ], + "dependencies": { + "jotai-effect": "^2.2.3" + }, "devDependencies": { "concurrently": "^9.2.1", "cypress": "^15.12.0", @@ -7530,6 +7533,18 @@ } } }, + "node_modules/jotai-effect": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/jotai-effect/-/jotai-effect-2.2.3.tgz", + "integrity": "sha512-MKw5eUydU6aL+fGtrMbdWLB+KFHc4R1AfQcCnXYpvc2AN+tiE/qS0yZwuL2HEBQoEm6quGPb5M1Uib6viK2Eeg==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "jotai": ">=2.16.0" + } + }, "node_modules/jotai-location": { "version": "0.6.2", "resolved": "https://registry.npmjs.org/jotai-location/-/jotai-location-0.6.2.tgz", diff --git a/package.json b/package.json index 2d3acb54..e57c4401 100644 --- a/package.json +++ b/package.json @@ -44,5 +44,8 @@ "last 1 firefox version", "last 1 safari version" ] + }, + "dependencies": { + "jotai-effect": "^2.2.3" } } From 17a3d6bb7079ce7f5d273ab86263b1f0b25eda96 Mon Sep 17 00:00:00 2001 From: Jon Eugster Date: Fri, 24 Apr 2026 01:02:37 +0200 Subject: [PATCH 09/36] bump lean4monaco --- client/package.json | 4 +++- {infoview => client/src/api}/rpc_api.ts | 9 +-------- client/src/store/editor-atoms.ts | 3 +-- package-lock.json | 19 ++++++++++++------- package.json | 3 --- 5 files changed, 17 insertions(+), 21 deletions(-) rename {infoview => client/src/api}/rpc_api.ts (84%) diff --git a/client/package.json b/client/package.json index 56a6e16e..23ad2b6d 100644 --- a/client/package.json +++ b/client/package.json @@ -11,6 +11,7 @@ "@fortawesome/free-regular-svg-icons": "^7.2.0", "@fortawesome/free-solid-svg-icons": "^7.2.0", "@fortawesome/react-fontawesome": "^3.2.0", + "@leanprover/infoview-api": "^0.13.0", "@mui/material": "^5.11.1", "@reduxjs/toolkit": "^1.9.1", "@tanstack/query-core": "^5.90.20", @@ -19,9 +20,10 @@ "i18next": "^25.8.18", "i18next-http-backend": "^3.0.2", "jotai": "^2.13.1", + "jotai-effect": "^2.2.3", "jotai-location": "^0.6.2", "jotai-tanstack-query": "^0.11.0", - "lean4monaco": "^1.1.9", + "lean4monaco": "^1.1.10", "monaco-editor-wrapper": "^5.3.1", "react": "^18.2.0", "react-country-flag": "^3.1.0", diff --git a/infoview/rpc_api.ts b/client/src/api/rpc_api.ts similarity index 84% rename from infoview/rpc_api.ts rename to client/src/api/rpc_api.ts index aebdd8be..fb7e17f9 100644 --- a/infoview/rpc_api.ts +++ b/client/src/api/rpc_api.ts @@ -1,11 +1,4 @@ -/** - * @fileOverview Defines the interface for the communication with the server. - * - * This file is based on `vscode-lean4/vscode-lean4/src/rpcApi.ts` - */ -import type { Range } from 'vscode-languageserver-protocol'; -import type { ContextInfo, FVarId, CodeWithInfos, MVarId } from '@leanprover/infoview-api'; -import { InteractiveDiagnostic, TermInfo } from '@leanprover/infoview/*'; +import { CodeWithInfos, ContextInfo, FVarId, InteractiveDiagnostic, MVarId, TermInfo } from '@leanprover/infoview-api' export interface InteractiveHypothesisBundle { /** The pretty names of the variables in the bundle. Anonymous names are rendered diff --git a/client/src/store/editor-atoms.ts b/client/src/store/editor-atoms.ts index 3fef19b5..d8b6ee4f 100644 --- a/client/src/store/editor-atoms.ts +++ b/client/src/store/editor-atoms.ts @@ -6,11 +6,10 @@ import { gameIdAtom, levelIdAtom, worldIdAtom } from "./location-atoms"; import { levelProgressAtom, progressAtom } from "./progress-atoms"; import { Selection } from "./progress-types"; import { levelInfoAtom } from "./query-atoms"; -import { ProofState } from "../../../infoview/rpc_api"; import { Diagnostic, DiagnosticSeverity } from 'vscode-languageserver-types' import { preferencesAtom } from './preferences-atoms'; import { deletedChatAtom } from './chat-atoms'; -import { DocumentPosition } from '../components/infoview/types'; +import { ProofState } from '../api/rpc_api'; /** The unique leanMonaco instance for the entire application */ export const leanMonacoAtom = atom(null) diff --git a/package-lock.json b/package-lock.json index f4f55e15..dc38bc96 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,9 +11,6 @@ "client", "relay" ], - "dependencies": { - "jotai-effect": "^2.2.3" - }, "devDependencies": { "concurrently": "^9.2.1", "cypress": "^15.12.0", @@ -32,6 +29,7 @@ "@fortawesome/free-regular-svg-icons": "^7.2.0", "@fortawesome/free-solid-svg-icons": "^7.2.0", "@fortawesome/react-fontawesome": "^3.2.0", + "@leanprover/infoview-api": "^0.13.0", "@mui/material": "^5.11.1", "@reduxjs/toolkit": "^1.9.1", "@tanstack/query-core": "^5.90.20", @@ -40,9 +38,10 @@ "i18next": "^25.8.18", "i18next-http-backend": "^3.0.2", "jotai": "^2.13.1", + "jotai-effect": "^2.2.3", "jotai-location": "^0.6.2", "jotai-tanstack-query": "^0.11.0", - "lean4monaco": "^1.1.9", + "lean4monaco": "^1.1.10", "monaco-editor-wrapper": "^5.3.1", "react": "^18.2.0", "react-country-flag": "^3.1.0", @@ -1676,6 +1675,12 @@ "tslib": "2" } }, + "node_modules/@leanprover/infoview-api": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@leanprover/infoview-api/-/infoview-api-0.13.0.tgz", + "integrity": "sha512-WmRqGyXNG7a6mmI7B7xjzRHiyZc7I+D7MrBTlwbMo7n2TA3ts8yTSWZhbio3mOOEMCVMf0jzTnnS8TVJgqkoBA==", + "license": "Apache-2.0" + }, "node_modules/@leanprover/unicode-input": { "version": "0.1.9", "resolved": "https://registry.npmjs.org/@leanprover/unicode-input/-/unicode-input-0.1.9.tgz", @@ -7721,9 +7726,9 @@ "link": true }, "node_modules/lean4monaco": { - "version": "1.1.9", - "resolved": "https://registry.npmjs.org/lean4monaco/-/lean4monaco-1.1.9.tgz", - "integrity": "sha512-oCnCA4HlRaJJhPvs6uLZrdKGXcrqEU1Q9MadzRQJXfhKJY7oLfaM/qJbU9pWxmYmLTHj7c5524EanjAkN/USag==", + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/lean4monaco/-/lean4monaco-1.1.10.tgz", + "integrity": "sha512-46ZARbXzwZQ6wb10Z2miaAHtZ9dHryZoXCH08KAsXXVZoDndMyXYAAL45HQZLR0fyXXk0FU60Kd0D1Zb5sICkw==", "license": "Apache-2.0", "dependencies": { "@leanprover/infoview": "~0.8.5", diff --git a/package.json b/package.json index e57c4401..2d3acb54 100644 --- a/package.json +++ b/package.json @@ -44,8 +44,5 @@ "last 1 firefox version", "last 1 safari version" ] - }, - "dependencies": { - "jotai-effect": "^2.2.3" } } From 69b49cb9ac0d80eb9465f785f9edda0caf81d029 Mon Sep 17 00:00:00 2001 From: Jon Eugster Date: Fri, 24 Apr 2026 01:19:05 +0200 Subject: [PATCH 10/36] fix --- client/src/components/infoview/ProofStep.tsx | 2 +- client/src/components/infoview/types.ts | 11 ----------- client/src/components/landing_page.tsx | 2 +- client/src/store/editor-atoms.ts | 13 +++++++------ 4 files changed, 9 insertions(+), 19 deletions(-) diff --git a/client/src/components/infoview/ProofStep.tsx b/client/src/components/infoview/ProofStep.tsx index d8a37603..900a85dd 100644 --- a/client/src/components/infoview/ProofStep.tsx +++ b/client/src/components/infoview/ProofStep.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { InteractiveGoalsWithHints } from "./types"; +import { InteractiveGoalsWithHints } from "../../api/rpc_api"; export function ProofStep({step, idx}: {step:InteractiveGoalsWithHints, idx: number}) { return
Step {idx}
diff --git a/client/src/components/infoview/types.ts b/client/src/components/infoview/types.ts index f4629413..fd45bb1b 100644 --- a/client/src/components/infoview/types.ts +++ b/client/src/components/infoview/types.ts @@ -4,14 +4,3 @@ export interface GameHint { rawText: string; varNames: string[][]; // in Lean: `Array (Name × Name)` } - -export interface InteractiveGoalWithHints { - // goal: InteractiveGoal; // FIXME - hints: GameHint[]; -} - -export interface InteractiveGoalsWithHints { - goals: InteractiveGoalWithHints[]; - command: string; - // diags: InteractiveDiagnostic[]; // FIXME -} diff --git a/client/src/components/landing_page.tsx b/client/src/components/landing_page.tsx index a152b611..e71516b8 100644 --- a/client/src/components/landing_page.tsx +++ b/client/src/components/landing_page.tsx @@ -63,7 +63,7 @@ function Tile({gameId}: {gameId: string}) { if (preferences.useFlags && langOpt?.flag) { return } else { - return {lang} + return {lang} } })} diff --git a/client/src/store/editor-atoms.ts b/client/src/store/editor-atoms.ts index d8b6ee4f..e25dfa21 100644 --- a/client/src/store/editor-atoms.ts +++ b/client/src/store/editor-atoms.ts @@ -1,7 +1,7 @@ import { editor, Selection as selector, } from 'monaco-editor' import { atom } from "jotai"; import { atomEffect } from 'jotai-effect'; -import { LeanMonaco, LeanMonacoOptions, LeanMonacoEditor, RpcSessionAtPos} from 'lean4monaco' +import { LeanMonaco, LeanMonacoOptions, LeanMonacoEditor, RpcSessionAtPos, DocumentPosition} from 'lean4monaco' import { gameIdAtom, levelIdAtom, worldIdAtom } from "./location-atoms"; import { levelProgressAtom, progressAtom } from "./progress-atoms"; import { Selection } from "./progress-types"; @@ -13,16 +13,22 @@ import { ProofState } from '../api/rpc_api'; /** The unique leanMonaco instance for the entire application */ export const leanMonacoAtom = atom(null) + export const leanMonacoEditorAtom = atom(null) + export const oneLineEditorAtom = atom(null) + /** The proof consists of multiple steps that are processed one after the other. * In particular multi-line terms like `match`-statements will not be supported. * * Note that the first step will always have "" as command */ export const proofAtom = atom() + export const rpcSessionAtom = atom(null) + export const isProcessingAtom = atom(false) + export const typewriterContentAtom = atom("") /** Options for the LeanMonaco instance */ @@ -114,7 +120,6 @@ export const typewriterModeAtom = atom( export const restoreErrorCommandEffect = atomEffect( (get, set) => { const errorCommand = get(lastProofStepErrorCommandAtom) - if (errorCommand) { set(typewriterContentAtom, errorCommand) } @@ -127,9 +132,7 @@ export const lastProofStepHasErrorsAtom = atom( if (!proof?.steps.length) { return false } - let diags = [...proof.steps[proof.steps.length - 1].diags, ...proof.diagnostics] - return diags.some( (d) => (d.severity == DiagnosticSeverity.Error) ) @@ -140,11 +143,9 @@ export const lastProofStepErrorCommandAtom = atom( (get) => { const proof = get(proofAtom) const hasErrors = get(lastProofStepHasErrorsAtom) - if (!hasErrors || !proof?.steps) { return null } - return proof.steps[proof.steps.length - 1].command } ) From e7da7aa63994fe6cfc6d1b4d037f42e5b88015db Mon Sep 17 00:00:00 2001 From: Jon Eugster Date: Fri, 24 Apr 2026 01:38:13 +0200 Subject: [PATCH 11/36] wip --- client/src/components/infoview/GameEditor.tsx | 2 +- client/src/components/infoview/typewriter.tsx | 232 +----------------- 2 files changed, 4 insertions(+), 230 deletions(-) diff --git a/client/src/components/infoview/GameEditor.tsx b/client/src/components/infoview/GameEditor.tsx index a6986f01..ae639330 100644 --- a/client/src/components/infoview/GameEditor.tsx +++ b/client/src/components/infoview/GameEditor.tsx @@ -19,7 +19,7 @@ export function GameEditor({ codeviewRef } : { codeviewRef: React.MutableRefObje return <> -
+
{typewriterMode && } diff --git a/client/src/components/infoview/typewriter.tsx b/client/src/components/infoview/typewriter.tsx index 11ce4ff8..a2cf310b 100644 --- a/client/src/components/infoview/typewriter.tsx +++ b/client/src/components/infoview/typewriter.tsx @@ -18,15 +18,16 @@ import path from "node:path"; import { proofAtom } from "../../store/editor-atoms"; import { ExerciseStatement } from "./ExerciseStatement"; import { ProofStep } from "./ProofStep"; +import { TypewriterCommandLine } from "./typewriter/TypewriterCommandline"; /** * Der Typewriter bestehend aus Eingabezeile, Beweisschritten, Aufgabenstellung und Hintergrundbild */ export function Typewriter() { const [{ data: gameInfo }] = useAtom(gameInfoAtom) - const [gameId, navigateToGame] = useAtom(gameIdAtom) + const [gameId] = useAtom(gameIdAtom) const [worldId] = useAtom(worldIdAtom) - const [proof, setProof ] = useAtom(proofAtom) + const [proof] = useAtom(proofAtom) const proofPanelRef = useRef(null) @@ -44,230 +45,3 @@ export function Typewriter() {
} - -/** The input field */ -export function TypewriterCommandLine() { - let { t } = useTranslation() - - const [typewriter, setTypewriter] = useAtom(typewriterContentAtom) - const [oneLineEditor, setOneLineEditor] = useAtom(oneLineEditorAtom) - const [proof] = useAtom(proofAtom) - const [isProcessing] = useAtom(isProcessingAtom) - const runCommand = useSetAtom(runCommandAtom) - - useAtom(syncTypewriterToEditorEffect) - useAtom(syncEditorPositionEffect) - useAtom(restoreErrorCommandEffect) - - const oneLineEditorRef = useRef(null) - const inputRef = useRef(null) - - useEffect(() => { - console.log("TypewriterCommandLine: Editor initialization useEffect") - - // Guard: only create once - if (oneLineEditorRef.current) { - console.log("Editor already exists, skipping initialization") - return - } - - // Guard: wait for DOM element - if (!inputRef.current) { - console.log("inputRef.current is not ready yet") - return - } - - const editorConfig: monaco.editor.IStandaloneEditorConstructionOptions = { - value: typewriter, - language: "lean4", - quickSuggestions: false, - unicodeHighlight: { - ambiguousCharacters: false, - }, - automaticLayout: true, - minimap: { - enabled: false - }, - lineNumbers: 'off', - tabSize: 2, - wordWrap: 'on', - glyphMargin: false, - folding: false, - lineDecorationsWidth: 0, - lineNumbersMinChars: 0, - 'semanticHighlighting.enabled': true, - overviewRulerLanes: 0, - hideCursorInOverviewRuler: true, - padding: { - top: 0, - bottom: 0, - }, - scrollbar: { - verticalScrollbarSize: 3 - }, - scrollBeyondLastLine: false, - overviewRulerBorder: false, - theme: 'vs-code-theme-converted', - contextmenu: false - } - - console.log("Creating Monaco editor instance...") - const myEditor = monaco.editor.create(inputRef.current, editorConfig) - - const layoutInput = () => { - const lineHeight = myEditor.getOption(monaco.editor.EditorOption.lineHeight) - const height = Math.min(myEditor.getContentHeight(), lineHeight + 2, window.innerHeight / 3) - if (inputRef.current) { - inputRef.current.style.height = `${height}px` - console.log(`Single-line editor width: ${inputRef.current.clientWidth}`) - myEditor.layout({ - width: inputRef.current.clientWidth, - height - }) - } - } - - myEditor.onDidContentSizeChange(layoutInput) - layoutInput() - - const changeDisposable = myEditor.getModel()?.onDidChangeContent(() => { - const value = myEditor.getValue() - console.log(`Editor content changed to: "${value}"`) - setTypewriter(value) - - // Prevent newlines (single-line behavior) - const newValue = value.replace(/[\n\r]/g, '') - if (value !== newValue) { - myEditor.setValue(newValue) - } - }) - - const keyDownDisposable = myEditor.onKeyDown((ev) => { - if (ev.code === "Enter" || ev.code === "NumpadEnter") { - ev.preventDefault() - console.log("Enter pressed, running command...") - runCommand() - } - }) - - oneLineEditorRef.current = myEditor - setOneLineEditor(myEditor) - console.log("Editor initialized successfully") - - return () => { - console.log("Cleaning up editor...") - changeDisposable?.dispose() - keyDownDisposable?.dispose() - myEditor.dispose() - oneLineEditorRef.current = null - setOneLineEditor(null) - } - }, [typewriter, setTypewriter, setOneLineEditor, runCommand]) - - //const oneLineEditorRef = useRef(null) - //const [typewriterContent, setTypewriterContent] = useState("") - //const leanMonacoEditor = useAtom(leanMonacoEditorAtom) - //let inputRef = getInputRef() - - /** Process the entered command */ - const handleSubmit : React.FormEventHandler = (ev) => { - ev.preventDefault() - runCommand() - } - - // do not display if the proof is completed (with potential warnings still present) - return
- -
-
-
- - -
-} - -export function getInputRef() { - const [typewriter, setTypewriter] = useAtom(typewriterContentAtom) - const [oneLineEditor, setOneLineEditor] = useAtom(oneLineEditorAtom) - // added mutability so oneLineEditorRef.current can be reassigned - const oneLineEditorRef = useRef(null) as MutableRefObject - const inputRef = useRef() - - useAtom(syncTypewriterToEditorEffect) - useAtom(syncEditorPositionEffect) - - useEffect(() => { - console.log("getInputRef useEffect") - if (oneLineEditorRef.current) { - console.log("oneLineEditorRef.current is") - return - } - - const editorConfig: monaco.editor.IStandaloneEditorConstructionOptions = { - value: typewriter, - language: "lean4", - quickSuggestions: false, - // lightbulb: { - // enabled: true - // }, - unicodeHighlight: { - ambiguousCharacters: false, - }, - automaticLayout: true, - minimap: { - enabled: false - }, - lineNumbers: 'off', - tabSize: 2, - wordWrap: 'on', - glyphMargin: false, - folding: false, - lineDecorationsWidth: 0, - lineNumbersMinChars: 0, - 'semanticHighlighting.enabled': true, - overviewRulerLanes: 0, - hideCursorInOverviewRuler: true, - padding: { - top: 0, - bottom: 0, - }, - scrollbar: { - verticalScrollbarSize: 3 - }, - scrollBeyondLastLine: false, - overviewRulerBorder: false, - theme: 'vs-code-theme-converted', - contextmenu: false - }; - - const myEditor = monaco.editor.create(inputRef.current!, editorConfig) - - const layoutInput = () => { - const lineHeight = myEditor.getOption(monaco.editor.EditorOption.lineHeight) - const height = Math.min(myEditor.getContentHeight(), lineHeight + 2, window.innerHeight / 3) - inputRef.current!.style.height = `${height}px` - console.log(`width of single line editor: ${inputRef.current!.clientWidth}`) - myEditor.layout({ - width: inputRef.current!.clientWidth, - height - }) - } - - myEditor.onDidContentSizeChange(layoutInput) - layoutInput() - - oneLineEditorRef.current = myEditor - setOneLineEditor(myEditor) - - return () => { - // abbrevRewriter.dispose() - myEditor.dispose() - oneLineEditorRef.current = null - setOneLineEditor(null) - } - }, []) - - return inputRef -} From de2da5e585092985c63a90b0f88223e8efbe5a4b Mon Sep 17 00:00:00 2001 From: Jon Eugster Date: Fri, 24 Apr 2026 21:14:51 +0200 Subject: [PATCH 12/36] missing file --- .../typewriter/TypewriterCommandline.tsx | 155 ++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 client/src/components/infoview/typewriter/TypewriterCommandline.tsx diff --git a/client/src/components/infoview/typewriter/TypewriterCommandline.tsx b/client/src/components/infoview/typewriter/TypewriterCommandline.tsx new file mode 100644 index 00000000..c765df59 --- /dev/null +++ b/client/src/components/infoview/typewriter/TypewriterCommandline.tsx @@ -0,0 +1,155 @@ +import React, { useEffect, useRef } from "react"; + +import "../../../css/typewriter.css" +import { useTranslation } from "react-i18next"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faWandMagicSparkles } from "@fortawesome/free-solid-svg-icons"; +import { useAtom, useSetAtom } from "jotai"; + +import * as monaco from 'monaco-editor' +import { typewriterContentAtom, restoreErrorCommandEffect, oneLineEditorAtom, syncTypewriterToEditorEffect, syncEditorPositionEffect, isProcessingAtom, runCommandAtom } from "../../../store/editor-atoms"; +import { proofAtom } from "../../../store/editor-atoms"; + +/** The input field */ +export function TypewriterCommandLine() { + let { t } = useTranslation() + + const [typewriter, setTypewriter] = useAtom(typewriterContentAtom) + const [oneLineEditor, setOneLineEditor] = useAtom(oneLineEditorAtom) + const [proof] = useAtom(proofAtom) + const [isProcessing] = useAtom(isProcessingAtom) + const runCommand = useSetAtom(runCommandAtom) + + // useAtom(syncTypewriterToEditorEffect) + // useAtom(syncEditorPositionEffect) + // useAtom(restoreErrorCommandEffect) + + const oneLineEditorRef = useRef(null) + const inputRef = useRef(null) + + useEffect(() => { + console.log("TypewriterCommandLine: Editor initialization useEffect") + + // Guard: only create once + if (oneLineEditorRef.current) { + console.log("Editor already exists, skipping initialization") + return + } + + // Guard: wait for DOM element + if (!inputRef.current) { + console.log("inputRef.current is not ready yet") + return + } + + const editorConfig: monaco.editor.IStandaloneEditorConstructionOptions = { + value: typewriter, + language: "lean4", + quickSuggestions: false, + unicodeHighlight: { + ambiguousCharacters: false, + }, + automaticLayout: true, + minimap: { + enabled: false + }, + lineNumbers: 'off', + tabSize: 2, + wordWrap: 'on', + glyphMargin: false, + folding: false, + lineDecorationsWidth: 0, + lineNumbersMinChars: 0, + 'semanticHighlighting.enabled': true, + overviewRulerLanes: 0, + hideCursorInOverviewRuler: true, + padding: { + top: 0, + bottom: 0, + }, + scrollbar: { + verticalScrollbarSize: 3 + }, + scrollBeyondLastLine: false, + overviewRulerBorder: false, + theme: 'vs-code-theme-converted', + contextmenu: false + } + + console.log("Creating Monaco editor instance...") + const myEditor = monaco.editor.create(inputRef.current, editorConfig) + + const layoutInput = () => { + const lineHeight = myEditor.getOption(monaco.editor.EditorOption.lineHeight) + const height = Math.min(myEditor.getContentHeight(), lineHeight + 2, window.innerHeight / 3) + if (inputRef.current) { + inputRef.current.style.height = `${height}px` + console.log(`Single-line editor width: ${inputRef.current.clientWidth}`) + myEditor.layout({ + width: inputRef.current.clientWidth, + height + }) + } + } + + myEditor.onDidContentSizeChange(layoutInput) + layoutInput() + + const changeDisposable = myEditor.getModel()?.onDidChangeContent(() => { + const value = myEditor.getValue() + console.log(`Editor content changed to: "${value}"`) + setTypewriter(value) + + // Prevent newlines (single-line behavior) + const newValue = value.replace(/[\n\r]/g, '') + if (value !== newValue) { + myEditor.setValue(newValue) + } + }) + + const keyDownDisposable = myEditor.onKeyDown((ev) => { + if (ev.code === "Enter" || ev.code === "NumpadEnter") { + ev.preventDefault() + console.log("Enter pressed, running command...") + runCommand() + } + }) + + oneLineEditorRef.current = myEditor + setOneLineEditor(myEditor) + console.log("Editor initialized successfully") + + return () => { + console.log("Cleaning up editor...") + changeDisposable?.dispose() + keyDownDisposable?.dispose() + myEditor.dispose() + oneLineEditorRef.current = null + setOneLineEditor(null) + } + }, [setTypewriter, setOneLineEditor, runCommand]) + + //const oneLineEditorRef = useRef(null) + //const [typewriterContent, setTypewriterContent] = useState("") + //const leanMonacoEditor = useAtom(leanMonacoEditorAtom) + //let inputRef = getInputRef() + + /** Process the entered command */ + const handleSubmit : React.FormEventHandler = (ev) => { + ev.preventDefault() + console.debug("execute!") + runCommand() + } + + // do not display if the proof is completed (with potential warnings still present) + return
+
+
+
+
+ + +
+} From 538017773a21d7e8d8db29312179aafdb7b5bc28 Mon Sep 17 00:00:00 2001 From: Jon Eugster Date: Fri, 24 Apr 2026 21:56:12 +0200 Subject: [PATCH 13/36] bump lean4monaco to import WithRpcSessions --- client/package.json | 2 +- .../infoview/typewriter/TypewriterCommandline.tsx | 4 ++++ package-lock.json | 8 ++++---- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/client/package.json b/client/package.json index 23ad2b6d..40ad734f 100644 --- a/client/package.json +++ b/client/package.json @@ -23,7 +23,7 @@ "jotai-effect": "^2.2.3", "jotai-location": "^0.6.2", "jotai-tanstack-query": "^0.11.0", - "lean4monaco": "^1.1.10", + "lean4monaco": "^1.1.13", "monaco-editor-wrapper": "^5.3.1", "react": "^18.2.0", "react-country-flag": "^3.1.0", diff --git a/client/src/components/infoview/typewriter/TypewriterCommandline.tsx b/client/src/components/infoview/typewriter/TypewriterCommandline.tsx index c765df59..3258ad77 100644 --- a/client/src/components/infoview/typewriter/TypewriterCommandline.tsx +++ b/client/src/components/infoview/typewriter/TypewriterCommandline.tsx @@ -10,6 +10,10 @@ import * as monaco from 'monaco-editor' import { typewriterContentAtom, restoreErrorCommandEffect, oneLineEditorAtom, syncTypewriterToEditorEffect, syncEditorPositionEffect, isProcessingAtom, runCommandAtom } from "../../../store/editor-atoms"; import { proofAtom } from "../../../store/editor-atoms"; +// bitte schön :) +import { RpcSessions } from "@leanprover/infoview-api"; +import { WithRpcSessions } from "lean4monaco"; + /** The input field */ export function TypewriterCommandLine() { let { t } = useTranslation() diff --git a/package-lock.json b/package-lock.json index dc38bc96..60e74396 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,7 +41,7 @@ "jotai-effect": "^2.2.3", "jotai-location": "^0.6.2", "jotai-tanstack-query": "^0.11.0", - "lean4monaco": "^1.1.10", + "lean4monaco": "^1.1.13", "monaco-editor-wrapper": "^5.3.1", "react": "^18.2.0", "react-country-flag": "^3.1.0", @@ -7726,9 +7726,9 @@ "link": true }, "node_modules/lean4monaco": { - "version": "1.1.10", - "resolved": "https://registry.npmjs.org/lean4monaco/-/lean4monaco-1.1.10.tgz", - "integrity": "sha512-46ZARbXzwZQ6wb10Z2miaAHtZ9dHryZoXCH08KAsXXVZoDndMyXYAAL45HQZLR0fyXXk0FU60Kd0D1Zb5sICkw==", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/lean4monaco/-/lean4monaco-1.1.13.tgz", + "integrity": "sha512-ktaWDZyjWXXIUHLogvs/TAtzP9MGSaVWZSZaJl4jFNT0I+g3OiVZpH+Xl1Zbdg6QJ1CqT2+aLwp1CHG8UT+/Tw==", "license": "Apache-2.0", "dependencies": { "@leanprover/infoview": "~0.8.5", From 7afa09a4113cc8432e3f70fadab3b6cd9b658048 Mon Sep 17 00:00:00 2001 From: matlorr Date: Sat, 2 May 2026 19:23:27 +0200 Subject: [PATCH 14/36] Instantiate LeanMonacoEditor in PlayableLevel component and set corresponding atom. --- client/src/components/level.tsx | 28 +++++++++++++++------------- client/src/store/editor-atoms.ts | 8 +++++++- 2 files changed, 22 insertions(+), 14 deletions(-) diff --git a/client/src/components/level.tsx b/client/src/components/level.tsx index 46c9cd7c..d104aad3 100644 --- a/client/src/components/level.tsx +++ b/client/src/components/level.tsx @@ -22,7 +22,7 @@ import { useTranslation } from 'react-i18next' import { useGameTranslation } from '../utils/translation' import { InventoryPanel } from './inventory/inventory_panel' import { useAtom } from 'jotai' -import { codeAtom, leanMonacoAtom, lockEditorModeAtom, proofAtom, selectionsAtom, typewriterModeAtom } from '../store/editor-atoms' +import { codeAtom, leanMonacoAtom, leanMonacoEditorAtom, lockEditorModeAtom, proofAtom, typewriterModeAtom } from '../store/editor-atoms' import { gameIdAtom, levelIdAtom, worldIdAtom } from '../store/location-atoms' import { gameInfoAtom, levelInfoAtom } from '../store/query-atoms' import { deletedChatAtom, helpAtom, selectedStepAtom } from '../store/chat-atoms' @@ -233,7 +233,7 @@ function PlayableLevel() { const [inventoryDoc, setInventoryDoc] = useState<{name: string, type: string} | null>(null) function closeInventoryDoc () {setInventoryDoc(null)} - const [leanMonacoEditor, setLeanMonacoEditor] = useState(null) + const [leanMonacoEditor, setLeanMonacoEditor] = useAtom(leanMonacoEditorAtom)//useState(null) // Start the editor useEffect(() => { @@ -246,7 +246,7 @@ function PlayableLevel() { console.debug('[demo]: starting editor') await leanMonacoEditor.start(codeviewRef.current!, uriStr, code ?? "") console.debug('[demo]: editor started') - setLeanMonacoEditor(leanMonacoEditor) + setLeanMonacoEditor(leanMonacoEditor.editor) })() return () => { @@ -257,7 +257,7 @@ function PlayableLevel() { // Persist editor text into progress whenever the model changes. useEffect(() => { - const editor = leanMonacoEditor?.editor + const editor = leanMonacoEditor //?.editor if (!editor) { return } @@ -270,7 +270,7 @@ function PlayableLevel() { return () => { disposable.dispose() } - }, [leanMonacoEditor?.editor, setCode]) + }, [leanMonacoEditor, setCode]) //[leanMonacoEditor?.editor, setCode]) // Select and highlight proof steps and corresponding hints // TODO: with the new design, there is no difference between the introduction and @@ -280,7 +280,7 @@ function PlayableLevel() { useEffect (() => { // Lock editor mode if (levelInfo?.template) { - const model = leanMonacoEditor?.editor?.getModel() + const model = leanMonacoEditor?.getModel()//leanMonacoEditor?.editor?.getModel() if (model) { let code = model.getLinesContent() @@ -291,7 +291,8 @@ function PlayableLevel() { console.debug(`inserting template:\n${levelInfo?.template}`) // TODO: This does not work! HERE // Probably overwritten by a query to the server - leanMonacoEditor?.editor.executeEdits("template-writer", [{ + // DOC: REMOVED editor here + leanMonacoEditor?.executeEdits("template-writer", [{ range: model.getFullModelRange(), text: levelInfo?.template + `\n`, forceMoveMarkers: true @@ -302,7 +303,7 @@ function PlayableLevel() { } } else { } - }, [levelInfo, levelId, worldId, gameId, leanMonacoEditor?.editor]) + }, [levelInfo, levelId, worldId, gameId, leanMonacoEditor]) //[levelInfo, levelId, worldId, gameId, leanMonacoEditor?.editor]) useEffect(() => { @@ -313,7 +314,7 @@ function PlayableLevel() { }, [gameId, worldId, levelId]) useEffect(() => { - const editor = leanMonacoEditor?.editor; + const editor = leanMonacoEditor //?.editor; const selection = editor?.getSelection(); if (!typewriterMode && editor && selection) { // Delete last input attempt from command line @@ -322,7 +323,7 @@ function PlayableLevel() { text: "", forceMoveMarkers: false }]); - leanMonacoEditor?.editor.focus() + leanMonacoEditor?.focus() // leanMonacoEditor?.editor.focus() } }, [typewriterMode]) @@ -344,10 +345,11 @@ function PlayableLevel() { // Effect when command line mode gets enabled useEffect(() => { - const model = leanMonacoEditor?.editor?.getModel() + const model = leanMonacoEditor?.getModel() //.editor?.getModel() if (model&& (typewriterMode)) { let code = model.getLinesContent().filter(line => line.trim()) - leanMonacoEditor?.editor.executeEdits("typewriter", [{ + // REMOVED .editor call + leanMonacoEditor?.executeEdits("typewriter", [{ range: model.getFullModelRange(), text: code.length ? code.join('\n') + '\n' : '', forceMoveMarkers: true @@ -368,7 +370,7 @@ function PlayableLevel() { // editor.setSelection(monaco.Selection.fromPositions(endPos, endPos)) // } } - }, [leanMonacoEditor, leanMonacoEditor?.editor, typewriterMode, lockEditorMode]) + }, [leanMonacoEditor, leanMonacoEditor, typewriterMode, lockEditorMode]) //[leanMonacoEditor, leanMonacoEditor?.editor, typewriterMode, lockEditorMode]) return <> { levelInfoIsLoading &&
} diff --git a/client/src/store/editor-atoms.ts b/client/src/store/editor-atoms.ts index e25dfa21..54859913 100644 --- a/client/src/store/editor-atoms.ts +++ b/client/src/store/editor-atoms.ts @@ -1,7 +1,8 @@ import { editor, Selection as selector, } from 'monaco-editor' import { atom } from "jotai"; import { atomEffect } from 'jotai-effect'; -import { LeanMonaco, LeanMonacoOptions, LeanMonacoEditor, RpcSessionAtPos, DocumentPosition} from 'lean4monaco' +import { LeanMonaco, LeanMonacoOptions, LeanMonacoEditor, DocumentPosition} from 'lean4monaco' +import { RpcSessionAtPos } from 'lean4monaco/dist/vscode-lean4/lean4-infoview-api/src/rpcSessions' import { gameIdAtom, levelIdAtom, worldIdAtom } from "./location-atoms"; import { levelProgressAtom, progressAtom } from "./progress-atoms"; import { Selection } from "./progress-types"; @@ -10,6 +11,9 @@ import { Diagnostic, DiagnosticSeverity } from 'vscode-languageserver-types' import { preferencesAtom } from './preferences-atoms'; import { deletedChatAtom } from './chat-atoms'; import { ProofState } from '../api/rpc_api'; +import { EditorConnection } from '../components/infoview/editor/EditorConnection'; + +export const editorConnectionAtom = atom(null) /** The unique leanMonaco instance for the entire application */ export const leanMonacoAtom = atom(null) @@ -64,7 +68,9 @@ export const leanMonacoEditorUriAtom = atom( export const hasLeanMonacoEditorAtom = atom( (get) => { const editor = get(leanMonacoEditorAtom) + console.log(`hasLeanMonacoEditorAtom: editor state is ${editor}`) const model = get(leanMonacoEditorModelAtom) + console.log(`hasLeanMonacoEditorAtom: model state is ${model}`) return Boolean(editor && model) }) From 2395a8a55a64aff696908880253d7f95eded2095 Mon Sep 17 00:00:00 2001 From: matlorr Date: Fri, 8 May 2026 16:42:26 +0200 Subject: [PATCH 15/36] Disected main.tsx in its constituants and replaced context creations in DualEditor by equivalent Jotai atom initializations. --- client/package.json | 2 +- client/src/components/main/DualEditor.tsx | 180 ++++++++++ client/src/components/main/Main.tsx | 199 ++++++++++ .../components/main/TypewriterInterface.tsx | 340 ++++++++++++++++++ .../main/TypewriterInterfaceWrapper.tsx | 38 ++ package-lock.json | 8 +- 6 files changed, 762 insertions(+), 5 deletions(-) create mode 100644 client/src/components/main/DualEditor.tsx create mode 100644 client/src/components/main/Main.tsx create mode 100644 client/src/components/main/TypewriterInterface.tsx create mode 100644 client/src/components/main/TypewriterInterfaceWrapper.tsx diff --git a/client/package.json b/client/package.json index 40ad734f..687cb4ca 100644 --- a/client/package.json +++ b/client/package.json @@ -23,7 +23,7 @@ "jotai-effect": "^2.2.3", "jotai-location": "^0.6.2", "jotai-tanstack-query": "^0.11.0", - "lean4monaco": "^1.1.13", + "lean4monaco": "^1.1.14", "monaco-editor-wrapper": "^5.3.1", "react": "^18.2.0", "react-country-flag": "^3.1.0", diff --git a/client/src/components/main/DualEditor.tsx b/client/src/components/main/DualEditor.tsx new file mode 100644 index 00000000..e421c3db --- /dev/null +++ b/client/src/components/main/DualEditor.tsx @@ -0,0 +1,180 @@ +import { useAtom } from "jotai" +import { editorConnectionAtom, infoviewConfigAtom, leanFileProgressAtom, lspDiagnosticsAtom, proofAtom, rpcSessionsAtom, serverVersionAtom, typewriterModeAtom } from "../../store/editor-atoms" +import React from "react" +import { ExerciseStatement } from "../infoview/ExerciseStatement" +import { gameIdAtom, levelIdAtom, worldIdAtom } from "../../store/location-atoms" +import { gameInfoAtom, levelInfoAtom } from "../../store/query-atoms" +import { completedAtom, progressAtom } from "../../store/progress-atoms" +import { inventoryAtom } from "../../store/inventory-atoms" +import { TypewriterInterfaceWrapper } from "./TypewriterInterfaceWrapper" +import { Main } from "./Main" +import { useClientNotificationEffect, useEvent, useEventResult, useServerNotificationState } from "lean4monaco/dist/vscode-lean4/lean4-infoview/src/infoview/util" +import type { Diagnostic, DidCloseTextDocumentParams, DocumentUri, PublishDiagnosticsParams } from 'vscode-languageserver-protocol'; +import { LeanFileProgressParams, LeanFileProgressProcessingInfo, RpcCallParams, defaultInfoviewConfig } from '@leanprover/infoview-api'; +import { ServerVersion } from 'lean4monaco/dist/vscode-lean4/lean4-infoview/src/infoview/serverVersion' +import { RpcSessionAtPos, RpcSessions } from 'lean4monaco/dist/vscode-lean4/lean4-infoview-api/src/rpcSessions' +import { RpcReleaseParams } from "lean4monaco/dist/vscode-lean4/lean4-infoview-api/src/lspTypes" + + + +/** Wrapper for the two editors. It is important that the `div` with `codeViewRef` is + * always present, or the monaco editor cannot start. + */ +export function DualEditor({ codeviewRef } : { codeviewRef: any }) { + const [typewriterMode] = useAtom(typewriterModeAtom) + const ec = useAtom(editorConnectionAtom)//React.useContext(EditorContext) + const showTypewriter = Boolean(ec) && typewriterMode + return <> +
+ +
+
+ {ec ? + : + // TODO: Style this if relevant. + <> + } + +} + +/** The part of the two editors that needs the editor connection first */ +function DualEditorMain() { + const [editorConnection,] = useAtom(editorConnectionAtom)//React.useContext(EditorContext) + const [rpcSessions, setRpcSessions] = useAtom(rpcSessionsAtom) + const [, setServerVersion] = useAtom(serverVersionAtom) + const [, setInfoviewConfig] = useAtom(infoviewConfigAtom) + const [, setLspDiagnostics] = useAtom(lspDiagnosticsAtom) + const [, setLeanFileProgressAtom] = useAtom(leanFileProgressAtom) + const [gameId] = useAtom(gameIdAtom) + const [worldId] = useAtom(worldIdAtom) + const [levelId] = useAtom(levelIdAtom) + const [{ data: gameInfo }] = useAtom(gameInfoAtom) + const [{ data: levelInfo }] = useAtom(levelInfoAtom) + + const [, setCompleted] = useAtom(completedAtom) + + const [, addToInventory] = useAtom(inventoryAtom) + const [typewriterMode, setTypewriterMode] = useAtom(typewriterModeAtom) + + const [proof] = useAtom(proofAtom) + + // REPLACES WithRpcSessions + // Initialize RpcSessions when editorConnection is available + React.useEffect(() => { + if (!editorConnection) { + console.log("[DualEditorMain] No editor connection available during RPC sesssion creation.") + return; + } + + const sessions = new RpcSessions({ + createRpcSession: (uri: DocumentUri) => editorConnection.api.createRpcSession(uri), + closeRpcSession: (uri: DocumentUri) => editorConnection.api.closeRpcSession(uri), + call: (params: RpcCallParams) => editorConnection.api.sendClientRequest(params.textDocument.uri, '$/lean/rpc/call', params), + release: (params: RpcReleaseParams) => editorConnection.api.sendClientNotification(params.uri, '$/lean/rpc/release', params), + }); + + setRpcSessions(sessions); + + // Clean up the sessions on unmount + return () => sessions.dispose(); + }, [editorConnection, setRpcSessions]); + + React.useEffect(() => { + if (proof?.completed) { + setCompleted(true) + + // On completion, add the names of all new items to the local storage + let newTiles = [ + ...levelInfo?.tactics ?? [], + ...levelInfo?.lemmas ?? [], + ...levelInfo?.definitions ?? [] + ].filter((tile) => tile.new).map((tile) => tile.name) + + // Add the proven statement to the local storage as well. + if (levelInfo?.statementName != null) { + newTiles.push(levelInfo?.statementName) + } + addToInventory(newTiles) + } + }, [proof, levelInfo]) + + // REPLACES the ConfigContext + /* Set up updates to the global infoview state on editor events. */ + React.useEffect(() => { + if (!editorConnection) { + console.log("[DualEditorMain] No editor connection available during InfoviewConfig creation.") + return; + } + + // The value for the messageOrder had to be copied from @leanprovers definition. + // This is needed, because EditorConnection is imported from lean4monaco where the + // the definition for the InfoViewConfig does not contain messageOrder. + // This is a symptom of several dependency crashed that need to be addressed at some point. + const unsubscribe = editorConnection.events.changedInfoviewConfig.on((config) => { + setInfoviewConfig({...config, messageOrder: 'Sort by proximity to text cursor'}) + }); + + // Clean up the subscription on unmount + return () => unsubscribe?.dispose(); + }, [editorConnection, setInfoviewConfig]); + + // REPLACES the VersionContext + // Update the server version whenever the server restarts + React.useEffect(() => { + if (!editorConnection) return; + + const unsubscribeServer = editorConnection.events.serverRestarted.on((result) => { + setServerVersion(new ServerVersion(result.serverInfo?.version ?? '')); + }); + + return () => unsubscribeServer?.dispose(); + }, [editorConnection, setServerVersion]); + + // Handle closing sessions for closed files + useClientNotificationEffect( + 'textDocument/didClose', + (params: DidCloseTextDocumentParams) => { + const [rpcSessions] = useAtom(rpcSessionsAtom); + rpcSessions?.closeSessionForFile(params.textDocument.uri); + }, + [], + ); + + // REPLACES the WithLspDiagnosticsContext + // Update the LSP diagnostics whenever the notification is received + useServerNotificationState( + 'textDocument/publishDiagnostics', + new Map(), + async (params: PublishDiagnosticsParams) => (diags) => { + const newDiags = new Map(diags); + newDiags.set(params.uri, params.diagnostics); + setLspDiagnostics(newDiags); + return newDiags; + }, + [] + ); + + // REPLACES the ProgressContext + const [allProgress, _1] = useServerNotificationState( + '$/lean/fileProgress', + new Map(), + async (params: LeanFileProgressParams) => (allProgress) => { + const newProgress = new Map(allProgress); + newProgress.set(params.textDocument.uri, params.processing); + setLeanFileProgressAtom(newProgress) + return newProgress + }, []) + + // Handle server restart + useEvent(editorConnection!.events.serverRestarted, () => { + rpcSessions?.closeAllSessions(); + }); + + return <> + {(typewriterMode) ? + + : +
+ } + +} diff --git a/client/src/components/main/Main.tsx b/client/src/components/main/Main.tsx new file mode 100644 index 00000000..191f2ea9 --- /dev/null +++ b/client/src/components/main/Main.tsx @@ -0,0 +1,199 @@ +// TODO: This is only used in `EditorInterface` + +import { useTranslation } from "react-i18next"; +import { useGameTranslation } from "../../utils/translation"; +import { crashedAtom, lockEditorModeAtom, proofAtom, typewriterModeAtom } from "../../store/editor-atoms"; +import { useAtom } from "jotai"; +import React from "react"; +import { gameIdAtom, levelIdAtom, worldIdAtom } from "../../store/location-atoms"; +import { gameInfoAtom, levelInfoAtom } from "../../store/query-atoms"; +import { helpAtom, selectedStepAtom } from "../../store/chat-atoms"; +import { useRpcSessionAtPos } from "lean4monaco/dist/vscode-lean4/lean4-infoview/src/infoview/rpcSessions"; +import { loadGoals } from "../../../../infoview/goals"; +import { useEventResult } from "../infoview/editor/util"; +import { useServerNotificationState } from "lean4monaco/dist/vscode-lean4/lean4-infoview/src/infoview/util"; + +// TODO: This is only used in `EditorInterface` +// while `TypewriterInterface` has this copy-pasted in. +export function Main() { + let { t } = useTranslation() + const { t: gT } = useGameTranslation() + const [lockEditorMode] = useAtom(lockEditorModeAtom) + const ec = React.useContext(EditorContext); + const [gameId] = useAtom(gameIdAtom) + const [worldId] = useAtom(worldIdAtom) + const [levelId] = useAtom(levelIdAtom) + const [{ data: gameInfo }] = useAtom(gameInfoAtom) + const [{ data: levelInfo }] = useAtom(levelInfoAtom) + const [help, setHelp] = useAtom(helpAtom) + + const [typewriterMode] = useAtom(typewriterModeAtom) + + const [proof, setProof] = useAtom(proofAtom) + const [, setCrashed] = useAtom(crashedAtom) + const [selectedStep, setSelectedStep] = useAtom(selectedStepAtom) + const editor = React.useContext(MonacoEditorContext) + const model = editor?.getModel() + const uri = model?.uri.toString() + const rpcSess = useRpcSessionAtPos({ uri: uri ?? '', line: 0, character: 0 }) + + React.useEffect(() => { + if (!uri || !worldId || !levelId) { + return + } + loadGoals(rpcSess, uri, worldId, levelId, setProof, setCrashed) + }, [rpcSess, uri, worldId, levelId, setProof, setCrashed]) + + function toggleSelection(line: number) { + return (ev: any) => { + console.debug('toggled selection') + if (selectedStep == line) { + setSelectedStep(undefined) + } else { + setSelectedStep(line) + } + } + } + //console.debug(`template: ${props.data?.template}`) + + // React.useEffect (() => { + // if (props.data.template) { + // let code: string = selectCode(gameId, worldId, levelId)(store.getState()) + // if (!code.length) { + // //models.push(monaco.editor.createModel(code, 'lean4', uri)) + // } + // } + // }, [props.data.template]) + + /* Set up updates to the global infoview state on editor events. */ + const config = useEventResult(ec.events.changedInfoviewConfig) ?? defaultInfoviewConfig; + + const [allProgress, _1] = useServerNotificationState( + '$/lean/fileProgress', + new Map(), + async (params: LeanFileProgressParams) => (allProgress) => { + const newProgress = new Map(allProgress); + return newProgress.set(params.textDocument.uri, params.processing); + }, + [] + ); + + const curUri = useEventResult(ec.events.changedCursorLocation, loc => loc?.uri); + + const curPos: DocumentPosition | undefined = + useEventResult(ec.events.changedCursorLocation, loc => loc ? { uri: loc.uri, ...loc.range.start } : undefined) + + React.useEffect(() => { + if (typewriterMode) { + return + } + if (!uri) { + return + } + loadGoals(rpcSess, uri, worldId!, levelId!, setProof, setCrashed) + }, [typewriterMode, lockEditorMode, uri, worldId, levelId, rpcSess, setProof, setCrashed]) + + useServerNotificationEffect('textDocument/publishDiagnostics', (params: any) => { + if (typewriterMode) { + return + } + if (!uri || params?.uri !== uri) { + return + } + loadGoals(rpcSess, uri, worldId!, levelId!, setProof, setCrashed) + }, [typewriterMode, lockEditorMode, uri, worldId, levelId, rpcSess, setProof, setCrashed]) + + const hintLine = (() => { + const isEditorMode = !(typewriterMode) + const curLine = Number.isFinite(curPos?.line) + ? curPos?.line + : (Number.isFinite((curPos as any)?._line) ? (curPos as any)._line : undefined) + const curChar = Number.isFinite(curPos?.character) + ? curPos.character + : (Number.isFinite((curPos as any)?._character) ? (curPos as any)._character : undefined) + if (isEditorMode && Number.isFinite(curLine)) { + const baseLine = curLine + return (curChar === 0 && baseLine > 0) ? baseLine - 1 : baseLine + } + if (Number.isFinite(selectedStep)) { + return selectedStep + } + if (!proof?.steps?.length) { + return 0 + } + const lastIndex = proof.steps.length - 1 + return lastStepHasErrors(proof) ? Math.max(0, lastIndex - 1) : lastIndex + })() + + const clampedHintLine = Math.max(0, Math.min(Number.isFinite(hintLine) ? hintLine : 0, (proof?.steps?.length ?? 1) - 1)) + const hintStepIndex = (() => { + if (Number.isFinite(selectedStep)) { + return selectedStep + } + if (!proof?.steps?.length) { + return 0 + } + const maxIndex = proof.steps.length - 1 + return Math.min(clampedHintLine + 1, maxIndex) + })() + const hintsToShow = (() => { + if (!proof?.steps?.length) { + return undefined + } + return proof.steps[hintStepIndex]?.goals?.[0]?.hints + })() + + // Effect when the cursor changes in the editor + React.useEffect(() => { + // TODO: this is a bit of a hack and will yield unexpected behaviour if lines + // are indented. + const newPos = curPos?.line + (curPos?.character == 0 ? 0 : 1) + + if (Number.isFinite(newPos)) { + // scroll the chat along + setSelectedStep(newPos) + } + }, [curPos]) + + useClientNotificationEffect( + 'textDocument/didClose', + (params: DidCloseTextDocumentParams) => { + if (ec.events.changedCursorLocation.current && + ec.events.changedCursorLocation.current.uri === params.textDocument.uri) { + ec.events.changedCursorLocation.fire(undefined) + } + }, + [] + ); + + const serverVersion = + useEventResult(ec.events.serverRestarted, result => new ServerVersion(result.serverInfo?.version ?? '')) + const serverStoppedResult = useEventResult(ec.events.serverStopped); + // NB: the cursor may temporarily become `undefined` when a file is closed. In this case + // it's important not to reconstruct the `WithBlah` wrappers below since they contain state + // that we want to persist. + let ret + if (serverStoppedResult) { + ret =

{serverStoppedResult.message}

{serverStoppedResult.reason}

+ } else { + ret =
+
+ {proof?.completedWithWarnings && +
+ {proof?.completed ? t("Level completed! 🎉") : t("Level completed with warnings 🎭")} +
+ } + +
+ {hintsToShow && ( + + )} + +
+ } + + return ret +} diff --git a/client/src/components/main/TypewriterInterface.tsx b/client/src/components/main/TypewriterInterface.tsx new file mode 100644 index 00000000..da5710a1 --- /dev/null +++ b/client/src/components/main/TypewriterInterface.tsx @@ -0,0 +1,340 @@ +import path from 'path'; +import React from "react" +import * as monaco from 'monaco-editor/esm/vs/editor/editor.api.js' + +import { useAtom } from "jotai" +import { useTranslation } from "react-i18next" +import { gameIdAtom, levelIdAtom, worldIdAtom } from "../../store/location-atoms" +import { gameInfoAtom } from "../../store/query-atoms" +import { deletedChatAtom, helpAtom, selectedStepAtom } from "../../store/chat-atoms" +import { mobileAtom } from "../../store/preferences-atoms" +import { crashedAtom, editorConnectionAtom, interimDiagsAtom, leanMonacoEditorAtom, proofAtom, rpcSessionAtPosAtom, typewriterContentAtom } from "../../store/editor-atoms" +import { Goal, isLastStepWithErrors, lastStepHasErrors, loadGoals } from "../../../../infoview/goals" +import { filterHints, Hint, Hints, MoreHelpButton } from "../hints" +import { GameHint } from "../infoview/types" +import { ExerciseStatement } from "../infoview/ExerciseStatement" +import { useRpcSessionAtPos } from "lean4monaco/dist/vscode-lean4/lean4-infoview/src/infoview/rpcSessions" +import { useServerNotificationEffect } from "lean4monaco/dist/vscode-lean4/lean4-infoview/src/infoview/util" +import { DiagnosticSeverity } from 'vscode-languageclient'; +import { getInteractiveDiagsAt, hasInteractiveErrors } from "../../../../infoview/typewriter" +import { Button } from '../button'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { faArrowRight, faDeleteLeft, faHome } from "@fortawesome/free-solid-svg-icons" +import { InteractiveGoalsWithHints, ProofState } from "../../api/rpc_api" +import { Errors } from '../infoview/messages/Error' +import { CircularProgress } from '@mui/material'; +import { Typewriter } from '../infoview/typewriter'; +import { IconProp } from '@fortawesome/fontawesome-svg-core'; + + +/** The interface in command line mode */ +export function TypewriterInterface() { + let { t } = useTranslation() + const [editorConnection, _] = useAtom(editorConnectionAtom)//React.useContext(EditorContext) + const [gameId, navigateToGame] = useAtom(gameIdAtom) + const [worldId] = useAtom(worldIdAtom) + const [levelId, navigateToLevel] = useAtom(levelIdAtom) + const [{ data: gameInfo }] = useAtom(gameInfoAtom) + const [help, setHelp] = useAtom(helpAtom) + + const [editor] = useAtom(leanMonacoEditorAtom)//React.useContext(MonacoEditorContext) + const model = editor?.getModel() + const uri = model?.uri.toString() ?? '' + + const worldSize = gameInfo?.worldSize?.[worldId ?? ""] ?? 0 + + const fallbackUri = `file:///${worldId}/${levelId}.lean` + const effectiveUri = uri || fallbackUri + let image: string | undefined = gameInfo?.worlds?.nodes[worldId!]?.image + + + const [disableInput, setDisableInput] = React.useState(false) + const [loadingProgress, setLoadingProgress] = React.useState(0) + const [, setDeletedChat] = useAtom(deletedChatAtom) + const [mobile] = useAtom(mobileAtom) + const [proof, setProof ] = useAtom(proofAtom) + const [crashed, setCrashed ] = useAtom(crashedAtom) + const [interimDiags ] = useAtom(interimDiagsAtom) + + const [, setTypewriter] = useAtom(typewriterContentAtom) + const [selectedStep, setSelectedStep] = useAtom(selectedStepAtom) + + const proofPanelRef = React.useRef(null) + // const config = useEventResult(ec.events.changedInfoviewConfig) ?? defaultInfoviewConfig; + // const curUri = useEventResult(ec.events.changedCursorLocation, loc => loc?.uri); + + const rpcSess = useRpcSessionAtPos({uri: effectiveUri, line: 0, character: 0}) + const [rpcSession, setRpcSession] = useAtom(rpcSessionAtPosAtom) + + // Set the RPC session in the atom + React.useEffect(() => { + setRpcSession(rpcSess); + }, [rpcSess, setRpcSession]); + + React.useEffect(() => { + if (!effectiveUri) { + return + } + setCrashed(false) + loadGoals(rpcSess, effectiveUri, worldId!, levelId!, setProof, setCrashed) + }, [rpcSess, effectiveUri, worldId, levelId, setProof, setCrashed]) + + /** Delete all proof lines starting from a given line. + * Note that the first line (i.e. deleting everything) is `1`! + */ + function deleteProof(line: number) { + return (ev: any) => { + let deletedChat: Array = [] + proof?.steps.slice(line).map((step, i) => { + let filteredHints = filterHints(step.goals[0]?.hints, proof?.steps[i-1]?.goals[0]?.hints) + + // Only add these hidden hints to the deletion stack which were visible + deletedChat = [...deletedChat, ...filteredHints.filter(hint => (!hint.hidden || help.has(line + i)))] + }) + setDeletedChat(deletedChat) + + // delete showHelp for deleted steps + setHelp(new Set(Array.from(help).filter(i => i < line - 1))) + + editor!.executeEdits("typewriter", [{ + range: monaco.Selection.fromPositions( + { lineNumber: line, column: 1 }, + editor!.getModel()?.getFullModelRange().getEndPosition() + ), + text: '', + forceMoveMarkers: false + }]) + setSelectedStep(undefined) + setTypewriter(proof?.steps[line].command ?? "") + // Reload proof on deleting + loadGoals(rpcSess, uri, worldId!, levelId!, setProof, setCrashed) + ev.stopPropagation() + } + } + + function toggleSelectStep(line: number) { + return (ev: any) => { + if (mobile) {return} + if (selectedStep == line) { + setSelectedStep(undefined) + console.debug(`unselected step`) + } else { + setSelectedStep(line) + console.debug(`step ${line} selected`) + } + } + } + + // Scroll to the end of the proof if it is updated. + React.useEffect(() => { + if (proof?.steps?.length && proof?.steps?.length > 1) { + proofPanelRef.current?.lastElementChild?.scrollIntoView() //scrollTo(0,0) + } else { + proofPanelRef.current?.scrollTo(0,0) + } + // also reenable the commandline when the proof changes + + // BUG: If selecting 2nd goal on a intermediate proofstep and then delete proof to there, + // the commandline is not displaying disabled even though it should. + setDisableInput(false) + }, [proof]) + + // Scroll to element if selection changes + React.useEffect(() => { + if (typeof selectedStep !== 'undefined') { + Array.from(proofPanelRef.current!.getElementsByClassName(`step-${selectedStep}`)).map((elem) => { + elem.scrollIntoView({ block: "center" }) + }) + } + }, [selectedStep]) + + // TODO: superfluous, can be replaced with `withErr` from above + let lastStepErrors = proof?.steps.length ? hasInteractiveErrors(getInteractiveDiagsAt(proof, proof?.steps.length)) : false + + + useServerNotificationEffect("$/game/loading", (params : any) => { + if (params.kind == "loadConstants") { + setLoadingProgress(params.counter/100*50) + } else if (params.kind == "finalizeExtensions") { + setLoadingProgress(50 + params.counter/150*50) + } else { + console.error(`Unknown loading kind: ${params.kind}`) + } + }) + + let introText: Array = t(gameInfo?.introduction ?? "", {ns: gameId}).split(/\n(\s*\n)+/) + + return
+
+
+ {image && + + } + +
+
+ {/*
+ +
*/} +
+
+ + {((crashed && (interimDiags.length > 0 || proof!.steps.length > 0))) ?
+

{t("Crashed! Go to editor mode and fix your proof! Last server response:")}

+ {interimDiags.map((diag, index) => { + const severityClass = diag.severity ? { + [DiagnosticSeverity.Error]: 'error', + [DiagnosticSeverity.Warning]: 'warning', + [DiagnosticSeverity.Information]: 'information', + [DiagnosticSeverity.Hint]: 'hint', + }[diag.severity] : ''; + + return
+
+

{t("Line")} {diag.range.start.line}, {t("Character")} {diag.range.start.character}

+
+                  {diag.message}
+                
+
+
+ })} + +
: proof?.steps.length ? + <> + {proof?.steps.map((step, i) => { + let filteredHints = filterHints(step.goals[0]?.hints, proof?.steps[i-1]?.goals[0]?.hints) + + // if (i == proof?.steps.length - 1 && hasInteractiveErrors(step.diags)) { + // // if the last command contains an error, we only display the errors but not the + // // entered command as it is still present in the command line. + // // TODO: Should not use index as key. + // return
+ // + //
+ // } else { + return
+ + + {mobile && i == 0 && gameInfo?.introduction && + introText?.filter(it => it.trim()).map(((it, i) => + // Show the level's intro text as hints, too + + )) + } + {mobile && + + } + {/* setDisableInput(n > 0) : (n) => {}}/> */} + {!(isLastStepWithErrors(proof, i)) && + setDisableInput(n > 0) : (n) => {}}/> + } + {mobile && i == proof?.steps.length - 1 && + + } + + {/* Show a message that there are no goals left */} + {/* {!step.goals.length && ( +
+ {proof?.completed ? +

Level completed! 🎉

: +

+ no goals left
+ This probably means you solved the level with warnings or Lean encountered a parsing error. +

+ } +
+ )} */} +
+ } + //} + )} + {proof?.diagnostics.length > 0 && +
+ +
+ } + {mobile && proof?.completed && +
+ {levelId! >= worldSize ? + + : + + } +
+ } + : + + // + // note: since we don't know the total number of files, + // we use a function which strictly monotonely increases towards `100` as `x → ∞` + // The base is chosen at random s.t. we get roughly 91% for `x = 100`. + } +
+
+ +
+} + +/** The display of a single entered lean command */ +function Command({ proof, i, deleteProof }: { proof: ProofState, i: number, deleteProof: any }) { + let {t} = useTranslation() + + // The first step will always have an empty command + if (!proof?.steps[i]?.command) { return <> } + + if (isLastStepWithErrors(proof, i)) { + // If the last step has errors, we display the command in a different style + // indicating that it will be removed on the next try. + return
+ {t("Failed command")}: {proof?.steps[i].command} +
+ } else { + return
+
{proof?.steps[i].command}
+ +
+ } +} + +const goalFilter = { + reverse: false, + showType: true, + showInstance: true, + showHiddenAssumption: true, + showLetValue: true +} + + +/** The tabs of goals that lean ahs after the command of this step has been processed */ +function GoalsTabs({ proofStep, last, onClick, onGoalChange=(n)=>{}}: { proofStep: InteractiveGoalsWithHints, last : boolean, onClick? : any, onGoalChange?: (n?: number) => void }) { + let { t } = useTranslation() + const [mobile] = useAtom(mobileAtom) + const [selectedGoal, setSelectedGoal] = React.useState(0) + + if (proofStep.goals.length == 0) { + return <> + } + + return
+
+ {proofStep.goals.map((goal, i) => ( + // TODO: Should not use index as key. +
{ onGoalChange(i); setSelectedGoal(i); ev.stopPropagation() }}> + {i ? t("Goal") + ` ${i + 1}` : t("Active Goal")} +
+ ))} +
+
+ +
+
+} diff --git a/client/src/components/main/TypewriterInterfaceWrapper.tsx b/client/src/components/main/TypewriterInterfaceWrapper.tsx new file mode 100644 index 00000000..82160337 --- /dev/null +++ b/client/src/components/main/TypewriterInterfaceWrapper.tsx @@ -0,0 +1,38 @@ +import React from "react"; +import { TypewriterInterface } from "./TypewriterInterface"; +import { useClientNotificationEffect, useEventResult } from "lean4monaco/dist/vscode-lean4/lean4-infoview/src/infoview/util"; +import { ServerVersion } from 'lean4monaco/dist/vscode-lean4/lean4-infoview/src/infoview/serverVersion'; +import type { DidCloseTextDocumentParams, DocumentUri } from 'vscode-languageserver-protocol'; +import { useAtom } from "jotai"; +import { editorConnectionAtom } from "../../store/editor-atoms"; + +// Splitting up Typewriter into two parts is a HACK +export function TypewriterInterfaceWrapper() { + const [editorConnection, _] = useAtom(editorConnectionAtom)//React.useContext(EditorContext) + + useClientNotificationEffect( + 'textDocument/didClose', + (params: DidCloseTextDocumentParams) => { + if (editorConnection!.events.changedCursorLocation.current && + editorConnection!.events.changedCursorLocation.current.uri === params.textDocument.uri) { + editorConnection!.events.changedCursorLocation.fire(undefined) + } + }, [] + ) + + const serverVersion = + useEventResult(editorConnection!.events.serverRestarted, result => new ServerVersion(result.serverInfo?.version ?? '')) + const serverStoppedResult = useEventResult(editorConnection!.events.serverStopped); + // NB: the cursor may temporarily become `undefined` when a file is closed. In this case + // it's important not to reconstruct the `WithBlah` wrappers below since they contain state + // that we want to persist. + + if (serverStoppedResult) { + return
+

{serverStoppedResult.message}

+

{serverStoppedResult.reason}

+
+ } + + return +} diff --git a/package-lock.json b/package-lock.json index 7d366760..23c2b58e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,7 +41,7 @@ "jotai-effect": "^2.2.3", "jotai-location": "^0.6.2", "jotai-tanstack-query": "^0.11.0", - "lean4monaco": "^1.1.13", + "lean4monaco": "^1.1.14", "monaco-editor-wrapper": "^5.3.1", "react": "^18.2.0", "react-country-flag": "^3.1.0", @@ -7726,9 +7726,9 @@ "link": true }, "node_modules/lean4monaco": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/lean4monaco/-/lean4monaco-1.1.13.tgz", - "integrity": "sha512-ktaWDZyjWXXIUHLogvs/TAtzP9MGSaVWZSZaJl4jFNT0I+g3OiVZpH+Xl1Zbdg6QJ1CqT2+aLwp1CHG8UT+/Tw==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/lean4monaco/-/lean4monaco-1.1.14.tgz", + "integrity": "sha512-VryKJReDZsBQWFJAKUkR/6s2V6O+lPmabvI6hgvGld/6XuaYb2y20UPz/qHbAGfGwAvqgIDiATthnUD1WJefOw==", "license": "Apache-2.0", "dependencies": { "@leanprover/infoview": "~0.8.5", From 7bfd4e5b4a1a984c326275987c913dbeb60589be Mon Sep 17 00:00:00 2001 From: matlorr Date: Tue, 19 May 2026 09:11:47 +0200 Subject: [PATCH 16/36] Replace contexts by respective atoms holding state. --- client/src/store/editor-atoms.ts | 31 +++++++++++++++++++++++++------ 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/client/src/store/editor-atoms.ts b/client/src/store/editor-atoms.ts index 54859913..87faab46 100644 --- a/client/src/store/editor-atoms.ts +++ b/client/src/store/editor-atoms.ts @@ -2,16 +2,33 @@ import { editor, Selection as selector, } from 'monaco-editor' import { atom } from "jotai"; import { atomEffect } from 'jotai-effect'; import { LeanMonaco, LeanMonacoOptions, LeanMonacoEditor, DocumentPosition} from 'lean4monaco' -import { RpcSessionAtPos } from 'lean4monaco/dist/vscode-lean4/lean4-infoview-api/src/rpcSessions' +import { RpcSessionAtPos, RpcSessions } from 'lean4monaco/dist/vscode-lean4/lean4-infoview-api/src/rpcSessions' import { gameIdAtom, levelIdAtom, worldIdAtom } from "./location-atoms"; import { levelProgressAtom, progressAtom } from "./progress-atoms"; import { Selection } from "./progress-types"; import { levelInfoAtom } from "./query-atoms"; -import { Diagnostic, DiagnosticSeverity } from 'vscode-languageserver-types' +import { Diagnostic, DiagnosticSeverity, DocumentUri } from 'vscode-languageserver-types' import { preferencesAtom } from './preferences-atoms'; import { deletedChatAtom } from './chat-atoms'; import { ProofState } from '../api/rpc_api'; -import { EditorConnection } from '../components/infoview/editor/EditorConnection'; +import { EditorConnection } from "lean4monaco/dist/vscode-lean4/lean4-infoview/src/infoview/editorConnection" +import { ServerVersion } from 'lean4monaco/dist/vscode-lean4/lean4-infoview/src/infoview/serverVersion' +import { defaultInfoviewConfig, InfoviewConfig, LeanFileProgressProcessingInfo } from '@leanprover/infoview-api'; + +/* +I went up the dependency graph starting from the former +info.tsx -> infos.ts -> main.tsx -> level.tsx -> index.tsx and extracted +contexts by their respective atoms. Some of the atoms do not belong here +and have been put here temporarly during my refactoring attempts. +*/ + +export const leanFileProgressAtom = atom>(new Map()) + +export const lspDiagnosticsAtom = atom>(new Map()); + +export const serverVersionAtom = atom(new ServerVersion('')) + +export const infoviewConfigAtom = atom(defaultInfoviewConfig) export const editorConnectionAtom = atom(null) @@ -29,7 +46,9 @@ export const oneLineEditorAtom = atom(null) */ export const proofAtom = atom() -export const rpcSessionAtom = atom(null) +export const rpcSessionsAtom = atom(null) + +export const rpcSessionAtPosAtom = atom(null) export const isProcessingAtom = atom(false) @@ -238,7 +257,7 @@ export const runCommandAtom = atom( if (typewriter) { set(isProcessingAtom, true) - + console.log("[runCommand] start executing edits") editor.executeEdits("typewriter", [{ range: selector.fromPositions( pos!, @@ -259,7 +278,7 @@ export const runCommandAtom = atom( export const loadGoalsAtom = atom( null, (get, set) => { - const rpcSess = get(rpcSessionAtom) + const rpcSess = get(rpcSessionAtPosAtom) if (!rpcSess) { console.error("RpcSession is not available"); From 070544b722132cecace2c310c7f2d3d0883e9ab6 Mon Sep 17 00:00:00 2001 From: matlorr Date: Tue, 19 May 2026 09:15:43 +0200 Subject: [PATCH 17/36] Disected original main.tsx into several files and replaced context setting and retrival with atom setting and retrival. --- client/src/components/main/DualEditor.tsx | 5 +- client/src/components/main/Main.tsx | 62 ++++++++++++------- .../components/main/TypewriterInterface.tsx | 2 +- 3 files changed, 44 insertions(+), 25 deletions(-) diff --git a/client/src/components/main/DualEditor.tsx b/client/src/components/main/DualEditor.tsx index e421c3db..645c4d91 100644 --- a/client/src/components/main/DualEditor.tsx +++ b/client/src/components/main/DualEditor.tsx @@ -81,6 +81,7 @@ function DualEditorMain() { React.useEffect(() => { if (proof?.completed) { + console.log(`[DualEditorMain] Start proof-completion procedure`) setCompleted(true) // On completion, add the names of all new items to the local storage @@ -124,7 +125,9 @@ function DualEditorMain() { if (!editorConnection) return; const unsubscribeServer = editorConnection.events.serverRestarted.on((result) => { - setServerVersion(new ServerVersion(result.serverInfo?.version ?? '')); + const serverVersion = new ServerVersion(result.serverInfo?.version ?? '') + console.log(`[DualEditorMain] Setting server session after restart: ${serverVersion}`) + setServerVersion(serverVersion); }); return () => unsubscribeServer?.dispose(); diff --git a/client/src/components/main/Main.tsx b/client/src/components/main/Main.tsx index 191f2ea9..504ea7e6 100644 --- a/client/src/components/main/Main.tsx +++ b/client/src/components/main/Main.tsx @@ -2,16 +2,20 @@ import { useTranslation } from "react-i18next"; import { useGameTranslation } from "../../utils/translation"; -import { crashedAtom, lockEditorModeAtom, proofAtom, typewriterModeAtom } from "../../store/editor-atoms"; +import { crashedAtom, editorConnectionAtom, leanMonacoEditorAtom, lockEditorModeAtom, proofAtom, serverVersionAtom, typewriterModeAtom } from "../../store/editor-atoms"; import { useAtom } from "jotai"; import React from "react"; import { gameIdAtom, levelIdAtom, worldIdAtom } from "../../store/location-atoms"; import { gameInfoAtom, levelInfoAtom } from "../../store/query-atoms"; import { helpAtom, selectedStepAtom } from "../../store/chat-atoms"; import { useRpcSessionAtPos } from "lean4monaco/dist/vscode-lean4/lean4-infoview/src/infoview/rpcSessions"; -import { loadGoals } from "../../../../infoview/goals"; -import { useEventResult } from "../infoview/editor/util"; -import { useServerNotificationState } from "lean4monaco/dist/vscode-lean4/lean4-infoview/src/infoview/util"; +import { lastStepHasErrors, loadGoals } from "../../../../infoview/goals"; +import { DocumentPosition } from "../infoview/editor/util"; +import { useServerNotificationEffect, useServerNotificationState, useEventResult, useClientNotificationEffect } from "lean4monaco/dist/vscode-lean4/lean4-infoview/src/infoview/util"; +import { defaultInfoviewConfig, LeanFileProgressParams, LeanFileProgressProcessingInfo } from "@leanprover/infoview-api"; +import type { DidCloseTextDocumentParams, DocumentUri } from 'vscode-languageserver-protocol' +import { ServerVersion } from "lean4monaco/dist/vscode-lean4/lean4-infoview/src/infoview/serverVersion"; +import { Hints } from "../hints"; // TODO: This is only used in `EditorInterface` // while `TypewriterInterface` has this copy-pasted in. @@ -19,7 +23,7 @@ export function Main() { let { t } = useTranslation() const { t: gT } = useGameTranslation() const [lockEditorMode] = useAtom(lockEditorModeAtom) - const ec = React.useContext(EditorContext); + const [editorConnection,] = useAtom(editorConnectionAtom)//React.useContext(EditorContext); const [gameId] = useAtom(gameIdAtom) const [worldId] = useAtom(worldIdAtom) const [levelId] = useAtom(levelIdAtom) @@ -32,7 +36,8 @@ export function Main() { const [proof, setProof] = useAtom(proofAtom) const [, setCrashed] = useAtom(crashedAtom) const [selectedStep, setSelectedStep] = useAtom(selectedStepAtom) - const editor = React.useContext(MonacoEditorContext) + const [editor,] = useAtom(leanMonacoEditorAtom)//React.useContext(MonacoEditorContext) + const [, setServerVersion] = useAtom(serverVersionAtom) const model = editor?.getModel() const uri = model?.uri.toString() const rpcSess = useRpcSessionAtPos({ uri: uri ?? '', line: 0, character: 0 }) @@ -66,7 +71,8 @@ export function Main() { // }, [props.data.template]) /* Set up updates to the global infoview state on editor events. */ - const config = useEventResult(ec.events.changedInfoviewConfig) ?? defaultInfoviewConfig; + // config is not used + //const config = useEventResult(editorConnection!.events.changedInfoviewConfig) ?? defaultInfoviewConfig; const [allProgress, _1] = useServerNotificationState( '$/lean/fileProgress', @@ -78,10 +84,13 @@ export function Main() { [] ); - const curUri = useEventResult(ec.events.changedCursorLocation, loc => loc?.uri); + // curUri is not used + //const curUri = useEventResult(editorConnection.events.changedCursorLocation, loc => loc?.uri); const curPos: DocumentPosition | undefined = - useEventResult(ec.events.changedCursorLocation, loc => loc ? { uri: loc.uri, ...loc.range.start } : undefined) + useEventResult( + editorConnection!.events.changedCursorLocation, + loc => loc ? { uri: loc.uri, ...loc.range.start } : undefined) React.useEffect(() => { if (typewriterMode) { @@ -109,7 +118,7 @@ export function Main() { ? curPos?.line : (Number.isFinite((curPos as any)?._line) ? (curPos as any)._line : undefined) const curChar = Number.isFinite(curPos?.character) - ? curPos.character + ? curPos?.character : (Number.isFinite((curPos as any)?._character) ? (curPos as any)._character : undefined) if (isEditorMode && Number.isFinite(curLine)) { const baseLine = curLine @@ -126,6 +135,7 @@ export function Main() { })() const clampedHintLine = Math.max(0, Math.min(Number.isFinite(hintLine) ? hintLine : 0, (proof?.steps?.length ?? 1) - 1)) + const hintStepIndex = (() => { if (Number.isFinite(selectedStep)) { return selectedStep @@ -136,18 +146,19 @@ export function Main() { const maxIndex = proof.steps.length - 1 return Math.min(clampedHintLine + 1, maxIndex) })() + const hintsToShow = (() => { if (!proof?.steps?.length) { return undefined } - return proof.steps[hintStepIndex]?.goals?.[0]?.hints + return proof.steps[hintStepIndex!]?.goals?.[0]?.hints })() // Effect when the cursor changes in the editor React.useEffect(() => { // TODO: this is a bit of a hack and will yield unexpected behaviour if lines // are indented. - const newPos = curPos?.line + (curPos?.character == 0 ? 0 : 1) + const newPos = curPos!.line + (curPos?.character == 0 ? 0 : 1) if (Number.isFinite(newPos)) { // scroll the chat along @@ -158,17 +169,22 @@ export function Main() { useClientNotificationEffect( 'textDocument/didClose', (params: DidCloseTextDocumentParams) => { - if (ec.events.changedCursorLocation.current && - ec.events.changedCursorLocation.current.uri === params.textDocument.uri) { - ec.events.changedCursorLocation.fire(undefined) + if (editorConnection!.events.changedCursorLocation.current && + editorConnection!.events.changedCursorLocation.current.uri === params.textDocument.uri) { + editorConnection!.events.changedCursorLocation.fire(undefined) } }, [] ); - const serverVersion = - useEventResult(ec.events.serverRestarted, result => new ServerVersion(result.serverInfo?.version ?? '')) - const serverStoppedResult = useEventResult(ec.events.serverStopped); + // serverVersion has to be given to what was former the version context + const serverVersion = useEventResult(editorConnection!.events.serverRestarted, result => new ServerVersion(result.serverInfo?.version ?? '')) + + console.log(`server version: ${serverVersion}`) + + setServerVersion(serverVersion!) + + const serverStoppedResult = useEventResult(editorConnection!.events.serverStopped); // NB: the cursor may temporarily become `undefined` when a file is closed. In this case // it's important not to reconstruct the `WithBlah` wrappers below since they contain state // that we want to persist. @@ -183,15 +199,15 @@ export function Main() { {proof?.completed ? t("Level completed! 🎉") : t("Level completed with warnings 🎭")}
} - + {/* */}
{hintsToShow && ( + showHidden={help.has(hintStepIndex!)} step={hintStepIndex!} + selected={selectedStep} toggleSelection={toggleSelection(hintStepIndex!)} + lastLevel={hintStepIndex == proof!.steps.length - 1}/> )} - + {/* */}
} diff --git a/client/src/components/main/TypewriterInterface.tsx b/client/src/components/main/TypewriterInterface.tsx index da5710a1..41654b96 100644 --- a/client/src/components/main/TypewriterInterface.tsx +++ b/client/src/components/main/TypewriterInterface.tsx @@ -229,7 +229,7 @@ export function TypewriterInterface() { } {/* setDisableInput(n > 0) : (n) => {}}/> */} {!(isLastStepWithErrors(proof, i)) && - setDisableInput(n > 0) : (n) => {}}/> + setDisableInput(n! > 0) : (n) => {}}/> } {mobile && i == proof?.steps.length - 1 && From 1441339980e49073da3bf7e3324fb291fb954821 Mon Sep 17 00:00:00 2001 From: matlorr Date: Tue, 19 May 2026 09:25:38 +0200 Subject: [PATCH 18/36] Copied from vscode-lean4/lean4infoview/src/infoview as these files were needed to rebuild origina main.tsx behaviour. --- .../src/components/infoview/editor/event.ts | 77 +++++++++++++++++++ client/src/components/infoview/editor/util.ts | 51 ++++++++++++ 2 files changed, 128 insertions(+) create mode 100644 client/src/components/infoview/editor/event.ts create mode 100644 client/src/components/infoview/editor/util.ts diff --git a/client/src/components/infoview/editor/event.ts b/client/src/components/infoview/editor/event.ts new file mode 100644 index 00000000..09cd81af --- /dev/null +++ b/client/src/components/infoview/editor/event.ts @@ -0,0 +1,77 @@ +import type { Disposable } from 'vscode-languageserver-protocol' + +export class EventEmitter { + private freshId: number = 0 + private handlers = new Map void>() + private handlersWithKey = new Map void)[]>() + current?: E + + /** + * Register a handler that will receive events from this emitter + * and return a closure that removes the handler registration. + * + * If `key` is specified, only events fired with that key + * will be propagated to this handler. + */ + on(handler: (_: E) => void, key?: K): Disposable { + const id = this.freshId + this.freshId += 1 + if (key) { + const handlersForKey = this.handlersWithKey.get(key) ?? [] + handlersForKey.push(handler) + this.handlersWithKey.set(key, handlersForKey) + } else { + this.handlers.set(id, handler) + } + return { + dispose: () => { + if (key) { + const handlersForKey = this.handlersWithKey.get(key) ?? [] + // We assume that no key has so many handlers registered + // that the linear `filter` operation becomes a perf issue. + this.handlersWithKey.set( + key, + handlersForKey.filter(h => h !== handler), + ) + } else { + this.handlers.delete(id) + } + }, + } + } + + /** + * Propagate the event to registered handlers. + * + * The event is propagated to all keyless handlers. + * Furthermore if `key` is provided, + * the event is also propagated to handlers registered with that key. + */ + fire(event: E, key?: K): void { + this.current = event + for (const h of this.handlers.values()) { + h(event) + } + if (key) { + const handlersForKey = this.handlersWithKey.get(key) ?? [] + for (const h of handlersForKey) { + h(event) + } + } + } +} + +type ExcludeNonEvent = T extends (...args: any) => Promise ? U : never + +/** + * Turn all fields in `T` which extend `(...args: As) => Promise` + * into event emitter fields `f: EventEmitter`. + * Other fields are removed. + */ +export type Eventify = { + [P in keyof T as ExcludeNonEvent]: T[P] extends (arg: infer A) => Promise + ? EventEmitter + : T[P] extends (...args: infer As) => Promise + ? EventEmitter + : never +} diff --git a/client/src/components/infoview/editor/util.ts b/client/src/components/infoview/editor/util.ts new file mode 100644 index 00000000..2c0f3ad4 --- /dev/null +++ b/client/src/components/infoview/editor/util.ts @@ -0,0 +1,51 @@ +import type { DocumentUri, Position, Range, TextDocumentPositionParams } from 'vscode-languageserver-protocol' +import { EventEmitter } from './event' +import React from 'react' + +/** A document URI and a position in that document. */ +export interface DocumentPosition extends Position { + uri: DocumentUri +} + +export namespace DocumentPosition { + export function isEqual(p1: DocumentPosition, p2: DocumentPosition): boolean { + return p1.uri === p2.uri && p1.line === p2.line && p1.character === p2.character + } + + export function toTdpp(p: DocumentPosition): TextDocumentPositionParams { + return { textDocument: { uri: p.uri }, position: { line: p.line, character: p.character } } + } + + export function toString(p: DocumentPosition) { + return `${p.uri}:${p.line + 1}:${p.character}` + } +} + +/** + * A piece of React {@link React.useState} which returns the data that `ev` most recently fired with. + * If `f` is provided, the data is mapped through `f` first. */ +export function useEventResult(ev: EventEmitter): E | undefined +export function useEventResult(ev: EventEmitter, f: (newVal: E) => T): T | undefined +export function useEventResult(ev: EventEmitter, f?: (_: E) => T): T | undefined { + const fn: (_: E) => T = f ?? (x => x as any) + const [s, setS] = React.useState(ev.current ? fn(ev.current) : undefined) + useEvent(ev, newV => setS(newV ? fn(newV) : undefined)) + return s +} + +/** + * A specialization of {@link React.useEffect} which executes `f` with the event data + * whenever `ev` fires. + * If `key` is provided, `f` is only invoked on events fired with that key. + */ +export function useEvent( + ev: EventEmitter, + f: (_: E) => void, + dependencies?: React.DependencyList, + key?: K, +): void { + React.useEffect(() => { + const h = ev.on(f, key) + return () => h.dispose() + }, dependencies) +} From 36ad0b171366912a093f690a184be43aa265c2c4 Mon Sep 17 00:00:00 2001 From: matlorr Date: Tue, 19 May 2026 09:35:48 +0200 Subject: [PATCH 19/36] Copied over PlayableLevel component --- client/src/components/level/PlayableLevel.tsx | 751 ++++++++++++++++++ 1 file changed, 751 insertions(+) create mode 100644 client/src/components/level/PlayableLevel.tsx diff --git a/client/src/components/level/PlayableLevel.tsx b/client/src/components/level/PlayableLevel.tsx new file mode 100644 index 00000000..4f8c4009 --- /dev/null +++ b/client/src/components/level/PlayableLevel.tsx @@ -0,0 +1,751 @@ +import React from "react" +import Split from 'react-split' + +import { useAtom } from "jotai" +import { useTranslation } from "react-i18next" +import { useGameTranslation } from "../../utils/translation" +import { useEffect, useRef, useState } from "react" +import { codeAtom, editorConnectionAtom, leanMonacoAtom, leanMonacoEditorAtom, lockEditorModeAtom, proofAtom, typewriterModeAtom } from "../../store/editor-atoms" +import { gameIdAtom, levelIdAtom, worldIdAtom } from "../../store/location-atoms" +import { mobileAtom } from "../../store/preferences-atoms" +import { gameInfoAtom, levelInfoAtom } from "../../store/query-atoms" +import { LeanMonacoEditor } from "lean4monaco" +import { EditorApi, InfoviewApi } from '@leanprover/infoview-api' +import { EventEmitter } from "lean4monaco/dist/vscode-lean4/lean4-infoview/src/infoview/event" +import { EditorConnection, EditorEvents } from "lean4monaco/dist/vscode-lean4/lean4-infoview/src/infoview/editorConnection" +import { CircularProgress } from '@mui/material' +import { LevelAppBar } from "../app_bar" +import { GameEditor } from '../infoview/GameEditor' +import { InventoryPanel } from '../inventory/inventory_panel' +import { deletedChatAtom, helpAtom, selectedStepAtom } from "../../store/chat-atoms" +import { DeletedHints, filterHints, Hint, Hints, MoreHelpButton } from "../hints" +import { Markdown } from '../markdown' +import { Button } from '../button' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { faHome, faArrowRight } from '@fortawesome/free-solid-svg-icons' +import { Location } from "vscode-languageserver-types" + + +export function PlayableLevel() { + let { t } = useTranslation() + const { t : gT } = useGameTranslation() + const codeviewRef = useRef(null) + const infoviewRef = useRef(null) + const [leanMonaco] = useAtom(leanMonacoAtom) + const [gameId] = useAtom(gameIdAtom) + const [worldId] = useAtom(worldIdAtom) + const [levelId] = useAtom(levelIdAtom) + const [typewriterMode, setTypewriterMode] = useAtom(typewriterModeAtom) + const [mobile] = useAtom(mobileAtom) + const [code, setCode] = useAtom(codeAtom) + const [{ data: gameInfo }] = useAtom(gameInfoAtom) + const [{ data: levelInfo, isLoading: levelInfoIsLoading }] = useAtom(levelInfoAtom) + // Only for mobile layout + const [pageNumber, setPageNumber] = useState(0) + // set to true to prevent switching between typewriter and editor + const [lockEditorMode] = useAtom(lockEditorModeAtom) + const [, setTypewriterInput] = useState("") + const lastLevel = worldId && (levelId !== undefined) && levelId >= (gameInfo?.worldSize?.[worldId] ?? 0) + + // When clicking on an inventory item, the inventory is overlayed by the item's doc. + // If this state is set to a pair `(name, type)` then the according doc will be open. + // Set `inventoryDoc` to `null` to close the doc + const [inventoryDoc, setInventoryDoc] = useState<{name: string, type: string} | null>(null) + function closeInventoryDoc () {setInventoryDoc(null)} + + // Keep as useState<>() because leanMonacoEditor is local to this component + const [leanMonacoEditor, setLeanMonacoEditor] = useState(null) + + //const [editorConnection, setEditorConnection] = useState(null) + const [editorConnection, setEditorConnection] = useAtom(editorConnectionAtom) + const [, setMonacoEditor] = useAtom(leanMonacoEditorAtom) + + // REPLACES EditorContext + // Start the editor + useEffect(() => { + if (leanMonaco) { + const leanMonacoEditor = new LeanMonacoEditor() + const uriStr = `file:///${worldId}/${levelId}.lean` + + ;(async () => { + await leanMonaco!.whenReady + console.debug('[demo]: starting editor') + await leanMonacoEditor.start(codeviewRef.current!, uriStr, code ?? "") + console.debug('[demo]: editor started') + setLeanMonacoEditor(leanMonacoEditor) + + // Set atom so other components can access it + setMonacoEditor(leanMonacoEditor.editor ?? null) + + // TODO: old stuff from here on + + const infoProvider = leanMonaco.infoProvider as { + editorApi: EditorApi + webviewPanel?: { api: InfoviewApi, visible: boolean } + sendConfig?: () => Promise + sendPosition?: () => Promise + } | undefined + + if (!infoProvider?.editorApi) { + console.warn('Lean infoview is not ready yet.') + return + } + + const editorEvents: EditorEvents = { + initialize: new EventEmitter(), + gotServerNotification: new EventEmitter(), + sentClientNotification: new EventEmitter(), + serverRestarted: new EventEmitter(), + serverStopped: new EventEmitter(), + changedCursorLocation: new EventEmitter(), + changedInfoviewConfig: new EventEmitter(), + runTestScript: new EventEmitter(), + requestedAction: new EventEmitter(), + clickedContextMenu: new EventEmitter() + } + + const infoviewApi: InfoviewApi = { + initialize: async l => editorEvents.initialize.fire(l), + gotServerNotification: async (method, params) => { + editorEvents.gotServerNotification.fire([method, params]) + }, + sentClientNotification: async (method, params) => { + editorEvents.sentClientNotification.fire([method, params]) + }, + serverRestarted: async r => editorEvents.serverRestarted.fire(r), + serverStopped: async serverStoppedReason => { + editorEvents.serverStopped.fire(serverStoppedReason) + }, + changedCursorLocation: async loc => editorEvents.changedCursorLocation.fire(loc), + changedInfoviewConfig: async conf => editorEvents.changedInfoviewConfig.fire(conf), + requestedAction: async action => editorEvents.requestedAction.fire(action), + runTestScript: async script => new Function(script)(), + getInfoviewHtml: async () => document.body.innerHTML, + clickedContextMenu: async () => console.log("clickedContextMenu") + } + + infoProvider.webviewPanel = { + api: infoviewApi, + visible: true, + } + + const editorConnection = new EditorConnection(infoProvider.editorApi, editorEvents) + setEditorConnection(editorConnection) + + const model = leanMonacoEditor.editor?.getModel() + const fireCursorLocation = () => { + const selection = leanMonacoEditor.editor?.getSelection() + const position = leanMonacoEditor.editor?.getPosition() + if (!selection || !model) { + if (!position || !model) { + return + } + const loc: Location = { + uri: model.uri.toString(), + range: { + start: { + line: position.lineNumber - 1, + character: position.column - 1, + }, + end: { + line: position.lineNumber - 1, + character: position.column - 1, + }, + }, + } + editorEvents.changedCursorLocation.fire(loc) + return + } + const loc: Location = { + uri: model.uri.toString(), + range: { + start: { + line: selection.startLineNumber - 1, + character: selection.startColumn - 1, + }, + end: { + line: selection.endLineNumber - 1, + character: selection.endColumn - 1, + }, + }, + } + editorEvents.changedCursorLocation.fire(loc) + } + + fireCursorLocation() + leanMonacoEditor.editor?.onDidChangeCursorSelection(() => { + fireCursorLocation() + }) + + await infoProvider.sendConfig?.() + await infoProvider.sendPosition?.() + + })() + + return () => { + leanMonacoEditor.dispose() + // Clean up atoms + setEditorConnection(null) + setMonacoEditor(null) + } + } + }, [leanMonaco, worldId, levelId]) + + // Persist editor text into progress whenever the model changes. + useEffect(() => { + const editor = leanMonacoEditor?.editor + if (!editor) { + return + } + + setCode(editor.getValue()) + const disposable = editor.onDidChangeModelContent(() => { + setCode(editor.getValue()) + }) + + return () => { + disposable.dispose() + } + }, [leanMonacoEditor?.editor, setCode]) + + // /** Unused. Was implementing an undo button, which has been replaced by `deleteProof` inside + // * `TypewriterInterface`. + // */ + // const handleUndo = () => { + // const endPos = leanMonacoEditor?.editor.getModel().getFullModelRange().getEndPosition() + // let range + // console.log(endPos?.column) + // if (endPos?.column === 1) { + // range = monaco.Selection.fromPositions( + // new monaco.Position(endPos.lineNumber - 1, 1), + // endPos + // ) + // } else { + // range = monaco.Selection.fromPositions( + // new monaco.Position(endPos.lineNumber, 1), + // endPos + // ) + // } + // leanMonacoEditor?.editor.executeEdits("undo-button", [{ + // range, + // text: "", + // forceMoveMarkers: false + // }]); + // } + + // Select and highlight proof steps and corresponding hints + // TODO: with the new design, there is no difference between the introduction and + // a hint at the beginning of the proof... + const [selectedStep, setSelectedStep] = useState() + + + useEffect (() => { + // Lock editor mode + if (levelInfo?.template) { + const model = leanMonacoEditor?.editor?.getModel() + + if (model) { + let code = model.getLinesContent() + + // console.log(`insert. code: ${code}`) + // console.log(`insert. join: ${code.join('')}`) + // console.log(`insert. trim: ${code.join('').trim()}`) + // console.log(`insert. length: ${code.join('').trim().length}`) + // console.log(`insert. range: ${editor.getModel().getFullModelRange()}`) + + + // TODO: It does seem that the template is always indented by spaces. + // This is a hack, assuming there are exactly two. + if (!code.join("").trim().length) { + console.debug(`inserting template:\n${levelInfo?.template}`) + // TODO: This does not work! HERE + // Probably overwritten by a query to the server + leanMonacoEditor?.editor.executeEdits("template-writer", [{ + range: model.getFullModelRange(), + text: levelInfo?.template + `\n`, + forceMoveMarkers: true + }]) + } else { + console.debug(`not inserting template.`) + } + } + } else { + } + }, [levelInfo, levelId, worldId, gameId, leanMonacoEditor?.editor]) + + + useEffect(() => { + // TODO: That's a problem if the saved proof contains an error + // Reset command line input when loading a new level + setTypewriterInput("") + + }, [gameId, worldId, levelId]) + + useEffect(() => { + const editor = leanMonacoEditor?.editor; + const selection = editor?.getSelection(); + if (!typewriterMode && editor && selection) { + // Delete last input attempt from command line + editor.executeEdits("typewriter", [{ + range: selection, + text: "", + forceMoveMarkers: false + }]); + leanMonacoEditor?.editor.focus() + } + }, [typewriterMode]) + + // useEffect(() => { + // // Forget whether hidden hints are displayed for steps that don't exist yet + // if (proof?.steps.length) { + // console.debug(Array.from(help)) + // setHelp([...new Set(Array.from(help).filter(i => (i < proof?.steps.length)))]) + // } + // }, [proof]) + + // // save showed help in store + // useEffect(() => { + // if (proof?.steps.length) { + // console.debug(`showHelp:\n ${showHelp}`) + // setHelp(Array.from(showHelp)) + // } + // }, [showHelp]) + + // Effect when command line mode gets enabled + useEffect(() => { + const model = leanMonacoEditor?.editor?.getModel() + if (model&& (typewriterMode)) { + let code = model.getLinesContent().filter(line => line.trim()) + leanMonacoEditor?.editor.executeEdits("typewriter", [{ + range: model.getFullModelRange(), + text: code.length ? code.join('\n') + '\n' : '', + forceMoveMarkers: true + }]); + + // let endPos = editor.getModel().getFullModelRange().getEndPosition() + // if (editor.getModel().getLineContent(endPos.lineNumber).trim() !== "") { + // editor.executeEdits("typewriter", [{ + // range: monaco.Selection.fromPositions(endPos, endPos), + // text: "\n", + // forceMoveMarkers: true + // }]); + // } + // let endPos = editor.getModel().getFullModelRange().getEndPosition() + // let currPos = editor.getPosition() + // if (currPos.column != 1 || (currPos.lineNumber != endPos.lineNumber && currPos.lineNumber != endPos.lineNumber - 1)) { + // // This is not a position that would naturally occur from Typewriter, reset: + // editor.setSelection(monaco.Selection.fromPositions(endPos, endPos)) + // } + } + }, [leanMonacoEditor, leanMonacoEditor?.editor, typewriterMode, lockEditorMode]) + + return <> +
+ + {mobile? + // TODO: This is copied from the `Split` component below... + <> +
+ + +
+ + : + + + + + + } + +} + + +export function PlayableLevelIGNORE() { + let { t } = useTranslation() + const { t : gT } = useGameTranslation() + const codeviewRef = useRef(null) + const [leanMonaco] = useAtom(leanMonacoAtom) + const [gameId] = useAtom(gameIdAtom) + const [worldId] = useAtom(worldIdAtom) + const [levelId] = useAtom(levelIdAtom) + const [typewriterMode, setTypewriterMode] = useAtom(typewriterModeAtom) + const [mobile] = useAtom(mobileAtom) + const [code, setCode] = useAtom(codeAtom) + const [{ data: gameInfo }] = useAtom(gameInfoAtom) + const [{ data: levelInfo, isLoading: levelInfoIsLoading }] = useAtom(levelInfoAtom) + // Only for mobile layout + const [pageNumber, setPageNumber] = useState(0) + // set to true to prevent switching between typewriter and editor + const [lockEditorMode] = useAtom(lockEditorModeAtom) + const [, setTypewriterInput] = useState("") + const lastLevel = worldId && (levelId !== undefined) && levelId >= (gameInfo?.worldSize?.[worldId] ?? 0) + + // When clicking on an inventory item, the inventory is overlayed by the item's doc. + // If this state is set to a pair `(name, type)` then the according doc will be open. + // Set `inventoryDoc` to `null` to close the doc + const [inventoryDoc, setInventoryDoc] = useState<{name: string, type: string} | null>(null) + function closeInventoryDoc () {setInventoryDoc(null)} + + const [leanMonacoEditor, setLeanMonacoEditor] = useAtom(leanMonacoEditorAtom)//useState(null) + const [editorConnection, setEditorConnection] = useAtom(editorConnectionAtom) + + // Start the editor + useEffect(() => { + if (leanMonaco) { + const leanMonacoEditor = new LeanMonacoEditor() + const uriStr = `file:///${worldId}/${levelId}.lean` + + ;(async () => { + await leanMonaco!.whenReady + console.debug('[demo]: starting editor') + await leanMonacoEditor.start(codeviewRef.current!, uriStr, code ?? "") + console.debug('[demo]: editor started') + setLeanMonacoEditor(leanMonacoEditor.editor) + })() + + const infoProvider = leanMonaco.infoProvider as { + editorApi: EditorApi + webviewPanel?: { api: InfoviewApi, visible: boolean } + sendConfig?: () => Promise + sendPosition?: () => Promise + } | undefined + + if (!infoProvider?.editorApi) { + console.warn('Lean infoview is not ready yet.') + return + } + + const editorEvents: EditorEvents = { + initialize: new EventEmitter(), + gotServerNotification: new EventEmitter(), + sentClientNotification: new EventEmitter(), + serverRestarted: new EventEmitter(), + serverStopped: new EventEmitter(), + changedCursorLocation: new EventEmitter(), + changedInfoviewConfig: new EventEmitter(), + runTestScript: new EventEmitter(), + requestedAction: new EventEmitter(), + clickedContextMenu: new EventEmitter() + } + + const infoviewApi: InfoviewApi = { + initialize: async l => editorEvents.initialize.fire(l), + gotServerNotification: async (method, params) => { + editorEvents.gotServerNotification.fire([method, params]) + }, + sentClientNotification: async (method, params) => { + editorEvents.sentClientNotification.fire([method, params]) + }, + serverRestarted: async r => editorEvents.serverRestarted.fire(r), + serverStopped: async serverStoppedReason => { + editorEvents.serverStopped.fire(serverStoppedReason) + }, + changedCursorLocation: async loc => editorEvents.changedCursorLocation.fire(loc), + changedInfoviewConfig: async conf => editorEvents.changedInfoviewConfig.fire(conf), + requestedAction: async action => editorEvents.requestedAction.fire(action), + runTestScript: async script => new Function(script)(), + getInfoviewHtml: async () => document.body.innerHTML, + clickedContextMenu: async () => console.log("ClickedContextMenu") + } + + infoProvider.webviewPanel = { + api: infoviewApi, + visible: true, + } + + const editorConnection = new EditorConnection(infoProvider.editorApi, editorEvents) + setEditorConnection(editorConnection) + console.log("Editor Connection Atom has been initialized.") + + return () => { + leanMonacoEditor.dispose() + } + } + }, [leanMonaco, worldId, levelId]) + + // Persist editor text into progress whenever the model changes. + useEffect(() => { + const editor = leanMonacoEditor //?.editor + if (!editor) { + return + } + + setCode(editor.getValue()) + const disposable = editor.onDidChangeModelContent(() => { + setCode(editor.getValue()) + }) + + return () => { + disposable.dispose() + } + }, [leanMonacoEditor, setCode]) //[leanMonacoEditor?.editor, setCode]) + + // Select and highlight proof steps and corresponding hints + // TODO: with the new design, there is no difference between the introduction and + // a hint at the beginning of the proof... + const [selectedStep, setSelectedStep] = useState() + + useEffect (() => { + // Lock editor mode + if (levelInfo?.template) { + const model = leanMonacoEditor?.getModel()//leanMonacoEditor?.editor?.getModel() + + if (model) { + let code = model.getLinesContent() + + // TODO: It does seem that the template is always indented by spaces. + // This is a hack, assuming there are exactly two. + if (!code.join("").trim().length) { + console.debug(`inserting template:\n${levelInfo?.template}`) + // TODO: This does not work! HERE + // Probably overwritten by a query to the server + // DOC: REMOVED editor here + leanMonacoEditor?.executeEdits("template-writer", [{ + range: model.getFullModelRange(), + text: levelInfo?.template + `\n`, + forceMoveMarkers: true + }]) + } else { + console.debug(`not inserting template.`) + } + } + } else { + } + }, [levelInfo, levelId, worldId, gameId, leanMonacoEditor]) //[levelInfo, levelId, worldId, gameId, leanMonacoEditor?.editor]) + + + useEffect(() => { + // TODO: That's a problem if the saved proof contains an error + // Reset command line input when loading a new level + setTypewriterInput("") + + }, [gameId, worldId, levelId]) + + useEffect(() => { + const editor = leanMonacoEditor //?.editor; + const selection = editor?.getSelection(); + if (!typewriterMode && editor && selection) { + // Delete last input attempt from command line + editor.executeEdits("typewriter", [{ + range: selection, + text: "", + forceMoveMarkers: false + }]); + leanMonacoEditor?.focus() // leanMonacoEditor?.editor.focus() + } + }, [typewriterMode]) + + // useEffect(() => { + // // Forget whether hidden hints are displayed for steps that don't exist yet + // if (proof?.steps.length) { + // console.debug(Array.from(help)) + // setHelp([...new Set(Array.from(help).filter(i => (i < proof?.steps.length)))]) + // } + // }, [proof]) + + // // save showed help in store + // useEffect(() => { + // if (proof?.steps.length) { + // console.debug(`showHelp:\n ${showHelp}`) + // setHelp(Array.from(showHelp)) + // } + // }, [showHelp]) + + // Effect when command line mode gets enabled + useEffect(() => { + const model = leanMonacoEditor?.getModel() //.editor?.getModel() + if (model&& (typewriterMode)) { + let code = model.getLinesContent().filter(line => line.trim()) + // REMOVED .editor call + leanMonacoEditor?.executeEdits("typewriter", [{ + range: model.getFullModelRange(), + text: code.length ? code.join('\n') + '\n' : '', + forceMoveMarkers: true + }]); + + // let endPos = editor.getModel().getFullModelRange().getEndPosition() + // if (editor.getModel().getLineContent(endPos.lineNumber).trim() !== "") { + // editor.executeEdits("typewriter", [{ + // range: monaco.Selection.fromPositions(endPos, endPos), + // text: "\n", + // forceMoveMarkers: true + // }]); + // } + // let endPos = editor.getModel().getFullModelRange().getEndPosition() + // let currPos = editor.getPosition() + // if (currPos.column != 1 || (currPos.lineNumber != endPos.lineNumber && currPos.lineNumber != endPos.lineNumber - 1)) { + // // This is not a position that would naturally occur from Typewriter, reset: + // editor.setSelection(monaco.Selection.fromPositions(endPos, endPos)) + // } + } + }, [leanMonacoEditor, leanMonacoEditor, typewriterMode, lockEditorMode]) //[leanMonacoEditor, leanMonacoEditor?.editor, typewriterMode, lockEditorMode]) + + return <> + { levelInfoIsLoading &&
} + + {mobile? + // TODO: This is copied from the `Split` component below... + <> +
+ + +
+ + : + + + + + + } + +} + +function ExercisePanel({codeviewRef, visible=true}: {codeviewRef: React.MutableRefObject, visible?: boolean}) { + return
+ +
+} + +function ChatPanel({lastLevel, visible = true}: {lastLevel: boolean, visible: boolean}) { + let { t } = useTranslation() + const { t : gT } = useGameTranslation() + const chatRef = useRef(null) + const [mobile] = useAtom(mobileAtom) + const [gameId, navigateToGame] = useAtom(gameIdAtom) + const [levelId, navigateToLevel] = useAtom(levelIdAtom) + const [{ data: levelInfo }] = useAtom(levelInfoAtom) + const [help] = useAtom(helpAtom) + const [proof] = useAtom(proofAtom) + const [deletedChat] = useAtom(deletedChatAtom) + const [selectedStep, setSelectedStep] = useAtom(selectedStepAtom) + + let k = proof?.steps.length ? proof?.steps.length - (lastStepHasErrors(proof) ? 2 : 1) : 0 + + function toggleSelection(line: number) { + return (ev: any) => { + console.debug('toggled selection') + if (selectedStep == line) { + setSelectedStep(undefined) + } else { + setSelectedStep(line) + } + } + } + + useEffect(() => { + // TODO: For some reason this is always called twice + console.debug('scroll chat') + if (!mobile) { + chatRef.current!.lastElementChild?.scrollIntoView() //scrollTo(0,0) + } + }, [proof, help]) + + // Scroll to element if selection changes + useEffect(() => { + if (typeof selectedStep !== 'undefined') { + Array.from(chatRef.current!.getElementsByClassName(`step-${selectedStep}`)).map((elem) => { + elem.scrollIntoView({block: "center"}) + }) + } + }, [selectedStep]) + + // useEffect(() => { + // // // Scroll to top when loading a new level + // // // TODO: Thats the wrong behaviour probably + // // chatRef.current!.scrollTo(0,0) + // }, [gameId, worldId, levelId]) + + let introText: Array = gT(levelInfo?.introduction ?? "").split(/\n(\s*\n)+/) + + const focusRef = useRef() + useEffect(() => { + if (proof?.completed) { + focusRef.current?.focus() + } + }, [!!proof?.completed]) + + return
+
+ {introText?.filter(it => it.trim()).map(((it, i) => + // Show the level's intro text as hints, too + + ))} + {proof?.steps.map((step, i) => { + let filteredHints = filterHints(step.goals[0]?.hints, proof?.steps[i-1]?.goals[0]?.hints) + if (step.goals.length > 0 && !isLastStepWithErrors(proof, i)) { + return + } + })} + + {/* {modifiedHints.map((step, i) => { + // It the last step has errors, it will have the same hints + // as the second-to-last step. Therefore we should not display them. + if (!(i == proof?.steps.length - 1 && withErr)) { + // TODO: Should not use index as key. + return + } + })} */} + + {proof?.completed && + <> +
+ {t("Level completed! 🎉")} +
+ {levelInfo?.conclusion?.trim() && +
+ {gT(levelInfo?.conclusion ?? "")} +
+ } + + } +
+
+ {proof?.completed && (lastLevel ? + : + ) + } + +
+
+} + + +//FIXME: implement +function lastStepHasErrors(x: any) {return false} + + +//FIXME: implement +function isLastStepWithErrors(x: any, y: any) {return false} From dfd4752cae887f33d2cfe918c3af0cd9d08cc41a Mon Sep 17 00:00:00 2001 From: matlorr Date: Tue, 19 May 2026 09:42:09 +0200 Subject: [PATCH 20/36] Delete re-implementation of withRpcSessions as it is not needed to create and instantiate a global rpcSession. --- .../typewriter/TypewriterCommandline.tsx | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/client/src/components/infoview/typewriter/TypewriterCommandline.tsx b/client/src/components/infoview/typewriter/TypewriterCommandline.tsx index 3258ad77..adda5687 100644 --- a/client/src/components/infoview/typewriter/TypewriterCommandline.tsx +++ b/client/src/components/infoview/typewriter/TypewriterCommandline.tsx @@ -7,12 +7,12 @@ import { faWandMagicSparkles } from "@fortawesome/free-solid-svg-icons"; import { useAtom, useSetAtom } from "jotai"; import * as monaco from 'monaco-editor' -import { typewriterContentAtom, restoreErrorCommandEffect, oneLineEditorAtom, syncTypewriterToEditorEffect, syncEditorPositionEffect, isProcessingAtom, runCommandAtom } from "../../../store/editor-atoms"; +import { typewriterContentAtom, restoreErrorCommandEffect, oneLineEditorAtom, syncTypewriterToEditorEffect, syncEditorPositionEffect, isProcessingAtom, runCommandAtom, editorConnectionAtom, rpcSessionAtPosAtom, leanMonacoEditorModelAtom, rpcSessionsAtom } from "../../../store/editor-atoms"; import { proofAtom } from "../../../store/editor-atoms"; // bitte schön :) -import { RpcSessions } from "@leanprover/infoview-api"; -import { WithRpcSessions } from "lean4monaco"; +// Diese imports habe ich doch woanders gebraucht +import { DocumentPosition, RpcSessionAtPos, RpcSessions} from "lean4monaco"; /** The input field */ export function TypewriterCommandLine() { @@ -147,13 +147,13 @@ export function TypewriterCommandLine() { // do not display if the proof is completed (with potential warnings still present) return
-
-
-
-
- - -
+
+
+
+
+ + +
} From 3b391c12c169c30a51c9ae7c2a96e8f6b62c8860 Mon Sep 17 00:00:00 2001 From: matlorr Date: Tue, 19 May 2026 09:46:38 +0200 Subject: [PATCH 21/36] Copied over error message component --- .../components/infoview/messages/Error.tsx | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 client/src/components/infoview/messages/Error.tsx diff --git a/client/src/components/infoview/messages/Error.tsx b/client/src/components/infoview/messages/Error.tsx new file mode 100644 index 00000000..e43c0459 --- /dev/null +++ b/client/src/components/infoview/messages/Error.tsx @@ -0,0 +1,43 @@ +// TODO: Should not use index as key. +import React from "react"; + +import { InteractiveDiagnostic } from "@leanprover/infoview-api"; +import { DiagnosticSeverity } from "vscode-languageserver-types"; + +/** A list of messages (info/warning/error) that are produced after this command */ +export function Errors ({errors, typewriterMode} : {errors : InteractiveDiagnostic[], typewriterMode : boolean}) { + return
+ {errors.map((err, i) => ())} +
+} + +/** A list of messages (info/warning/error) that are produced after this command */ +function Error({error, typewriterMode} : {error : InteractiveDiagnostic, typewriterMode : boolean}) { + // The first step will always have an empty command + + const severityClass = error.severity ? { + [DiagnosticSeverity.Error]: 'error', + [DiagnosticSeverity.Warning]: 'warning', + [DiagnosticSeverity.Information]: 'information', + [DiagnosticSeverity.Hint]: 'hint', + }[error.severity] : ''; + + const {line, character} = error.range.start; + const title = `Line ${line+1}, Character ${character}`; + + // Hide "unsolved goals" messages + let message; + if ("append" in error.message && "text" in error.message.append[0] && + error.message?.append[0].text === "unsolved goals") { + message = error.message.append[0] + } else { + message = error.message + } + + return
+ {!typewriterMode &&

{title}

} +
+      

message

+
+
+} From 54c76fcbeaf21b1d7f088bff981a970dbe4b9a67 Mon Sep 17 00:00:00 2001 From: Jon Eugster Date: Tue, 19 May 2026 23:07:20 +0100 Subject: [PATCH 22/36] working RPC session initialisation and receiving proof step; and installed twMerge btw --- client/src/components/infoview/typewriter.tsx | 14 +- .../typewriter/TypewriterCommandline.tsx | 95 ++- client/src/components/level.tsx | 89 ++- client/src/components/level/PlayableLevel.tsx | 751 ------------------ client/src/components/main/Main.tsx | 4 +- .../components/main/TypewriterInterface.tsx | 4 +- client/src/store/chat-atoms.ts | 2 +- client/src/store/editor-atoms.ts | 174 +++- package-lock.json | 13 + package.json | 3 + server/GameServer/RpcHandlers.lean | 2 - 11 files changed, 281 insertions(+), 870 deletions(-) delete mode 100644 client/src/components/level/PlayableLevel.tsx diff --git a/client/src/components/infoview/typewriter.tsx b/client/src/components/infoview/typewriter.tsx index a2cf310b..90928bd3 100644 --- a/client/src/components/infoview/typewriter.tsx +++ b/client/src/components/infoview/typewriter.tsx @@ -1,19 +1,9 @@ import React, { MutableRefObject, useEffect, useRef, useState } from "react"; import "../../css/typewriter.css" -import { useTranslation } from "react-i18next"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { faWandMagicSparkles } from "@fortawesome/free-solid-svg-icons"; -import { useAtom, useSetAtom } from "jotai"; +import { useAtom } from "jotai"; import { gameInfoAtom } from "../../store/query-atoms"; - -import { DiagnosticSeverity, PublishDiagnosticsParams, DocumentUri } from 'vscode-languageserver-protocol'; -import * as monaco from 'monaco-editor' -import { levelIdAtom, gameIdAtom, worldIdAtom } from "../../store/location-atoms"; -import { leanMonacoEditorAtom, typewriterContentAtom, interimDiagsAtom, crashedAtom, leanMonacoEditorModelAtom, leanMonacoEditorUriAtom, hasLeanMonacoEditorAtom, lastProofStepErrorCommandAtom, restoreErrorCommandEffect, oneLineEditorAtom, syncTypewriterToEditorEffect, syncEditorPositionEffect, isProcessingAtom, runCommandAtom } from "../../store/editor-atoms"; -import { deletedChatAtom } from '../../store/chat-atoms' -import { preferencesAtom } from '../../store/preferences-atoms' - +import { gameIdAtom, worldIdAtom } from "../../store/location-atoms"; import path from "node:path"; import { proofAtom } from "../../store/editor-atoms"; import { ExerciseStatement } from "./ExerciseStatement"; diff --git a/client/src/components/infoview/typewriter/TypewriterCommandline.tsx b/client/src/components/infoview/typewriter/TypewriterCommandline.tsx index adda5687..739078f0 100644 --- a/client/src/components/infoview/typewriter/TypewriterCommandline.tsx +++ b/client/src/components/infoview/typewriter/TypewriterCommandline.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef } from "react"; +import React, { FormEvent, useCallback, useEffect, useRef, useState } from "react"; import "../../../css/typewriter.css" import { useTranslation } from "react-i18next"; @@ -7,22 +7,30 @@ import { faWandMagicSparkles } from "@fortawesome/free-solid-svg-icons"; import { useAtom, useSetAtom } from "jotai"; import * as monaco from 'monaco-editor' -import { typewriterContentAtom, restoreErrorCommandEffect, oneLineEditorAtom, syncTypewriterToEditorEffect, syncEditorPositionEffect, isProcessingAtom, runCommandAtom, editorConnectionAtom, rpcSessionAtPosAtom, leanMonacoEditorModelAtom, rpcSessionsAtom } from "../../../store/editor-atoms"; +import { typewriterContentAtom, oneLineEditorAtom, runCommandAtom, codeAtom, modelAtom, uriAtom, currentRpcSessionAtom } from "../../../store/editor-atoms"; import { proofAtom } from "../../../store/editor-atoms"; -// bitte schön :) -// Diese imports habe ich doch woanders gebraucht -import { DocumentPosition, RpcSessionAtPos, RpcSessions} from "lean4monaco"; +import { twMerge } from "tailwind-merge"; +import { DocumentPosition } from "lean4monaco"; +import { levelIdAtom, worldIdAtom } from "../../../store/location-atoms"; +import { ProofState } from "../../../api/rpc_api"; /** The input field */ export function TypewriterCommandLine() { let { t } = useTranslation() + const [worldId] = useAtom(worldIdAtom) + const [levelId] = useAtom(levelIdAtom) + const [isProcessing, setProcessing] = useState(false) + const [oneLineEditor, setOneLineEditor] = useAtom(oneLineEditorAtom) + const [model] = useAtom(modelAtom) + const [uri] = useAtom(uriAtom) + const [code] = useAtom(codeAtom) + const [rpcSess] = useAtom(currentRpcSessionAtom) + const [proof, setProof] = useAtom(proofAtom) + + const [typewriter, setTypewriter] = useAtom(typewriterContentAtom) - const [oneLineEditor, setOneLineEditor] = useAtom(oneLineEditorAtom) - const [proof] = useAtom(proofAtom) - const [isProcessing] = useAtom(isProcessingAtom) - const runCommand = useSetAtom(runCommandAtom) // useAtom(syncTypewriterToEditorEffect) // useAtom(syncEditorPositionEffect) @@ -31,6 +39,40 @@ export function TypewriterCommandLine() { const oneLineEditorRef = useRef(null) const inputRef = useRef(null) + /** Handle keyboard events */ + function handleKeyDown(event: KeyboardEvent) { + if (event.key == "Enter" || event.key == "NumpadEnter") { + event.preventDefault() + handleSubmit() + } + } + + //const oneLineEditorRef = useRef(null) + //const [typewriterContent, setTypewriterContent] = useState("") + //const leanMonacoEditor = useAtom(editorAtom) + //let inputRef = getInputRef() + + /** Wrapper to set the typewriter to processing. */ + function handleSubmit(ev?: FormEvent) { + ev?.preventDefault() + setProcessing(true) + try { handleSubmitAux(ev) } finally { setProcessing(false) } + } + + /** Process the entered command */ + async function handleSubmitAux(ev?: FormEvent) { + const content = typewriter.trimEnd() + if (content.length == 0) return + + // Add typewriter content to the editor + const oldCode = code?.trimEnd() ?? '' + const newCode = oldCode.length > 0 ? `${oldCode}\n${content}\n` : `${content}\n` + model?.setValue(newCode) + + // Clear typewriter + oneLineEditor?.getModel()?.setValue('') + } + useEffect(() => { console.log("TypewriterCommandLine: Editor initialization useEffect") @@ -88,7 +130,6 @@ export function TypewriterCommandLine() { const height = Math.min(myEditor.getContentHeight(), lineHeight + 2, window.innerHeight / 3) if (inputRef.current) { inputRef.current.style.height = `${height}px` - console.log(`Single-line editor width: ${inputRef.current.clientWidth}`) myEditor.layout({ width: inputRef.current.clientWidth, height @@ -101,7 +142,6 @@ export function TypewriterCommandLine() { const changeDisposable = myEditor.getModel()?.onDidChangeContent(() => { const value = myEditor.getValue() - console.log(`Editor content changed to: "${value}"`) setTypewriter(value) // Prevent newlines (single-line behavior) @@ -111,14 +151,6 @@ export function TypewriterCommandLine() { } }) - const keyDownDisposable = myEditor.onKeyDown((ev) => { - if (ev.code === "Enter" || ev.code === "NumpadEnter") { - ev.preventDefault() - console.log("Enter pressed, running command...") - runCommand() - } - }) - oneLineEditorRef.current = myEditor setOneLineEditor(myEditor) console.log("Editor initialized successfully") @@ -126,32 +158,27 @@ export function TypewriterCommandLine() { return () => { console.log("Cleaning up editor...") changeDisposable?.dispose() - keyDownDisposable?.dispose() myEditor.dispose() oneLineEditorRef.current = null setOneLineEditor(null) } - }, [setTypewriter, setOneLineEditor, runCommand]) + }, [setTypewriter, setOneLineEditor]) - //const oneLineEditorRef = useRef(null) - //const [typewriterContent, setTypewriterContent] = useState("") - //const leanMonacoEditor = useAtom(leanMonacoEditorAtom) - //let inputRef = getInputRef() - - /** Process the entered command */ - const handleSubmit : React.FormEventHandler = (ev) => { - ev.preventDefault() - console.debug("execute!") - runCommand() - } + // Handle keyboard events (i.e. "Enter") + useEffect(() => { + document.addEventListener('keydown', handleKeyDown) + return () => { + document.removeEventListener('keydown', handleKeyDown) + } + }, [handleKeyDown]) // do not display if the proof is completed (with potential warnings still present) - return
+ return
- diff --git a/client/src/components/level.tsx b/client/src/components/level.tsx index d104aad3..186e9e9f 100644 --- a/client/src/components/level.tsx +++ b/client/src/components/level.tsx @@ -5,7 +5,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faHome, faArrowRight } from '@fortawesome/free-solid-svg-icons' import { CircularProgress } from '@mui/material' import type { Location } from 'vscode-languageserver-protocol' -import { LeanMonaco, LeanMonacoEditor, LeanMonacoOptions } from 'lean4monaco' +import { LeanMonaco, LeanMonacoEditor, LeanMonacoOptions, RpcSessionAtPos } from 'lean4monaco' import { setupMonacoClient } from 'lean4monaco/dist/monacoleanclient' import { Button } from './button' import { Markdown } from './markdown' @@ -22,7 +22,7 @@ import { useTranslation } from 'react-i18next' import { useGameTranslation } from '../utils/translation' import { InventoryPanel } from './inventory/inventory_panel' import { useAtom } from 'jotai' -import { codeAtom, leanMonacoAtom, leanMonacoEditorAtom, lockEditorModeAtom, proofAtom, typewriterModeAtom } from '../store/editor-atoms' +import { leanMonacoAtom, editorAtom, lockEditorModeAtom, proofAtom, typewriterModeAtom, codeStorageAtom, modelAtom, clientAtom, uriAtom, currentRpcSessionAtom } from '../store/editor-atoms' import { gameIdAtom, levelIdAtom, worldIdAtom } from '../store/location-atoms' import { gameInfoAtom, levelInfoAtom } from '../store/query-atoms' import { deletedChatAtom, helpAtom, selectedStepAtom } from '../store/chat-atoms' @@ -30,6 +30,7 @@ import { inventoryOverviewAtom } from '../store/inventory-atoms' import { mobileAtom } from '../store/preferences-atoms' import { readWorldIntroAtom } from '../store/progress-atoms' import { GameEditor } from './infoview/GameEditor' +import { RpcConnectParams } from '@leanprover/infoview-api' const reconfigureLeanMonacoClient = async (leanMonaco: LeanMonaco, options: LeanMonacoOptions) => { const maybeLeanMonaco = leanMonaco as unknown as { @@ -217,7 +218,7 @@ function PlayableLevel() { const [levelId] = useAtom(levelIdAtom) const [typewriterMode, setTypewriterMode] = useAtom(typewriterModeAtom) const [mobile] = useAtom(mobileAtom) - const [code, setCode] = useAtom(codeAtom) + const [code, setCode] = useAtom(codeStorageAtom) const [{ data: gameInfo }] = useAtom(gameInfoAtom) const [{ data: levelInfo, isLoading: levelInfoIsLoading }] = useAtom(levelInfoAtom) // Only for mobile layout @@ -233,9 +234,13 @@ function PlayableLevel() { const [inventoryDoc, setInventoryDoc] = useState<{name: string, type: string} | null>(null) function closeInventoryDoc () {setInventoryDoc(null)} - const [leanMonacoEditor, setLeanMonacoEditor] = useAtom(leanMonacoEditorAtom)//useState(null) + const [editor, setEditor] = useAtom(editorAtom) + const [model] = useAtom(modelAtom) + const [client, setClient] = useAtom(clientAtom) + const [uri] = useAtom(uriAtom) + const [rpcSess, setRpcSess] = useAtom(currentRpcSessionAtom) - // Start the editor + // Start the editor, following lean4monaco demo useEffect(() => { if (leanMonaco) { const leanMonacoEditor = new LeanMonacoEditor() @@ -243,10 +248,10 @@ function PlayableLevel() { ;(async () => { await leanMonaco!.whenReady - console.debug('[demo]: starting editor') + console.debug('[lean4game]: starting editor') await leanMonacoEditor.start(codeviewRef.current!, uriStr, code ?? "") - console.debug('[demo]: editor started') - setLeanMonacoEditor(leanMonacoEditor.editor) + console.debug('[lean4game]: editor started') + setEditor(leanMonacoEditor.editor) })() return () => { @@ -257,10 +262,7 @@ function PlayableLevel() { // Persist editor text into progress whenever the model changes. useEffect(() => { - const editor = leanMonacoEditor //?.editor - if (!editor) { - return - } + if (!editor) return setCode(editor.getValue()) const disposable = editor.onDidChangeModelContent(() => { @@ -270,7 +272,53 @@ function PlayableLevel() { return () => { disposable.dispose() } - }, [leanMonacoEditor, setCode]) //[leanMonacoEditor?.editor, setCode]) + }, [editor, setCode]) + + // wait until the client exists + // TODO: it didn't work to define an atom `get(editorAtom)?.clientProvider?.getClients()?.[0]` + // is there a way to tell atoms to fully reevaluate until they get a non-null result? + useEffect(() => { + const updateClient = () => { + const clients = leanMonaco?.clientProvider?.getClients() + const firstClient = clients?.[0] ?? null + if (firstClient) { + setClient(firstClient) + return true + } + return false + } + updateClient() + const interval = setInterval(() => { + // try to get `client` until successful + if (updateClient()) { + clearInterval(interval) + } + }, 500) + return () => clearInterval(interval) + }, [leanMonaco?.clientProvider]) + + // Start RPC session + useEffect(() => { + function updateRpcSession() { + if (rpcSess) return true + if (!client || !uri) return false + console.debug('rpc session connecting...') + client.sendRequest('$/lean/rpc/connect', {uri: uri.toString()} as RpcConnectParams).then(result => { + const sessionId = result.sessionId + console.debug(`rpc session id: ${sessionId}`) + const _rpcSess = new RpcSessionAtPos(client, sessionId, uri.toString()) + setRpcSess(_rpcSess) + }) + } + updateRpcSession() + const interval = setInterval(() => { + // try to get `rpcSess` until successful + if (updateRpcSession()) { + clearInterval(interval) + } + }, 500) + return () => clearInterval(interval) + }, [client, uri, rpcSess]) // Select and highlight proof steps and corresponding hints // TODO: with the new design, there is no difference between the introduction and @@ -280,7 +328,6 @@ function PlayableLevel() { useEffect (() => { // Lock editor mode if (levelInfo?.template) { - const model = leanMonacoEditor?.getModel()//leanMonacoEditor?.editor?.getModel() if (model) { let code = model.getLinesContent() @@ -292,7 +339,7 @@ function PlayableLevel() { // TODO: This does not work! HERE // Probably overwritten by a query to the server // DOC: REMOVED editor here - leanMonacoEditor?.executeEdits("template-writer", [{ + editor?.executeEdits("template-writer", [{ range: model.getFullModelRange(), text: levelInfo?.template + `\n`, forceMoveMarkers: true @@ -303,7 +350,7 @@ function PlayableLevel() { } } else { } - }, [levelInfo, levelId, worldId, gameId, leanMonacoEditor]) //[levelInfo, levelId, worldId, gameId, leanMonacoEditor?.editor]) + }, [levelInfo, levelId, worldId, gameId, editor]) useEffect(() => { @@ -314,7 +361,6 @@ function PlayableLevel() { }, [gameId, worldId, levelId]) useEffect(() => { - const editor = leanMonacoEditor //?.editor; const selection = editor?.getSelection(); if (!typewriterMode && editor && selection) { // Delete last input attempt from command line @@ -323,7 +369,7 @@ function PlayableLevel() { text: "", forceMoveMarkers: false }]); - leanMonacoEditor?.focus() // leanMonacoEditor?.editor.focus() + editor?.focus() // leanMonacoEditor?.editor.focus() } }, [typewriterMode]) @@ -345,11 +391,10 @@ function PlayableLevel() { // Effect when command line mode gets enabled useEffect(() => { - const model = leanMonacoEditor?.getModel() //.editor?.getModel() if (model&& (typewriterMode)) { let code = model.getLinesContent().filter(line => line.trim()) // REMOVED .editor call - leanMonacoEditor?.executeEdits("typewriter", [{ + editor?.executeEdits("typewriter", [{ range: model.getFullModelRange(), text: code.length ? code.join('\n') + '\n' : '', forceMoveMarkers: true @@ -370,7 +415,7 @@ function PlayableLevel() { // editor.setSelection(monaco.Selection.fromPositions(endPos, endPos)) // } } - }, [leanMonacoEditor, leanMonacoEditor, typewriterMode, lockEditorMode]) //[leanMonacoEditor, leanMonacoEditor?.editor, typewriterMode, lockEditorMode]) + }, [editor, typewriterMode, lockEditorMode]) // TODO: typewriterMode too? return <> { levelInfoIsLoading &&
} @@ -420,7 +465,7 @@ function IntroductionPanel() {
{text?.filter(t => t.trim()).map(((t, i) => + hint={{text: t, hidden: false, rawText: t, varNames: []}} step={0} selected={undefined} toggleSelection={undefined} /> ))}
diff --git a/client/src/components/level/PlayableLevel.tsx b/client/src/components/level/PlayableLevel.tsx deleted file mode 100644 index 4f8c4009..00000000 --- a/client/src/components/level/PlayableLevel.tsx +++ /dev/null @@ -1,751 +0,0 @@ -import React from "react" -import Split from 'react-split' - -import { useAtom } from "jotai" -import { useTranslation } from "react-i18next" -import { useGameTranslation } from "../../utils/translation" -import { useEffect, useRef, useState } from "react" -import { codeAtom, editorConnectionAtom, leanMonacoAtom, leanMonacoEditorAtom, lockEditorModeAtom, proofAtom, typewriterModeAtom } from "../../store/editor-atoms" -import { gameIdAtom, levelIdAtom, worldIdAtom } from "../../store/location-atoms" -import { mobileAtom } from "../../store/preferences-atoms" -import { gameInfoAtom, levelInfoAtom } from "../../store/query-atoms" -import { LeanMonacoEditor } from "lean4monaco" -import { EditorApi, InfoviewApi } from '@leanprover/infoview-api' -import { EventEmitter } from "lean4monaco/dist/vscode-lean4/lean4-infoview/src/infoview/event" -import { EditorConnection, EditorEvents } from "lean4monaco/dist/vscode-lean4/lean4-infoview/src/infoview/editorConnection" -import { CircularProgress } from '@mui/material' -import { LevelAppBar } from "../app_bar" -import { GameEditor } from '../infoview/GameEditor' -import { InventoryPanel } from '../inventory/inventory_panel' -import { deletedChatAtom, helpAtom, selectedStepAtom } from "../../store/chat-atoms" -import { DeletedHints, filterHints, Hint, Hints, MoreHelpButton } from "../hints" -import { Markdown } from '../markdown' -import { Button } from '../button' -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' -import { faHome, faArrowRight } from '@fortawesome/free-solid-svg-icons' -import { Location } from "vscode-languageserver-types" - - -export function PlayableLevel() { - let { t } = useTranslation() - const { t : gT } = useGameTranslation() - const codeviewRef = useRef(null) - const infoviewRef = useRef(null) - const [leanMonaco] = useAtom(leanMonacoAtom) - const [gameId] = useAtom(gameIdAtom) - const [worldId] = useAtom(worldIdAtom) - const [levelId] = useAtom(levelIdAtom) - const [typewriterMode, setTypewriterMode] = useAtom(typewriterModeAtom) - const [mobile] = useAtom(mobileAtom) - const [code, setCode] = useAtom(codeAtom) - const [{ data: gameInfo }] = useAtom(gameInfoAtom) - const [{ data: levelInfo, isLoading: levelInfoIsLoading }] = useAtom(levelInfoAtom) - // Only for mobile layout - const [pageNumber, setPageNumber] = useState(0) - // set to true to prevent switching between typewriter and editor - const [lockEditorMode] = useAtom(lockEditorModeAtom) - const [, setTypewriterInput] = useState("") - const lastLevel = worldId && (levelId !== undefined) && levelId >= (gameInfo?.worldSize?.[worldId] ?? 0) - - // When clicking on an inventory item, the inventory is overlayed by the item's doc. - // If this state is set to a pair `(name, type)` then the according doc will be open. - // Set `inventoryDoc` to `null` to close the doc - const [inventoryDoc, setInventoryDoc] = useState<{name: string, type: string} | null>(null) - function closeInventoryDoc () {setInventoryDoc(null)} - - // Keep as useState<>() because leanMonacoEditor is local to this component - const [leanMonacoEditor, setLeanMonacoEditor] = useState(null) - - //const [editorConnection, setEditorConnection] = useState(null) - const [editorConnection, setEditorConnection] = useAtom(editorConnectionAtom) - const [, setMonacoEditor] = useAtom(leanMonacoEditorAtom) - - // REPLACES EditorContext - // Start the editor - useEffect(() => { - if (leanMonaco) { - const leanMonacoEditor = new LeanMonacoEditor() - const uriStr = `file:///${worldId}/${levelId}.lean` - - ;(async () => { - await leanMonaco!.whenReady - console.debug('[demo]: starting editor') - await leanMonacoEditor.start(codeviewRef.current!, uriStr, code ?? "") - console.debug('[demo]: editor started') - setLeanMonacoEditor(leanMonacoEditor) - - // Set atom so other components can access it - setMonacoEditor(leanMonacoEditor.editor ?? null) - - // TODO: old stuff from here on - - const infoProvider = leanMonaco.infoProvider as { - editorApi: EditorApi - webviewPanel?: { api: InfoviewApi, visible: boolean } - sendConfig?: () => Promise - sendPosition?: () => Promise - } | undefined - - if (!infoProvider?.editorApi) { - console.warn('Lean infoview is not ready yet.') - return - } - - const editorEvents: EditorEvents = { - initialize: new EventEmitter(), - gotServerNotification: new EventEmitter(), - sentClientNotification: new EventEmitter(), - serverRestarted: new EventEmitter(), - serverStopped: new EventEmitter(), - changedCursorLocation: new EventEmitter(), - changedInfoviewConfig: new EventEmitter(), - runTestScript: new EventEmitter(), - requestedAction: new EventEmitter(), - clickedContextMenu: new EventEmitter() - } - - const infoviewApi: InfoviewApi = { - initialize: async l => editorEvents.initialize.fire(l), - gotServerNotification: async (method, params) => { - editorEvents.gotServerNotification.fire([method, params]) - }, - sentClientNotification: async (method, params) => { - editorEvents.sentClientNotification.fire([method, params]) - }, - serverRestarted: async r => editorEvents.serverRestarted.fire(r), - serverStopped: async serverStoppedReason => { - editorEvents.serverStopped.fire(serverStoppedReason) - }, - changedCursorLocation: async loc => editorEvents.changedCursorLocation.fire(loc), - changedInfoviewConfig: async conf => editorEvents.changedInfoviewConfig.fire(conf), - requestedAction: async action => editorEvents.requestedAction.fire(action), - runTestScript: async script => new Function(script)(), - getInfoviewHtml: async () => document.body.innerHTML, - clickedContextMenu: async () => console.log("clickedContextMenu") - } - - infoProvider.webviewPanel = { - api: infoviewApi, - visible: true, - } - - const editorConnection = new EditorConnection(infoProvider.editorApi, editorEvents) - setEditorConnection(editorConnection) - - const model = leanMonacoEditor.editor?.getModel() - const fireCursorLocation = () => { - const selection = leanMonacoEditor.editor?.getSelection() - const position = leanMonacoEditor.editor?.getPosition() - if (!selection || !model) { - if (!position || !model) { - return - } - const loc: Location = { - uri: model.uri.toString(), - range: { - start: { - line: position.lineNumber - 1, - character: position.column - 1, - }, - end: { - line: position.lineNumber - 1, - character: position.column - 1, - }, - }, - } - editorEvents.changedCursorLocation.fire(loc) - return - } - const loc: Location = { - uri: model.uri.toString(), - range: { - start: { - line: selection.startLineNumber - 1, - character: selection.startColumn - 1, - }, - end: { - line: selection.endLineNumber - 1, - character: selection.endColumn - 1, - }, - }, - } - editorEvents.changedCursorLocation.fire(loc) - } - - fireCursorLocation() - leanMonacoEditor.editor?.onDidChangeCursorSelection(() => { - fireCursorLocation() - }) - - await infoProvider.sendConfig?.() - await infoProvider.sendPosition?.() - - })() - - return () => { - leanMonacoEditor.dispose() - // Clean up atoms - setEditorConnection(null) - setMonacoEditor(null) - } - } - }, [leanMonaco, worldId, levelId]) - - // Persist editor text into progress whenever the model changes. - useEffect(() => { - const editor = leanMonacoEditor?.editor - if (!editor) { - return - } - - setCode(editor.getValue()) - const disposable = editor.onDidChangeModelContent(() => { - setCode(editor.getValue()) - }) - - return () => { - disposable.dispose() - } - }, [leanMonacoEditor?.editor, setCode]) - - // /** Unused. Was implementing an undo button, which has been replaced by `deleteProof` inside - // * `TypewriterInterface`. - // */ - // const handleUndo = () => { - // const endPos = leanMonacoEditor?.editor.getModel().getFullModelRange().getEndPosition() - // let range - // console.log(endPos?.column) - // if (endPos?.column === 1) { - // range = monaco.Selection.fromPositions( - // new monaco.Position(endPos.lineNumber - 1, 1), - // endPos - // ) - // } else { - // range = monaco.Selection.fromPositions( - // new monaco.Position(endPos.lineNumber, 1), - // endPos - // ) - // } - // leanMonacoEditor?.editor.executeEdits("undo-button", [{ - // range, - // text: "", - // forceMoveMarkers: false - // }]); - // } - - // Select and highlight proof steps and corresponding hints - // TODO: with the new design, there is no difference between the introduction and - // a hint at the beginning of the proof... - const [selectedStep, setSelectedStep] = useState() - - - useEffect (() => { - // Lock editor mode - if (levelInfo?.template) { - const model = leanMonacoEditor?.editor?.getModel() - - if (model) { - let code = model.getLinesContent() - - // console.log(`insert. code: ${code}`) - // console.log(`insert. join: ${code.join('')}`) - // console.log(`insert. trim: ${code.join('').trim()}`) - // console.log(`insert. length: ${code.join('').trim().length}`) - // console.log(`insert. range: ${editor.getModel().getFullModelRange()}`) - - - // TODO: It does seem that the template is always indented by spaces. - // This is a hack, assuming there are exactly two. - if (!code.join("").trim().length) { - console.debug(`inserting template:\n${levelInfo?.template}`) - // TODO: This does not work! HERE - // Probably overwritten by a query to the server - leanMonacoEditor?.editor.executeEdits("template-writer", [{ - range: model.getFullModelRange(), - text: levelInfo?.template + `\n`, - forceMoveMarkers: true - }]) - } else { - console.debug(`not inserting template.`) - } - } - } else { - } - }, [levelInfo, levelId, worldId, gameId, leanMonacoEditor?.editor]) - - - useEffect(() => { - // TODO: That's a problem if the saved proof contains an error - // Reset command line input when loading a new level - setTypewriterInput("") - - }, [gameId, worldId, levelId]) - - useEffect(() => { - const editor = leanMonacoEditor?.editor; - const selection = editor?.getSelection(); - if (!typewriterMode && editor && selection) { - // Delete last input attempt from command line - editor.executeEdits("typewriter", [{ - range: selection, - text: "", - forceMoveMarkers: false - }]); - leanMonacoEditor?.editor.focus() - } - }, [typewriterMode]) - - // useEffect(() => { - // // Forget whether hidden hints are displayed for steps that don't exist yet - // if (proof?.steps.length) { - // console.debug(Array.from(help)) - // setHelp([...new Set(Array.from(help).filter(i => (i < proof?.steps.length)))]) - // } - // }, [proof]) - - // // save showed help in store - // useEffect(() => { - // if (proof?.steps.length) { - // console.debug(`showHelp:\n ${showHelp}`) - // setHelp(Array.from(showHelp)) - // } - // }, [showHelp]) - - // Effect when command line mode gets enabled - useEffect(() => { - const model = leanMonacoEditor?.editor?.getModel() - if (model&& (typewriterMode)) { - let code = model.getLinesContent().filter(line => line.trim()) - leanMonacoEditor?.editor.executeEdits("typewriter", [{ - range: model.getFullModelRange(), - text: code.length ? code.join('\n') + '\n' : '', - forceMoveMarkers: true - }]); - - // let endPos = editor.getModel().getFullModelRange().getEndPosition() - // if (editor.getModel().getLineContent(endPos.lineNumber).trim() !== "") { - // editor.executeEdits("typewriter", [{ - // range: monaco.Selection.fromPositions(endPos, endPos), - // text: "\n", - // forceMoveMarkers: true - // }]); - // } - // let endPos = editor.getModel().getFullModelRange().getEndPosition() - // let currPos = editor.getPosition() - // if (currPos.column != 1 || (currPos.lineNumber != endPos.lineNumber && currPos.lineNumber != endPos.lineNumber - 1)) { - // // This is not a position that would naturally occur from Typewriter, reset: - // editor.setSelection(monaco.Selection.fromPositions(endPos, endPos)) - // } - } - }, [leanMonacoEditor, leanMonacoEditor?.editor, typewriterMode, lockEditorMode]) - - return <> -
- - {mobile? - // TODO: This is copied from the `Split` component below... - <> -
- - -
- - : - - - - - - } - -} - - -export function PlayableLevelIGNORE() { - let { t } = useTranslation() - const { t : gT } = useGameTranslation() - const codeviewRef = useRef(null) - const [leanMonaco] = useAtom(leanMonacoAtom) - const [gameId] = useAtom(gameIdAtom) - const [worldId] = useAtom(worldIdAtom) - const [levelId] = useAtom(levelIdAtom) - const [typewriterMode, setTypewriterMode] = useAtom(typewriterModeAtom) - const [mobile] = useAtom(mobileAtom) - const [code, setCode] = useAtom(codeAtom) - const [{ data: gameInfo }] = useAtom(gameInfoAtom) - const [{ data: levelInfo, isLoading: levelInfoIsLoading }] = useAtom(levelInfoAtom) - // Only for mobile layout - const [pageNumber, setPageNumber] = useState(0) - // set to true to prevent switching between typewriter and editor - const [lockEditorMode] = useAtom(lockEditorModeAtom) - const [, setTypewriterInput] = useState("") - const lastLevel = worldId && (levelId !== undefined) && levelId >= (gameInfo?.worldSize?.[worldId] ?? 0) - - // When clicking on an inventory item, the inventory is overlayed by the item's doc. - // If this state is set to a pair `(name, type)` then the according doc will be open. - // Set `inventoryDoc` to `null` to close the doc - const [inventoryDoc, setInventoryDoc] = useState<{name: string, type: string} | null>(null) - function closeInventoryDoc () {setInventoryDoc(null)} - - const [leanMonacoEditor, setLeanMonacoEditor] = useAtom(leanMonacoEditorAtom)//useState(null) - const [editorConnection, setEditorConnection] = useAtom(editorConnectionAtom) - - // Start the editor - useEffect(() => { - if (leanMonaco) { - const leanMonacoEditor = new LeanMonacoEditor() - const uriStr = `file:///${worldId}/${levelId}.lean` - - ;(async () => { - await leanMonaco!.whenReady - console.debug('[demo]: starting editor') - await leanMonacoEditor.start(codeviewRef.current!, uriStr, code ?? "") - console.debug('[demo]: editor started') - setLeanMonacoEditor(leanMonacoEditor.editor) - })() - - const infoProvider = leanMonaco.infoProvider as { - editorApi: EditorApi - webviewPanel?: { api: InfoviewApi, visible: boolean } - sendConfig?: () => Promise - sendPosition?: () => Promise - } | undefined - - if (!infoProvider?.editorApi) { - console.warn('Lean infoview is not ready yet.') - return - } - - const editorEvents: EditorEvents = { - initialize: new EventEmitter(), - gotServerNotification: new EventEmitter(), - sentClientNotification: new EventEmitter(), - serverRestarted: new EventEmitter(), - serverStopped: new EventEmitter(), - changedCursorLocation: new EventEmitter(), - changedInfoviewConfig: new EventEmitter(), - runTestScript: new EventEmitter(), - requestedAction: new EventEmitter(), - clickedContextMenu: new EventEmitter() - } - - const infoviewApi: InfoviewApi = { - initialize: async l => editorEvents.initialize.fire(l), - gotServerNotification: async (method, params) => { - editorEvents.gotServerNotification.fire([method, params]) - }, - sentClientNotification: async (method, params) => { - editorEvents.sentClientNotification.fire([method, params]) - }, - serverRestarted: async r => editorEvents.serverRestarted.fire(r), - serverStopped: async serverStoppedReason => { - editorEvents.serverStopped.fire(serverStoppedReason) - }, - changedCursorLocation: async loc => editorEvents.changedCursorLocation.fire(loc), - changedInfoviewConfig: async conf => editorEvents.changedInfoviewConfig.fire(conf), - requestedAction: async action => editorEvents.requestedAction.fire(action), - runTestScript: async script => new Function(script)(), - getInfoviewHtml: async () => document.body.innerHTML, - clickedContextMenu: async () => console.log("ClickedContextMenu") - } - - infoProvider.webviewPanel = { - api: infoviewApi, - visible: true, - } - - const editorConnection = new EditorConnection(infoProvider.editorApi, editorEvents) - setEditorConnection(editorConnection) - console.log("Editor Connection Atom has been initialized.") - - return () => { - leanMonacoEditor.dispose() - } - } - }, [leanMonaco, worldId, levelId]) - - // Persist editor text into progress whenever the model changes. - useEffect(() => { - const editor = leanMonacoEditor //?.editor - if (!editor) { - return - } - - setCode(editor.getValue()) - const disposable = editor.onDidChangeModelContent(() => { - setCode(editor.getValue()) - }) - - return () => { - disposable.dispose() - } - }, [leanMonacoEditor, setCode]) //[leanMonacoEditor?.editor, setCode]) - - // Select and highlight proof steps and corresponding hints - // TODO: with the new design, there is no difference between the introduction and - // a hint at the beginning of the proof... - const [selectedStep, setSelectedStep] = useState() - - useEffect (() => { - // Lock editor mode - if (levelInfo?.template) { - const model = leanMonacoEditor?.getModel()//leanMonacoEditor?.editor?.getModel() - - if (model) { - let code = model.getLinesContent() - - // TODO: It does seem that the template is always indented by spaces. - // This is a hack, assuming there are exactly two. - if (!code.join("").trim().length) { - console.debug(`inserting template:\n${levelInfo?.template}`) - // TODO: This does not work! HERE - // Probably overwritten by a query to the server - // DOC: REMOVED editor here - leanMonacoEditor?.executeEdits("template-writer", [{ - range: model.getFullModelRange(), - text: levelInfo?.template + `\n`, - forceMoveMarkers: true - }]) - } else { - console.debug(`not inserting template.`) - } - } - } else { - } - }, [levelInfo, levelId, worldId, gameId, leanMonacoEditor]) //[levelInfo, levelId, worldId, gameId, leanMonacoEditor?.editor]) - - - useEffect(() => { - // TODO: That's a problem if the saved proof contains an error - // Reset command line input when loading a new level - setTypewriterInput("") - - }, [gameId, worldId, levelId]) - - useEffect(() => { - const editor = leanMonacoEditor //?.editor; - const selection = editor?.getSelection(); - if (!typewriterMode && editor && selection) { - // Delete last input attempt from command line - editor.executeEdits("typewriter", [{ - range: selection, - text: "", - forceMoveMarkers: false - }]); - leanMonacoEditor?.focus() // leanMonacoEditor?.editor.focus() - } - }, [typewriterMode]) - - // useEffect(() => { - // // Forget whether hidden hints are displayed for steps that don't exist yet - // if (proof?.steps.length) { - // console.debug(Array.from(help)) - // setHelp([...new Set(Array.from(help).filter(i => (i < proof?.steps.length)))]) - // } - // }, [proof]) - - // // save showed help in store - // useEffect(() => { - // if (proof?.steps.length) { - // console.debug(`showHelp:\n ${showHelp}`) - // setHelp(Array.from(showHelp)) - // } - // }, [showHelp]) - - // Effect when command line mode gets enabled - useEffect(() => { - const model = leanMonacoEditor?.getModel() //.editor?.getModel() - if (model&& (typewriterMode)) { - let code = model.getLinesContent().filter(line => line.trim()) - // REMOVED .editor call - leanMonacoEditor?.executeEdits("typewriter", [{ - range: model.getFullModelRange(), - text: code.length ? code.join('\n') + '\n' : '', - forceMoveMarkers: true - }]); - - // let endPos = editor.getModel().getFullModelRange().getEndPosition() - // if (editor.getModel().getLineContent(endPos.lineNumber).trim() !== "") { - // editor.executeEdits("typewriter", [{ - // range: monaco.Selection.fromPositions(endPos, endPos), - // text: "\n", - // forceMoveMarkers: true - // }]); - // } - // let endPos = editor.getModel().getFullModelRange().getEndPosition() - // let currPos = editor.getPosition() - // if (currPos.column != 1 || (currPos.lineNumber != endPos.lineNumber && currPos.lineNumber != endPos.lineNumber - 1)) { - // // This is not a position that would naturally occur from Typewriter, reset: - // editor.setSelection(monaco.Selection.fromPositions(endPos, endPos)) - // } - } - }, [leanMonacoEditor, leanMonacoEditor, typewriterMode, lockEditorMode]) //[leanMonacoEditor, leanMonacoEditor?.editor, typewriterMode, lockEditorMode]) - - return <> - { levelInfoIsLoading &&
} - - {mobile? - // TODO: This is copied from the `Split` component below... - <> -
- - -
- - : - - - - - - } - -} - -function ExercisePanel({codeviewRef, visible=true}: {codeviewRef: React.MutableRefObject, visible?: boolean}) { - return
- -
-} - -function ChatPanel({lastLevel, visible = true}: {lastLevel: boolean, visible: boolean}) { - let { t } = useTranslation() - const { t : gT } = useGameTranslation() - const chatRef = useRef(null) - const [mobile] = useAtom(mobileAtom) - const [gameId, navigateToGame] = useAtom(gameIdAtom) - const [levelId, navigateToLevel] = useAtom(levelIdAtom) - const [{ data: levelInfo }] = useAtom(levelInfoAtom) - const [help] = useAtom(helpAtom) - const [proof] = useAtom(proofAtom) - const [deletedChat] = useAtom(deletedChatAtom) - const [selectedStep, setSelectedStep] = useAtom(selectedStepAtom) - - let k = proof?.steps.length ? proof?.steps.length - (lastStepHasErrors(proof) ? 2 : 1) : 0 - - function toggleSelection(line: number) { - return (ev: any) => { - console.debug('toggled selection') - if (selectedStep == line) { - setSelectedStep(undefined) - } else { - setSelectedStep(line) - } - } - } - - useEffect(() => { - // TODO: For some reason this is always called twice - console.debug('scroll chat') - if (!mobile) { - chatRef.current!.lastElementChild?.scrollIntoView() //scrollTo(0,0) - } - }, [proof, help]) - - // Scroll to element if selection changes - useEffect(() => { - if (typeof selectedStep !== 'undefined') { - Array.from(chatRef.current!.getElementsByClassName(`step-${selectedStep}`)).map((elem) => { - elem.scrollIntoView({block: "center"}) - }) - } - }, [selectedStep]) - - // useEffect(() => { - // // // Scroll to top when loading a new level - // // // TODO: Thats the wrong behaviour probably - // // chatRef.current!.scrollTo(0,0) - // }, [gameId, worldId, levelId]) - - let introText: Array = gT(levelInfo?.introduction ?? "").split(/\n(\s*\n)+/) - - const focusRef = useRef() - useEffect(() => { - if (proof?.completed) { - focusRef.current?.focus() - } - }, [!!proof?.completed]) - - return
-
- {introText?.filter(it => it.trim()).map(((it, i) => - // Show the level's intro text as hints, too - - ))} - {proof?.steps.map((step, i) => { - let filteredHints = filterHints(step.goals[0]?.hints, proof?.steps[i-1]?.goals[0]?.hints) - if (step.goals.length > 0 && !isLastStepWithErrors(proof, i)) { - return - } - })} - - {/* {modifiedHints.map((step, i) => { - // It the last step has errors, it will have the same hints - // as the second-to-last step. Therefore we should not display them. - if (!(i == proof?.steps.length - 1 && withErr)) { - // TODO: Should not use index as key. - return - } - })} */} - - {proof?.completed && - <> -
- {t("Level completed! 🎉")} -
- {levelInfo?.conclusion?.trim() && -
- {gT(levelInfo?.conclusion ?? "")} -
- } - - } -
-
- {proof?.completed && (lastLevel ? - : - ) - } - -
-
-} - - -//FIXME: implement -function lastStepHasErrors(x: any) {return false} - - -//FIXME: implement -function isLastStepWithErrors(x: any, y: any) {return false} diff --git a/client/src/components/main/Main.tsx b/client/src/components/main/Main.tsx index 504ea7e6..f60ead3f 100644 --- a/client/src/components/main/Main.tsx +++ b/client/src/components/main/Main.tsx @@ -2,7 +2,7 @@ import { useTranslation } from "react-i18next"; import { useGameTranslation } from "../../utils/translation"; -import { crashedAtom, editorConnectionAtom, leanMonacoEditorAtom, lockEditorModeAtom, proofAtom, serverVersionAtom, typewriterModeAtom } from "../../store/editor-atoms"; +import { crashedAtom, editorConnectionAtom, editorAtom, lockEditorModeAtom, proofAtom, serverVersionAtom, typewriterModeAtom } from "../../store/editor-atoms"; import { useAtom } from "jotai"; import React from "react"; import { gameIdAtom, levelIdAtom, worldIdAtom } from "../../store/location-atoms"; @@ -36,7 +36,7 @@ export function Main() { const [proof, setProof] = useAtom(proofAtom) const [, setCrashed] = useAtom(crashedAtom) const [selectedStep, setSelectedStep] = useAtom(selectedStepAtom) - const [editor,] = useAtom(leanMonacoEditorAtom)//React.useContext(MonacoEditorContext) + const [editor,] = useAtom(editorAtom)//React.useContext(MonacoEditorContext) const [, setServerVersion] = useAtom(serverVersionAtom) const model = editor?.getModel() const uri = model?.uri.toString() diff --git a/client/src/components/main/TypewriterInterface.tsx b/client/src/components/main/TypewriterInterface.tsx index 41654b96..5082c4d9 100644 --- a/client/src/components/main/TypewriterInterface.tsx +++ b/client/src/components/main/TypewriterInterface.tsx @@ -8,7 +8,7 @@ import { gameIdAtom, levelIdAtom, worldIdAtom } from "../../store/location-atoms import { gameInfoAtom } from "../../store/query-atoms" import { deletedChatAtom, helpAtom, selectedStepAtom } from "../../store/chat-atoms" import { mobileAtom } from "../../store/preferences-atoms" -import { crashedAtom, editorConnectionAtom, interimDiagsAtom, leanMonacoEditorAtom, proofAtom, rpcSessionAtPosAtom, typewriterContentAtom } from "../../store/editor-atoms" +import { crashedAtom, editorConnectionAtom, interimDiagsAtom, editorAtom, proofAtom, rpcSessionAtPosAtom, typewriterContentAtom } from "../../store/editor-atoms" import { Goal, isLastStepWithErrors, lastStepHasErrors, loadGoals } from "../../../../infoview/goals" import { filterHints, Hint, Hints, MoreHelpButton } from "../hints" import { GameHint } from "../infoview/types" @@ -37,7 +37,7 @@ export function TypewriterInterface() { const [{ data: gameInfo }] = useAtom(gameInfoAtom) const [help, setHelp] = useAtom(helpAtom) - const [editor] = useAtom(leanMonacoEditorAtom)//React.useContext(MonacoEditorContext) + const [editor] = useAtom(editorAtom)//React.useContext(MonacoEditorContext) const model = editor?.getModel() const uri = model?.uri.toString() ?? '' diff --git a/client/src/store/chat-atoms.ts b/client/src/store/chat-atoms.ts index bb892ae3..22416b20 100644 --- a/client/src/store/chat-atoms.ts +++ b/client/src/store/chat-atoms.ts @@ -1,6 +1,6 @@ import { atom } from "jotai" import { levelProgressAtom, progressAtom, defaultLevelProgress } from "./progress-atoms" -import { GameHint } from "../../../infoview/rpc_api" +import { GameHint } from "../api/rpc_api" /** User read the game introduction. Only relevant on mobile */ export const readGameIntroAtom = atom( diff --git a/client/src/store/editor-atoms.ts b/client/src/store/editor-atoms.ts index 87faab46..91950b0a 100644 --- a/client/src/store/editor-atoms.ts +++ b/client/src/store/editor-atoms.ts @@ -1,8 +1,7 @@ import { editor, Selection as selector, } from 'monaco-editor' import { atom } from "jotai"; import { atomEffect } from 'jotai-effect'; -import { LeanMonaco, LeanMonacoOptions, LeanMonacoEditor, DocumentPosition} from 'lean4monaco' -import { RpcSessionAtPos, RpcSessions } from 'lean4monaco/dist/vscode-lean4/lean4-infoview-api/src/rpcSessions' +import { LeanMonaco, LeanMonacoOptions, DocumentPosition, RpcSessionAtPos, LeanClient} from 'lean4monaco' import { gameIdAtom, levelIdAtom, worldIdAtom } from "./location-atoms"; import { levelProgressAtom, progressAtom } from "./progress-atoms"; import { Selection } from "./progress-types"; @@ -11,9 +10,121 @@ import { Diagnostic, DiagnosticSeverity, DocumentUri } from 'vscode-languageserv import { preferencesAtom } from './preferences-atoms'; import { deletedChatAtom } from './chat-atoms'; import { ProofState } from '../api/rpc_api'; -import { EditorConnection } from "lean4monaco/dist/vscode-lean4/lean4-infoview/src/infoview/editorConnection" -import { ServerVersion } from 'lean4monaco/dist/vscode-lean4/lean4-infoview/src/infoview/serverVersion' + import { defaultInfoviewConfig, InfoviewConfig, LeanFileProgressProcessingInfo } from '@leanprover/infoview-api'; +import { atomWithQuery } from 'jotai-tanstack-query'; +import { Rpc } from 'lean4monaco/dist/vscode-lean4/vscode-lean4/src/rpc'; + +/** The code of the current level as stored in local storage. + * + * The setter must not be used directly, expect inside the + * listener which listens to changes of the editor's content. + * This is because the editor won't update leading to inconsistent state. + * + * Instead, use `codeAtom` to set the editor's content and the code + * in storage. +*/ +export const codeStorageAtom = atom( + get => { + const levelProgress = get(levelProgressAtom) + return levelProgress?.code + }, + (get, set, val: string) => { + const levelProgress = get(levelProgressAtom) + if (levelProgress == null) return + set(levelProgressAtom, { ...levelProgress, code: val }) + } +) + +/** The unique leanMonaco instance for the entire application */ +export const leanMonacoAtom = atom(null) + +/** The active client. lean4game only uses one client simultaneously */ +export const clientAtom = atom() +// get => { +// const clients = get(leanMonacoAtom)?.clientProvider?.getClients() ?? [] +// return clients?.[0] +// }) + +/** The current (multiline) editor */ +export const editorAtom = atom(null) + +/** The model of the current editor */ +export const modelAtom = atom(get => get(editorAtom)?.getModel() ?? undefined) + +/** + * The code of the current level as stored in local storage. + * + * Can be used to manually update the editor's content which + * in turn keeps the code in local storage up to date. +*/ +export const codeAtom = atom( + get => get(codeStorageAtom), + (get, set, val: string) => { + const model = get(modelAtom) + model?.setValue(val) + } +) + +/** The URI of the currently opened file */ +export const uriAtom = atom(get => get(modelAtom)?.uri) + +/** The currently used RPC session */ +export const currentRpcSessionAtom = atom() + +/** The editor powering the typewriter (single line) input */ +export const oneLineEditorAtom = atom(null) + +/** Query which receives the goals throughout the proof over RPC */ +export const proofQueryAtom = atomWithQuery((get) => { + const worldId = get(worldIdAtom) + const levelId = get(levelIdAtom) + const code = get(codeStorageAtom) + const rpcSess = get(currentRpcSessionAtom) + return { + queryKey: ['proof', worldId, levelId, rpcSess?.sessionId, code], + queryFn: async () => { + console.debug('refetching...') + if (!rpcSess) return undefined + const res = await rpcSess.client.sendRequest( + '$/lean/rpc/call', + { + method: "Game.getProofState", + params: { + textDocument: {uri: rpcSess.uri}, + position: {line: 0, character: 0}, + worldId, + levelId, + }, + textDocument: {uri: rpcSess.uri}, + position: {line: 0, character: 0}, + sessionId: rpcSess.sessionId + } + ) + return res + }, + enabled: rpcSess?.sessionId != undefined && code != undefined && worldId != undefined && levelId != undefined + } +}) + +/** + * The proof consists of multiple steps that are processed one after the other. + * In particular multi-line terms like `match`-statements will not be supported. + * + * Note that the first step will always have "" as command + */ +export const proofAtom = atom(get => get(proofQueryAtom).data) + + + + + + + + + + + /* I went up the dependency graph starting from the former @@ -26,29 +137,17 @@ export const leanFileProgressAtom = atom>(new Map()); -export const serverVersionAtom = atom(new ServerVersion('')) +// export const serverVersionAtom = atom(new ServerVersion('')) export const infoviewConfigAtom = atom(defaultInfoviewConfig) -export const editorConnectionAtom = atom(null) - -/** The unique leanMonaco instance for the entire application */ -export const leanMonacoAtom = atom(null) - -export const leanMonacoEditorAtom = atom(null) +// export const editorConnectionAtom = atom(null) -export const oneLineEditorAtom = atom(null) -/** The proof consists of multiple steps that are processed one after the other. - * In particular multi-line terms like `match`-statements will not be supported. - * - * Note that the first step will always have "" as command - */ -export const proofAtom = atom() -export const rpcSessionsAtom = atom(null) +// export const rpcSessionsAtom = atom(null) -export const rpcSessionAtPosAtom = atom(null) +// export const rpcSessionAtPosAtom = atom(null) export const isProcessingAtom = atom(false) @@ -72,7 +171,7 @@ export const leanMonacoOptionsAtom = atom(get => { export const leanMonacoEditorModelAtom = atom( (get) => { - const editor = get(leanMonacoEditorAtom) + const editor = get(editorAtom) return editor?.getModel() } ) @@ -84,27 +183,15 @@ export const leanMonacoEditorUriAtom = atom( } ) -export const hasLeanMonacoEditorAtom = atom( +export const haseditorAtom = atom( (get) => { - const editor = get(leanMonacoEditorAtom) - console.log(`hasLeanMonacoEditorAtom: editor state is ${editor}`) + const editor = get(editorAtom) + console.log(`haseditorAtom: editor state is ${editor}`) const model = get(leanMonacoEditorModelAtom) - console.log(`hasLeanMonacoEditorAtom: model state is ${model}`) + console.log(`haseditorAtom: model state is ${model}`) return Boolean(editor && model) }) -export const codeAtom = atom( - get => { - const levelProgress = get(levelProgressAtom) - return levelProgress?.code - }, - (get, set, val: string) => { - const levelProgress = get(levelProgressAtom) - if (levelProgress == null) return - set(levelProgressAtom, { ...levelProgress, code: val }) - } -) - export const selectionsAtom = atom( get => { const levelProgress = get(levelProgressAtom) @@ -207,12 +294,11 @@ export const syncTypewriterToEditorEffect = atomEffect( } ) - export const syncEditorPositionEffect = atomEffect( (get, set) => { const oneLineEditor = get(oneLineEditorAtom) - const editor = get(leanMonacoEditorAtom) - const hasEditor = get(hasLeanMonacoEditorAtom) + const editor = get(editorAtom) + const hasEditor = get(haseditorAtom) const { isSuggestionsMobileMode } = get(preferencesAtom) if (!oneLineEditor || !hasEditor || !editor) return @@ -240,7 +326,7 @@ export const runCommandAtom = atom( return } - const hasEditor = get(hasLeanMonacoEditorAtom) + const hasEditor = get(haseditorAtom) const typewriter = get(typewriterContentAtom) if (processing || !hasEditor) { @@ -278,7 +364,7 @@ export const runCommandAtom = atom( export const loadGoalsAtom = atom( null, (get, set) => { - const rpcSess = get(rpcSessionAtPosAtom) + const rpcSess = get(currentRpcSessionAtom) if (!rpcSess) { console.error("RpcSession is not available"); @@ -296,11 +382,11 @@ export const loadGoalsAtom = atom( worldId, levelId } ).then( - (proof: ProofState) => { + (proof: unknown) => { if (typeof proof !== 'undefined') { console.info(`received a proof state!`) console.log(proof) - set(proofAtom, proof) + set(proofAtom, proof as any) // TODO set(crashedAtom, false) } else { console.warn('received undefined proof state!') diff --git a/package-lock.json b/package-lock.json index 23c2b58e..2d532fe7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,9 @@ "client", "relay" ], + "dependencies": { + "tailwind-merge": "^3.6.0" + }, "devDependencies": { "concurrently": "^9.2.1", "cypress": "^15.12.0", @@ -11343,6 +11346,16 @@ "resolved": "https://registry.npmjs.org/tachyons/-/tachyons-4.12.0.tgz", "integrity": "sha512-2nA2IrYFy3raCM9fxJ2KODRGHVSZNTW3BR0YnlGsLUf1DA3pk3YfWZ/DdfbnZK6zLZS+jUenlUGJsKcA5fUiZg==" }, + "node_modules/tailwind-merge": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz", + "integrity": "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, "node_modules/teex": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", diff --git a/package.json b/package.json index 2d3acb54..b9325278 100644 --- a/package.json +++ b/package.json @@ -44,5 +44,8 @@ "last 1 firefox version", "last 1 safari version" ] + }, + "dependencies": { + "tailwind-merge": "^3.6.0" } } diff --git a/server/GameServer/RpcHandlers.lean b/server/GameServer/RpcHandlers.lean index ac4c4185..524c3b17 100644 --- a/server/GameServer/RpcHandlers.lean +++ b/server/GameServer/RpcHandlers.lean @@ -349,8 +349,6 @@ def getInteractiveGoals (p : Lsp.PlainGoalParams) : RequestM (RequestTask (Optio end GameServer - - @[server_rpc_method] def Game.getInteractiveGoals (p : Lsp.PlainGoalParams) : RequestM (RequestTask (Option Widget.InteractiveGoals)) := FileWorker.getInteractiveGoals p From 6447f48bb10dff3fcdbd96b24bb09ddff4005b03 Mon Sep 17 00:00:00 2001 From: Jon Eugster Date: Tue, 19 May 2026 23:45:01 +0100 Subject: [PATCH 23/36] reorganise stuff --- client/src/components/chat/ChatPanel.tsx | 134 +++++ client/src/components/chat/IntroPanel.tsx | 52 ++ .../typewriter/TypewriterCommandline.tsx | 28 +- client/src/components/level.tsx | 525 ------------------ client/src/components/level/ExercisePanel.tsx | 9 + client/src/components/level/IntroLevel.tsx | 52 ++ client/src/components/level/Level.tsx | 40 ++ client/src/components/level/PlayableLevel.tsx | 252 +++++++++ client/src/index.tsx | 2 +- client/src/store/editor-atoms.ts | 46 +- 10 files changed, 547 insertions(+), 593 deletions(-) create mode 100644 client/src/components/chat/ChatPanel.tsx create mode 100644 client/src/components/chat/IntroPanel.tsx delete mode 100644 client/src/components/level.tsx create mode 100644 client/src/components/level/ExercisePanel.tsx create mode 100644 client/src/components/level/IntroLevel.tsx create mode 100644 client/src/components/level/Level.tsx create mode 100644 client/src/components/level/PlayableLevel.tsx diff --git a/client/src/components/chat/ChatPanel.tsx b/client/src/components/chat/ChatPanel.tsx new file mode 100644 index 00000000..492a40e2 --- /dev/null +++ b/client/src/components/chat/ChatPanel.tsx @@ -0,0 +1,134 @@ +import * as React from 'react' +import { useTranslation } from 'react-i18next' +import { useGameTranslation } from '../../utils/translation' +import { useEffect, useRef } from 'react' +import { useAtom } from 'jotai' +import { mobileAtom } from '../../store/preferences-atoms' +import { gameIdAtom, levelIdAtom } from '../../store/location-atoms' +import { levelInfoAtom } from '../../store/query-atoms' +import { deletedChatAtom, helpAtom, selectedStepAtom } from '../../store/chat-atoms' +import { proofAtom } from '../../store/editor-atoms' +import { DeletedHints, filterHints, Hint, Hints, MoreHelpButton } from '../hints' +import Markdown from 'react-markdown' +import { faArrowRight, faHome } from '@fortawesome/free-solid-svg-icons' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { Button } from '../button' + +/** Appears on the left of playable levels */ +export function ChatPanel({lastLevel, visible = true}: {lastLevel: boolean, visible: boolean}) { + let { t } = useTranslation() + const { t : gT } = useGameTranslation() + const chatRef = useRef(null) + const [mobile] = useAtom(mobileAtom) + const [gameId, navigateToGame] = useAtom(gameIdAtom) + const [levelId, navigateToLevel] = useAtom(levelIdAtom) + const [{ data: levelInfo }] = useAtom(levelInfoAtom) + const [help] = useAtom(helpAtom) + const [proof] = useAtom(proofAtom) + const [deletedChat] = useAtom(deletedChatAtom) + const [selectedStep, setSelectedStep] = useAtom(selectedStepAtom) + + let k = proof?.steps.length ? proof?.steps.length - (lastStepHasErrors(proof) ? 2 : 1) : 0 + + function toggleSelection(line: number) { + return (ev: any) => { + console.debug('toggled selection') + if (selectedStep == line) { + setSelectedStep(undefined) + } else { + setSelectedStep(line) + } + } + } + + useEffect(() => { + // TODO: For some reason this is always called twice + console.debug('scroll chat') + if (!mobile) { + chatRef.current!.lastElementChild?.scrollIntoView() //scrollTo(0,0) + } + }, [proof, help]) + + // Scroll to element if selection changes + useEffect(() => { + if (typeof selectedStep !== 'undefined') { + Array.from(chatRef.current!.getElementsByClassName(`step-${selectedStep}`)).map((elem) => { + elem.scrollIntoView({block: "center"}) + }) + } + }, [selectedStep]) + + // useEffect(() => { + // // // Scroll to top when loading a new level + // // // TODO: Thats the wrong behaviour probably + // // chatRef.current!.scrollTo(0,0) + // }, [gameId, worldId, levelId]) + + let introText: Array = gT(levelInfo?.introduction ?? "").split(/\n(\s*\n)+/) + + const focusRef = useRef() + useEffect(() => { + if (proof?.completed) { + focusRef.current?.focus() + } + }, [!!proof?.completed]) + + return
+
+ {introText?.filter(it => it.trim()).map(((it, i) => + // Show the level's intro text as hints, too + + ))} + {proof?.steps.map((step, i) => { + let filteredHints = filterHints(step.goals[0]?.hints, proof?.steps[i-1]?.goals[0]?.hints) + if (step.goals.length > 0 && !isLastStepWithErrors(proof, i)) { + return + } + })} + + {/* {modifiedHints.map((step, i) => { + // It the last step has errors, it will have the same hints + // as the second-to-last step. Therefore we should not display them. + if (!(i == proof?.steps.length - 1 && withErr)) { + // TODO: Should not use index as key. + return + } + })} */} + + {proof?.completed && + <> +
+ {t("Level completed! 🎉")} +
+ {levelInfo?.conclusion?.trim() && +
+ {gT(levelInfo?.conclusion ?? "")} +
+ } + + } +
+
+ {proof?.completed && (lastLevel ? + : + ) + } + +
+
+} + +//FIXME: implement +function lastStepHasErrors(x: any) {return false} + +//FIXME: implement +function isLastStepWithErrors(x: any, y: any) {return false} diff --git a/client/src/components/chat/IntroPanel.tsx b/client/src/components/chat/IntroPanel.tsx new file mode 100644 index 00000000..fada2984 --- /dev/null +++ b/client/src/components/chat/IntroPanel.tsx @@ -0,0 +1,52 @@ +import { useTranslation } from "react-i18next" +import { useGameTranslation } from "../../utils/translation" +import { useAtom } from "jotai" +import { gameIdAtom, levelIdAtom, worldIdAtom } from "../../store/location-atoms" +import { gameInfoAtom } from "../../store/query-atoms" +import { readWorldIntroAtom } from "../../store/progress-atoms" +import { mobileAtom } from "../../store/preferences-atoms" +import { useEffect, useRef } from "react" +import React from "react" +import { Hint } from "../hints" +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" +import { faArrowRight, faHome } from "@fortawesome/free-solid-svg-icons" +import { Button } from "../button" + +/** Appears on the left side of the intro of a world */ +export function IntroPanel() { + let { t } = useTranslation() + const { t : gT } = useGameTranslation() + const [gameId, navigateToGame] = useAtom(gameIdAtom) + const [worldId] = useAtom(worldIdAtom) + const [{ data: gameInfo }] = useAtom(gameInfoAtom) + const [, navigateToLevel] = useAtom(levelIdAtom) + const [, readWorldIntro] = useAtom(readWorldIntroAtom) + + const [mobile] = useAtom(mobileAtom) + + let text: Array = gT(gameInfo?.worlds?.nodes[worldId ?? ""]?.introduction ?? "").split(/\n(\s*\n)+/) + + const focusRef = useRef() + useEffect(() => { + focusRef.current?.focus() + }, []) + + return
+
+ {text?.filter(t => t.trim()).map(((t, i) => + + ))} +
+
+ {gameInfo?.worldSize?.[worldId ?? ""] == 0 ? + : + + } +
+
+} diff --git a/client/src/components/infoview/typewriter/TypewriterCommandline.tsx b/client/src/components/infoview/typewriter/TypewriterCommandline.tsx index 739078f0..444e3bf2 100644 --- a/client/src/components/infoview/typewriter/TypewriterCommandline.tsx +++ b/client/src/components/infoview/typewriter/TypewriterCommandline.tsx @@ -11,31 +11,18 @@ import { typewriterContentAtom, oneLineEditorAtom, runCommandAtom, codeAtom, mo import { proofAtom } from "../../../store/editor-atoms"; import { twMerge } from "tailwind-merge"; -import { DocumentPosition } from "lean4monaco"; import { levelIdAtom, worldIdAtom } from "../../../store/location-atoms"; -import { ProofState } from "../../../api/rpc_api"; +import { deletedChatAtom } from "../../../store/chat-atoms"; /** The input field */ export function TypewriterCommandLine() { let { t } = useTranslation() - const [worldId] = useAtom(worldIdAtom) - const [levelId] = useAtom(levelIdAtom) const [isProcessing, setProcessing] = useState(false) const [oneLineEditor, setOneLineEditor] = useAtom(oneLineEditorAtom) const [model] = useAtom(modelAtom) - const [uri] = useAtom(uriAtom) const [code] = useAtom(codeAtom) - const [rpcSess] = useAtom(currentRpcSessionAtom) - const [proof, setProof] = useAtom(proofAtom) - - - + const [, setDeletedChatAtom] = useAtom(deletedChatAtom) const [typewriter, setTypewriter] = useAtom(typewriterContentAtom) - - // useAtom(syncTypewriterToEditorEffect) - // useAtom(syncEditorPositionEffect) - // useAtom(restoreErrorCommandEffect) - const oneLineEditorRef = useRef(null) const inputRef = useRef(null) @@ -47,11 +34,6 @@ export function TypewriterCommandLine() { } } - //const oneLineEditorRef = useRef(null) - //const [typewriterContent, setTypewriterContent] = useState("") - //const leanMonacoEditor = useAtom(editorAtom) - //let inputRef = getInputRef() - /** Wrapper to set the typewriter to processing. */ function handleSubmit(ev?: FormEvent) { ev?.preventDefault() @@ -69,13 +51,15 @@ export function TypewriterCommandLine() { const newCode = oldCode.length > 0 ? `${oldCode}\n${content}\n` : `${content}\n` model?.setValue(newCode) + // TODO: Desired logic is to only reset this after a new *error-free* command has been entered + setDeletedChatAtom([]) + // Clear typewriter oneLineEditor?.getModel()?.setValue('') } + // start the editor used in the typewriter command line useEffect(() => { - console.log("TypewriterCommandLine: Editor initialization useEffect") - // Guard: only create once if (oneLineEditorRef.current) { console.log("Editor already exists, skipping initialization") diff --git a/client/src/components/level.tsx b/client/src/components/level.tsx deleted file mode 100644 index 186e9e9f..00000000 --- a/client/src/components/level.tsx +++ /dev/null @@ -1,525 +0,0 @@ -import * as React from 'react' -import { useEffect, useState, useRef } from 'react' -import Split from 'react-split' -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' -import { faHome, faArrowRight } from '@fortawesome/free-solid-svg-icons' -import { CircularProgress } from '@mui/material' -import type { Location } from 'vscode-languageserver-protocol' -import { LeanMonaco, LeanMonacoEditor, LeanMonacoOptions, RpcSessionAtPos } from 'lean4monaco' -import { setupMonacoClient } from 'lean4monaco/dist/monacoleanclient' -import { Button } from './button' -import { Markdown } from './markdown' -import { DeletedHints, Hint, Hints, MoreHelpButton, filterHints } from './hints' -import path from 'path'; - -import '@fontsource/roboto/300.css' -import '@fontsource/roboto/400.css' -import '@fontsource/roboto/500.css' -import '@fontsource/roboto/700.css' -import '../css/level.css' -import { LevelAppBar } from './app_bar' -import { useTranslation } from 'react-i18next' -import { useGameTranslation } from '../utils/translation' -import { InventoryPanel } from './inventory/inventory_panel' -import { useAtom } from 'jotai' -import { leanMonacoAtom, editorAtom, lockEditorModeAtom, proofAtom, typewriterModeAtom, codeStorageAtom, modelAtom, clientAtom, uriAtom, currentRpcSessionAtom } from '../store/editor-atoms' -import { gameIdAtom, levelIdAtom, worldIdAtom } from '../store/location-atoms' -import { gameInfoAtom, levelInfoAtom } from '../store/query-atoms' -import { deletedChatAtom, helpAtom, selectedStepAtom } from '../store/chat-atoms' -import { inventoryOverviewAtom } from '../store/inventory-atoms' -import { mobileAtom } from '../store/preferences-atoms' -import { readWorldIntroAtom } from '../store/progress-atoms' -import { GameEditor } from './infoview/GameEditor' -import { RpcConnectParams } from '@leanprover/infoview-api' - -const reconfigureLeanMonacoClient = async (leanMonaco: LeanMonaco, options: LeanMonacoOptions) => { - const maybeLeanMonaco = leanMonaco as unknown as { - clientProvider?: { - getClients?: () => Array<{ getClientFolder?: () => unknown; stop?: () => Promise }> - stopClient?: (folder: unknown) => Promise - setupClient?: unknown - } - getWebSocketOptions?: (options: LeanMonacoOptions) => unknown - } - if (!maybeLeanMonaco.clientProvider || !maybeLeanMonaco.getWebSocketOptions) { - return - } - maybeLeanMonaco.clientProvider.setupClient = setupMonacoClient( - maybeLeanMonaco.getWebSocketOptions(options) as any - ) - const clients = maybeLeanMonaco.clientProvider.getClients?.() ?? [] - for (const client of clients) { - const folder = client?.getClientFolder?.() - if (folder && maybeLeanMonaco.clientProvider.stopClient) { - await maybeLeanMonaco.clientProvider.stopClient(folder) - } else { - await client?.stop?.() - } - } -} - -//FIXME: implement -function lastStepHasErrors(x: any) {return false} - - -//FIXME: implement -function isLastStepWithErrors(x: any, y: any) {return false} - -function Level() { - const { t: gT, i18n } = useGameTranslation() - const [gameId] = useAtom(gameIdAtom) - const [worldId] = useAtom(worldIdAtom) - const [levelId] = useAtom(levelIdAtom) - const [{ data: gameInfo }] = useAtom(gameInfoAtom) - - // Load the namespace of the game - i18n.loadNamespaces(gameId ?? "").catch(err => { - console.warn(`translations for ${gameId} do not exist.`) - }) - - // set the window title - useEffect(() => { - if (gameInfo?.title) { - window.document.title = gT(gameInfo.title) - } - }, [gameInfo?.title, i18n.language]) - - - if (levelId == 0) return - return -} - -function ChatPanel({lastLevel, visible = true}: {lastLevel: boolean, visible: boolean}) { - let { t } = useTranslation() - const { t : gT } = useGameTranslation() - const chatRef = useRef(null) - const [mobile] = useAtom(mobileAtom) - const [gameId, navigateToGame] = useAtom(gameIdAtom) - const [levelId, navigateToLevel] = useAtom(levelIdAtom) - const [{ data: levelInfo }] = useAtom(levelInfoAtom) - const [help] = useAtom(helpAtom) - const [proof] = useAtom(proofAtom) - const [deletedChat] = useAtom(deletedChatAtom) - const [selectedStep, setSelectedStep] = useAtom(selectedStepAtom) - - let k = proof?.steps.length ? proof?.steps.length - (lastStepHasErrors(proof) ? 2 : 1) : 0 - - function toggleSelection(line: number) { - return (ev: any) => { - console.debug('toggled selection') - if (selectedStep == line) { - setSelectedStep(undefined) - } else { - setSelectedStep(line) - } - } - } - - useEffect(() => { - // TODO: For some reason this is always called twice - console.debug('scroll chat') - if (!mobile) { - chatRef.current!.lastElementChild?.scrollIntoView() //scrollTo(0,0) - } - }, [proof, help]) - - // Scroll to element if selection changes - useEffect(() => { - if (typeof selectedStep !== 'undefined') { - Array.from(chatRef.current!.getElementsByClassName(`step-${selectedStep}`)).map((elem) => { - elem.scrollIntoView({block: "center"}) - }) - } - }, [selectedStep]) - - // useEffect(() => { - // // // Scroll to top when loading a new level - // // // TODO: Thats the wrong behaviour probably - // // chatRef.current!.scrollTo(0,0) - // }, [gameId, worldId, levelId]) - - let introText: Array = gT(levelInfo?.introduction ?? "").split(/\n(\s*\n)+/) - - const focusRef = useRef() - useEffect(() => { - if (proof?.completed) { - focusRef.current?.focus() - } - }, [!!proof?.completed]) - - return
-
- {introText?.filter(it => it.trim()).map(((it, i) => - // Show the level's intro text as hints, too - - ))} - {proof?.steps.map((step, i) => { - let filteredHints = filterHints(step.goals[0]?.hints, proof?.steps[i-1]?.goals[0]?.hints) - if (step.goals.length > 0 && !isLastStepWithErrors(proof, i)) { - return - } - })} - - {/* {modifiedHints.map((step, i) => { - // It the last step has errors, it will have the same hints - // as the second-to-last step. Therefore we should not display them. - if (!(i == proof?.steps.length - 1 && withErr)) { - // TODO: Should not use index as key. - return - } - })} */} - - {proof?.completed && - <> -
- {t("Level completed! 🎉")} -
- {levelInfo?.conclusion?.trim() && -
- {gT(levelInfo?.conclusion ?? "")} -
- } - - } -
-
- {proof?.completed && (lastLevel ? - : - ) - } - -
-
-} - - -function ExercisePanel({codeviewRef, visible=true}: {codeviewRef: React.MutableRefObject, visible?: boolean}) { - return
- -
-} - -function PlayableLevel() { - let { t } = useTranslation() - const { t : gT } = useGameTranslation() - const codeviewRef = useRef(null) - const [leanMonaco] = useAtom(leanMonacoAtom) - const [gameId] = useAtom(gameIdAtom) - const [worldId] = useAtom(worldIdAtom) - const [levelId] = useAtom(levelIdAtom) - const [typewriterMode, setTypewriterMode] = useAtom(typewriterModeAtom) - const [mobile] = useAtom(mobileAtom) - const [code, setCode] = useAtom(codeStorageAtom) - const [{ data: gameInfo }] = useAtom(gameInfoAtom) - const [{ data: levelInfo, isLoading: levelInfoIsLoading }] = useAtom(levelInfoAtom) - // Only for mobile layout - const [pageNumber, setPageNumber] = useState(0) - // set to true to prevent switching between typewriter and editor - const [lockEditorMode] = useAtom(lockEditorModeAtom) - const [, setTypewriterInput] = useState("") - const lastLevel = worldId && (levelId !== undefined) && levelId >= (gameInfo?.worldSize?.[worldId] ?? 0) - - // When clicking on an inventory item, the inventory is overlayed by the item's doc. - // If this state is set to a pair `(name, type)` then the according doc will be open. - // Set `inventoryDoc` to `null` to close the doc - const [inventoryDoc, setInventoryDoc] = useState<{name: string, type: string} | null>(null) - function closeInventoryDoc () {setInventoryDoc(null)} - - const [editor, setEditor] = useAtom(editorAtom) - const [model] = useAtom(modelAtom) - const [client, setClient] = useAtom(clientAtom) - const [uri] = useAtom(uriAtom) - const [rpcSess, setRpcSess] = useAtom(currentRpcSessionAtom) - - // Start the editor, following lean4monaco demo - useEffect(() => { - if (leanMonaco) { - const leanMonacoEditor = new LeanMonacoEditor() - const uriStr = `file:///${worldId}/${levelId}.lean` - - ;(async () => { - await leanMonaco!.whenReady - console.debug('[lean4game]: starting editor') - await leanMonacoEditor.start(codeviewRef.current!, uriStr, code ?? "") - console.debug('[lean4game]: editor started') - setEditor(leanMonacoEditor.editor) - })() - - return () => { - leanMonacoEditor.dispose() - } - } - }, [leanMonaco, worldId, levelId]) - - // Persist editor text into progress whenever the model changes. - useEffect(() => { - if (!editor) return - - setCode(editor.getValue()) - const disposable = editor.onDidChangeModelContent(() => { - setCode(editor.getValue()) - }) - - return () => { - disposable.dispose() - } - }, [editor, setCode]) - - // wait until the client exists - // TODO: it didn't work to define an atom `get(editorAtom)?.clientProvider?.getClients()?.[0]` - // is there a way to tell atoms to fully reevaluate until they get a non-null result? - useEffect(() => { - const updateClient = () => { - const clients = leanMonaco?.clientProvider?.getClients() - const firstClient = clients?.[0] ?? null - if (firstClient) { - setClient(firstClient) - return true - } - return false - } - updateClient() - const interval = setInterval(() => { - // try to get `client` until successful - if (updateClient()) { - clearInterval(interval) - } - }, 500) - return () => clearInterval(interval) - }, [leanMonaco?.clientProvider]) - - // Start RPC session - useEffect(() => { - function updateRpcSession() { - if (rpcSess) return true - if (!client || !uri) return false - console.debug('rpc session connecting...') - client.sendRequest('$/lean/rpc/connect', {uri: uri.toString()} as RpcConnectParams).then(result => { - const sessionId = result.sessionId - console.debug(`rpc session id: ${sessionId}`) - const _rpcSess = new RpcSessionAtPos(client, sessionId, uri.toString()) - setRpcSess(_rpcSess) - }) - } - updateRpcSession() - const interval = setInterval(() => { - // try to get `rpcSess` until successful - if (updateRpcSession()) { - clearInterval(interval) - } - }, 500) - return () => clearInterval(interval) - }, [client, uri, rpcSess]) - - // Select and highlight proof steps and corresponding hints - // TODO: with the new design, there is no difference between the introduction and - // a hint at the beginning of the proof... - const [selectedStep, setSelectedStep] = useState() - - useEffect (() => { - // Lock editor mode - if (levelInfo?.template) { - - if (model) { - let code = model.getLinesContent() - - // TODO: It does seem that the template is always indented by spaces. - // This is a hack, assuming there are exactly two. - if (!code.join("").trim().length) { - console.debug(`inserting template:\n${levelInfo?.template}`) - // TODO: This does not work! HERE - // Probably overwritten by a query to the server - // DOC: REMOVED editor here - editor?.executeEdits("template-writer", [{ - range: model.getFullModelRange(), - text: levelInfo?.template + `\n`, - forceMoveMarkers: true - }]) - } else { - console.debug(`not inserting template.`) - } - } - } else { - } - }, [levelInfo, levelId, worldId, gameId, editor]) - - - useEffect(() => { - // TODO: That's a problem if the saved proof contains an error - // Reset command line input when loading a new level - setTypewriterInput("") - - }, [gameId, worldId, levelId]) - - useEffect(() => { - const selection = editor?.getSelection(); - if (!typewriterMode && editor && selection) { - // Delete last input attempt from command line - editor.executeEdits("typewriter", [{ - range: selection, - text: "", - forceMoveMarkers: false - }]); - editor?.focus() // leanMonacoEditor?.editor.focus() - } - }, [typewriterMode]) - - // useEffect(() => { - // // Forget whether hidden hints are displayed for steps that don't exist yet - // if (proof?.steps.length) { - // console.debug(Array.from(help)) - // setHelp([...new Set(Array.from(help).filter(i => (i < proof?.steps.length)))]) - // } - // }, [proof]) - - // // save showed help in store - // useEffect(() => { - // if (proof?.steps.length) { - // console.debug(`showHelp:\n ${showHelp}`) - // setHelp(Array.from(showHelp)) - // } - // }, [showHelp]) - - // Effect when command line mode gets enabled - useEffect(() => { - if (model&& (typewriterMode)) { - let code = model.getLinesContent().filter(line => line.trim()) - // REMOVED .editor call - editor?.executeEdits("typewriter", [{ - range: model.getFullModelRange(), - text: code.length ? code.join('\n') + '\n' : '', - forceMoveMarkers: true - }]); - - // let endPos = editor.getModel().getFullModelRange().getEndPosition() - // if (editor.getModel().getLineContent(endPos.lineNumber).trim() !== "") { - // editor.executeEdits("typewriter", [{ - // range: monaco.Selection.fromPositions(endPos, endPos), - // text: "\n", - // forceMoveMarkers: true - // }]); - // } - // let endPos = editor.getModel().getFullModelRange().getEndPosition() - // let currPos = editor.getPosition() - // if (currPos.column != 1 || (currPos.lineNumber != endPos.lineNumber && currPos.lineNumber != endPos.lineNumber - 1)) { - // // This is not a position that would naturally occur from Typewriter, reset: - // editor.setSelection(monaco.Selection.fromPositions(endPos, endPos)) - // } - } - }, [editor, typewriterMode, lockEditorMode]) // TODO: typewriterMode too? - - return <> - { levelInfoIsLoading &&
} - - {mobile? - // TODO: This is copied from the `Split` component below... - <> -
- - -
- - : - - - - - - } - -} - -function IntroductionPanel() { - let { t } = useTranslation() - const { t : gT } = useGameTranslation() - const [gameId, navigateToGame] = useAtom(gameIdAtom) - const [worldId] = useAtom(worldIdAtom) - const [{ data: gameInfo }] = useAtom(gameInfoAtom) - const [, navigateToLevel] = useAtom(levelIdAtom) - const [, readWorldIntro] = useAtom(readWorldIntroAtom) - - const [mobile] = useAtom(mobileAtom) - - let text: Array = gT(gameInfo?.worlds?.nodes[worldId ?? ""]?.introduction ?? "").split(/\n(\s*\n)+/) - - const focusRef = useRef() - useEffect(() => { - focusRef.current?.focus() - }, []) - - return
-
- {text?.filter(t => t.trim()).map(((t, i) => - - ))} -
-
- {gameInfo?.worldSize?.[worldId ?? ""] == 0 ? - : - - } -
-
-} - -export default Level - -/** The site with the introduction text of a world */ -function Introduction() { - let { t } = useTranslation() - - const [gameId] = useAtom(gameIdAtom) - const [worldId] = useAtom(worldIdAtom) - - const [mobile] = useAtom(mobileAtom) - - const [{ data: inventory }] = useAtom(inventoryOverviewAtom) - - const [{ data: gameInfo, isLoading: isLoadingGameInfo }] = useAtom(gameInfoAtom) - - - let image: string = worldId ? gameInfo?.worlds?.nodes[worldId]?.image ?? "" : "" - - return <> - - {isLoadingGameInfo ? -
- : mobile ? - - : - - -
- {image && gameId && - - } - -
- -
- } - - -} -function useGetGameInfoQuery(arg0: { game: string | undefined }) { - throw new Error('Function not implemented.') -} diff --git a/client/src/components/level/ExercisePanel.tsx b/client/src/components/level/ExercisePanel.tsx new file mode 100644 index 00000000..73a29c4d --- /dev/null +++ b/client/src/components/level/ExercisePanel.tsx @@ -0,0 +1,9 @@ +import React from "react" +import { GameEditor } from "../infoview/GameEditor" + +/** The center-piece of a playable level */ +export function ExercisePanel({codeviewRef, visible=true}: {codeviewRef: React.MutableRefObject, visible?: boolean}) { + return
+ +
+} diff --git a/client/src/components/level/IntroLevel.tsx b/client/src/components/level/IntroLevel.tsx new file mode 100644 index 00000000..a359ad45 --- /dev/null +++ b/client/src/components/level/IntroLevel.tsx @@ -0,0 +1,52 @@ +import { useAtom } from "jotai" +import { useTranslation } from "react-i18next" +import { gameIdAtom, worldIdAtom } from "../../store/location-atoms" +import { mobileAtom } from "../../store/preferences-atoms" +import { inventoryOverviewAtom } from "../../store/inventory-atoms" +import { gameInfoAtom } from "../../store/query-atoms" +import { CircularProgress } from "@mui/material" +import { LevelAppBar } from "../app_bar" +import React from "react" +import { IntroPanel } from "../chat/IntroPanel" +import Split from "react-split" +import path from 'path' +import { InventoryPanel } from "../inventory/inventory_panel" + + +/** The site with the introduction text of a world */ +export function IntroLevel() { + let { t } = useTranslation() + + const [gameId] = useAtom(gameIdAtom) + const [worldId] = useAtom(worldIdAtom) + + const [mobile] = useAtom(mobileAtom) + + const [{ data: inventory }] = useAtom(inventoryOverviewAtom) + + const [{ data: gameInfo, isLoading: isLoadingGameInfo }] = useAtom(gameInfoAtom) + + + let image: string = worldId ? gameInfo?.worlds?.nodes[worldId]?.image ?? "" : "" + + return <> + + {isLoadingGameInfo ? +
+ : mobile ? + + : + + +
+ {image && gameId && + + } + +
+ +
+ } + + +} diff --git a/client/src/components/level/Level.tsx b/client/src/components/level/Level.tsx new file mode 100644 index 00000000..0c9f1b10 --- /dev/null +++ b/client/src/components/level/Level.tsx @@ -0,0 +1,40 @@ +import * as React from 'react' +import { useEffect } from 'react' + +import '@fontsource/roboto/300.css' +import '@fontsource/roboto/400.css' +import '@fontsource/roboto/500.css' +import '@fontsource/roboto/700.css' +import '../../css/level.css' +import { useGameTranslation } from '../../utils/translation' +import { useAtom } from 'jotai' +import { gameIdAtom, levelIdAtom, worldIdAtom } from '../../store/location-atoms' +import { gameInfoAtom } from '../../store/query-atoms' +import { PlayableLevel } from './PlayableLevel' +import { IntroLevel } from './IntroLevel' + +export default function Level() { + const { t: gT, i18n } = useGameTranslation() + const [gameId] = useAtom(gameIdAtom) + const [worldId] = useAtom(worldIdAtom) + const [levelId] = useAtom(levelIdAtom) + const [{ data: gameInfo }] = useAtom(gameInfoAtom) + + // Load the namespace of the game + i18n.loadNamespaces(gameId ?? "").catch(err => { + console.warn(`translations for ${gameId} do not exist.`) + }) + + // set the window title + useEffect(() => { + if (gameInfo?.title) { + window.document.title = gT(gameInfo.title) + } + }, [gameInfo?.title, i18n.language]) + + return ( + levelId == 0 ? + : + + ) +} diff --git a/client/src/components/level/PlayableLevel.tsx b/client/src/components/level/PlayableLevel.tsx new file mode 100644 index 00000000..9bb5510b --- /dev/null +++ b/client/src/components/level/PlayableLevel.tsx @@ -0,0 +1,252 @@ +import { useTranslation } from "react-i18next" +import { useGameTranslation } from "../../utils/translation" +import { useEffect, useRef, useState } from "react" +import { useAtom } from "jotai" +import { clientAtom, codeStorageAtom, currentRpcSessionAtom, editorAtom, leanMonacoAtom, lockEditorModeAtom, modelAtom, typewriterModeAtom, uriAtom } from "../../store/editor-atoms" +import { gameIdAtom, levelIdAtom, worldIdAtom } from "../../store/location-atoms" +import { mobileAtom } from "../../store/preferences-atoms" +import { gameInfoAtom, levelInfoAtom } from "../../store/query-atoms" +import { LeanMonacoEditor, RpcSessionAtPos } from "lean4monaco" +import { RpcConnectParams } from "@leanprover/infoview-api" +import { CircularProgress } from "@mui/material" +import { LevelAppBar } from "../app_bar" +import React from "react" +import { ExercisePanel } from "./ExercisePanel" +import { InventoryPanel } from "../inventory/inventory_panel" +import { ChatPanel } from "../chat/ChatPanel" +import Split from 'react-split' + +export function PlayableLevel() { + let { t } = useTranslation() + const { t : gT } = useGameTranslation() + const codeviewRef = useRef(null) + const [leanMonaco] = useAtom(leanMonacoAtom) + const [gameId] = useAtom(gameIdAtom) + const [worldId] = useAtom(worldIdAtom) + const [levelId] = useAtom(levelIdAtom) + const [typewriterMode, setTypewriterMode] = useAtom(typewriterModeAtom) + const [mobile] = useAtom(mobileAtom) + const [code, setCode] = useAtom(codeStorageAtom) + const [{ data: gameInfo }] = useAtom(gameInfoAtom) + const [{ data: levelInfo, isLoading: levelInfoIsLoading }] = useAtom(levelInfoAtom) + // Only for mobile layout + const [pageNumber, setPageNumber] = useState(0) + // set to true to prevent switching between typewriter and editor + const [lockEditorMode] = useAtom(lockEditorModeAtom) + const [, setTypewriterInput] = useState("") + const lastLevel = worldId && (levelId !== undefined) && levelId >= (gameInfo?.worldSize?.[worldId] ?? 0) + + // When clicking on an inventory item, the inventory is overlayed by the item's doc. + // If this state is set to a pair `(name, type)` then the according doc will be open. + // Set `inventoryDoc` to `null` to close the doc + const [inventoryDoc, setInventoryDoc] = useState<{name: string, type: string} | null>(null) + function closeInventoryDoc () {setInventoryDoc(null)} + + const [editor, setEditor] = useAtom(editorAtom) + const [model] = useAtom(modelAtom) + const [client, setClient] = useAtom(clientAtom) + const [uri] = useAtom(uriAtom) + const [rpcSess, setRpcSess] = useAtom(currentRpcSessionAtom) + + // Start the editor, following lean4monaco demo + useEffect(() => { + if (leanMonaco) { + const leanMonacoEditor = new LeanMonacoEditor() + const uriStr = `file:///${worldId}/${levelId}.lean` + + ;(async () => { + await leanMonaco!.whenReady + console.debug('[lean4game]: starting editor') + await leanMonacoEditor.start(codeviewRef.current!, uriStr, code ?? "") + console.debug('[lean4game]: editor started') + setEditor(leanMonacoEditor.editor) + })() + + return () => { + leanMonacoEditor.dispose() + } + } + }, [leanMonaco, worldId, levelId]) + + // Persist editor text into progress whenever the model changes. + useEffect(() => { + if (!editor) return + + setCode(editor.getValue()) + const disposable = editor.onDidChangeModelContent(() => { + setCode(editor.getValue()) + }) + + return () => { + disposable.dispose() + } + }, [editor, setCode]) + + // wait until the client exists + // TODO: it didn't work to define an atom `get(editorAtom)?.clientProvider?.getClients()?.[0]` + // is there a way to tell atoms to fully reevaluate until they get a non-null result? + useEffect(() => { + const updateClient = () => { + const clients = leanMonaco?.clientProvider?.getClients() + const firstClient = clients?.[0] ?? null + if (firstClient) { + setClient(firstClient) + return true + } + return false + } + updateClient() + const interval = setInterval(() => { + // try to get `client` until successful + if (updateClient()) { + clearInterval(interval) + } + }, 500) + return () => clearInterval(interval) + }, [leanMonaco?.clientProvider]) + + // Start RPC session + useEffect(() => { + function updateRpcSession() { + if (rpcSess) return true + if (!client || !uri) return false + console.debug('rpc session connecting...') + client.sendRequest('$/lean/rpc/connect', {uri: uri.toString()} as RpcConnectParams).then(result => { + const sessionId = result.sessionId + console.debug(`rpc session id: ${sessionId}`) + const _rpcSess = new RpcSessionAtPos(client, sessionId, uri.toString()) + setRpcSess(_rpcSess) + }) + } + updateRpcSession() + const interval = setInterval(() => { + // try to get `rpcSess` until successful + if (updateRpcSession()) { + clearInterval(interval) + } + }, 500) + return () => clearInterval(interval) + }, [client, uri, rpcSess]) + + // Select and highlight proof steps and corresponding hints + // TODO: with the new design, there is no difference between the introduction and + // a hint at the beginning of the proof... + const [selectedStep, setSelectedStep] = useState() + + useEffect (() => { + // Lock editor mode + if (levelInfo?.template) { + + if (model) { + let code = model.getLinesContent() + + // TODO: It does seem that the template is always indented by spaces. + // This is a hack, assuming there are exactly two. + if (!code.join("").trim().length) { + console.debug(`inserting template:\n${levelInfo?.template}`) + // TODO: This does not work! HERE + // Probably overwritten by a query to the server + // DOC: REMOVED editor here + editor?.executeEdits("template-writer", [{ + range: model.getFullModelRange(), + text: levelInfo?.template + `\n`, + forceMoveMarkers: true + }]) + } else { + console.debug(`not inserting template.`) + } + } + } else { + } + }, [levelInfo, levelId, worldId, gameId, editor]) + + + useEffect(() => { + // TODO: That's a problem if the saved proof contains an error + // Reset command line input when loading a new level + setTypewriterInput("") + + }, [gameId, worldId, levelId]) + + useEffect(() => { + const selection = editor?.getSelection(); + if (!typewriterMode && editor && selection) { + // Delete last input attempt from command line + editor.executeEdits("typewriter", [{ + range: selection, + text: "", + forceMoveMarkers: false + }]); + editor?.focus() // leanMonacoEditor?.editor.focus() + } + }, [typewriterMode]) + + // useEffect(() => { + // // Forget whether hidden hints are displayed for steps that don't exist yet + // if (proof?.steps.length) { + // console.debug(Array.from(help)) + // setHelp([...new Set(Array.from(help).filter(i => (i < proof?.steps.length)))]) + // } + // }, [proof]) + + // // save showed help in store + // useEffect(() => { + // if (proof?.steps.length) { + // console.debug(`showHelp:\n ${showHelp}`) + // setHelp(Array.from(showHelp)) + // } + // }, [showHelp]) + + // Effect when command line mode gets enabled + useEffect(() => { + if (model && (typewriterMode)) { + let code = model.getLinesContent().filter(line => line.trim()) + // REMOVED .editor call + editor?.executeEdits("typewriter", [{ + range: model.getFullModelRange(), + text: code.length ? code.join('\n') + '\n' : '', + forceMoveMarkers: true + }]); + + // let endPos = editor.getModel().getFullModelRange().getEndPosition() + // if (editor.getModel().getLineContent(endPos.lineNumber).trim() !== "") { + // editor.executeEdits("typewriter", [{ + // range: monaco.Selection.fromPositions(endPos, endPos), + // text: "\n", + // forceMoveMarkers: true + // }]); + // } + // let endPos = editor.getModel().getFullModelRange().getEndPosition() + // let currPos = editor.getPosition() + // if (currPos.column != 1 || (currPos.lineNumber != endPos.lineNumber && currPos.lineNumber != endPos.lineNumber - 1)) { + // // This is not a position that would naturally occur from Typewriter, reset: + // editor.setSelection(monaco.Selection.fromPositions(endPos, endPos)) + // } + } + }, [editor, typewriterMode, lockEditorMode]) // TODO: typewriterMode too? + + return <> + { levelInfoIsLoading &&
} + + {mobile? + // TODO: This is copied from the `Split` component below... + <> +
+ + +
+ + : + + + + + + } + +} diff --git a/client/src/index.tsx b/client/src/index.tsx index 690307a4..e6ec33e1 100644 --- a/client/src/index.tsx +++ b/client/src/index.tsx @@ -4,7 +4,7 @@ import App from './app' import { Provider } from 'react-redux' import Welcome from './components/welcome' import LandingPage from './components/landing_page' -import Level from './components/level' +import Level from './components/level/Level' import './i18n'; import { useAtom } from 'jotai' import { gameIdAtom, hashSegmentsAtom, levelIdAtom, pathSegmentsAtom, redirectAtom, worldIdAtom } from './store/location-atoms' diff --git a/client/src/store/editor-atoms.ts b/client/src/store/editor-atoms.ts index 91950b0a..1fc5de88 100644 --- a/client/src/store/editor-atoms.ts +++ b/client/src/store/editor-atoms.ts @@ -75,7 +75,7 @@ export const currentRpcSessionAtom = atom() /** The editor powering the typewriter (single line) input */ export const oneLineEditorAtom = atom(null) -/** Query which receives the goals throughout the proof over RPC */ +/** The analysied proof, received over RPC */ export const proofQueryAtom = atomWithQuery((get) => { const worldId = get(worldIdAtom) const levelId = get(levelIdAtom) @@ -84,7 +84,6 @@ export const proofQueryAtom = atomWithQuery((get) => { return { queryKey: ['proof', worldId, levelId, rpcSess?.sessionId, code], queryFn: async () => { - console.debug('refetching...') if (!rpcSess) return undefined const res = await rpcSess.client.sendRequest( '$/lean/rpc/call', @@ -360,46 +359,3 @@ export const runCommandAtom = atom( editor.setPosition(pos!) } ) - -export const loadGoalsAtom = atom( - null, - (get, set) => { - const rpcSess = get(currentRpcSessionAtom) - - if (!rpcSess) { - console.error("RpcSession is not available"); - return; - } - - const uri = get(leanMonacoEditorUriAtom) - const worldId = get(worldIdAtom) - const levelId = get(levelIdAtom) - - console.info('sending rpc request to load the proof state') - rpcSess.call('Game.getProofState', - { - ...DocumentPosition.toTdpp({line: 0, character: 0, uri: uri}), - worldId, levelId - } - ).then( - (proof: unknown) => { - if (typeof proof !== 'undefined') { - console.info(`received a proof state!`) - console.log(proof) - set(proofAtom, proof as any) // TODO - set(crashedAtom, false) - } else { - console.warn('received undefined proof state!') - // Avoid transient crash state while the server warms up. - } - } - ).catch((error: string) => { - if (error === 'No connection to Lean') { - console.warn(error) - return - } - set(crashedAtom, true) - console.warn(error) - }) - } -) From 659b71317b0c8cef575b2ea500427cafd2167286 Mon Sep 17 00:00:00 2001 From: Jon Eugster Date: Wed, 20 May 2026 00:03:31 +0100 Subject: [PATCH 24/36] reorganise more --- .../ExerciseStatement.tsx | 0 .../{infoview => editor}/GameEditor.tsx | 2 +- .../{infoview => editor}/ProofStep.tsx | 2 +- .../typewriter.tsx => editor/Typewriter.tsx} | 5 +- .../{infoview => editor}/messages/Error.tsx | 0 .../components/{infoview => editor}/types.ts | 0 .../typewriter/TypewriterCommandline.tsx | 11 +- .../src/components/infoview/editor/event.ts | 77 ------------- client/src/components/infoview/editor/util.ts | 51 --------- client/src/components/level/ExercisePanel.tsx | 2 +- client/src/components/level/PlayableLevel.tsx | 4 +- client/src/store/editor-atoms.ts | 104 +++++------------- 12 files changed, 39 insertions(+), 219 deletions(-) rename client/src/components/{infoview => editor}/ExerciseStatement.tsx (100%) rename client/src/components/{infoview => editor}/GameEditor.tsx (95%) rename client/src/components/{infoview => editor}/ProofStep.tsx (81%) rename client/src/components/{infoview/typewriter.tsx => editor/Typewriter.tsx} (89%) rename client/src/components/{infoview => editor}/messages/Error.tsx (100%) rename client/src/components/{infoview => editor}/types.ts (100%) rename client/src/components/{infoview => editor}/typewriter/TypewriterCommandline.tsx (92%) delete mode 100644 client/src/components/infoview/editor/event.ts delete mode 100644 client/src/components/infoview/editor/util.ts diff --git a/client/src/components/infoview/ExerciseStatement.tsx b/client/src/components/editor/ExerciseStatement.tsx similarity index 100% rename from client/src/components/infoview/ExerciseStatement.tsx rename to client/src/components/editor/ExerciseStatement.tsx diff --git a/client/src/components/infoview/GameEditor.tsx b/client/src/components/editor/GameEditor.tsx similarity index 95% rename from client/src/components/infoview/GameEditor.tsx rename to client/src/components/editor/GameEditor.tsx index ae639330..310de8a0 100644 --- a/client/src/components/infoview/GameEditor.tsx +++ b/client/src/components/editor/GameEditor.tsx @@ -2,7 +2,7 @@ import { useAtom } from "jotai" import { leanMonacoAtom, typewriterModeAtom } from "../../store/editor-atoms" import React, { useEffect, useLayoutEffect, useRef } from "react" import { ExerciseStatement } from "./ExerciseStatement" -import { Typewriter } from "./typewriter" +import { Typewriter } from "./Typewriter" /** * Note: It is important that the `div` with `codeViewRef` is diff --git a/client/src/components/infoview/ProofStep.tsx b/client/src/components/editor/ProofStep.tsx similarity index 81% rename from client/src/components/infoview/ProofStep.tsx rename to client/src/components/editor/ProofStep.tsx index 900a85dd..d090ec76 100644 --- a/client/src/components/infoview/ProofStep.tsx +++ b/client/src/components/editor/ProofStep.tsx @@ -2,5 +2,5 @@ import React from "react"; import { InteractiveGoalsWithHints } from "../../api/rpc_api"; export function ProofStep({step, idx}: {step:InteractiveGoalsWithHints, idx: number}) { - return
Step {idx}
+ return
Step {idx}
} diff --git a/client/src/components/infoview/typewriter.tsx b/client/src/components/editor/Typewriter.tsx similarity index 89% rename from client/src/components/infoview/typewriter.tsx rename to client/src/components/editor/Typewriter.tsx index 90928bd3..992eef1c 100644 --- a/client/src/components/infoview/typewriter.tsx +++ b/client/src/components/editor/Typewriter.tsx @@ -1,5 +1,4 @@ -import React, { MutableRefObject, useEffect, useRef, useState } from "react"; - +import React, { useRef } from "react"; import "../../css/typewriter.css" import { useAtom } from "jotai"; import { gameInfoAtom } from "../../store/query-atoms"; @@ -29,7 +28,7 @@ export function Typewriter() {
- {proof?.steps.map((step, i) => )} + {proof?.steps.map((step, i) => )}
diff --git a/client/src/components/infoview/messages/Error.tsx b/client/src/components/editor/messages/Error.tsx similarity index 100% rename from client/src/components/infoview/messages/Error.tsx rename to client/src/components/editor/messages/Error.tsx diff --git a/client/src/components/infoview/types.ts b/client/src/components/editor/types.ts similarity index 100% rename from client/src/components/infoview/types.ts rename to client/src/components/editor/types.ts diff --git a/client/src/components/infoview/typewriter/TypewriterCommandline.tsx b/client/src/components/editor/typewriter/TypewriterCommandline.tsx similarity index 92% rename from client/src/components/infoview/typewriter/TypewriterCommandline.tsx rename to client/src/components/editor/typewriter/TypewriterCommandline.tsx index 444e3bf2..cd97e7cb 100644 --- a/client/src/components/infoview/typewriter/TypewriterCommandline.tsx +++ b/client/src/components/editor/typewriter/TypewriterCommandline.tsx @@ -1,17 +1,12 @@ -import React, { FormEvent, useCallback, useEffect, useRef, useState } from "react"; - +import React, { FormEvent, useEffect, useRef, useState } from "react"; import "../../../css/typewriter.css" import { useTranslation } from "react-i18next"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faWandMagicSparkles } from "@fortawesome/free-solid-svg-icons"; -import { useAtom, useSetAtom } from "jotai"; - +import { useAtom } from "jotai"; import * as monaco from 'monaco-editor' -import { typewriterContentAtom, oneLineEditorAtom, runCommandAtom, codeAtom, modelAtom, uriAtom, currentRpcSessionAtom } from "../../../store/editor-atoms"; -import { proofAtom } from "../../../store/editor-atoms"; - +import { typewriterContentAtom, oneLineEditorAtom, codeAtom, modelAtom } from "../../../store/editor-atoms"; import { twMerge } from "tailwind-merge"; -import { levelIdAtom, worldIdAtom } from "../../../store/location-atoms"; import { deletedChatAtom } from "../../../store/chat-atoms"; /** The input field */ diff --git a/client/src/components/infoview/editor/event.ts b/client/src/components/infoview/editor/event.ts deleted file mode 100644 index 09cd81af..00000000 --- a/client/src/components/infoview/editor/event.ts +++ /dev/null @@ -1,77 +0,0 @@ -import type { Disposable } from 'vscode-languageserver-protocol' - -export class EventEmitter { - private freshId: number = 0 - private handlers = new Map void>() - private handlersWithKey = new Map void)[]>() - current?: E - - /** - * Register a handler that will receive events from this emitter - * and return a closure that removes the handler registration. - * - * If `key` is specified, only events fired with that key - * will be propagated to this handler. - */ - on(handler: (_: E) => void, key?: K): Disposable { - const id = this.freshId - this.freshId += 1 - if (key) { - const handlersForKey = this.handlersWithKey.get(key) ?? [] - handlersForKey.push(handler) - this.handlersWithKey.set(key, handlersForKey) - } else { - this.handlers.set(id, handler) - } - return { - dispose: () => { - if (key) { - const handlersForKey = this.handlersWithKey.get(key) ?? [] - // We assume that no key has so many handlers registered - // that the linear `filter` operation becomes a perf issue. - this.handlersWithKey.set( - key, - handlersForKey.filter(h => h !== handler), - ) - } else { - this.handlers.delete(id) - } - }, - } - } - - /** - * Propagate the event to registered handlers. - * - * The event is propagated to all keyless handlers. - * Furthermore if `key` is provided, - * the event is also propagated to handlers registered with that key. - */ - fire(event: E, key?: K): void { - this.current = event - for (const h of this.handlers.values()) { - h(event) - } - if (key) { - const handlersForKey = this.handlersWithKey.get(key) ?? [] - for (const h of handlersForKey) { - h(event) - } - } - } -} - -type ExcludeNonEvent = T extends (...args: any) => Promise ? U : never - -/** - * Turn all fields in `T` which extend `(...args: As) => Promise` - * into event emitter fields `f: EventEmitter`. - * Other fields are removed. - */ -export type Eventify = { - [P in keyof T as ExcludeNonEvent]: T[P] extends (arg: infer A) => Promise - ? EventEmitter - : T[P] extends (...args: infer As) => Promise - ? EventEmitter - : never -} diff --git a/client/src/components/infoview/editor/util.ts b/client/src/components/infoview/editor/util.ts deleted file mode 100644 index 2c0f3ad4..00000000 --- a/client/src/components/infoview/editor/util.ts +++ /dev/null @@ -1,51 +0,0 @@ -import type { DocumentUri, Position, Range, TextDocumentPositionParams } from 'vscode-languageserver-protocol' -import { EventEmitter } from './event' -import React from 'react' - -/** A document URI and a position in that document. */ -export interface DocumentPosition extends Position { - uri: DocumentUri -} - -export namespace DocumentPosition { - export function isEqual(p1: DocumentPosition, p2: DocumentPosition): boolean { - return p1.uri === p2.uri && p1.line === p2.line && p1.character === p2.character - } - - export function toTdpp(p: DocumentPosition): TextDocumentPositionParams { - return { textDocument: { uri: p.uri }, position: { line: p.line, character: p.character } } - } - - export function toString(p: DocumentPosition) { - return `${p.uri}:${p.line + 1}:${p.character}` - } -} - -/** - * A piece of React {@link React.useState} which returns the data that `ev` most recently fired with. - * If `f` is provided, the data is mapped through `f` first. */ -export function useEventResult(ev: EventEmitter): E | undefined -export function useEventResult(ev: EventEmitter, f: (newVal: E) => T): T | undefined -export function useEventResult(ev: EventEmitter, f?: (_: E) => T): T | undefined { - const fn: (_: E) => T = f ?? (x => x as any) - const [s, setS] = React.useState(ev.current ? fn(ev.current) : undefined) - useEvent(ev, newV => setS(newV ? fn(newV) : undefined)) - return s -} - -/** - * A specialization of {@link React.useEffect} which executes `f` with the event data - * whenever `ev` fires. - * If `key` is provided, `f` is only invoked on events fired with that key. - */ -export function useEvent( - ev: EventEmitter, - f: (_: E) => void, - dependencies?: React.DependencyList, - key?: K, -): void { - React.useEffect(() => { - const h = ev.on(f, key) - return () => h.dispose() - }, dependencies) -} diff --git a/client/src/components/level/ExercisePanel.tsx b/client/src/components/level/ExercisePanel.tsx index 73a29c4d..905d5865 100644 --- a/client/src/components/level/ExercisePanel.tsx +++ b/client/src/components/level/ExercisePanel.tsx @@ -1,5 +1,5 @@ import React from "react" -import { GameEditor } from "../infoview/GameEditor" +import { GameEditor } from "../editor/GameEditor" /** The center-piece of a playable level */ export function ExercisePanel({codeviewRef, visible=true}: {codeviewRef: React.MutableRefObject, visible?: boolean}) { diff --git a/client/src/components/level/PlayableLevel.tsx b/client/src/components/level/PlayableLevel.tsx index 9bb5510b..d37173cf 100644 --- a/client/src/components/level/PlayableLevel.tsx +++ b/client/src/components/level/PlayableLevel.tsx @@ -2,7 +2,7 @@ import { useTranslation } from "react-i18next" import { useGameTranslation } from "../../utils/translation" import { useEffect, useRef, useState } from "react" import { useAtom } from "jotai" -import { clientAtom, codeStorageAtom, currentRpcSessionAtom, editorAtom, leanMonacoAtom, lockEditorModeAtom, modelAtom, typewriterModeAtom, uriAtom } from "../../store/editor-atoms" +import { clientAtom, codeStorageAtom, rpcSessionAtom, editorAtom, leanMonacoAtom, lockEditorModeAtom, modelAtom, typewriterModeAtom, uriAtom } from "../../store/editor-atoms" import { gameIdAtom, levelIdAtom, worldIdAtom } from "../../store/location-atoms" import { mobileAtom } from "../../store/preferences-atoms" import { gameInfoAtom, levelInfoAtom } from "../../store/query-atoms" @@ -46,7 +46,7 @@ export function PlayableLevel() { const [model] = useAtom(modelAtom) const [client, setClient] = useAtom(clientAtom) const [uri] = useAtom(uriAtom) - const [rpcSess, setRpcSess] = useAtom(currentRpcSessionAtom) + const [rpcSess, setRpcSess] = useAtom(rpcSessionAtom) // Start the editor, following lean4monaco demo useEffect(() => { diff --git a/client/src/store/editor-atoms.ts b/client/src/store/editor-atoms.ts index 1fc5de88..2bd1aab1 100644 --- a/client/src/store/editor-atoms.ts +++ b/client/src/store/editor-atoms.ts @@ -1,19 +1,39 @@ -import { editor, Selection as selector, } from 'monaco-editor' +import { editor } from 'monaco-editor' import { atom } from "jotai"; import { atomEffect } from 'jotai-effect'; -import { LeanMonaco, LeanMonacoOptions, DocumentPosition, RpcSessionAtPos, LeanClient} from 'lean4monaco' +import { LeanMonaco, LeanMonacoOptions, RpcSessionAtPos, LeanClient} from 'lean4monaco' import { gameIdAtom, levelIdAtom, worldIdAtom } from "./location-atoms"; import { levelProgressAtom, progressAtom } from "./progress-atoms"; import { Selection } from "./progress-types"; import { levelInfoAtom } from "./query-atoms"; import { Diagnostic, DiagnosticSeverity, DocumentUri } from 'vscode-languageserver-types' import { preferencesAtom } from './preferences-atoms'; -import { deletedChatAtom } from './chat-atoms'; import { ProofState } from '../api/rpc_api'; import { defaultInfoviewConfig, InfoviewConfig, LeanFileProgressProcessingInfo } from '@leanprover/infoview-api'; import { atomWithQuery } from 'jotai-tanstack-query'; -import { Rpc } from 'lean4monaco/dist/vscode-lean4/vscode-lean4/src/rpc'; + +/** The unique leanMonaco instance for the entire application */ +export const leanMonacoAtom = atom(null) + +/** The active client. lean4game only uses one client simultaneously */ +export const clientAtom = atom() +// get => { +// const clients = get(leanMonacoAtom)?.clientProvider?.getClients() ?? [] +// return clients?.[0] +// }) + +/** The current (multiline) editor */ +export const editorAtom = atom(null) + +/** The model of the current editor */ +export const modelAtom = atom(get => get(editorAtom)?.getModel() ?? undefined) + +/** The URI of the currently opened file */ +export const uriAtom = atom(get => get(modelAtom)?.uri) + +/** The currently used RPC session */ +export const rpcSessionAtom = atom() /** The code of the current level as stored in local storage. * @@ -36,22 +56,6 @@ export const codeStorageAtom = atom( } ) -/** The unique leanMonaco instance for the entire application */ -export const leanMonacoAtom = atom(null) - -/** The active client. lean4game only uses one client simultaneously */ -export const clientAtom = atom() -// get => { -// const clients = get(leanMonacoAtom)?.clientProvider?.getClients() ?? [] -// return clients?.[0] -// }) - -/** The current (multiline) editor */ -export const editorAtom = atom(null) - -/** The model of the current editor */ -export const modelAtom = atom(get => get(editorAtom)?.getModel() ?? undefined) - /** * The code of the current level as stored in local storage. * @@ -66,21 +70,15 @@ export const codeAtom = atom( } ) -/** The URI of the currently opened file */ -export const uriAtom = atom(get => get(modelAtom)?.uri) - -/** The currently used RPC session */ -export const currentRpcSessionAtom = atom() - /** The editor powering the typewriter (single line) input */ export const oneLineEditorAtom = atom(null) -/** The analysied proof, received over RPC */ +/** The analysed proof, received over RPC */ export const proofQueryAtom = atomWithQuery((get) => { const worldId = get(worldIdAtom) const levelId = get(levelIdAtom) const code = get(codeStorageAtom) - const rpcSess = get(currentRpcSessionAtom) + const rpcSess = get(rpcSessionAtom) return { queryKey: ['proof', worldId, levelId, rpcSess?.sessionId, code], queryFn: async () => { @@ -119,6 +117,9 @@ export const proofAtom = atom(get => get(proofQueryAtom).data) +/* + * FIXME: everything below here is not curated yet! + */ @@ -312,50 +313,3 @@ export const syncEditorPositionEffect = atomEffect( } } ) - -export const runCommandAtom = atom( - null, - (get, set) => { - console.log("start running command") - const processing = get(isProcessingAtom) - const editor = get(oneLineEditorAtom) - - if (!editor){ - console.log("oneLineEditor not initialized") - return - } - - const hasEditor = get(haseditorAtom) - const typewriter = get(typewriterContentAtom) - - if (processing || !hasEditor) { - console.log("Editor not available or a process is currently running.") - console.log(`Currently running a process: ${processing}`) - console.log(`Does Editor exist: ${hasEditor}`) - return - } - - // TODO: Desired logic is to only reset this after a new *error-free* command has been entered - set(deletedChatAtom, []) - - const pos = editor.getPosition() - - if (typewriter) { - set(isProcessingAtom, true) - console.log("[runCommand] start executing edits") - editor.executeEdits("typewriter", [{ - range: selector.fromPositions( - pos!, - editor.getModel()?.getFullModelRange().getEndPosition() - ), - text: typewriter.trim() + "\n", - forceMoveMarkers: false - }]) - set(typewriterContentAtom, '') - // Load proof after executing edits - set(loadGoalsAtom) - } - - editor.setPosition(pos!) - } -) From b7717d2426a1a1e595d748a89ee82f33e0e762c2 Mon Sep 17 00:00:00 2001 From: matlorr Date: Sun, 24 May 2026 12:20:18 +0200 Subject: [PATCH 25/36] Add text box displaying last executed command and show completion message if proof is completed. --- client/src/components/editor/ProofStep.tsx | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/client/src/components/editor/ProofStep.tsx b/client/src/components/editor/ProofStep.tsx index d090ec76..3e3e524c 100644 --- a/client/src/components/editor/ProofStep.tsx +++ b/client/src/components/editor/ProofStep.tsx @@ -1,6 +1,26 @@ import React from "react"; import { InteractiveGoalsWithHints } from "../../api/rpc_api"; +import { Button } from "../button"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faDeleteLeft } from "@fortawesome/free-solid-svg-icons"; +import { useAtom } from "jotai"; +import { proofAtom } from "../../store/editor-atoms"; export function ProofStep({step, idx}: {step:InteractiveGoalsWithHints, idx: number}) { - return
Step {idx}
+ const [proofState] = useAtom(proofAtom) + + return
Step {idx} {step.command} +
+
{step.command}
+ +
+
+ {proofState?.completed && +
+
level completed! 🎉
+
} +
+
} From 666da9a97b53cc79b49a62771463ff015f30ee48 Mon Sep 17 00:00:00 2001 From: Jon Eugster Date: Sat, 23 May 2026 00:58:59 +0100 Subject: [PATCH 26/36] wip --- client/src/components/level/IntroLevel.tsx | 4 +--- client/src/components/level/PlayableLevel.tsx | 2 ++ client/src/components/main/Main.tsx | 2 +- client/src/css/inventory.css | 1 + client/src/store/editor-atoms.ts | 5 ++++- client/src/store/inventory-atoms.ts | 2 +- client/src/store/query-atoms.ts | 2 +- client/src/store/world-tree-atoms.ts | 2 +- package-lock.json | 13 +++++++++++++ package.json | 1 + 10 files changed, 26 insertions(+), 8 deletions(-) diff --git a/client/src/components/level/IntroLevel.tsx b/client/src/components/level/IntroLevel.tsx index a359ad45..5744dbeb 100644 --- a/client/src/components/level/IntroLevel.tsx +++ b/client/src/components/level/IntroLevel.tsx @@ -22,8 +22,6 @@ export function IntroLevel() { const [mobile] = useAtom(mobileAtom) - const [{ data: inventory }] = useAtom(inventoryOverviewAtom) - const [{ data: gameInfo, isLoading: isLoadingGameInfo }] = useAtom(gameInfoAtom) @@ -44,7 +42,7 @@ export function IntroLevel() { }
- + } diff --git a/client/src/components/level/PlayableLevel.tsx b/client/src/components/level/PlayableLevel.tsx index d37173cf..96ba34cd 100644 --- a/client/src/components/level/PlayableLevel.tsx +++ b/client/src/components/level/PlayableLevel.tsx @@ -116,6 +116,8 @@ export function PlayableLevel() { console.debug(`rpc session id: ${sessionId}`) const _rpcSess = new RpcSessionAtPos(client, sessionId, uri.toString()) setRpcSess(_rpcSess) + }).catch((err) => { + console.debug("rpc connect failed: ", err) }) } updateRpcSession() diff --git a/client/src/components/main/Main.tsx b/client/src/components/main/Main.tsx index f60ead3f..3a652dd1 100644 --- a/client/src/components/main/Main.tsx +++ b/client/src/components/main/Main.tsx @@ -10,7 +10,7 @@ import { gameInfoAtom, levelInfoAtom } from "../../store/query-atoms"; import { helpAtom, selectedStepAtom } from "../../store/chat-atoms"; import { useRpcSessionAtPos } from "lean4monaco/dist/vscode-lean4/lean4-infoview/src/infoview/rpcSessions"; import { lastStepHasErrors, loadGoals } from "../../../../infoview/goals"; -import { DocumentPosition } from "../infoview/editor/util"; +import { DocumentPosition } from "../editor/editor/util"; import { useServerNotificationEffect, useServerNotificationState, useEventResult, useClientNotificationEffect } from "lean4monaco/dist/vscode-lean4/lean4-infoview/src/infoview/util"; import { defaultInfoviewConfig, LeanFileProgressParams, LeanFileProgressProcessingInfo } from "@leanprover/infoview-api"; import type { DidCloseTextDocumentParams, DocumentUri } from 'vscode-languageserver-protocol' diff --git a/client/src/css/inventory.css b/client/src/css/inventory.css index b1494951..348c4f95 100644 --- a/client/src/css/inventory.css +++ b/client/src/css/inventory.css @@ -6,6 +6,7 @@ left: 0; z-index: 10; background-color: white; + padding: 1em; } .documentation .nav-button { diff --git a/client/src/store/editor-atoms.ts b/client/src/store/editor-atoms.ts index 2bd1aab1..f6ae5304 100644 --- a/client/src/store/editor-atoms.ts +++ b/client/src/store/editor-atoms.ts @@ -97,7 +97,10 @@ export const proofQueryAtom = atomWithQuery((get) => { position: {line: 0, character: 0}, sessionId: rpcSess.sessionId } - ) + ).catch((err) => { + console.warn(err) + return null + }) return res }, enabled: rpcSess?.sessionId != undefined && code != undefined && worldId != undefined && levelId != undefined diff --git a/client/src/store/inventory-atoms.ts b/client/src/store/inventory-atoms.ts index 31ab9746..ba4c2926 100644 --- a/client/src/store/inventory-atoms.ts +++ b/client/src/store/inventory-atoms.ts @@ -2,9 +2,9 @@ import { Atom, atom } from "jotai"; import { Doc, InventoryOverview, InventoryTile } from "./api"; import { gameIdAtom, levelIdAtom, worldIdAtom } from "./location-atoms"; import { atomWithQuery } from "jotai-tanstack-query"; -import { atomFamily } from "jotai/utils"; import { progressAtom } from "./progress-atoms"; import { preferencesAtom } from "./preferences-atoms"; +import { atomFamily } from "jotai-family"; /** Valid inventory tabs */ export enum InventoryTab { diff --git a/client/src/store/query-atoms.ts b/client/src/store/query-atoms.ts index 4814bfb6..2ec51c5a 100644 --- a/client/src/store/query-atoms.ts +++ b/client/src/store/query-atoms.ts @@ -1,9 +1,9 @@ import { atomWithQuery } from 'jotai-tanstack-query' import { gameIdAtom, levelIdAtom, worldIdAtom } from './location-atoms' import { Doc, GameInfo, InventoryOverview, LevelInfo } from './api' -import { atomFamily } from 'jotai/utils' import { atom } from 'jotai' import { InventoryTab } from './inventory-atoms' +import { atomFamily } from 'jotai-family' /** The info about all games */ export const gameInfoAtomFamily = atomFamily((gameId: string) => atomWithQuery(() => { diff --git a/client/src/store/world-tree-atoms.ts b/client/src/store/world-tree-atoms.ts index 0a035d8b..4f29144b 100644 --- a/client/src/store/world-tree-atoms.ts +++ b/client/src/store/world-tree-atoms.ts @@ -1,7 +1,7 @@ import { atom } from "jotai" -import { atomFamily } from "jotai/utils" import { progressAtom } from "./progress-atoms" import { gameInfoAtom, gameInfoAtomFamily } from "./query-atoms" +import { atomFamily } from "jotai-family" /** World progress family */ export const worldProgressAtomFamily = atomFamily((worldId: string) => atom(get => { diff --git a/package-lock.json b/package-lock.json index 2d532fe7..d5af61c4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "relay" ], "dependencies": { + "jotai-family": "^1.0.2", "tailwind-merge": "^3.6.0" }, "devDependencies": { @@ -7553,6 +7554,18 @@ "jotai": ">=2.16.0" } }, + "node_modules/jotai-family": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/jotai-family/-/jotai-family-1.0.2.tgz", + "integrity": "sha512-U1aTMGxmsmz2Z8gaJD1/ljmMnsmG4/dqrcsfwGbjWV7p6boB9Vy3+75YwUpwPj7GbLNJk9O8rl1VNi8d7+6Rxw==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "jotai": ">=2.9.0" + } + }, "node_modules/jotai-location": { "version": "0.6.2", "resolved": "https://registry.npmjs.org/jotai-location/-/jotai-location-0.6.2.tgz", diff --git a/package.json b/package.json index b9325278..cb6d7fe0 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ ] }, "dependencies": { + "jotai-family": "^1.0.2", "tailwind-merge": "^3.6.0" } } From 838202042657f6975ed9396f98ac4463bce21385 Mon Sep 17 00:00:00 2001 From: Jon Eugster Date: Sat, 23 May 2026 18:19:52 +0100 Subject: [PATCH 27/36] typewriter content --- client/src/api/rpc_api.ts | 21 +---- client/src/app.tsx | 11 ++- client/src/components/editor/GameEditor.tsx | 2 +- .../editor/{ => typewriter}/Typewriter.tsx | 18 ++-- .../typewriter/TypewriterCommandline.tsx | 7 +- .../editor/typewriter/proof_step/Command.tsx | 44 ++++++++++ .../editor/typewriter/proof_step/Goal.tsx | 55 ++++++++++++ .../editor/typewriter/proof_step/GoalsTab.tsx | 35 ++++++++ .../editor/typewriter/proof_step/Hyp.tsx | 37 ++++++++ .../typewriter/proof_step/ProofStep.tsx | 86 +++++++++++++++++++ .../editor/typewriter/typewriter-atoms.ts | 5 ++ client/src/store/editor-atoms.ts | 40 ++++++++- 12 files changed, 324 insertions(+), 37 deletions(-) rename client/src/components/editor/{ => typewriter}/Typewriter.tsx (61%) create mode 100644 client/src/components/editor/typewriter/proof_step/Command.tsx create mode 100644 client/src/components/editor/typewriter/proof_step/Goal.tsx create mode 100644 client/src/components/editor/typewriter/proof_step/GoalsTab.tsx create mode 100644 client/src/components/editor/typewriter/proof_step/Hyp.tsx create mode 100644 client/src/components/editor/typewriter/proof_step/ProofStep.tsx create mode 100644 client/src/components/editor/typewriter/typewriter-atoms.ts diff --git a/client/src/api/rpc_api.ts b/client/src/api/rpc_api.ts index fb7e17f9..52081799 100644 --- a/client/src/api/rpc_api.ts +++ b/client/src/api/rpc_api.ts @@ -1,25 +1,6 @@ -import { CodeWithInfos, ContextInfo, FVarId, InteractiveDiagnostic, MVarId, TermInfo } from '@leanprover/infoview-api' +import { CodeWithInfos, ContextInfo, FVarId, InteractiveDiagnostic, InteractiveGoalCore, MVarId, TermInfo } from '@leanprover/infoview-api' -export interface InteractiveHypothesisBundle { - /** The pretty names of the variables in the bundle. Anonymous names are rendered - * as `"[anonymous]"` whereas inaccessible ones have a `✝` appended at the end. - * Use `InteractiveHypothesisBundle_nonAnonymousNames` to filter anonymouse ones out. */ - names: string[]; - fvarIds?: FVarId[]; - type: CodeWithInfos; - val?: CodeWithInfos; - isInstance?: boolean; - isType?: boolean; - isInserted?: boolean; - isRemoved?: boolean; - isAssumption?: boolean; -} -export interface InteractiveGoalCore { - hyps: InteractiveHypothesisBundle[]; - type: CodeWithInfos; - ctx?: ContextInfo; -} export interface InteractiveGoal extends InteractiveGoalCore { userName?: string; diff --git a/client/src/app.tsx b/client/src/app.tsx index 8cf78ce3..66ac7b12 100644 --- a/client/src/app.tsx +++ b/client/src/app.tsx @@ -9,9 +9,16 @@ import '@fontsource/roboto/700.css'; import './css/reset.css'; import './css/app.css'; -import './css/infoview.css' +import './css/error_page.css'; +import "./css/infoview.css" +import "./css/landing_page.css" +import './css/layout.css'; +import './css/level.css'; +import "./css/popup.css" import "./css/tab_bar.css" -import "./css/layout.css" +import "./css/typewriter.css" +import "./css/welcome.css" +import "./css/world_tree.css" import i18n from './i18n'; import { Popup } from './components/popup/popup'; diff --git a/client/src/components/editor/GameEditor.tsx b/client/src/components/editor/GameEditor.tsx index 310de8a0..77888b0f 100644 --- a/client/src/components/editor/GameEditor.tsx +++ b/client/src/components/editor/GameEditor.tsx @@ -2,7 +2,7 @@ import { useAtom } from "jotai" import { leanMonacoAtom, typewriterModeAtom } from "../../store/editor-atoms" import React, { useEffect, useLayoutEffect, useRef } from "react" import { ExerciseStatement } from "./ExerciseStatement" -import { Typewriter } from "./Typewriter" +import { Typewriter } from "./typewriter/Typewriter" /** * Note: It is important that the `div` with `codeViewRef` is diff --git a/client/src/components/editor/Typewriter.tsx b/client/src/components/editor/typewriter/Typewriter.tsx similarity index 61% rename from client/src/components/editor/Typewriter.tsx rename to client/src/components/editor/typewriter/Typewriter.tsx index 992eef1c..4af749c9 100644 --- a/client/src/components/editor/Typewriter.tsx +++ b/client/src/components/editor/typewriter/Typewriter.tsx @@ -1,13 +1,14 @@ import React, { useRef } from "react"; -import "../../css/typewriter.css" import { useAtom } from "jotai"; -import { gameInfoAtom } from "../../store/query-atoms"; -import { gameIdAtom, worldIdAtom } from "../../store/location-atoms"; +import { gameInfoAtom } from "../../../store/query-atoms"; +import { gameIdAtom, worldIdAtom } from "../../../store/location-atoms"; import path from "node:path"; -import { proofAtom } from "../../store/editor-atoms"; -import { ExerciseStatement } from "./ExerciseStatement"; -import { ProofStep } from "./ProofStep"; -import { TypewriterCommandLine } from "./typewriter/TypewriterCommandline"; +import { proofAtom } from "../../../store/editor-atoms"; +import { ExerciseStatement } from "../ExerciseStatement"; +import { ProofStep } from "./proof_step/ProofStep"; +import { TypewriterCommandLine } from "./TypewriterCommandline"; +import { LoadingIcon } from "../../utils"; +import { CircularProgress } from "@mui/material"; /** * Der Typewriter bestehend aus Eingabezeile, Beweisschritten, Aufgabenstellung und Hintergrundbild @@ -28,7 +29,8 @@ export function Typewriter() {
- {proof?.steps.map((step, i) => )} + {proof ? proof.steps.map((step, i) => ) + : }
diff --git a/client/src/components/editor/typewriter/TypewriterCommandline.tsx b/client/src/components/editor/typewriter/TypewriterCommandline.tsx index cd97e7cb..fac6c64a 100644 --- a/client/src/components/editor/typewriter/TypewriterCommandline.tsx +++ b/client/src/components/editor/typewriter/TypewriterCommandline.tsx @@ -5,9 +5,10 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faWandMagicSparkles } from "@fortawesome/free-solid-svg-icons"; import { useAtom } from "jotai"; import * as monaco from 'monaco-editor' -import { typewriterContentAtom, oneLineEditorAtom, codeAtom, modelAtom } from "../../../store/editor-atoms"; +import { typewriterContentAtom, codeAtom, modelAtom } from "../../../store/editor-atoms"; import { twMerge } from "tailwind-merge"; import { deletedChatAtom } from "../../../store/chat-atoms"; +import { oneLineEditorAtom } from "./typewriter-atoms"; /** The input field */ export function TypewriterCommandLine() { @@ -42,9 +43,7 @@ export function TypewriterCommandLine() { if (content.length == 0) return // Add typewriter content to the editor - const oldCode = code?.trimEnd() ?? '' - const newCode = oldCode.length > 0 ? `${oldCode}\n${content}\n` : `${content}\n` - model?.setValue(newCode) + // TODO: Desired logic is to only reset this after a new *error-free* command has been entered setDeletedChatAtom([]) diff --git a/client/src/components/editor/typewriter/proof_step/Command.tsx b/client/src/components/editor/typewriter/proof_step/Command.tsx new file mode 100644 index 00000000..5c5b3fb5 --- /dev/null +++ b/client/src/components/editor/typewriter/proof_step/Command.tsx @@ -0,0 +1,44 @@ +import { useTranslation } from "react-i18next" +import { GameHint, ProofState } from "../../../../api/rpc_api" +import React from "react" +import { Button } from "@mui/material" +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" +import { faDeleteLeft } from "@fortawesome/free-solid-svg-icons" +import { filterHints } from "../../../hints" +import { deletedChatAtom, helpAtom } from "../../../../store/chat-atoms" +import { useAtom } from "jotai" +import { editor } from 'monaco-editor' +import { codeAtom, deleteCodeFromLineAtom } from "../../../../store/editor-atoms" + +//FIXME: implement +function isLastStepWithErrors(x: any, y: any) {return false} + +/** The display of a single entered lean command */ +export function Command({ proof, i }: { proof: ProofState, i: number }) { + let {t} = useTranslation() + const [, setDeletedChat] = useAtom(deletedChatAtom) + const [help, setHelp] = useAtom(helpAtom) + const [, setCode] = useAtom(codeAtom) + const [, deleteCodeFromLine] = useAtom(deleteCodeFromLineAtom) + + // The first step will always have an empty command + if (!proof.steps[i]?.command) { return <> } + + if (isLastStepWithErrors(proof, i)) { + // If the last step has errors, we display the command in a different style + // indicating that it will be removed on the next try. + return
+ {t("Failed command")}: {proof.steps[i].command} +
+ } else { + return
+
{proof.steps[i].command}
+ +
+ } +} diff --git a/client/src/components/editor/typewriter/proof_step/Goal.tsx b/client/src/components/editor/typewriter/proof_step/Goal.tsx new file mode 100644 index 00000000..09502c97 --- /dev/null +++ b/client/src/components/editor/typewriter/proof_step/Goal.tsx @@ -0,0 +1,55 @@ +import { useAtom } from "jotai"; +import React from "react"; +import { typewriterModeAtom } from "../../../../store/editor-atoms"; +import { gameInfoAtom } from "../../../../store/query-atoms"; +import { mobileAtom } from "../../../../store/preferences-atoms"; +import { useTranslation } from "react-i18next"; +import { InteractiveGoal, ProofState } from "../../../../api/rpc_api"; +import { Hyp } from "./Hyp"; +import { InteractiveHypothesisBundle } from "@leanprover/infoview-api"; + + +export function Goal({goal} : { goal: InteractiveGoal}) { + const { t} = useTranslation() + const [mobile] = useAtom(mobileAtom) + const [typewriterMode] = useAtom(typewriterModeAtom) + const [{ data: gameInfo }] = useAtom(gameInfoAtom) + + function unbundleHyps (hyps: InteractiveHypothesisBundle[]) : InteractiveHypothesisBundle[] { + return hyps.flatMap(hyp => ( + gameInfo?.settings?.unbundleHyps ? hyp.names.map(name => {return {...hyp, names: [name]}}) : [hyp] + )) + } + + const objectHyps = unbundleHyps(goal.hyps.filter(hyp => !(hyp as any).isAssumption)) + const assumptionHyps = unbundleHyps(goal.hyps.filter(hyp => (hyp as any).isAssumption)) + + return ( + <> +
+ {objectHyps.length > 0 && +
{t("Objects")}:
+ {objectHyps.map((h, i) => )}
} + {assumptionHyps.length > 0 && +
{t("Assumptions")}:
+ {assumptionHyps.map((h, i) => { + h = {...h, val: undefined} // JE: never show value of assumptions (proof irrelevance) + return })}
} + +
+ { (!mobile && typewriterMode) && +
+ + + + +
+ } +
+ {(mobile || !typewriterMode) &&
{t("Goal")}:
} + Goal + {/* FIXME: */} +
+ + ) +} diff --git a/client/src/components/editor/typewriter/proof_step/GoalsTab.tsx b/client/src/components/editor/typewriter/proof_step/GoalsTab.tsx new file mode 100644 index 00000000..dd7b2a01 --- /dev/null +++ b/client/src/components/editor/typewriter/proof_step/GoalsTab.tsx @@ -0,0 +1,35 @@ +import { useTranslation } from "react-i18next" +import { InteractiveGoalsWithHints } from "../../../../api/rpc_api" +import { useAtom } from "jotai" +import { mobileAtom } from "../../../../store/preferences-atoms" +import React from "react" +import { Goal } from "./Goal" + +/** The tabs of goals that lean ahs after the command of this step has been processed */ +export function GoalsTabs({ proofStep, last, onClick, onGoalChange=(n)=>{}}: { proofStep: InteractiveGoalsWithHints, last : boolean, onClick? : any, onGoalChange?: (n?: number) => void }) { + let { t } = useTranslation() + const [mobile] = useAtom(mobileAtom) + const [selectedGoal, setSelectedGoal] = React.useState(0) + + if (proofStep.goals.length == 0) { + return <> + } + + const goal = proofStep.goals[selectedGoal]?.goal + + if (!goal) return + + return
+
+ {proofStep.goals.map((goal, i) => ( + // TODO: Should not use index as key. +
{ onGoalChange(i); setSelectedGoal(i); ev.stopPropagation() }}> + {i ? t("Goal") + ` ${i + 1}` : t("Active Goal")} +
+ ))} +
+
+ +
+
+} diff --git a/client/src/components/editor/typewriter/proof_step/Hyp.tsx b/client/src/components/editor/typewriter/proof_step/Hyp.tsx new file mode 100644 index 00000000..c624ccad --- /dev/null +++ b/client/src/components/editor/typewriter/proof_step/Hyp.tsx @@ -0,0 +1,37 @@ +import { InteractiveHypothesisBundle, InteractiveHypothesisBundle_nonAnonymousNames, MVarId } from "@leanprover/infoview-api" +import React from "react" + +interface HypProps { + hyp: InteractiveHypothesisBundle + mvarId?: MVarId +} + +function isInaccessibleName(h: string): boolean { + return h.indexOf('✝') >= 0; +} + +export function Hyp({ hyp: h, mvarId }: HypProps) { + + const namecls: string = 'mr1 hyp-name ' + + (h.isInserted ? 'inserted-text ' : '') + + (h.isRemoved ? 'removed-text ' : '') + + // const appendTypewriterInput = useAppendTypewriterInput() + const names = InteractiveHypothesisBundle_nonAnonymousNames(h).map((n, i) => + { + // appendTypewriterInput(ev.shiftKey, n, (h as any).isAssumption ?? false, (h as any).isAssumption ?? false) + ev.stopPropagation() + } } + >{n} + ) + return
+ {names} + :  + h.type + {h.val && ` := ${h.val}`} +
+} diff --git a/client/src/components/editor/typewriter/proof_step/ProofStep.tsx b/client/src/components/editor/typewriter/proof_step/ProofStep.tsx new file mode 100644 index 00000000..229da305 --- /dev/null +++ b/client/src/components/editor/typewriter/proof_step/ProofStep.tsx @@ -0,0 +1,86 @@ +import React from "react"; +import { editor } from 'monaco-editor' +import { InteractiveGoalsWithHints } from "../../../../api/rpc_api"; +import { useAtom } from "jotai"; +import { helpAtom, selectedStepAtom } from "../../../../store/chat-atoms"; +import { twMerge } from "tailwind-merge"; +import { Command } from "./Command"; +import { proofAtom } from "../../../../store/editor-atoms"; +import { Errors } from "../../messages/Error"; +import { mobileAtom } from "../../../../store/preferences-atoms"; +import { gameInfoAtom, levelInfoAtom } from "../../../../store/query-atoms"; +import { useGameTranslation } from "../../../../utils/translation"; +import { filterHints, Hint, Hints } from "../../../hints"; +import { i } from "@tanstack/query-core/build/legacy/hydration-BlEVG2Lp"; +import { GoalsTabs } from "./GoalsTab"; + +export function ProofStep({step, idx}: {step:InteractiveGoalsWithHints, idx: number}) { + const {t : gT} = useGameTranslation() + const [selectedStep, setSelectedStep] = useAtom(selectedStepAtom) + const [proof] = useAtom(proofAtom) + const [help] = useAtom(helpAtom) + const [mobile] = useAtom(mobileAtom) + const [{ data: gameInfo }] = useAtom(gameInfoAtom) + const [{ data: levelInfo }] = useAtom(levelInfoAtom) + let introText: Array = gT(levelInfo?.introduction ?? "").split(/\n(\s*\n)+/) + + function toggleSelectStep(line: number) { + if (mobile) {return} + setSelectedStep(selectedStep != line ? line : undefined) + } + + // TODO: this should be an atom + let filteredHints = proof ? filterHints(step.goals[0]?.hints, proof.steps[idx-1]?.goals[0]?.hints) : [] + + if (!proof) return + + return ( +
+ + + {mobile && idx == 0 && gameInfo?.introduction && + introText?.filter(it => it.trim()).map(((it, i) => + // Show the level's intro text as hints, too + + )) + } + {mobile && + + } + setDisableInput(n > 0) : (n) => {}} + /> +
+ ) +} + + +{/* + + + {!(isLastStepWithErrors(proof, i)) && + setDisableInput(n > 0) : (n) => {}}/> + } + {mobile && i == proof?.steps.length - 1 && + + } + + Show a message that there are no goals left + {!step.goals.length && ( +
+ {proof?.completed ? +

Level completed! 🎉

: +

+ no goals left
+ This probably means you solved the level with warnings or Lean encountered a parsing error. +

+ } +
+ )} +
*/} diff --git a/client/src/components/editor/typewriter/typewriter-atoms.ts b/client/src/components/editor/typewriter/typewriter-atoms.ts new file mode 100644 index 00000000..a3edad1b --- /dev/null +++ b/client/src/components/editor/typewriter/typewriter-atoms.ts @@ -0,0 +1,5 @@ +import { atom } from "jotai"; +import { editor } from 'monaco-editor' + +/** The editor powering the typewriter (single line) input */ +export const oneLineEditorAtom = atom(null) diff --git a/client/src/store/editor-atoms.ts b/client/src/store/editor-atoms.ts index f6ae5304..5c618e09 100644 --- a/client/src/store/editor-atoms.ts +++ b/client/src/store/editor-atoms.ts @@ -12,6 +12,9 @@ import { ProofState } from '../api/rpc_api'; import { defaultInfoviewConfig, InfoviewConfig, LeanFileProgressProcessingInfo } from '@leanprover/infoview-api'; import { atomWithQuery } from 'jotai-tanstack-query'; +import { oneLineEditorAtom } from '../components/editor/typewriter/typewriter-atoms'; +import { deletedChatAtom, helpAtom, selectedStepAtom } from './chat-atoms'; +import { filterHints } from '../components/hints'; /** The unique leanMonaco instance for the entire application */ export const leanMonacoAtom = atom(null) @@ -70,8 +73,41 @@ export const codeAtom = atom( } ) -/** The editor powering the typewriter (single line) input */ -export const oneLineEditorAtom = atom(null) +/** Appends a new line of code at the end of the current code */ +export const appendCodeLineAtom = atom(null, (get, set, line: string) => { + const code = get(codeAtom) + + const oldCode = code?.trimEnd() ?? '' + const newCode = oldCode.length > 0 ? `${oldCode}\n${content}\n` : `${content}\n` + set(codeAtom, newCode) +}) + +/** Delete all code starting at line `line` */ +export const deleteCodeFromLineAtom = atom(null, (get, set, line: number) => { + const code = get(codeAtom) + if (code == undefined) return + + set(selectedStepAtom, undefined) + set(typewriterContentAtom, "") + + const help = get(helpAtom) + const proof = get(proofAtom) + if (proof) { + const newDeletedChat = proof.steps.slice(line).flatMap((step, i) => { + let filteredHints = filterHints(step.goals[0]?.hints, proof.steps[i-1]?.goals[0]?.hints) + return filteredHints.filter(hint => (!hint.hidden || help.has(line + i))) + }) + set(deletedChatAtom, newDeletedChat) + } + + // delete the info about displayed help where the lines were deleted + const newHelp = new Set(Array.from(help).filter(i => i < line - 1)) + set(helpAtom, newHelp) + + const lines = code.split("\n") + const newCode = lines.slice(0, line).join("\n") + set(codeAtom, newCode) +}) /** The analysed proof, received over RPC */ export const proofQueryAtom = atomWithQuery((get) => { From bdeea7e89d551107a973cd0be2ecaf28da47482a Mon Sep 17 00:00:00 2001 From: Jon Eugster Date: Sat, 23 May 2026 20:54:16 +0100 Subject: [PATCH 28/36] fix: add missing appendLine --- client/src/components/editor/messages/Error.tsx | 6 +++--- .../components/editor/typewriter/TypewriterCommandline.tsx | 5 +++-- client/src/store/editor-atoms.ts | 2 +- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/client/src/components/editor/messages/Error.tsx b/client/src/components/editor/messages/Error.tsx index e43c0459..69db904d 100644 --- a/client/src/components/editor/messages/Error.tsx +++ b/client/src/components/editor/messages/Error.tsx @@ -1,7 +1,7 @@ // TODO: Should not use index as key. import React from "react"; -import { InteractiveDiagnostic } from "@leanprover/infoview-api"; +import { InteractiveDiagnostic, MsgEmbed, TaggedText } from "@leanprover/infoview-api"; import { DiagnosticSeverity } from "vscode-languageserver-types"; /** A list of messages (info/warning/error) that are produced after this command */ @@ -26,7 +26,7 @@ function Error({error, typewriterMode} : {error : InteractiveDiagnostic, typewri const title = `Line ${line+1}, Character ${character}`; // Hide "unsolved goals" messages - let message; + let message: TaggedText; if ("append" in error.message && "text" in error.message.append[0] && error.message?.append[0].text === "unsolved goals") { message = error.message.append[0] @@ -37,7 +37,7 @@ function Error({error, typewriterMode} : {error : InteractiveDiagnostic, typewri return
{!typewriterMode &&

{title}

}
-      

message

+

{message.text /* FIXME */}

} diff --git a/client/src/components/editor/typewriter/TypewriterCommandline.tsx b/client/src/components/editor/typewriter/TypewriterCommandline.tsx index fac6c64a..6550e792 100644 --- a/client/src/components/editor/typewriter/TypewriterCommandline.tsx +++ b/client/src/components/editor/typewriter/TypewriterCommandline.tsx @@ -5,7 +5,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faWandMagicSparkles } from "@fortawesome/free-solid-svg-icons"; import { useAtom } from "jotai"; import * as monaco from 'monaco-editor' -import { typewriterContentAtom, codeAtom, modelAtom } from "../../../store/editor-atoms"; +import { typewriterContentAtom, codeAtom, modelAtom, appendCodeLineAtom } from "../../../store/editor-atoms"; import { twMerge } from "tailwind-merge"; import { deletedChatAtom } from "../../../store/chat-atoms"; import { oneLineEditorAtom } from "./typewriter-atoms"; @@ -17,6 +17,7 @@ export function TypewriterCommandLine() { const [oneLineEditor, setOneLineEditor] = useAtom(oneLineEditorAtom) const [model] = useAtom(modelAtom) const [code] = useAtom(codeAtom) + const [, appendLine] = useAtom(appendCodeLineAtom) const [, setDeletedChatAtom] = useAtom(deletedChatAtom) const [typewriter, setTypewriter] = useAtom(typewriterContentAtom) const oneLineEditorRef = useRef(null) @@ -43,7 +44,7 @@ export function TypewriterCommandLine() { if (content.length == 0) return // Add typewriter content to the editor - + appendLine(content) // TODO: Desired logic is to only reset this after a new *error-free* command has been entered setDeletedChatAtom([]) diff --git a/client/src/store/editor-atoms.ts b/client/src/store/editor-atoms.ts index 5c618e09..468c1585 100644 --- a/client/src/store/editor-atoms.ts +++ b/client/src/store/editor-atoms.ts @@ -78,7 +78,7 @@ export const appendCodeLineAtom = atom(null, (get, set, line: string) => { const code = get(codeAtom) const oldCode = code?.trimEnd() ?? '' - const newCode = oldCode.length > 0 ? `${oldCode}\n${content}\n` : `${content}\n` + const newCode = oldCode.length > 0 ? `${oldCode}\n${line}\n` : `${line}\n` set(codeAtom, newCode) }) From 8dff53a8d523719e1b0d2ecc197cdd82b59c61f4 Mon Sep 17 00:00:00 2001 From: Jon Eugster Date: Sat, 23 May 2026 21:00:12 +0100 Subject: [PATCH 29/36] show non-interactive hyps and goal --- client/src/components/editor/messages/Error.tsx | 4 ++-- client/src/components/editor/typewriter/proof_step/Goal.tsx | 5 ++--- client/src/components/editor/typewriter/proof_step/Hyp.tsx | 4 ++-- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/client/src/components/editor/messages/Error.tsx b/client/src/components/editor/messages/Error.tsx index 69db904d..c45a9550 100644 --- a/client/src/components/editor/messages/Error.tsx +++ b/client/src/components/editor/messages/Error.tsx @@ -1,7 +1,7 @@ // TODO: Should not use index as key. import React from "react"; -import { InteractiveDiagnostic, MsgEmbed, TaggedText } from "@leanprover/infoview-api"; +import { InteractiveDiagnostic, MsgEmbed, TaggedText, TaggedText_stripTags } from "@leanprover/infoview-api"; import { DiagnosticSeverity } from "vscode-languageserver-types"; /** A list of messages (info/warning/error) that are produced after this command */ @@ -37,7 +37,7 @@ function Error({error, typewriterMode} : {error : InteractiveDiagnostic, typewri return
{!typewriterMode &&

{title}

}
-      

{message.text /* FIXME */}

+

{TaggedText_stripTags(message)}

} diff --git a/client/src/components/editor/typewriter/proof_step/Goal.tsx b/client/src/components/editor/typewriter/proof_step/Goal.tsx index 09502c97..c1e9fdbf 100644 --- a/client/src/components/editor/typewriter/proof_step/Goal.tsx +++ b/client/src/components/editor/typewriter/proof_step/Goal.tsx @@ -6,7 +6,7 @@ import { mobileAtom } from "../../../../store/preferences-atoms"; import { useTranslation } from "react-i18next"; import { InteractiveGoal, ProofState } from "../../../../api/rpc_api"; import { Hyp } from "./Hyp"; -import { InteractiveHypothesisBundle } from "@leanprover/infoview-api"; +import { InteractiveHypothesisBundle, TaggedText_stripTags } from "@leanprover/infoview-api"; export function Goal({goal} : { goal: InteractiveGoal}) { @@ -47,8 +47,7 @@ export function Goal({goal} : { goal: InteractiveGoal}) { }
{(mobile || !typewriterMode) &&
{t("Goal")}:
} - Goal - {/* FIXME: */} + {TaggedText_stripTags(goal.type)}
) diff --git a/client/src/components/editor/typewriter/proof_step/Hyp.tsx b/client/src/components/editor/typewriter/proof_step/Hyp.tsx index c624ccad..7ec484ea 100644 --- a/client/src/components/editor/typewriter/proof_step/Hyp.tsx +++ b/client/src/components/editor/typewriter/proof_step/Hyp.tsx @@ -1,4 +1,4 @@ -import { InteractiveHypothesisBundle, InteractiveHypothesisBundle_nonAnonymousNames, MVarId } from "@leanprover/infoview-api" +import { InteractiveHypothesisBundle, InteractiveHypothesisBundle_nonAnonymousNames, MVarId, TaggedText_stripTags } from "@leanprover/infoview-api" import React from "react" interface HypProps { @@ -31,7 +31,7 @@ export function Hyp({ hyp: h, mvarId }: HypProps) { return
{names} :  - h.type + {TaggedText_stripTags(h.type)} {h.val && ` := ${h.val}`}
} From f8a1106dc9df5a1ec08ca84603c768086bd4b321 Mon Sep 17 00:00:00 2001 From: Jon Eugster Date: Mon, 25 May 2026 01:45:51 +0200 Subject: [PATCH 30/36] wip --- .../src/components/editor/typewriter/TypewriterCommandline.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/components/editor/typewriter/TypewriterCommandline.tsx b/client/src/components/editor/typewriter/TypewriterCommandline.tsx index 6550e792..ffd1ef23 100644 --- a/client/src/components/editor/typewriter/TypewriterCommandline.tsx +++ b/client/src/components/editor/typewriter/TypewriterCommandline.tsx @@ -97,7 +97,7 @@ export function TypewriterCommandLine() { }, scrollBeyondLastLine: false, overviewRulerBorder: false, - theme: 'vs-code-theme-converted', + theme: 'Visual Studio Light', contextmenu: false } From 4858c3791e3cc3d8253b4be105646df41bdfe19a Mon Sep 17 00:00:00 2001 From: Jon Eugster Date: Mon, 25 May 2026 14:05:22 +0200 Subject: [PATCH 31/36] cleanup merge --- client/src/components/editor/ProofStep.tsx | 26 ---------------------- 1 file changed, 26 deletions(-) delete mode 100644 client/src/components/editor/ProofStep.tsx diff --git a/client/src/components/editor/ProofStep.tsx b/client/src/components/editor/ProofStep.tsx deleted file mode 100644 index 3e3e524c..00000000 --- a/client/src/components/editor/ProofStep.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import React from "react"; -import { InteractiveGoalsWithHints } from "../../api/rpc_api"; -import { Button } from "../button"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { faDeleteLeft } from "@fortawesome/free-solid-svg-icons"; -import { useAtom } from "jotai"; -import { proofAtom } from "../../store/editor-atoms"; - -export function ProofStep({step, idx}: {step:InteractiveGoalsWithHints, idx: number}) { - const [proofState] = useAtom(proofAtom) - - return
Step {idx} {step.command} -
-
{step.command}
- -
-
- {proofState?.completed && -
-
level completed! 🎉
-
} -
-
-} From e8f365bd7a86f28ccfb74edb4be51d775111017e Mon Sep 17 00:00:00 2001 From: matlorr Date: Thu, 28 May 2026 10:42:03 +0200 Subject: [PATCH 32/36] Re-introduce autoinsert feature into typewriter --- .../components/inventory/inventory_item.tsx | 80 ++++++++++++++++++- client/src/store/editor-atoms.ts | 24 ++++++ 2 files changed, 103 insertions(+), 1 deletion(-) diff --git a/client/src/components/inventory/inventory_item.tsx b/client/src/components/inventory/inventory_item.tsx index a0b2f155..f8f60ef2 100644 --- a/client/src/components/inventory/inventory_item.tsx +++ b/client/src/components/inventory/inventory_item.tsx @@ -6,6 +6,8 @@ import { InventoryTile } from "../../store/api" import { useAtom } from "jotai" import { selectedDocTileAtom } from "../../store/inventory-atoms" import { levelIdAtom } from "../../store/location-atoms" +import { typewriterAtom, typewriterContentAtom } from "../../store/editor-atoms" +import { preferencesAtom } from "../../store/preferences-atoms" export function InventoryItem({tile, isTheorem, recent=false, enableAll=false} : { tile: InventoryTile, @@ -16,6 +18,9 @@ export function InventoryItem({tile, isTheorem, recent=false, enableAll=false} : const { t } = useTranslation() const [, setDoc] = useAtom(selectedDocTileAtom) const [levelId] = useAtom(levelIdAtom) + const [typewriterContent,] = useAtom(typewriterContentAtom) + const [, setTypewriter] = useAtom(typewriterAtom) + const [{ isSuggestionsMobileMode }] = useAtom(preferencesAtom) const insertable: boolean = (levelId ?? 0) > 0 && (enableAll || !(tile.locked || tile.disabled)) @@ -32,8 +37,81 @@ export function InventoryItem({tile, isTheorem, recent=false, enableAll=false} : // const appendTypewriterInput = useAppendTypewriterInput() // FIXME: implement - function appendTypewriterInput(a: any, b: any, c: any, d: any) {} + // Step 1: Copy over from infoview/context + // Step 2: Auto-import the used atoms + // Step 3: Copy SUFFIX and PREFIX OVERRIDES into function scpe + // Step 4: Reformulate parameters to adhere to old implementation + // Step 5: Relocate useAtoms into enclosing scope of function to stop infinite re-renders + function appendTypewriterInput(shiftKey: boolean, suffix: string, isTheorem: boolean, isAssumption: boolean) { + const SUFFIX_OVERRIDES: Record = { + "induction": "generalizing", + "left": ".left", + "rewrite": "←", + "right": ".right", + "rw": "←", + } + const PREFIX_OVERRIDES: Record = { + "by_cases": "by_cases this :", + "contrapose": "contrapose!", + "have": "have :", + "obtain": "obtain ⟨⟩ :=", + "rewrite": "rw []", + "rw": "rw []", + "simp": "simp only []", + } + + //return (shiftKey: boolean, suffix: string, isTheorem: boolean, isAssumption: boolean) => { + if (!isSuggestionsMobileMode && !shiftKey) { + return false + } + // Automagically detect and adjust punctuation for mobile keyboardless usage + let _typewriter = typewriterContent.trim() + + if (!typewriterContent.length) { + _typewriter = Object.hasOwn(PREFIX_OVERRIDES, suffix) ? PREFIX_OVERRIDES[suffix] : isTheorem ? `rw [${suffix}]` : suffix + setTypewriter(_typewriter + " ") + return true + } + + suffix = !isAssumption && Object.hasOwn(SUFFIX_OVERRIDES, suffix) ? SUFFIX_OVERRIDES[suffix] : suffix + if (suffix === "ℕ") { + if (/ \d$/.test(_typewriter)) { + suffix = ((+_typewriter.slice(-2) + 1) % 10).toString() + _typewriter = _typewriter.slice(0, -2) + } else { + suffix = "0" + } + suffix = " " + suffix + } else if (suffix === "∈" && _typewriter.endsWith("∈")) { + suffix = " {} " + } else if (isAssumption && /^apply |^symm|^push_neg/.test(_typewriter)) { + suffix = " at " + suffix + } else if (suffix === "have") { + suffix = _typewriter === "have :" ? "=" : " :=" + } else if (/[\]}]$/.test(_typewriter)) { + if (isAssumption) { + suffix = " at " + suffix + } else { + const closing = _typewriter.slice(-1) + _typewriter = _typewriter.slice(0, -1) + if (suffix === "←") { + const imbalance = (_typewriter.match(/\(/)?.length ?? 0) - (_typewriter.match(/\)/)?.length ?? 0) + suffix = /[[,({]$/.test(_typewriter) ? "←" : /\([^)]*$/.test(_typewriter) ? ")" : " (" + } else { + if (!/[[,({]$/.test(_typewriter)) { + suffix = (isTheorem && !_typewriter.endsWith("←") && closing === "]" ? ", " : /^[ᶜ.]/.test(suffix) ? "" : " ") + suffix + } + } + suffix = suffix + closing + } + } else if (!/^[ᶜ.]/.test(suffix)) { + suffix = " " + suffix + } + setTypewriter(`${_typewriter}${suffix} `.trimStart()) + return true + //} + } const handleClick = (ev: any) => {setDoc(tile)} diff --git a/client/src/store/editor-atoms.ts b/client/src/store/editor-atoms.ts index 468c1585..02d180ac 100644 --- a/client/src/store/editor-atoms.ts +++ b/client/src/store/editor-atoms.ts @@ -192,6 +192,30 @@ export const isProcessingAtom = atom(false) export const typewriterContentAtom = atom("") +export const typewriterAtom = atom( + (get) => {get(typewriterContentAtom)}, + (get, set, val: string) => { + const oneLineEditor = get(oneLineEditorAtom) + const { isSuggestionsMobileMode } = get(preferencesAtom) + + if (!oneLineEditor) { + console.log("[TypewriterAtom] oneLineEditor is False") + return + } + + console.log(`[TypewriterAtom] Set value in oneLineEditor to ${val}`) + oneLineEditor.setValue(val) + oneLineEditor.setPosition({ + column: val.length + 1, + lineNumber: 1 + }) + + if (!isSuggestionsMobileMode) { + oneLineEditor.focus() + } + } +) + /** Options for the LeanMonaco instance */ export const leanMonacoOptionsAtom = atom(get => { const gameId = get(gameIdAtom) From 5b6a184d8e02219633c57d0d15d3953d4ab8b2db Mon Sep 17 00:00:00 2001 From: matlorr Date: Thu, 28 May 2026 12:32:23 +0200 Subject: [PATCH 33/36] Add custom hook for appending to typewrite input and reactivate insertion of assumptions into tactics via click. --- .../editor/typewriter/proof_step/Hyp.tsx | 7 +- .../typewriter/useAppendTypewriterInput.ts | 86 +++++++++++++++++++ .../components/inventory/inventory_item.tsx | 80 +---------------- 3 files changed, 92 insertions(+), 81 deletions(-) create mode 100644 client/src/components/editor/typewriter/useAppendTypewriterInput.ts diff --git a/client/src/components/editor/typewriter/proof_step/Hyp.tsx b/client/src/components/editor/typewriter/proof_step/Hyp.tsx index 7ec484ea..9b2ef967 100644 --- a/client/src/components/editor/typewriter/proof_step/Hyp.tsx +++ b/client/src/components/editor/typewriter/proof_step/Hyp.tsx @@ -1,5 +1,6 @@ import { InteractiveHypothesisBundle, InteractiveHypothesisBundle_nonAnonymousNames, MVarId, TaggedText_stripTags } from "@leanprover/infoview-api" import React from "react" +import {useAppendTypewriterInput} from "../useAppendTypewriterInput" interface HypProps { hyp: InteractiveHypothesisBundle @@ -11,19 +12,19 @@ function isInaccessibleName(h: string): boolean { } export function Hyp({ hyp: h, mvarId }: HypProps) { - const namecls: string = 'mr1 hyp-name ' + (h.isInserted ? 'inserted-text ' : '') + (h.isRemoved ? 'removed-text ' : '') - // const appendTypewriterInput = useAppendTypewriterInput() + const appendTypewriterInput = useAppendTypewriterInput() + const names = InteractiveHypothesisBundle_nonAnonymousNames(h).map((n, i) => { - // appendTypewriterInput(ev.shiftKey, n, (h as any).isAssumption ?? false, (h as any).isAssumption ?? false) + appendTypewriterInput(ev.shiftKey, n, (h as any).isAssumption ?? false, (h as any).isAssumption ?? false) ev.stopPropagation() } } >{n} diff --git a/client/src/components/editor/typewriter/useAppendTypewriterInput.ts b/client/src/components/editor/typewriter/useAppendTypewriterInput.ts new file mode 100644 index 00000000..f8686385 --- /dev/null +++ b/client/src/components/editor/typewriter/useAppendTypewriterInput.ts @@ -0,0 +1,86 @@ +import { useAtom } from "jotai" +import { typewriterAtom, typewriterContentAtom } from "../../../store/editor-atoms" +import { preferencesAtom } from "../../../store/preferences-atoms" + +// FIXME: implement +// Step 1: Copy over from infoview/context +// Step 2: Auto-import the used atoms +// Step 3: Copy SUFFIX and PREFIX OVERRIDES into function scpe +// Step 4: Reformulate parameters to adhere to old implementation +// Step 5: Relocate useAtoms into enclosing scope of function to stop infinite re-renders +export function useAppendTypewriterInput() { + const [typewriterContent] = useAtom(typewriterContentAtom) + const [, setTypewriter] = useAtom(typewriterAtom) + const [{ isSuggestionsMobileMode }] = useAtom(preferencesAtom) + + const SUFFIX_OVERRIDES: Record = { + "induction": "generalizing", + "left": ".left", + "rewrite": "←", + "right": ".right", + "rw": "←", + } + + const PREFIX_OVERRIDES: Record = { + "by_cases": "by_cases this :", + "contrapose": "contrapose!", + "have": "have :", + "obtain": "obtain ⟨⟩ :=", + "rewrite": "rw []", + "rw": "rw []", + "simp": "simp only []", + } + + return (shiftKey: boolean, suffix: string, isTheorem: boolean, isAssumption: boolean) => { + //return (shiftKey: boolean, suffix: string, isTheorem: boolean, isAssumption: boolean) => { + if (!isSuggestionsMobileMode && !shiftKey) { + return false + } + // Automagically detect and adjust punctuation for mobile keyboardless usage + let _typewriter = typewriterContent.trim() + + if (!typewriterContent.length) { + _typewriter = Object.hasOwn(PREFIX_OVERRIDES, suffix) ? PREFIX_OVERRIDES[suffix] : isTheorem ? `rw [${suffix}]` : suffix + setTypewriter(_typewriter + " ") + return true + } + + suffix = !isAssumption && Object.hasOwn(SUFFIX_OVERRIDES, suffix) ? SUFFIX_OVERRIDES[suffix] : suffix + + if (suffix === "ℕ") { + if (/ \d$/.test(_typewriter)) { + suffix = ((+_typewriter.slice(-2) + 1) % 10).toString() + _typewriter = _typewriter.slice(0, -2) + } else { + suffix = "0" + } + suffix = " " + suffix + } else if (suffix === "∈" && _typewriter.endsWith("∈")) { + suffix = " {} " + } else if (isAssumption && /^apply |^symm|^push_neg/.test(_typewriter)) { + suffix = " at " + suffix + } else if (suffix === "have") { + suffix = _typewriter === "have :" ? "=" : " :=" + } else if (/[\]}]$/.test(_typewriter)) { + if (isAssumption) { + suffix = " at " + suffix + } else { + const closing = _typewriter.slice(-1) + _typewriter = _typewriter.slice(0, -1) + if (suffix === "←") { + const imbalance = (_typewriter.match(/\(/)?.length ?? 0) - (_typewriter.match(/\)/)?.length ?? 0) + suffix = /[[,({]$/.test(_typewriter) ? "←" : /\([^)]*$/.test(_typewriter) ? ")" : " (" + } else { + if (!/[[,({]$/.test(_typewriter)) { + suffix = (isTheorem && !_typewriter.endsWith("←") && closing === "]" ? ", " : /^[ᶜ.]/.test(suffix) ? "" : " ") + suffix + } + } + suffix = suffix + closing + } + } else if (!/^[ᶜ.]/.test(suffix)) { + suffix = " " + suffix + } + setTypewriter(`${_typewriter}${suffix} `.trimStart()) + return true + } + } diff --git a/client/src/components/inventory/inventory_item.tsx b/client/src/components/inventory/inventory_item.tsx index f8f60ef2..31f29fbe 100644 --- a/client/src/components/inventory/inventory_item.tsx +++ b/client/src/components/inventory/inventory_item.tsx @@ -8,6 +8,7 @@ import { selectedDocTileAtom } from "../../store/inventory-atoms" import { levelIdAtom } from "../../store/location-atoms" import { typewriterAtom, typewriterContentAtom } from "../../store/editor-atoms" import { preferencesAtom } from "../../store/preferences-atoms" +import { useAppendTypewriterInput } from "../editor/typewriter/useAppendTypewriterInput" export function InventoryItem({tile, isTheorem, recent=false, enableAll=false} : { tile: InventoryTile, @@ -34,84 +35,7 @@ export function InventoryItem({tile, isTheorem, recent=false, enableAll=false} : const [inserted, setInserted] = useState(false) - // const appendTypewriterInput = useAppendTypewriterInput() - - // FIXME: implement - // Step 1: Copy over from infoview/context - // Step 2: Auto-import the used atoms - // Step 3: Copy SUFFIX and PREFIX OVERRIDES into function scpe - // Step 4: Reformulate parameters to adhere to old implementation - // Step 5: Relocate useAtoms into enclosing scope of function to stop infinite re-renders - function appendTypewriterInput(shiftKey: boolean, suffix: string, isTheorem: boolean, isAssumption: boolean) { - const SUFFIX_OVERRIDES: Record = { - "induction": "generalizing", - "left": ".left", - "rewrite": "←", - "right": ".right", - "rw": "←", - } - - const PREFIX_OVERRIDES: Record = { - "by_cases": "by_cases this :", - "contrapose": "contrapose!", - "have": "have :", - "obtain": "obtain ⟨⟩ :=", - "rewrite": "rw []", - "rw": "rw []", - "simp": "simp only []", - } - - //return (shiftKey: boolean, suffix: string, isTheorem: boolean, isAssumption: boolean) => { - if (!isSuggestionsMobileMode && !shiftKey) { - return false - } - // Automagically detect and adjust punctuation for mobile keyboardless usage - let _typewriter = typewriterContent.trim() - - if (!typewriterContent.length) { - _typewriter = Object.hasOwn(PREFIX_OVERRIDES, suffix) ? PREFIX_OVERRIDES[suffix] : isTheorem ? `rw [${suffix}]` : suffix - setTypewriter(_typewriter + " ") - return true - } - - suffix = !isAssumption && Object.hasOwn(SUFFIX_OVERRIDES, suffix) ? SUFFIX_OVERRIDES[suffix] : suffix - if (suffix === "ℕ") { - if (/ \d$/.test(_typewriter)) { - suffix = ((+_typewriter.slice(-2) + 1) % 10).toString() - _typewriter = _typewriter.slice(0, -2) - } else { - suffix = "0" - } - suffix = " " + suffix - } else if (suffix === "∈" && _typewriter.endsWith("∈")) { - suffix = " {} " - } else if (isAssumption && /^apply |^symm|^push_neg/.test(_typewriter)) { - suffix = " at " + suffix - } else if (suffix === "have") { - suffix = _typewriter === "have :" ? "=" : " :=" - } else if (/[\]}]$/.test(_typewriter)) { - if (isAssumption) { - suffix = " at " + suffix - } else { - const closing = _typewriter.slice(-1) - _typewriter = _typewriter.slice(0, -1) - if (suffix === "←") { - const imbalance = (_typewriter.match(/\(/)?.length ?? 0) - (_typewriter.match(/\)/)?.length ?? 0) - suffix = /[[,({]$/.test(_typewriter) ? "←" : /\([^)]*$/.test(_typewriter) ? ")" : " (" - } else { - if (!/[[,({]$/.test(_typewriter)) { - suffix = (isTheorem && !_typewriter.endsWith("←") && closing === "]" ? ", " : /^[ᶜ.]/.test(suffix) ? "" : " ") + suffix - } - } - suffix = suffix + closing - } - } else if (!/^[ᶜ.]/.test(suffix)) { - suffix = " " + suffix - } - setTypewriter(`${_typewriter}${suffix} `.trimStart()) - return true - //} - } + const appendTypewriterInput = useAppendTypewriterInput() const handleClick = (ev: any) => {setDoc(tile)} From 7afa1c0834a65d6878a58db265dac89ea82c109c Mon Sep 17 00:00:00 2001 From: matlorr Date: Fri, 29 May 2026 15:04:40 +0200 Subject: [PATCH 34/36] Display Error only if a text is available, that can be displyed. --- client/src/components/editor/messages/Error.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/client/src/components/editor/messages/Error.tsx b/client/src/components/editor/messages/Error.tsx index c45a9550..9fc67482 100644 --- a/client/src/components/editor/messages/Error.tsx +++ b/client/src/components/editor/messages/Error.tsx @@ -34,10 +34,14 @@ function Error({error, typewriterMode} : {error : InteractiveDiagnostic, typewri message = error.message } - return
+ const messageString = TaggedText_stripTags(message); + + return (messageString.trim().length > 0 && ( +
{!typewriterMode &&

{title}

}
-      

{TaggedText_stripTags(message)}

+

{messageString}

+ )) } From ec151a0e247b3e2ce892c7a1169cd259cccdd786 Mon Sep 17 00:00:00 2001 From: matlorr Date: Fri, 29 May 2026 15:06:39 +0200 Subject: [PATCH 35/36] Reactivate "Failed Command" notification in editor --- .../editor/typewriter/proof_step/Command.tsx | 10 +++++++--- .../editor/typewriter/proof_step/ProofStep.tsx | 3 ++- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/client/src/components/editor/typewriter/proof_step/Command.tsx b/client/src/components/editor/typewriter/proof_step/Command.tsx index 5c5b3fb5..ca9dc6e9 100644 --- a/client/src/components/editor/typewriter/proof_step/Command.tsx +++ b/client/src/components/editor/typewriter/proof_step/Command.tsx @@ -8,14 +8,17 @@ import { filterHints } from "../../../hints" import { deletedChatAtom, helpAtom } from "../../../../store/chat-atoms" import { useAtom } from "jotai" import { editor } from 'monaco-editor' -import { codeAtom, deleteCodeFromLineAtom } from "../../../../store/editor-atoms" +import { codeAtom, deleteCodeFromLineAtom, lastProofStepHasErrorsAtom } from "../../../../store/editor-atoms" //FIXME: implement -function isLastStepWithErrors(x: any, y: any) {return false} +function isLastStepWithErrors(x: any, y: any) { + return false +} /** The display of a single entered lean command */ export function Command({ proof, i }: { proof: ProofState, i: number }) { let {t} = useTranslation() + const [lastProofStepHasErrors,] = useAtom(lastProofStepHasErrorsAtom) const [, setDeletedChat] = useAtom(deletedChatAtom) const [help, setHelp] = useAtom(helpAtom) const [, setCode] = useAtom(codeAtom) @@ -24,7 +27,8 @@ export function Command({ proof, i }: { proof: ProofState, i: number }) { // The first step will always have an empty command if (!proof.steps[i]?.command) { return <> } - if (isLastStepWithErrors(proof, i)) { + //if (isLastStepWithErrors(proof, i)) + if (lastProofStepHasErrors){ // If the last step has errors, we display the command in a different style // indicating that it will be removed on the next try. return
diff --git a/client/src/components/editor/typewriter/proof_step/ProofStep.tsx b/client/src/components/editor/typewriter/proof_step/ProofStep.tsx index 229da305..5d88050d 100644 --- a/client/src/components/editor/typewriter/proof_step/ProofStep.tsx +++ b/client/src/components/editor/typewriter/proof_step/ProofStep.tsx @@ -13,6 +13,7 @@ import { useGameTranslation } from "../../../../utils/translation"; import { filterHints, Hint, Hints } from "../../../hints"; import { i } from "@tanstack/query-core/build/legacy/hydration-BlEVG2Lp"; import { GoalsTabs } from "./GoalsTab"; +import { GameHint } from "../../types"; export function ProofStep({step, idx}: {step:InteractiveGoalsWithHints, idx: number}) { const {t : gT} = useGameTranslation() @@ -30,7 +31,7 @@ export function ProofStep({step, idx}: {step:InteractiveGoalsWithHints, idx: num } // TODO: this should be an atom - let filteredHints = proof ? filterHints(step.goals[0]?.hints, proof.steps[idx-1]?.goals[0]?.hints) : [] + let filteredHints: GameHint[] = proof ? filterHints(step.goals[0]?.hints, proof.steps[idx-1]?.goals[0]?.hints) : [] if (!proof) return From 86ec1c3b0fe70637fda2d2a394b35710f06b4a94 Mon Sep 17 00:00:00 2001 From: matlorr Date: Fri, 29 May 2026 16:21:18 +0200 Subject: [PATCH 36/36] Centered circular progressbar --- client/src/components/editor/typewriter/Typewriter.tsx | 2 +- .../components/editor/typewriter/proof_step/Command.tsx | 2 +- client/src/css/typewriter.css | 7 ++++++- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/client/src/components/editor/typewriter/Typewriter.tsx b/client/src/components/editor/typewriter/Typewriter.tsx index 4af749c9..ec570950 100644 --- a/client/src/components/editor/typewriter/Typewriter.tsx +++ b/client/src/components/editor/typewriter/Typewriter.tsx @@ -30,7 +30,7 @@ export function Typewriter() {
{proof ? proof.steps.map((step, i) => ) - : } + :
}
diff --git a/client/src/components/editor/typewriter/proof_step/Command.tsx b/client/src/components/editor/typewriter/proof_step/Command.tsx index ca9dc6e9..58646a70 100644 --- a/client/src/components/editor/typewriter/proof_step/Command.tsx +++ b/client/src/components/editor/typewriter/proof_step/Command.tsx @@ -37,7 +37,7 @@ export function Command({ proof, i }: { proof: ProofState, i: number }) { } else { return
{proof.steps[i].command}
-