simplify infoview
parent
cdb2b64385
commit
8e3af92c03
@ -0,0 +1,241 @@
|
|||||||
|
/* Mostly copied from https://github.com/leanprover/vscode-lean4/blob/master/lean4-infoview/src/infoview/goals.tsx */
|
||||||
|
|
||||||
|
import * as React from 'react'
|
||||||
|
import { InteractiveCode } from '../../../../node_modules/lean4-infoview/src/infoview/interactiveCode'
|
||||||
|
import { InteractiveGoal, InteractiveGoals, InteractiveHypothesisBundle, InteractiveHypothesisBundle_nonAnonymousNames, MVarId, TaggedText_stripTags } from '@leanprover/infoview-api'
|
||||||
|
import { WithTooltipOnHover } from '../../../../node_modules/lean4-infoview/src/infoview/tooltips';
|
||||||
|
import { EditorContext } from '../../../../node_modules/lean4-infoview/src/infoview/contexts';
|
||||||
|
import { Locations, LocationsContext, SelectableLocation } from '../../../../node_modules/lean4-infoview/src/infoview/goalLocation';
|
||||||
|
|
||||||
|
/** 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(goalToString).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 ' +
|
||||||
|
(h.isInserted ? 'inserted-text ' : '') +
|
||||||
|
(h.isRemoved ? 'removed-text ' : '')
|
||||||
|
|
||||||
|
const names = InteractiveHypothesisBundle_nonAnonymousNames(h).map((n, i) =>
|
||||||
|
<span className={namecls + (isInaccessibleName(n) ? 'goal-inaccessible ' : '')} key={i}>
|
||||||
|
<SelectableLocation
|
||||||
|
locs={locs}
|
||||||
|
loc={mvarId && h.fvarIds && h.fvarIds.length > i ?
|
||||||
|
{ mvarId, loc: { hyp: h.fvarIds[i] }} :
|
||||||
|
undefined
|
||||||
|
}
|
||||||
|
alwaysHighlight={false}
|
||||||
|
>{n}</SelectableLocation>
|
||||||
|
</span>)
|
||||||
|
|
||||||
|
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 <div>
|
||||||
|
<strong className="goal-hyp">{names}</strong>
|
||||||
|
:
|
||||||
|
<LocationsContext.Provider value={typeLocs}>
|
||||||
|
<InteractiveCode fmt={h.type} />
|
||||||
|
</LocationsContext.Provider>
|
||||||
|
{h.val &&
|
||||||
|
<LocationsContext.Provider value={valLocs}>
|
||||||
|
:= <InteractiveCode fmt={h.val} />
|
||||||
|
</LocationsContext.Provider>}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GoalProps {
|
||||||
|
goal: InteractiveGoal
|
||||||
|
filter: GoalFilterState
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 { goal, filter } = props
|
||||||
|
|
||||||
|
const prefix = goal.goalPrefix ?? 'Prove: '
|
||||||
|
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 = <div key={'goal'}>
|
||||||
|
<strong className="goal-vdash">Prove: </strong>
|
||||||
|
<LocationsContext.Provider value={goalLocs}>
|
||||||
|
<InteractiveCode fmt={goal.type} />
|
||||||
|
</LocationsContext.Provider>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
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 '
|
||||||
|
|
||||||
|
if (goal.userName) {
|
||||||
|
return <details open className={cn}>
|
||||||
|
<summary className='mv1 pointer'>
|
||||||
|
<strong className="goal-case">case </strong>{goal.userName}
|
||||||
|
</summary>
|
||||||
|
{filter.reverse && goalLi}
|
||||||
|
{hyps.map((h, i) => <Hyp hyp={h} mvarId={goal.mvarId} key={i} />)}
|
||||||
|
{!filter.reverse && goalLi}
|
||||||
|
</details>
|
||||||
|
} else return <div className={cn}>
|
||||||
|
{filter.reverse && goalLi}
|
||||||
|
{hyps.map((h, i) => <Hyp hyp={h} mvarId={goal.mvarId} key={i} />)}
|
||||||
|
{!filter.reverse && goalLi}
|
||||||
|
</div>
|
||||||
|
})
|
||||||
|
|
||||||
|
interface GoalsProps {
|
||||||
|
goals: InteractiveGoals
|
||||||
|
filter: GoalFilterState
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Goals({ goals, filter }: GoalsProps) {
|
||||||
|
if (goals.goals.length === 0) {
|
||||||
|
return <>No goals</>
|
||||||
|
} else {
|
||||||
|
return <>
|
||||||
|
{goals.goals.map((g, i) => <Goal key={i} goal={g} filter={filter} />)}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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?: InteractiveGoals
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 =
|
||||||
|
<a className="link pointer mh2 dim codicon codicon-quote"
|
||||||
|
data-id="copy-goal-to-comment"
|
||||||
|
onClick={e => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (goals) void ec.copyToComment(goalsToString(goals))
|
||||||
|
}}
|
||||||
|
title="copy state to comment" />
|
||||||
|
|
||||||
|
const [goalFilters, setGoalFilters] = React.useState<GoalFilterState>(
|
||||||
|
{ 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 =
|
||||||
|
<a className={sortClasses} title="reverse list"
|
||||||
|
onClick={_ => setGoalFilters(s => ({ ...s, reverse: !s.reverse }))} />
|
||||||
|
|
||||||
|
const mkFilterButton = (filterFn: React.SetStateAction<GoalFilterState>, filledFn: (_: GoalFilterState) => boolean, name: string) =>
|
||||||
|
<a className='link pointer tooltip-menu-content' onClick={_ => { setGoalFilters(filterFn) }}>
|
||||||
|
<span className={'tooltip-menu-icon codicon ' + (filledFn(goalFilters) ? 'codicon-check ' : 'codicon-blank ')}> </span>
|
||||||
|
<span className='tooltip-menu-text '>{name}</span>
|
||||||
|
</a>
|
||||||
|
const filterMenu = <span>
|
||||||
|
{mkFilterButton(s => ({ ...s, showType: !s.showType }), gf => gf.showType, 'types')}
|
||||||
|
<br/>
|
||||||
|
{mkFilterButton(s => ({ ...s, showInstance: !s.showInstance }), gf => gf.showInstance, 'instances')}
|
||||||
|
<br/>
|
||||||
|
{mkFilterButton(s => ({ ...s, showHiddenAssumption: !s.showHiddenAssumption }), gf => gf.showHiddenAssumption, 'hidden assumptions')}
|
||||||
|
<br/>
|
||||||
|
{mkFilterButton(s => ({ ...s, showLetValue: !s.showLetValue }), gf => gf.showLetValue, 'let-values')}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
const isFiltered = !goalFilters.showInstance || !goalFilters.showType || !goalFilters.showHiddenAssumption || !goalFilters.showLetValue
|
||||||
|
const filterButton =
|
||||||
|
<WithTooltipOnHover mkTooltipContent={() => filterMenu}>
|
||||||
|
<a className={'link pointer mh2 dim codicon ' + (isFiltered ? 'codicon-filter-filled ': 'codicon-filter ')}/>
|
||||||
|
</WithTooltipOnHover>
|
||||||
|
|
||||||
|
return <div style={{display: goals !== undefined ? 'block' : 'none'}}>
|
||||||
|
<details open>
|
||||||
|
<summary className='mv2 pointer'>
|
||||||
|
{headerChildren}
|
||||||
|
<span className='fr'>{copyToCommentButton}{sortButton}{filterButton}</span>
|
||||||
|
</summary>
|
||||||
|
<div className='ml1'>
|
||||||
|
{goals && <Goals goals={goals} filter={goalFilters}></Goals>}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
})
|
@ -0,0 +1,399 @@
|
|||||||
|
/* Mostly copied from https://github.com/leanprover/vscode-lean4/blob/master/lean4-infoview/src/infoview/info.tsx */
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import type { Location, Diagnostic } from 'vscode-languageserver-protocol';
|
||||||
|
|
||||||
|
import { goalsToString, Goals, FilteredGoals } from './goals'
|
||||||
|
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 { lspDiagToInteractive, MessagesList } from './messages';
|
||||||
|
import { getInteractiveGoals, getInteractiveTermGoal, InteractiveDiagnostic,
|
||||||
|
InteractiveGoals, UserWidgetInstance, Widget_getWidgets, RpcSessionAtPos, isRpcError,
|
||||||
|
RpcErrorCode, getInteractiveDiagnostics, InteractiveTermGoal } from '@leanprover/infoview-api';
|
||||||
|
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';
|
||||||
|
|
||||||
|
type InfoStatus = 'updating' | 'error' | 'ready';
|
||||||
|
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>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const InfoDisplayContent = React.memo((props: InfoDisplayContentProps) => {
|
||||||
|
const {pos, messages, goals, termGoal, error, userWidgets, triggerUpdate, isPaused, setPaused} = 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])
|
||||||
|
|
||||||
|
/* 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}>
|
||||||
|
{goals && <Goals filter={{ reverse: false, showType: true, showInstance: true, showHiddenAssumption: true, showLetValue: true }} key='goals' goals={goals} />}
|
||||||
|
</LocationsContext.Provider>
|
||||||
|
<FilteredGoals headerChildren='Expected type' key='term-goal'
|
||||||
|
goals={termGoal !== undefined ? {goals: [termGoal]} : undefined} />
|
||||||
|
{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 : []} termGoal={termGoal}
|
||||||
|
selectedLocations={selectedLocs} widget={widget}/>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
<div style={{display: hasMessages ? 'block' : 'none'}} key='messages'>
|
||||||
|
{/* <details key='messages' open>
|
||||||
|
<summary className='mv2 pointer'>Messages ({messages.length})</summary> */}
|
||||||
|
<div className='ml1'>
|
||||||
|
<MessagesList uri={pos.uri} messages={messages} />
|
||||||
|
</div>
|
||||||
|
{/* </details> */}
|
||||||
|
</div>
|
||||||
|
{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> :
|
||||||
|
'No info found.')}
|
||||||
|
</>
|
||||||
|
})
|
||||||
|
|
||||||
|
interface InfoDisplayProps {
|
||||||
|
pos: DocumentPosition;
|
||||||
|
status: InfoStatus;
|
||||||
|
messages: InteractiveDiagnostic[];
|
||||||
|
goals?: InteractiveGoals;
|
||||||
|
termGoal?: InteractiveTermGoal;
|
||||||
|
error?: string;
|
||||||
|
userWidgets: UserWidgetInstance[];
|
||||||
|
rpcSess: RpcSessionAtPos;
|
||||||
|
triggerUpdate: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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<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);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RpcContext.Provider value={rpcSess}>
|
||||||
|
{/* <details open> */}
|
||||||
|
{/* <InfoStatusBar {...props} triggerUpdate={triggerDisplayUpdate} isPaused={isPaused} setPaused={setPaused} /> */}
|
||||||
|
<div className='ml1'>
|
||||||
|
<InfoDisplayContent {...props} 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) {
|
||||||
|
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<InfoDisplayProps, 'triggerUpdate'>
|
||||||
|
const [state, triggerUpdateCore] = useAsyncWithTrigger(() => new Promise<InfoRequestResult>((resolve, reject) => {
|
||||||
|
const goalsReq = getInteractiveGoals(rpcSess, 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,
|
||||||
|
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,
|
||||||
|
status: 'updating',
|
||||||
|
messages: [],
|
||||||
|
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') {
|
||||||
|
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} {...displayProps} />
|
||||||
|
}
|
@ -0,0 +1,131 @@
|
|||||||
|
/* 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/lean4-infoview/src/infoview/contexts';
|
||||||
|
import { DocumentPosition, Keyed, PositionHelpers, useClientNotificationEffect, useClientNotificationState, useEvent, useEventResult } from '../../../../node_modules/lean4-infoview/src/infoview/util';
|
||||||
|
import { Info, InfoProps } from './info';
|
||||||
|
|
||||||
|
/** Manages and displays pinned infos, as well as info for the current location. */
|
||||||
|
export function Infos() {
|
||||||
|
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] = useClientNotificationState(
|
||||||
|
'textDocument/didChange',
|
||||||
|
new Array<Keyed<DocumentPosition>>(),
|
||||||
|
(pinnedPositions, params: DidChangeTextDocumentParams) => {
|
||||||
|
if (pinnedPositions.length === 0) return pinnedPositions;
|
||||||
|
|
||||||
|
let changed: boolean = false;
|
||||||
|
const newPins = pinnedPositions.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<DocumentPosition> = { ...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<DocumentPosition>[];
|
||||||
|
return pinnedPositions;
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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<number>(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<InfoProps>[] = pinnedPositions.map(pos => ({ kind: 'pin', onPin: unpin, pos, key: pos.key }));
|
||||||
|
if (curPos) infoProps.push({ kind: 'cursor', onPin: pin, key: 'cursor' });
|
||||||
|
|
||||||
|
return <div>
|
||||||
|
{infoProps.map (ps => <Info {...ps} />)}
|
||||||
|
{!curPos && <p>Click somewhere in the Lean file to enable the infoview.</p> }
|
||||||
|
</div>;
|
||||||
|
}
|
@ -0,0 +1,18 @@
|
|||||||
|
|
||||||
|
.message {
|
||||||
|
margin: 10px 0;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border-radius: 3px 3px 3px 3px;
|
||||||
|
}
|
||||||
|
.message.info {
|
||||||
|
color: #059;
|
||||||
|
background-color: #BEF;
|
||||||
|
}
|
||||||
|
.message.warning {
|
||||||
|
color: #9F6000;
|
||||||
|
background-color: #FEEFB3;
|
||||||
|
}
|
||||||
|
.message.error {
|
||||||
|
color: #D8000C;
|
||||||
|
background-color: #FFBABA;
|
||||||
|
}
|
@ -0,0 +1,176 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import fastIsEqual from 'react-fast-compare';
|
||||||
|
import { Location, DocumentUri, Diagnostic, DiagnosticSeverity, PublishDiagnosticsParams } from 'vscode-languageserver-protocol';
|
||||||
|
|
||||||
|
import { LeanDiagnostic, RpcErrorCode } from '@leanprover/infoview-api';
|
||||||
|
|
||||||
|
import { basename, escapeHtml, usePausableState, useEvent, addUniqueKeys, DocumentPosition, useServerNotificationState } from '../../../../node_modules/lean4-infoview/src/infoview/util';
|
||||||
|
import { ConfigContext, EditorContext, LspDiagnosticsContext, VersionContext } from '../../../../node_modules/lean4-infoview/src/infoview/contexts';
|
||||||
|
import { Details } from '../../../../node_modules/lean4-infoview/src/infoview/collapsing';
|
||||||
|
import { InteractiveMessage } from '../../../../node_modules/lean4-infoview/src/infoview/traceExplorer';
|
||||||
|
import { getInteractiveDiagnostics, InteractiveDiagnostic, TaggedText_stripTags } from '@leanprover/infoview-api';
|
||||||
|
import { RpcContext, useRpcSessionAtPos } from '../../../../node_modules/lean4-infoview/src/infoview/rpcSessions';
|
||||||
|
|
||||||
|
interface MessageViewProps {
|
||||||
|
uri: DocumentUri;
|
||||||
|
diag: InteractiveDiagnostic;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MessageView = React.memo(({uri, diag}: MessageViewProps) => {
|
||||||
|
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 = `${fname}:${line+1}:${character}`;
|
||||||
|
return (
|
||||||
|
// <details open>
|
||||||
|
// <summary className={severityClass + ' mv2 pointer'}>{title}
|
||||||
|
// <span className="fr">
|
||||||
|
// <a className="link pointer mh2 dim codicon codicon-go-to-file"
|
||||||
|
// onClick={e => { e.preventDefault(); void ec.revealLocation(loc); }}
|
||||||
|
// title="reveal file location"></a>
|
||||||
|
// <a className="link pointer mh2 dim codicon codicon-quote"
|
||||||
|
// data-id="copy-to-comment"
|
||||||
|
// onClick={e => {e.preventDefault(); void ec.copyToComment(text)}}
|
||||||
|
// title="copy message to comment"></a>
|
||||||
|
// <a className="link pointer mh2 dim codicon codicon-clippy"
|
||||||
|
// onClick={e => {e.preventDefault(); void ec.api.copyToClipboard(text)}}
|
||||||
|
// title="copy message to clipboard"></a>
|
||||||
|
// </span>
|
||||||
|
// </summary>
|
||||||
|
<div className={severityClass + ' ml1 message'}>
|
||||||
|
<p className="mv2">{title}</p>
|
||||||
|
<pre className="font-code pre-wrap">
|
||||||
|
<InteractiveMessage fmt={diag.message} />
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
// </details>
|
||||||
|
)
|
||||||
|
}, 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 <>No messages.</> }
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ml1">
|
||||||
|
{mkMessageViewProps(uri, messages).map(m => <MessageView {...m} />)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
|
||||||
|
function lazy<T>(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({uri: uri0}: { uri: DocumentUri }) {
|
||||||
|
const ec = React.useContext(EditorContext);
|
||||||
|
const sv = React.useContext(VersionContext);
|
||||||
|
const rs0 = useRpcSessionAtPos({ uri: uri0, line: 0, character: 0 });
|
||||||
|
const dc = React.useContext(LspDiagnosticsContext);
|
||||||
|
const config = React.useContext(ConfigContext);
|
||||||
|
const diags0 = dc.get(uri0) || [];
|
||||||
|
|
||||||
|
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, uri0, diags0]);
|
||||||
|
const [{ isPaused, setPaused }, [uri, rs, diags, iDiags], _] = usePausableState(false, [uri0, 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<React.Dispatch<React.SetStateAction<boolean>>>();
|
||||||
|
useEvent(ec.events.requestedAction, act => {
|
||||||
|
if (act.kind === 'toggleAllMessages' && setOpenRef.current !== undefined) {
|
||||||
|
setOpenRef.current(t => !t);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RpcContext.Provider value={rs}>
|
||||||
|
<Details setOpenRef={setOpenRef as any} initiallyOpen={!config.autoOpenShowsGoal}>
|
||||||
|
<summary className="mv2 pointer">
|
||||||
|
All Messages ({diags.length})
|
||||||
|
<span className="fr">
|
||||||
|
<a className={'link pointer mh2 dim codicon ' + (isPaused ? 'codicon-debug-continue' : 'codicon-debug-pause')}
|
||||||
|
onClick={e => { e.preventDefault(); setPaused(p => !p); }}
|
||||||
|
title={isPaused ? 'continue updating' : 'pause updating'}>
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
</summary>
|
||||||
|
<AllMessagesBody uri={uri} messages={iDiags} />
|
||||||
|
</Details>
|
||||||
|
</RpcContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** We factor out the body of {@link AllMessages} which lazily fetches its contents only when expanded. */
|
||||||
|
function AllMessagesBody({uri, messages}: {uri: DocumentUri, messages: () => Promise<InteractiveDiagnostic[]>}) {
|
||||||
|
const [msgs, setMsgs] = React.useState<InteractiveDiagnostic[] | undefined>(undefined)
|
||||||
|
React.useEffect(() => { void messages().then(setMsgs) }, [messages])
|
||||||
|
if (msgs === undefined) return <>Loading messages...</>
|
||||||
|
else return <MessagesList uri={uri} messages={msgs}/>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<DocumentUri, Diagnostic[]>(),
|
||||||
|
async (params: PublishDiagnosticsParams) => diags =>
|
||||||
|
new Map(diags).set(params.uri, params.diagnostics),
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
return <LspDiagnosticsContext.Provider value={allDiags}>{children}</LspDiagnosticsContext.Provider>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Embeds a non-interactive diagnostic into the type `InteractiveDiagnostic`. */
|
||||||
|
export function lspDiagToInteractive(diag: Diagnostic): InteractiveDiagnostic {
|
||||||
|
return { ...(diag as LeanDiagnostic), message: { text: diag.message } };
|
||||||
|
}
|
Loading…
Reference in New Issue