@preact/signals-devtools-ui 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/LICENSE +21 -0
- package/README.md +139 -0
- package/dist/devtools-ui.d.ts +5 -0
- package/dist/devtools-ui.js +1 -0
- package/dist/devtools-ui.js.map +1 -0
- package/dist/devtools-ui.min.js +1 -0
- package/dist/devtools-ui.min.js.map +1 -0
- package/dist/devtools-ui.mjs +1 -0
- package/dist/devtools-ui.mjs.map +1 -0
- package/dist/devtools-ui.module.js +1 -0
- package/dist/devtools-ui.module.js.map +1 -0
- package/dist/styles.css +835 -0
- package/package.json +66 -0
- package/src/DevToolsPanel.tsx +107 -0
- package/src/components/Button.tsx +31 -0
- package/src/components/EmptyState.tsx +29 -0
- package/src/components/Graph.tsx +496 -0
- package/src/components/Header.tsx +39 -0
- package/src/components/SettingsPanel.tsx +129 -0
- package/src/components/StatusIndicator.tsx +22 -0
- package/src/components/UpdateItem.tsx +75 -0
- package/src/components/UpdateTreeNode.tsx +69 -0
- package/src/components/UpdatesContainer.tsx +41 -0
- package/src/components/index.ts +9 -0
- package/src/context.ts +359 -0
- package/src/index.ts +47 -0
- package/src/styles.css +835 -0
- package/src/types.ts +35 -0
|
@@ -0,0 +1,496 @@
|
|
|
1
|
+
import { useRef, useEffect } from "preact/hooks";
|
|
2
|
+
import { useComputed, useSignal } from "@preact/signals";
|
|
3
|
+
import type { GraphData, GraphLink, GraphNode } from "../types";
|
|
4
|
+
import type { SignalUpdate } from "../context";
|
|
5
|
+
import { getContext } from "../context";
|
|
6
|
+
|
|
7
|
+
const copyToClipboard = (text: string) => {
|
|
8
|
+
const copyEl = document.createElement("textarea");
|
|
9
|
+
try {
|
|
10
|
+
copyEl.value = text;
|
|
11
|
+
document.body.append(copyEl);
|
|
12
|
+
copyEl.select();
|
|
13
|
+
document.execCommand("copy");
|
|
14
|
+
} finally {
|
|
15
|
+
copyEl.remove();
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export function GraphVisualization() {
|
|
20
|
+
const { updatesStore, settingsStore } = getContext();
|
|
21
|
+
const updates = updatesStore.updates;
|
|
22
|
+
const disposedSignalIds = updatesStore.disposedSignalIds;
|
|
23
|
+
const svgRef = useRef<SVGSVGElement>(null);
|
|
24
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
25
|
+
const exportMenuRef = useRef<HTMLDivElement>(null);
|
|
26
|
+
|
|
27
|
+
// Pan and zoom state using signals
|
|
28
|
+
const panOffset = useSignal({ x: 0, y: 0 });
|
|
29
|
+
const zoom = useSignal(1);
|
|
30
|
+
const isPanning = useSignal(false);
|
|
31
|
+
const startPan = useSignal({ x: 0, y: 0 });
|
|
32
|
+
const showExportMenu = useSignal(false);
|
|
33
|
+
const toastText = useSignal<string>();
|
|
34
|
+
const hoveredNode = useSignal<GraphNode | null>(null);
|
|
35
|
+
const tooltipPos = useSignal({ x: 0, y: 0 });
|
|
36
|
+
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
const handleClickOutside = (e: MouseEvent) => {
|
|
39
|
+
if (
|
|
40
|
+
showExportMenu.value &&
|
|
41
|
+
exportMenuRef.current &&
|
|
42
|
+
!exportMenuRef.current.contains(e.target as Node)
|
|
43
|
+
) {
|
|
44
|
+
showExportMenu.value = false;
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
document.addEventListener("mousedown", handleClickOutside);
|
|
49
|
+
return () => {
|
|
50
|
+
document.removeEventListener("mousedown", handleClickOutside);
|
|
51
|
+
};
|
|
52
|
+
}, []);
|
|
53
|
+
|
|
54
|
+
// Build graph data from updates signal using a computed
|
|
55
|
+
const graphData = useComputed<GraphData>(() => {
|
|
56
|
+
const rawUpdates = updates.value;
|
|
57
|
+
const disposed = disposedSignalIds.value;
|
|
58
|
+
const showDisposed = settingsStore.showDisposedSignals;
|
|
59
|
+
|
|
60
|
+
if (!rawUpdates || rawUpdates.length === 0) return { nodes: [], links: [] };
|
|
61
|
+
|
|
62
|
+
const nodes = new Map<string, GraphNode>();
|
|
63
|
+
const links = new Map<string, GraphLink>();
|
|
64
|
+
|
|
65
|
+
// Process updates to build graph structure
|
|
66
|
+
const signalUpdates = rawUpdates.filter(
|
|
67
|
+
update => update.type !== "divider"
|
|
68
|
+
) as SignalUpdate[];
|
|
69
|
+
|
|
70
|
+
for (const update of signalUpdates) {
|
|
71
|
+
if (!update.signalId) continue;
|
|
72
|
+
|
|
73
|
+
// Skip disposed signals unless showDisposed is enabled
|
|
74
|
+
if (!showDisposed && disposed.has(update.signalId)) continue;
|
|
75
|
+
|
|
76
|
+
const type: "signal" | "computed" | "effect" = update.signalType;
|
|
77
|
+
const currentDepth = update.depth || 0;
|
|
78
|
+
|
|
79
|
+
if (!nodes.has(update.signalId)) {
|
|
80
|
+
nodes.set(update.signalId, {
|
|
81
|
+
id: update.signalId,
|
|
82
|
+
name: update.signalName,
|
|
83
|
+
type,
|
|
84
|
+
x: 0,
|
|
85
|
+
y: 0,
|
|
86
|
+
depth: currentDepth,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (update.subscribedTo) {
|
|
91
|
+
// Also skip links to/from disposed signals
|
|
92
|
+
const sourceDisposed =
|
|
93
|
+
!showDisposed && disposed.has(update.subscribedTo);
|
|
94
|
+
if (sourceDisposed) continue;
|
|
95
|
+
|
|
96
|
+
const linkKey = `${update.subscribedTo}->${update.signalId}`;
|
|
97
|
+
if (!links.has(linkKey)) {
|
|
98
|
+
links.set(linkKey, {
|
|
99
|
+
source: update.subscribedTo,
|
|
100
|
+
target: update.signalId,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Simple depth-based layout
|
|
107
|
+
const allNodes = Array.from(nodes.values());
|
|
108
|
+
const nodeSpacing = 120;
|
|
109
|
+
const depthSpacing = 250;
|
|
110
|
+
const startX = 100;
|
|
111
|
+
const startY = 80;
|
|
112
|
+
|
|
113
|
+
// Group nodes by depth
|
|
114
|
+
const nodesByDepth = new Map<number, GraphNode[]>();
|
|
115
|
+
allNodes.forEach(node => {
|
|
116
|
+
if (!nodesByDepth.has(node.depth)) {
|
|
117
|
+
nodesByDepth.set(node.depth, []);
|
|
118
|
+
}
|
|
119
|
+
nodesByDepth.get(node.depth)!.push(node);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// Layout nodes by depth, centering each depth level vertically
|
|
123
|
+
const maxDepth = Math.max(...allNodes.map(n => n.depth));
|
|
124
|
+
nodesByDepth.forEach((depthNodes, depth) => {
|
|
125
|
+
const depthHeight = (depthNodes.length - 1) * nodeSpacing;
|
|
126
|
+
const depthStartY = startY + maxDepth * 100 - depthHeight / 2;
|
|
127
|
+
|
|
128
|
+
depthNodes.forEach((node, index) => {
|
|
129
|
+
node.x = startX + depth * depthSpacing;
|
|
130
|
+
node.y = depthStartY + index * nodeSpacing;
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
nodes: allNodes,
|
|
136
|
+
links: Array.from(links.values()),
|
|
137
|
+
};
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// Mouse event handlers for panning
|
|
141
|
+
const handleMouseDown = (e: MouseEvent) => {
|
|
142
|
+
if (e.button !== 0) return;
|
|
143
|
+
isPanning.value = true;
|
|
144
|
+
startPan.value = {
|
|
145
|
+
x: e.clientX - panOffset.value.x,
|
|
146
|
+
y: e.clientY - panOffset.value.y,
|
|
147
|
+
};
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const handleMouseMove = (e: MouseEvent) => {
|
|
151
|
+
if (!isPanning.value) return;
|
|
152
|
+
panOffset.value = {
|
|
153
|
+
x: e.clientX - startPan.value.x,
|
|
154
|
+
y: e.clientY - startPan.value.y,
|
|
155
|
+
};
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const handleMouseUp = () => {
|
|
159
|
+
isPanning.value = false;
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const handleWheel = (e: WheelEvent) => {
|
|
163
|
+
e.preventDefault();
|
|
164
|
+
|
|
165
|
+
const container = containerRef.current;
|
|
166
|
+
if (!container) return;
|
|
167
|
+
|
|
168
|
+
const rect = container.getBoundingClientRect();
|
|
169
|
+
const mouseX = e.clientX - rect.left;
|
|
170
|
+
const mouseY = e.clientY - rect.top;
|
|
171
|
+
|
|
172
|
+
// Smoother zoom: smaller delta for less aggressive scrolling
|
|
173
|
+
const delta = e.deltaY > 0 ? 0.96 : 1.04;
|
|
174
|
+
const newZoom = Math.min(Math.max(0.1, zoom.value * delta), 5);
|
|
175
|
+
|
|
176
|
+
const zoomRatio = newZoom / zoom.value;
|
|
177
|
+
panOffset.value = {
|
|
178
|
+
x: mouseX - (mouseX - panOffset.value.x) * zoomRatio,
|
|
179
|
+
y: mouseY - (mouseY - panOffset.value.y) * zoomRatio,
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
zoom.value = newZoom;
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
const resetView = () => {
|
|
186
|
+
panOffset.value = { x: 0, y: 0 };
|
|
187
|
+
zoom.value = 1;
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const toggleExportMenu = () => {
|
|
191
|
+
showExportMenu.value = !showExportMenu.value;
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
const mermaidIdPattern = /[^a-zA-Z0-9]/g;
|
|
195
|
+
const computeMermaidId = (id: string) => id.replace(mermaidIdPattern, "_");
|
|
196
|
+
|
|
197
|
+
// Calculate node radius based on name length
|
|
198
|
+
const getNodeRadius = (node: GraphNode) => {
|
|
199
|
+
const baseRadius = 30;
|
|
200
|
+
const charWidth = 6.5; // Approximate width per character
|
|
201
|
+
const padding = 16;
|
|
202
|
+
const textWidth = node.name.length * charWidth + padding;
|
|
203
|
+
return Math.max(baseRadius, Math.min(textWidth / 2, 70));
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
// Handle node hover for tooltip
|
|
207
|
+
const handleNodeMouseEnter = (node: GraphNode, e: MouseEvent) => {
|
|
208
|
+
hoveredNode.value = node;
|
|
209
|
+
const container = containerRef.current;
|
|
210
|
+
if (container) {
|
|
211
|
+
const rect = container.getBoundingClientRect();
|
|
212
|
+
tooltipPos.value = {
|
|
213
|
+
x: e.clientX - rect.left,
|
|
214
|
+
y: e.clientY - rect.top,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
const handleNodeMouseMove = (e: MouseEvent) => {
|
|
220
|
+
const container = containerRef.current;
|
|
221
|
+
if (container && hoveredNode.value) {
|
|
222
|
+
const rect = container.getBoundingClientRect();
|
|
223
|
+
tooltipPos.value = {
|
|
224
|
+
x: e.clientX - rect.left,
|
|
225
|
+
y: e.clientY - rect.top,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
const handleNodeMouseLeave = () => {
|
|
231
|
+
hoveredNode.value = null;
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
const showToast = (text: string) => {
|
|
235
|
+
toastText.value = text;
|
|
236
|
+
setTimeout(() => {
|
|
237
|
+
toastText.value = undefined;
|
|
238
|
+
}, 2000);
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
const handleExportMermaid = async () => {
|
|
242
|
+
showExportMenu.value = false;
|
|
243
|
+
|
|
244
|
+
const lines: string[] = ["graph LR"];
|
|
245
|
+
|
|
246
|
+
graphData.value.nodes.forEach(node => {
|
|
247
|
+
const id = computeMermaidId(node.id);
|
|
248
|
+
const name = node.name;
|
|
249
|
+
|
|
250
|
+
switch (node.type) {
|
|
251
|
+
case "signal":
|
|
252
|
+
lines.push(` ${id}((${name}))`);
|
|
253
|
+
break;
|
|
254
|
+
case "computed":
|
|
255
|
+
lines.push(` ${id}(${name})`);
|
|
256
|
+
break;
|
|
257
|
+
case "effect":
|
|
258
|
+
lines.push(` ${id}([${name}])`);
|
|
259
|
+
break;
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
for (const link of graphData.value.links) {
|
|
264
|
+
const sourceId = computeMermaidId(link.source);
|
|
265
|
+
const targetId = computeMermaidId(link.target);
|
|
266
|
+
lines.push(` ${sourceId} --> ${targetId}`);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
copyToClipboard(lines.join("\n"));
|
|
270
|
+
showToast("Copied to clipboard!");
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
const handleExportJSON = async () => {
|
|
274
|
+
showExportMenu.value = false;
|
|
275
|
+
const value = JSON.stringify(graphData.value, null, 2);
|
|
276
|
+
copyToClipboard(value);
|
|
277
|
+
showToast("Copied to clipboard!");
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
if (graphData.value.nodes.length === 0) {
|
|
281
|
+
return (
|
|
282
|
+
<div className="graph-empty">
|
|
283
|
+
<div>
|
|
284
|
+
<h3>No Signal Dependencies</h3>
|
|
285
|
+
<p>
|
|
286
|
+
Create some signals with dependencies to see the graph
|
|
287
|
+
visualization.
|
|
288
|
+
</p>
|
|
289
|
+
</div>
|
|
290
|
+
</div>
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const svgWidth = Math.max(800, ...graphData.value.nodes.map(n => n.x + 100));
|
|
295
|
+
const svgHeight = Math.max(600, ...graphData.value.nodes.map(n => n.y + 100));
|
|
296
|
+
|
|
297
|
+
return (
|
|
298
|
+
<div className="graph-container">
|
|
299
|
+
<div
|
|
300
|
+
ref={containerRef}
|
|
301
|
+
className="graph-content"
|
|
302
|
+
onMouseDown={handleMouseDown}
|
|
303
|
+
onMouseMove={handleMouseMove}
|
|
304
|
+
onMouseUp={handleMouseUp}
|
|
305
|
+
onMouseLeave={handleMouseUp}
|
|
306
|
+
onWheel={handleWheel}
|
|
307
|
+
style={{ cursor: isPanning.value ? "grabbing" : "grab" }}
|
|
308
|
+
>
|
|
309
|
+
<svg
|
|
310
|
+
ref={svgRef}
|
|
311
|
+
className="graph-svg"
|
|
312
|
+
width={svgWidth}
|
|
313
|
+
height={svgHeight}
|
|
314
|
+
viewBox={`0 0 ${svgWidth} ${svgHeight}`}
|
|
315
|
+
>
|
|
316
|
+
<defs>
|
|
317
|
+
<marker
|
|
318
|
+
id="arrowhead"
|
|
319
|
+
markerWidth="8"
|
|
320
|
+
markerHeight="6"
|
|
321
|
+
refX="7"
|
|
322
|
+
refY="3"
|
|
323
|
+
orient="auto"
|
|
324
|
+
>
|
|
325
|
+
<polygon points="0 0, 8 3, 0 6" fill="#94a3b8" />
|
|
326
|
+
</marker>
|
|
327
|
+
</defs>
|
|
328
|
+
|
|
329
|
+
<g
|
|
330
|
+
transform={`translate(${panOffset.value.x}, ${panOffset.value.y}) scale(${zoom.value})`}
|
|
331
|
+
>
|
|
332
|
+
<g className="links">
|
|
333
|
+
{graphData.value.links.map((link, index) => {
|
|
334
|
+
const sourceNode = graphData.value.nodes.find(
|
|
335
|
+
n => n.id === link.source
|
|
336
|
+
);
|
|
337
|
+
const targetNode = graphData.value.nodes.find(
|
|
338
|
+
n => n.id === link.target
|
|
339
|
+
);
|
|
340
|
+
|
|
341
|
+
if (!sourceNode || !targetNode) return null;
|
|
342
|
+
|
|
343
|
+
const sourceRadius = getNodeRadius(sourceNode);
|
|
344
|
+
const targetRadius = getNodeRadius(targetNode);
|
|
345
|
+
const sourceX = sourceNode.x + sourceRadius;
|
|
346
|
+
const sourceY = sourceNode.y;
|
|
347
|
+
const targetX = targetNode.x - targetRadius - 8; // Extra space for arrow
|
|
348
|
+
const targetY = targetNode.y;
|
|
349
|
+
|
|
350
|
+
const midX = sourceX + (targetX - sourceX) * 0.5;
|
|
351
|
+
const pathData = `M ${sourceX} ${sourceY} C ${midX} ${sourceY}, ${midX} ${targetY}, ${targetX} ${targetY}`;
|
|
352
|
+
|
|
353
|
+
return (
|
|
354
|
+
<path
|
|
355
|
+
key={`link-${index}`}
|
|
356
|
+
className="graph-link"
|
|
357
|
+
d={pathData}
|
|
358
|
+
fill="none"
|
|
359
|
+
markerEnd="url(#arrowhead)"
|
|
360
|
+
/>
|
|
361
|
+
);
|
|
362
|
+
})}
|
|
363
|
+
</g>
|
|
364
|
+
|
|
365
|
+
<g className="nodes">
|
|
366
|
+
{graphData.value.nodes.map(node => {
|
|
367
|
+
const radius = getNodeRadius(node);
|
|
368
|
+
// Calculate max chars based on radius
|
|
369
|
+
const maxChars = Math.floor((radius * 2 - 16) / 6.5);
|
|
370
|
+
const displayName =
|
|
371
|
+
node.name.length > maxChars
|
|
372
|
+
? node.name.slice(0, maxChars - 1) + "…"
|
|
373
|
+
: node.name;
|
|
374
|
+
const isHovered = hoveredNode.value?.id === node.id;
|
|
375
|
+
|
|
376
|
+
return (
|
|
377
|
+
<g
|
|
378
|
+
key={node.id}
|
|
379
|
+
className={`graph-node-group ${isHovered ? "hovered" : ""}`}
|
|
380
|
+
onMouseEnter={(e: MouseEvent) =>
|
|
381
|
+
handleNodeMouseEnter(node, e)
|
|
382
|
+
}
|
|
383
|
+
onMouseMove={handleNodeMouseMove}
|
|
384
|
+
onMouseLeave={handleNodeMouseLeave}
|
|
385
|
+
>
|
|
386
|
+
<circle
|
|
387
|
+
className={`graph-node ${node.type}`}
|
|
388
|
+
cx={node.x}
|
|
389
|
+
cy={node.y}
|
|
390
|
+
r={radius}
|
|
391
|
+
/>
|
|
392
|
+
<text
|
|
393
|
+
className="graph-text"
|
|
394
|
+
x={node.x}
|
|
395
|
+
y={node.y}
|
|
396
|
+
textAnchor="middle"
|
|
397
|
+
dominantBaseline="central"
|
|
398
|
+
>
|
|
399
|
+
{displayName}
|
|
400
|
+
</text>
|
|
401
|
+
</g>
|
|
402
|
+
);
|
|
403
|
+
})}
|
|
404
|
+
</g>
|
|
405
|
+
</g>
|
|
406
|
+
</svg>
|
|
407
|
+
|
|
408
|
+
<div className="graph-controls">
|
|
409
|
+
<button
|
|
410
|
+
className="graph-reset-button"
|
|
411
|
+
onClick={resetView}
|
|
412
|
+
title="Reset view"
|
|
413
|
+
>
|
|
414
|
+
⟲ Reset View
|
|
415
|
+
</button>
|
|
416
|
+
|
|
417
|
+
<div ref={exportMenuRef} className="graph-export-container">
|
|
418
|
+
<button
|
|
419
|
+
className="graph-export-button"
|
|
420
|
+
onClick={toggleExportMenu}
|
|
421
|
+
title="Export graph"
|
|
422
|
+
>
|
|
423
|
+
↓ Export
|
|
424
|
+
</button>
|
|
425
|
+
{showExportMenu.value && (
|
|
426
|
+
<div className="graph-export-menu">
|
|
427
|
+
<button
|
|
428
|
+
className="graph-export-menu-item"
|
|
429
|
+
onClick={handleExportMermaid}
|
|
430
|
+
>
|
|
431
|
+
Mermaid
|
|
432
|
+
</button>
|
|
433
|
+
<button
|
|
434
|
+
className="graph-export-menu-item"
|
|
435
|
+
onClick={handleExportJSON}
|
|
436
|
+
>
|
|
437
|
+
JSON
|
|
438
|
+
</button>
|
|
439
|
+
</div>
|
|
440
|
+
)}
|
|
441
|
+
</div>
|
|
442
|
+
|
|
443
|
+
<div className="graph-zoom-indicator" title="Zoom level">
|
|
444
|
+
{Math.round(zoom.value * 100)}%
|
|
445
|
+
</div>
|
|
446
|
+
</div>
|
|
447
|
+
|
|
448
|
+
{toastText.value && (
|
|
449
|
+
<div className="graph-toast">{toastText.value}</div>
|
|
450
|
+
)}
|
|
451
|
+
|
|
452
|
+
{hoveredNode.value && (
|
|
453
|
+
<div
|
|
454
|
+
className="graph-tooltip"
|
|
455
|
+
style={{
|
|
456
|
+
left: tooltipPos.value.x + 12,
|
|
457
|
+
top: tooltipPos.value.y - 8,
|
|
458
|
+
}}
|
|
459
|
+
>
|
|
460
|
+
<div className="tooltip-header">
|
|
461
|
+
<span className={`tooltip-type ${hoveredNode.value.type}`}>
|
|
462
|
+
{hoveredNode.value.type}
|
|
463
|
+
</span>
|
|
464
|
+
</div>
|
|
465
|
+
<div className="tooltip-name">{hoveredNode.value.name}</div>
|
|
466
|
+
<div className="tooltip-id">ID: {hoveredNode.value.id}</div>
|
|
467
|
+
</div>
|
|
468
|
+
)}
|
|
469
|
+
|
|
470
|
+
<div className="graph-legend">
|
|
471
|
+
<div className="legend-item">
|
|
472
|
+
<div
|
|
473
|
+
className="legend-color"
|
|
474
|
+
style={{ backgroundColor: "#2196f3" }}
|
|
475
|
+
></div>
|
|
476
|
+
<span>Signal</span>
|
|
477
|
+
</div>
|
|
478
|
+
<div className="legend-item">
|
|
479
|
+
<div
|
|
480
|
+
className="legend-color"
|
|
481
|
+
style={{ backgroundColor: "#ff9800" }}
|
|
482
|
+
></div>
|
|
483
|
+
<span>Computed</span>
|
|
484
|
+
</div>
|
|
485
|
+
<div className="legend-item">
|
|
486
|
+
<div
|
|
487
|
+
className="legend-color"
|
|
488
|
+
style={{ backgroundColor: "#4caf50" }}
|
|
489
|
+
></div>
|
|
490
|
+
<span>Effect</span>
|
|
491
|
+
</div>
|
|
492
|
+
</div>
|
|
493
|
+
</div>
|
|
494
|
+
</div>
|
|
495
|
+
);
|
|
496
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { StatusIndicator } from "./StatusIndicator";
|
|
2
|
+
import { Button } from "./Button";
|
|
3
|
+
import { getContext } from "../context";
|
|
4
|
+
|
|
5
|
+
export function Header() {
|
|
6
|
+
const { connectionStore, updatesStore, settingsStore } = getContext();
|
|
7
|
+
|
|
8
|
+
const onToggleSettings = settingsStore.toggleSettings;
|
|
9
|
+
const onTogglePause = () => {
|
|
10
|
+
updatesStore.isPaused.value = !updatesStore.isPaused.value;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const onClear = () => {
|
|
14
|
+
updatesStore.clearUpdates();
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
return (
|
|
18
|
+
<header className="header">
|
|
19
|
+
<div className="header-title">
|
|
20
|
+
<h1>Signals</h1>
|
|
21
|
+
<StatusIndicator
|
|
22
|
+
status={connectionStore.status}
|
|
23
|
+
message={connectionStore.message}
|
|
24
|
+
/>
|
|
25
|
+
</div>
|
|
26
|
+
<div className="header-controls">
|
|
27
|
+
{onClear && <Button onClick={onClear}>Clear</Button>}
|
|
28
|
+
{onTogglePause && (
|
|
29
|
+
<Button onClick={onTogglePause} active={updatesStore.isPaused.value}>
|
|
30
|
+
{updatesStore.isPaused.value ? "Resume" : "Pause"}
|
|
31
|
+
</Button>
|
|
32
|
+
)}
|
|
33
|
+
{onToggleSettings && (
|
|
34
|
+
<Button onClick={onToggleSettings}>Settings</Button>
|
|
35
|
+
)}
|
|
36
|
+
</div>
|
|
37
|
+
</header>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { useSignal, useSignalEffect } from "@preact/signals";
|
|
2
|
+
import { Button } from "./Button";
|
|
3
|
+
import type { Settings } from "@preact/signals-devtools-adapter";
|
|
4
|
+
import { getContext } from "../context";
|
|
5
|
+
|
|
6
|
+
export function SettingsPanel() {
|
|
7
|
+
const { settingsStore } = getContext();
|
|
8
|
+
|
|
9
|
+
const onCancel = settingsStore.hideSettings;
|
|
10
|
+
const onApply = settingsStore.applySettings;
|
|
11
|
+
const settings = settingsStore.settings;
|
|
12
|
+
const isVisible = settingsStore.showSettings;
|
|
13
|
+
|
|
14
|
+
const localSettings = useSignal<Settings>(settings);
|
|
15
|
+
|
|
16
|
+
useSignalEffect(() => {
|
|
17
|
+
localSettings.value = settingsStore.settings;
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const handleApply = () => {
|
|
21
|
+
onApply(localSettings.value);
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
if (!isVisible) {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<div className="settings-panel">
|
|
30
|
+
<div className="settings-content">
|
|
31
|
+
<h3>Debug Configuration</h3>
|
|
32
|
+
|
|
33
|
+
<div className="setting-group">
|
|
34
|
+
<label>
|
|
35
|
+
<input
|
|
36
|
+
type="checkbox"
|
|
37
|
+
checked={localSettings.value.enabled}
|
|
38
|
+
onChange={e =>
|
|
39
|
+
(localSettings.value = {
|
|
40
|
+
...localSettings.value,
|
|
41
|
+
enabled: (e.target as HTMLInputElement).checked,
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
/>
|
|
45
|
+
Enable debug updates
|
|
46
|
+
</label>
|
|
47
|
+
</div>
|
|
48
|
+
|
|
49
|
+
<div className="setting-group">
|
|
50
|
+
<label>
|
|
51
|
+
<input
|
|
52
|
+
type="checkbox"
|
|
53
|
+
checked={localSettings.value.grouped}
|
|
54
|
+
onChange={e =>
|
|
55
|
+
(localSettings.value = {
|
|
56
|
+
...localSettings.value,
|
|
57
|
+
grouped: (e.target as HTMLInputElement).checked,
|
|
58
|
+
})
|
|
59
|
+
}
|
|
60
|
+
/>
|
|
61
|
+
Group related updates
|
|
62
|
+
</label>
|
|
63
|
+
</div>
|
|
64
|
+
|
|
65
|
+
<div className="setting-group">
|
|
66
|
+
<label htmlFor="maxUpdatesInput">Max updates per second:</label>
|
|
67
|
+
<input
|
|
68
|
+
type="number"
|
|
69
|
+
id="maxUpdatesInput"
|
|
70
|
+
value={localSettings.value.maxUpdatesPerSecond}
|
|
71
|
+
min="1"
|
|
72
|
+
max="1000"
|
|
73
|
+
onChange={e =>
|
|
74
|
+
(localSettings.value = {
|
|
75
|
+
...localSettings.value,
|
|
76
|
+
maxUpdatesPerSecond:
|
|
77
|
+
parseInt((e.target as HTMLInputElement).value) || 60,
|
|
78
|
+
})
|
|
79
|
+
}
|
|
80
|
+
/>
|
|
81
|
+
</div>
|
|
82
|
+
|
|
83
|
+
<div className="setting-group">
|
|
84
|
+
<label htmlFor="filterPatternsInput">
|
|
85
|
+
Filter patterns (one per line):
|
|
86
|
+
</label>
|
|
87
|
+
<textarea
|
|
88
|
+
id="filterPatternsInput"
|
|
89
|
+
placeholder="user.* .*State$ global"
|
|
90
|
+
value={localSettings.value.filterPatterns.join("\n")}
|
|
91
|
+
onChange={e =>
|
|
92
|
+
(localSettings.value = {
|
|
93
|
+
...localSettings.value,
|
|
94
|
+
filterPatterns: (e.target as HTMLTextAreaElement).value
|
|
95
|
+
.split("\n")
|
|
96
|
+
.map(pattern => pattern.trim())
|
|
97
|
+
.filter(pattern => pattern.length > 0),
|
|
98
|
+
})
|
|
99
|
+
}
|
|
100
|
+
/>
|
|
101
|
+
</div>
|
|
102
|
+
|
|
103
|
+
<h3>Graph Settings</h3>
|
|
104
|
+
|
|
105
|
+
<div className="setting-group">
|
|
106
|
+
<label>
|
|
107
|
+
<input
|
|
108
|
+
type="checkbox"
|
|
109
|
+
checked={settingsStore.showDisposedSignals}
|
|
110
|
+
onChange={() => settingsStore.toggleShowDisposedSignals()}
|
|
111
|
+
/>
|
|
112
|
+
Show disposed signals in graph
|
|
113
|
+
</label>
|
|
114
|
+
<p className="setting-description">
|
|
115
|
+
When enabled, signals and effects that have been disposed will still
|
|
116
|
+
be shown in the graph view.
|
|
117
|
+
</p>
|
|
118
|
+
</div>
|
|
119
|
+
|
|
120
|
+
<div className="settings-actions">
|
|
121
|
+
<Button onClick={handleApply} variant="primary">
|
|
122
|
+
Apply
|
|
123
|
+
</Button>
|
|
124
|
+
<Button onClick={onCancel}>Cancel</Button>
|
|
125
|
+
</div>
|
|
126
|
+
</div>
|
|
127
|
+
</div>
|
|
128
|
+
);
|
|
129
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { ConnectionStatusType } from "@preact/signals-devtools-adapter";
|
|
2
|
+
|
|
3
|
+
interface StatusIndicatorProps {
|
|
4
|
+
status: ConnectionStatusType;
|
|
5
|
+
message: string;
|
|
6
|
+
showIndicator?: boolean;
|
|
7
|
+
className?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function StatusIndicator({
|
|
11
|
+
status,
|
|
12
|
+
message,
|
|
13
|
+
showIndicator = true,
|
|
14
|
+
className = "",
|
|
15
|
+
}: StatusIndicatorProps) {
|
|
16
|
+
return (
|
|
17
|
+
<div className={`connection-status ${status} ${className}`}>
|
|
18
|
+
{showIndicator && <span className={`status-indicator ${status}`}></span>}
|
|
19
|
+
<span className="status-text">{message}</span>
|
|
20
|
+
</div>
|
|
21
|
+
);
|
|
22
|
+
}
|