Skip to content

Commit 4281721

Browse files
authored
playground: polish ast panel (#355)
1 parent 05f4a95 commit 4281721

File tree

5 files changed

+332
-12
lines changed

5 files changed

+332
-12
lines changed

website/theme/components/Playground/Editor.css

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,10 @@
33
height: 100%;
44
border: 1px solid #eee;
55
}
6+
7+
/* Highlight used when clicking AST nodes */
8+
.monaco-editor .ast-node-highlight {
9+
background: rgba(198, 228, 255, 0.65);
10+
outline: 1px solid #0969da;
11+
border-radius: 2px;
12+
}

website/theme/components/Playground/Editor.tsx

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,17 @@ window.MonacoEnvironment = {
2222
export interface EditorRef {
2323
getValue: () => string | undefined;
2424
attachDiag: (diags: Diagnostic[]) => void;
25+
revealRangeByOffset: (start: number, end: number) => void;
2526
}
2627

2728
export const Editor = ({
2829
ref,
2930
onChange,
31+
onSelectionChange,
3032
}: {
31-
ref: Ref<{ getValue: () => string | undefined }>;
33+
ref: Ref<EditorRef>;
3234
onChange: (value: string) => void;
35+
onSelectionChange?: (start: number, end: number) => void;
3336
}) => {
3437
const divEl = useRef<HTMLDivElement>(null);
3538
const editorRef =
@@ -84,6 +87,8 @@ export const Editor = ({
8487
}, 300);
8588
}
8689
// get value from editor using forwardRef
90+
const astHighlightDecorationIds = useRef<string[]>([]);
91+
8792
React.useImperativeHandle(ref, () => ({
8893
getValue: () => editorRef.current?.getValue(),
8994
attachDiag: (diags: Diagnostic[]) => {
@@ -109,6 +114,44 @@ export const Editor = ({
109114
monaco.editor.setModelMarkers(model, 'rslint', markers);
110115
}
111116
},
117+
revealRangeByOffset: (start: number, end: number) => {
118+
const editor = editorRef.current;
119+
const model = editor?.getModel();
120+
if (!editor || !model) return;
121+
const startPos = model.getPositionAt(Math.max(0, start));
122+
const endPos = model.getPositionAt(Math.max(start, end));
123+
const range = new monaco.Range(
124+
startPos.lineNumber,
125+
startPos.column,
126+
endPos.lineNumber,
127+
endPos.column,
128+
);
129+
130+
editor.setSelection(range);
131+
editor.revealRangeInCenter(range, 0 /* Immediate */);
132+
133+
// Apply a transient decoration for highlighting
134+
if (astHighlightDecorationIds.current.length) {
135+
astHighlightDecorationIds.current = editor.deltaDecorations(
136+
astHighlightDecorationIds.current,
137+
[],
138+
);
139+
}
140+
astHighlightDecorationIds.current = editor.deltaDecorations(
141+
[],
142+
[
143+
{
144+
range,
145+
options: {
146+
inlineClassName: 'ast-node-highlight',
147+
stickiness:
148+
monaco.editor.TrackedRangeStickiness
149+
.NeverGrowsWhenTypingAtEdges,
150+
},
151+
},
152+
],
153+
);
154+
},
112155
}));
113156

114157
useEffect(() => {
@@ -135,8 +178,28 @@ export const Editor = ({
135178
onChange(val);
136179
scheduleSerializeToUrl(val);
137180
});
181+
// Selection change -> report offsets
182+
const selDisposable = editor.onDidChangeCursorSelection(() => {
183+
const model = editor.getModel();
184+
if (!model) return;
185+
const sel = editor.getSelection();
186+
if (!sel) return;
187+
const startOffset = model.getOffsetAt({
188+
lineNumber: sel.startLineNumber,
189+
column: sel.startColumn,
190+
});
191+
const endOffset = model.getOffsetAt({
192+
lineNumber: sel.endLineNumber,
193+
column: sel.endColumn,
194+
});
195+
onSelectionChange?.(
196+
Math.min(startOffset, endOffset),
197+
Math.max(startOffset, endOffset),
198+
);
199+
});
138200

139201
return () => {
202+
selDisposable.dispose();
140203
editor.dispose();
141204
};
142205
}, []);

website/theme/components/Playground/ResultPanel.css

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,82 @@
186186
height: 100%;
187187
}
188188

189+
.ast-tree {
190+
height: 100%;
191+
overflow: auto;
192+
border: 1px solid #d0d7de;
193+
border-radius: 6px;
194+
background: #fff;
195+
padding: 6px 6px 10px 6px;
196+
}
197+
198+
.ast-node-row {
199+
display: flex;
200+
align-items: center;
201+
gap: 6px;
202+
padding: 2px 6px;
203+
border-radius: 4px;
204+
cursor: pointer;
205+
font-family:
206+
ui-sans-serif,
207+
system-ui,
208+
-apple-system,
209+
Segoe UI,
210+
Roboto,
211+
Helvetica,
212+
Arial,
213+
Apple Color Emoji,
214+
Segoe UI Emoji,
215+
Segoe UI Symbol;
216+
font-size: 12px;
217+
}
218+
.ast-node-row:hover {
219+
background: #f6f8fa;
220+
}
221+
.ast-node-row.selected {
222+
background: #e7f3ff;
223+
outline: 1px solid #96c7ff;
224+
}
225+
226+
.twisty {
227+
width: 0;
228+
height: 0;
229+
border-top: 5px solid transparent;
230+
border-bottom: 5px solid transparent;
231+
border-left: 6px solid #57606a;
232+
background: transparent;
233+
padding: 0;
234+
margin-right: 2px;
235+
cursor: pointer;
236+
}
237+
.twisty.open {
238+
transform: rotate(90deg);
239+
}
240+
.twisty.placeholder {
241+
width: 10px;
242+
height: 10px;
243+
border: none;
244+
}
245+
246+
.node-type {
247+
font-weight: 600;
248+
color: #24292f;
249+
}
250+
.node-range {
251+
color: #57606a;
252+
font-family:
253+
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono',
254+
'Courier New', monospace;
255+
}
256+
.node-preview {
257+
color: #6e7781;
258+
margin-left: 6px;
259+
}
260+
261+
.ast-children {
262+
margin-left: 14px;
263+
}
264+
189265
.ast-content {
190266
background: #f6f8fa;
191267
border: 1px solid #d0d7de;

website/theme/components/Playground/ResultPanel.tsx

Lines changed: 135 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,18 +13,40 @@ export interface Diagnostic {
1313
interface ResultPanelProps {
1414
diagnostics: Diagnostic[];
1515
ast?: string;
16+
astTree?: ASTNode;
1617
initialized?: boolean;
1718
error?: string;
1819
fixedCode?: string;
1920
typeInfo?: string;
2021
loading?: boolean;
22+
onAstNodeSelect?: (start: number, end: number) => void;
23+
selectedAstNodeRange?: { start: number; end: number };
2124
}
2225

2326
type TabType = 'lint' | 'fixed' | 'ast' | 'type';
2427

28+
interface ASTNode {
29+
type: string;
30+
start: number;
31+
end: number;
32+
name?: string;
33+
text?: string;
34+
children?: ASTNode[];
35+
}
36+
2537
export const ResultPanel: React.FC<ResultPanelProps> = props => {
26-
const { diagnostics, ast, error, initialized, fixedCode, typeInfo, loading } =
27-
props;
38+
const {
39+
diagnostics,
40+
ast,
41+
astTree,
42+
error,
43+
initialized,
44+
fixedCode,
45+
typeInfo,
46+
loading,
47+
onAstNodeSelect,
48+
selectedAstNodeRange,
49+
} = props;
2850
const [activeTab, setActiveTab] = useState<TabType>(() => {
2951
if (typeof window === 'undefined') return 'lint';
3052
const params = new URLSearchParams(window.location.search);
@@ -81,6 +103,112 @@ export const ResultPanel: React.FC<ResultPanelProps> = props => {
81103
return () => window.removeEventListener('popstate', handler);
82104
}, []);
83105

106+
// AST tree view state
107+
const [expanded, setExpanded] = useState<Set<string>>(() => new Set());
108+
const [selectedId, setSelectedId] = useState<string | null>(null);
109+
110+
function nodeId(n: ASTNode) {
111+
return `${n.type}:${n.start}-${n.end}`;
112+
}
113+
114+
function toggleNode(n: ASTNode) {
115+
const id = nodeId(n);
116+
setExpanded(prev => {
117+
const next = new Set(prev);
118+
if (next.has(id)) next.delete(id);
119+
else next.add(id);
120+
return next;
121+
});
122+
}
123+
124+
function clickNode(n: ASTNode) {
125+
setSelectedId(nodeId(n));
126+
onAstNodeSelect?.(n.start, n.end);
127+
}
128+
129+
function isExpandable(n?: ASTNode) {
130+
return !!(n && n.children && n.children.length);
131+
}
132+
133+
const renderNode = (n: ASTNode, depth = 0) => {
134+
const id = nodeId(n);
135+
const open = expanded.has(id);
136+
const hasKids = isExpandable(n);
137+
const preview = n.text ? n.text.replace(/\s+/g, ' ').slice(0, 40) : '';
138+
return (
139+
<div key={id} className="ast-node" style={{ paddingLeft: depth * 14 }}>
140+
<div
141+
className={`ast-node-row ${selectedId === id ? 'selected' : ''}`}
142+
onClick={() => clickNode(n)}
143+
>
144+
{hasKids ? (
145+
<button
146+
className={`twisty ${open ? 'open' : ''}`}
147+
onClick={e => {
148+
e.stopPropagation();
149+
toggleNode(n);
150+
}}
151+
aria-label={open ? 'Collapse' : 'Expand'}
152+
/>
153+
) : (
154+
<span className="twisty placeholder" />
155+
)}
156+
<span className="node-type">{n.type}</span>
157+
<span className="node-range">
158+
[{n.start}, {n.end}]
159+
</span>
160+
{preview && <span className="node-preview">{preview}</span>}
161+
</div>
162+
{open && hasKids && (
163+
<div className="ast-children">
164+
{n.children!.map(child => renderNode(child, depth + 1))}
165+
</div>
166+
)}
167+
</div>
168+
);
169+
};
170+
171+
// When selection in editor changes, select smallest covering AST node and expand its ancestors
172+
useEffect(() => {
173+
if (!astTree || !selectedAstNodeRange) return;
174+
const { start, end } = selectedAstNodeRange;
175+
176+
let best: { node: ASTNode; depth: number; path: ASTNode[] } | null = null;
177+
178+
function visit(node: ASTNode, depth: number, path: ASTNode[]) {
179+
if (node.start <= start && node.end >= end) {
180+
if (!best || depth > best.depth) {
181+
best = { node, depth, path: [...path, node] };
182+
}
183+
if (node.children) {
184+
for (const c of node.children) visit(c, depth + 1, [...path, node]);
185+
}
186+
}
187+
}
188+
visit(astTree, 0, []);
189+
if (best) {
190+
const id = nodeId(best.node);
191+
setSelectedId(id);
192+
setExpanded(prev => {
193+
const next = new Set(prev);
194+
for (const p of best!.path) next.add(nodeId(p));
195+
return next;
196+
});
197+
}
198+
}, [selectedAstNodeRange, astTree]);
199+
200+
// Auto-expand root when a new tree arrives
201+
useEffect(() => {
202+
if (!astTree) return;
203+
const id = nodeId(astTree);
204+
setExpanded(prev => {
205+
if (prev.has(id)) return prev;
206+
const next = new Set(prev);
207+
next.add(id);
208+
return next;
209+
});
210+
}, [astTree]);
211+
84212
return (
85213
<div className="result-panel">
86214
<div className="result-header">
@@ -144,7 +272,11 @@ export const ResultPanel: React.FC<ResultPanelProps> = props => {
144272

145273
{!error && activeTab === 'ast' && (
146274
<div className="ast-view">
147-
{ast ? (
275+
{astTree ? (
276+
<div className="ast-tree" role="tree">
277+
{renderNode(astTree)}
278+
</div>
279+
) : ast ? (
148280
<div className="code-block-wrapper">
149281
<pre className="ast-content">{ast}</pre>
150282
</div>

0 commit comments

Comments
 (0)