diff --git a/packages/website/src/components/Playground.module.css b/packages/website/src/components/Playground.module.css index e61a35bad03c..b824d441fafc 100644 --- a/packages/website/src/components/Playground.module.css +++ b/packages/website/src/components/Playground.module.css @@ -52,6 +52,7 @@ .tabCode { height: calc(100% - 41px); + overflow: auto; } .hidden { diff --git a/packages/website/src/components/Playground.tsx b/packages/website/src/components/Playground.tsx index 4f3a74bd5255..ef45fecab317 100644 --- a/packages/website/src/components/Playground.tsx +++ b/packages/website/src/components/Playground.tsx @@ -1,8 +1,6 @@ -import type { TSESTree } from '@typescript-eslint/utils'; import clsx from 'clsx'; import type * as ESQuery from 'esquery'; import React, { useCallback, useState } from 'react'; -import type { SourceFile } from 'typescript'; import ASTViewer from './ast/ASTViewer'; import ConfigEslint from './config/ConfigEslint'; @@ -14,17 +12,17 @@ import { ESQueryFilter } from './ESQueryFilter'; import useHashState from './hooks/useHashState'; import EditorTabs from './layout/EditorTabs'; import Loader from './layout/Loader'; +import type { UpdateModel } from './linter/types'; import { defaultConfig, detailTabs } from './options'; import OptionsSelector from './OptionsSelector'; import styles from './Playground.module.css'; import ConditionalSplitPane from './SplitPane/ConditionalSplitPane'; +import { TypesDetails } from './typeDetails/TypesDetails'; import type { ErrorGroup, RuleDetails, SelectedRange, TabType } from './types'; function Playground(): React.JSX.Element { const [state, setState] = useHashState(defaultConfig); - const [esAst, setEsAst] = useState(); - const [tsAst, setTsAST] = useState(); - const [scope, setScope] = useState | null>(); + const [astModel, setAstModel] = useState(); const [markers, setMarkers] = useState(); const [ruleNames, setRuleNames] = useState([]); const [isLoading, setIsLoading] = useState(true); @@ -62,15 +60,6 @@ function Playground(): React.JSX.Element { } }, []); - const astToShow = - state.showAST === 'ts' - ? tsAst - : state.showAST === 'scope' - ? scope - : state.showAST === 'es' - ? esAst - : undefined; - return (
@@ -137,9 +126,7 @@ function Playground(): React.JSX.Element { eslintrc={state.eslintrc} sourceType={state.sourceType} showAST={state.showAST} - onEsASTChange={setEsAst} - onTsASTChange={setTsAST} - onScopeChange={setScope} + onASTChange={setAstModel} onMarkersChange={setMarkers} selectedRange={selectedRange} onChange={setState} @@ -169,11 +156,27 @@ function Playground(): React.JSX.Element { value={esQueryError} /> )) || - (state.showAST && astToShow && ( + (state.showAST === 'types' && astModel?.storedTsAST && ( + + )) || + (state.showAST && astModel && ( = ({ eslintrc, selectedRange, fileType, - onEsASTChange, - onScopeChange, - onTsASTChange, + onASTChange, onMarkersChange, onChange, onSelect, @@ -140,12 +138,10 @@ export const LoadedEditor: React.FC = ({ useEffect(() => { const disposable = webLinter.onParse((uri, model) => { - onEsASTChange(model.storedAST); - onScopeChange(model.storedScope as Record | undefined); - onTsASTChange(model.storedTsAST); + onASTChange(model); }); return () => disposable(); - }, [webLinter, onEsASTChange, onScopeChange, onTsASTChange]); + }, [webLinter, onASTChange]); useEffect(() => { const createRuleUri = (name: string): string => diff --git a/packages/website/src/components/editor/types.ts b/packages/website/src/components/editor/types.ts index bc7b886f535f..e8933ce19f42 100644 --- a/packages/website/src/components/editor/types.ts +++ b/packages/website/src/components/editor/types.ts @@ -1,15 +1,11 @@ -import type { TSESTree } from '@typescript-eslint/utils'; -import type { SourceFile } from 'typescript'; - +import type { UpdateModel } from '../linter/types'; import type { ConfigModel, ErrorGroup, SelectedRange, TabType } from '../types'; export interface CommonEditorProps extends ConfigModel { readonly activeTab: TabType; readonly selectedRange?: SelectedRange; readonly onChange: (cfg: Partial) => void; - readonly onTsASTChange: (value: SourceFile | undefined) => void; - readonly onEsASTChange: (value: TSESTree.Program | undefined) => void; - readonly onScopeChange: (value: Record | undefined) => void; + readonly onASTChange: (value: undefined | UpdateModel) => void; readonly onMarkersChange: (value: ErrorGroup[]) => void; readonly onSelect: (position?: number) => void; } diff --git a/packages/website/src/components/hooks/useHashState.ts b/packages/website/src/components/hooks/useHashState.ts index 70293527f188..95573ba375b4 100644 --- a/packages/website/src/components/hooks/useHashState.ts +++ b/packages/website/src/components/hooks/useHashState.ts @@ -21,6 +21,7 @@ function readShowAST(value: string | null): ConfigShowAst { case 'es': case 'ts': case 'scope': + case 'types': return value; } return value ? 'es' : false; diff --git a/packages/website/src/components/linter/types.ts b/packages/website/src/components/linter/types.ts index 540ad0e45218..12e4c9654d41 100644 --- a/packages/website/src/components/linter/types.ts +++ b/packages/website/src/components/linter/types.ts @@ -8,7 +8,7 @@ export type { ParseSettings } from '@typescript-eslint/typescript-estree/use-at- export interface UpdateModel { storedAST?: TSESTree.Program; - storedTsAST?: ts.SourceFile; + storedTsAST?: ts.Node; storedScope?: ScopeManager; typeChecker?: ts.TypeChecker; } diff --git a/packages/website/src/components/options.ts b/packages/website/src/components/options.ts index 07e50240fb3d..f52e7784bc37 100644 --- a/packages/website/src/components/options.ts +++ b/packages/website/src/components/options.ts @@ -6,6 +6,7 @@ export const detailTabs: { value: ConfigShowAst; label: string }[] = [ { value: 'es', label: 'ESTree' }, { value: 'ts', label: 'TypeScript' }, { value: 'scope', label: 'Scope' }, + { value: 'types', label: 'Types' }, ]; /** diff --git a/packages/website/src/components/typeDetails/SimplifiedTreeView.tsx b/packages/website/src/components/typeDetails/SimplifiedTreeView.tsx new file mode 100644 index 000000000000..c5dc1b37926d --- /dev/null +++ b/packages/website/src/components/typeDetails/SimplifiedTreeView.tsx @@ -0,0 +1,77 @@ +import clsx from 'clsx'; +import React, { useCallback, useMemo } from 'react'; +import type * as ts from 'typescript'; + +import styles from '../ast/ASTViewer.module.css'; +import PropertyName from '../ast/PropertyName'; +import { tsEnumToString } from '../ast/tsUtils'; +import type { OnHoverNodeFn } from '../ast/types'; +import { getRange, isTSNode } from '../ast/utils'; + +export interface SimplifiedTreeViewProps { + readonly value: ts.Node; + readonly selectedNode: ts.Node | undefined; + readonly onSelect: (value: ts.Node) => void; + readonly onHoverNode?: OnHoverNodeFn; +} + +function SimplifiedItem({ + value, + onSelect, + selectedNode, + onHoverNode, +}: SimplifiedTreeViewProps): React.JSX.Element { + const items = useMemo(() => { + const result: ts.Node[] = []; + value.forEachChild(child => { + result.push(child); + }); + return result; + }, [value]); + + const onHover = useCallback( + (v: boolean) => { + if (isTSNode(value) && onHoverNode) { + return onHoverNode(v ? getRange(value, 'tsNode') : undefined); + } + }, + [onHoverNode, value], + ); + + return ( +
+ + { + onSelect(value); + }} + /> + + +
+ {items.map((item, index) => ( + + ))} +
+
+ ); +} + +export function SimplifiedTreeView( + params: SimplifiedTreeViewProps, +): React.JSX.Element { + return ( +
+ +
+ ); +} diff --git a/packages/website/src/components/typeDetails/TypeInfo.tsx b/packages/website/src/components/typeDetails/TypeInfo.tsx new file mode 100644 index 000000000000..87084650d124 --- /dev/null +++ b/packages/website/src/components/typeDetails/TypeInfo.tsx @@ -0,0 +1,141 @@ +import React, { useMemo } from 'react'; +import type * as ts from 'typescript'; + +import ASTViewer from '../ast/ASTViewer'; +import astStyles from '../ast/ASTViewer.module.css'; +import type { OnHoverNodeFn } from '../ast/types'; + +export interface TypeInfoProps { + readonly value: ts.Node; + readonly typeChecker?: ts.TypeChecker; + readonly onHoverNode?: OnHoverNodeFn; +} + +interface InfoModel { + type?: unknown; + typeString?: string; + contextualType?: unknown; + contextualTypeString?: string; + symbol?: unknown; + signature?: unknown; + flowNode?: unknown; +} + +interface SimpleFieldProps { + readonly value: string | undefined; + readonly label: string; +} + +interface TypeGroupProps { + readonly label: string; + readonly type?: unknown; + readonly string?: string; + readonly onHoverNode?: OnHoverNodeFn; +} + +function SimpleField(props: SimpleFieldProps): React.JSX.Element { + return ( +
+ {props.label} + : + {String(props.value)} +
+ ); +} + +function TypeGroup(props: TypeGroupProps): React.JSX.Element { + return ( + <> +

{props.label}

+ {props.type ? ( + <> + {props.string && ( + + )} + + + ) : ( +
None
+ )} + + ); +} + +export function TypeInfo({ + value, + typeChecker, + onHoverNode, +}: TypeInfoProps): React.JSX.Element { + const computed = useMemo(() => { + if (!typeChecker || !value) { + return undefined; + } + const info: InfoModel = {}; + try { + const type = typeChecker.getTypeAtLocation(value); + info.type = type; + info.typeString = typeChecker.typeToString(type); + info.symbol = type.getSymbol(); + let signature = type.getCallSignatures(); + if (signature.length === 0) { + signature = type.getConstructSignatures(); + } + info.signature = signature.length > 0 ? signature : undefined; + // @ts-expect-error not part of public api + info.flowNode = value.flowNode ?? value.endFlowNode ?? undefined; + } catch (e: unknown) { + info.type = e; + } + try { + // @ts-expect-error just fail if a node type is not correct + const contextualType = typeChecker.getContextualType(value); + info.contextualType = contextualType; + if (contextualType) { + info.contextualTypeString = typeChecker.typeToString(contextualType); + } + } catch { + info.contextualType = undefined; + } + return info; + }, [value, typeChecker]); + + if (!typeChecker || !computed) { + return
TypeChecker not available
; + } + + return ( +
+ <> +

Node

+ + + + + + + +
+ ); +} diff --git a/packages/website/src/components/typeDetails/TypesDetails.tsx b/packages/website/src/components/typeDetails/TypesDetails.tsx new file mode 100644 index 000000000000..28dd2f824deb --- /dev/null +++ b/packages/website/src/components/typeDetails/TypesDetails.tsx @@ -0,0 +1,62 @@ +import React, { useEffect, useState } from 'react'; +import type * as ts from 'typescript'; + +import { findSelectionPath } from '../ast/selectedRange'; +import type { OnHoverNodeFn } from '../ast/types'; +import { isTSNode } from '../ast/utils'; +import styles from '../Playground.module.css'; +import ConditionalSplitPane from '../SplitPane/ConditionalSplitPane'; +import { SimplifiedTreeView } from './SimplifiedTreeView'; +import { TypeInfo } from './TypeInfo'; + +export interface TypesDetailsProps { + readonly value: ts.Node; + readonly typeChecker?: ts.TypeChecker; + readonly cursorPosition?: number; + readonly onHoverNode?: OnHoverNodeFn; +} + +export function TypesDetails({ + cursorPosition, + value, + typeChecker, + onHoverNode, +}: TypesDetailsProps): React.JSX.Element { + const [selectedNode, setSelectedNode] = useState(value); + + useEffect(() => { + if (cursorPosition) { + const item = findSelectionPath(value, cursorPosition); + if (item.node && isTSNode(item.node)) { + setSelectedNode(item.node); + } + } + }, [cursorPosition, value]); + + return ( + +
+ +
+ {selectedNode && ( +
+ +
+ )} +
+ ); +} diff --git a/packages/website/src/components/types.ts b/packages/website/src/components/types.ts index 219443b6b952..4b99af65c887 100644 --- a/packages/website/src/components/types.ts +++ b/packages/website/src/components/types.ts @@ -17,7 +17,7 @@ export type TabType = 'code' | 'eslintrc' | 'tsconfig'; export type ConfigFileType = `${ts.Extension}`; -export type ConfigShowAst = 'es' | 'scope' | 'ts' | false; +export type ConfigShowAst = 'es' | 'scope' | 'ts' | 'types' | false; export interface ConfigModel { fileType?: ConfigFileType;