You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
lean4game/client/src/components/infoview/info.tsx

423 lines
20 KiB
TypeScript

/* 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, InteractiveTermGoal } from '@leanprover/infoview-api';
import { basename, DocumentPosition, RangeHelpers, useEvent, usePausableState, discardMethodNotFound,
mapRpcError, useAsyncWithTrigger, PausableProps } from '../../../../node_modules/lean4-infoview/src/infoview/util';
import { ConfigContext, EditorContext, LspDiagnosticsContext, ProgressContext } from '../../../../node_modules/lean4-infoview/src/infoview/contexts';
import { PanelWidgetDisplay } from '../../../../node_modules/lean4-infoview/src/infoview/userWidget'
import { RpcContext, useRpcSessionAtPos } from '../../../../node_modules/lean4-infoview/src/infoview/rpcSessions';
import { GoalsLocation, Locations, LocationsContext } from '../../../../node_modules/lean4-infoview/src/infoview/goalLocation';
import { AllMessages, lspDiagToInteractive, MessagesList } from './messages';
import { goalsToString, Goal, MainAssumptions, OtherGoals, FilteredGoals, ProofDisplay } from './goals'
import { InteractiveGoal, InteractiveGoals } from './rpc_api';
import { InputModeContext, MonacoEditorContext, HintContext, ProofStateProps, InfoStatus, ProofStateContext } from './context'
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<void>;
}
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 (
<summary style={{transition: 'color 0.5s ease'}} className={'mv2 pointer ' + statusColor}>
{locationString}
{isPinned && !isPaused && ' (pinned)'}
{!isPinned && isPaused && ' (paused)'}
{isPinned && isPaused && ' (pinned and paused)'}
<span className='fr'>
{isPinned &&
<a className='link pointer mh2 dim codicon codicon-go-to-file'
data-id='reveal-file-location'
onClick={e => { e.preventDefault(); void ec.revealPosition(pos); }}
title='reveal file location' />}
<a className={'link pointer mh2 dim codicon ' + (isPinned ? 'codicon-pinned ' : 'codicon-pin ')}
data-id='toggle-pinned'
onClick={e => { e.preventDefault(); onPin(pos); }}
title={isPinned ? 'unpin' : 'pin'} />
<a className={'link pointer mh2 dim codicon ' + (isPaused ? 'codicon-debug-continue ' : 'codicon-debug-pause ')}
data-id='toggle-paused'
onClick={e => { e.preventDefault(); setPaused(!isPaused); }}
title={isPaused ? 'continue updating' : 'pause updating'} />
<a className='link pointer mh2 dim codicon codicon-refresh'
data-id='update'
onClick={e => { e.preventDefault(); void triggerUpdate(); }}
title='update'/>
</span>
</summary>
);
})
interface InfoDisplayContentProps extends PausableProps {
pos: DocumentPosition;
messages: InteractiveDiagnostic[];
goals?: InteractiveGoals;
termGoal?: InteractiveTermGoal;
error?: string;
userWidgets: UserWidgetInstance[];
triggerUpdate: () => Promise<void>;
proof? : string;
}
const InfoDisplayContent = React.memo((props: InfoDisplayContentProps) => {
const {pos, messages, goals, termGoal, error, userWidgets, triggerUpdate, isPaused, setPaused, proof} = 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<GoalsLocation[]>([])
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 &&
<div className='error' key='errors'>
Error updating:{' '}{error}.
<a className='link pointer dim' onClick={e => { e.preventDefault(); void triggerUpdate(); }}>{' '}Try again.</a>
</div>}
<LocationsContext.Provider value={locs}>
<div className="goals-section">
{ goals && goals.goals.length > 0 && <>
<MainAssumptions filter={goalFilter} key='mainGoal' goals={goals.goals} />
<OtherGoals filter={goalFilter} goals={goals.goals} />
</>}
</div>
<div>
{ goals && (goals.goals.length > 0
? <Goal commandLine={true} filter={goalFilter} key='mainGoal' goal={goals.goals[0]} showHints={true} />
: <div className="goals-section-title">No Goals</div>
)}
</div>
</LocationsContext.Provider>
{userWidgets.map(widget =>
<details key={`widget::${widget.id}::${widget.range?.toString()}`} open>
<summary className='mv2 pointer'>{widget.name}</summary>
<PanelWidgetDisplay pos={pos} goals={goals ? goals.goals.map (goal => goal) : []}
termGoal={termGoal} selectedLocations={selectedLocs} widget={widget}/>
</details>
)}
{nothingToShow && (
isPaused ?
/* Adding {' '} to manage string literals properly: https://reactjs.org/docs/jsx-in-depth.html#string-literals-1 */
<span>Updating is paused.{' '}
<a className='link pointer dim' onClick={e => { e.preventDefault(); void triggerUpdate(); }}>Refresh</a>
{' '}or <a className='link pointer dim' onClick={e => { e.preventDefault(); setPaused(false); }}>resume updating</a>
{' '}to see information.
</span> :
<><CircularProgress /><div>Loading goal...</div></>)}
<AllMessages />
{/* <LocationsContext.Provider value={locs}>
{goals && goals.goals.length > 1 && <div className="goals-section other-goals">
<div className="goals-section-title">Weitere Goals</div>
{goals.goals.slice(1).map((goal, i) =>
<details key={i}><summary><InteractiveCode fmt={goal.type} /></summary> <Goal commandLine={false} filter={goalFilter} goal={goal} /></details>)}
</div>}
</LocationsContext.Provider> */}
</>
})
interface InfoDisplayProps {
pos: DocumentPosition;
userWidgets: UserWidgetInstance[];
rpcSess: RpcSessionAtPos;
triggerUpdate: () => Promise<void>;
}
/** Displays goal state and messages. Can be paused. */
function InfoDisplay(props0: ProofStateProps & 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<boolean>(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 (
<RpcContext.Provider value={rpcSess}>
{/* <details open> */}
{/* <InfoStatusBar {...props} triggerUpdate={triggerDisplayUpdate} isPaused={isPaused} setPaused={setPaused} /> */}
<div className="vscode-light">
<InfoDisplayContent {...props} proof={editor.getValue()} triggerUpdate={triggerDisplayUpdate} isPaused={isPaused} setPaused={setPaused} />
</div>
{/* </details> */}
</RpcContext.Provider>
);
}
/**
* 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 <InfoAtCursor {...props} />
else return <InfoAux {...props} pos={props.pos} />
}
function InfoAtCursor(props: InfoProps) {
const ec = React.useContext(EditorContext);
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const [curLoc, setCurLoc] = React.useState<Location>(ec.events.changedCursorLocation.current!);
useEvent(ec.events.changedCursorLocation, loc => loc && setCurLoc(loc), []);
const pos = { uri: curLoc.uri, ...curLoc.range.start };
return <InfoAux {...props} pos={pos} />
}
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) {
// TODO: proofStateContext is not quite implemented correctly yet.
// i.e. there is an asynchronous state in this file that seems to partally overlap
// search for `const [state, triggerUpdateCore]`
const hintContext = React.useContext(HintContext)
const proofStateContext = React.useContext(ProofStateContext)
const config = React.useContext(ConfigContext)
// 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<Diagnostic[]>([])
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<number>(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<ProofStateProps & InfoDisplayProps, 'triggerUpdate'>
const [state, triggerUpdateCore] = useAsyncWithTrigger(() => new Promise<InfoRequestResult>((resolve, reject) => {
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),
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([goalsReq, termGoalReq, widgetsReq, messagesReq]).then(
([goals, termGoal, userWidgets, messages]) => resolve({
pos,
status: 'ready',
messages,
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),
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<number>()
const clearUpdaterTimeout = () => {
if (updaterTimeout.current) {
window.clearTimeout(updaterTimeout.current)
updaterTimeout.current = undefined
}
}
const triggerUpdate = React.useCallback(() => new Promise<void>(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<InfoDisplayProps>({
pos,
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') {
proofStateContext.setProofState(dp => ({ ...dp, status: 'updating' }))
setDisplayProps(dp => ({ ...dp, status: 'updating' }))
}
else if (state.state === 'resolved') {
// if (state.value.goals?.goals?.length) {
// hintContext.setHints(state.value.goals.goals[0].hints)
// }
proofStateContext.setProofState({ ...state.value })
setDisplayProps({ ...state.value, triggerUpdate })
} 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 <InfoDisplay kind={props.kind} onPin={props.onPin} {...proofStateContext.proofState} {...displayProps} />
}