diff --git a/.changeset/nervous-pugs-switch.md b/.changeset/nervous-pugs-switch.md new file mode 100644 index 000000000..e5c8c0ca0 --- /dev/null +++ b/.changeset/nervous-pugs-switch.md @@ -0,0 +1,5 @@ +--- +"@preact/signals-debug": minor +--- + +Add devtools capabilities and component tracking diff --git a/.changeset/perfect-mirrors-whisper.md b/.changeset/perfect-mirrors-whisper.md new file mode 100644 index 000000000..064d4748f --- /dev/null +++ b/.changeset/perfect-mirrors-whisper.md @@ -0,0 +1,5 @@ +--- +"@preact/signals": minor +--- + +Call into component tracking of the chrome extension diff --git a/.gitignore b/.gitignore index f25cab940..e7fb3fce5 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ yarn-debug.log* yarn-error.log* lerna-debug.log* .pnpm-debug.log* +*.local.* # Diagnostic reports (https://nodejs.org/api/report.html) report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json diff --git a/docs/demos/index.tsx b/docs/demos/index.tsx index f169a359b..7332f81e5 100644 --- a/docs/demos/index.tsx +++ b/docs/demos/index.tsx @@ -1,8 +1,8 @@ +import "@preact/signals-debug"; import { render } from "preact"; import { LocationProvider, Router, useLocation, lazy } from "preact-iso"; -import { signal, useSignal } from "@preact/signals"; +import { signal, useComputed, useSignal } from "@preact/signals"; import { setFlashingEnabled, constrainFlashToChildren } from "./render-flasher"; -import "@preact/signals-debug"; // disable flashing during initial render: setFlashingEnabled(false); @@ -10,6 +10,7 @@ setTimeout(setFlashingEnabled, 100, true); const demos = { Counter, + Sum, GlobalCounter, DuelingCounters, Nesting: lazy(() => import("./nesting")), @@ -58,7 +59,7 @@ function displayName(name: string) { } function Counter() { - const count = useSignal(0, "counter"); + const count = useSignal(0, { name: "counter" }); return (
@@ -69,7 +70,40 @@ function Counter() { ); } -const globalCount = signal(0, "global-counter"); +function Sum() { + const a = useSignal(0, { name: "a" }); + const b = useSignal(0, { name: "b" }); + + const sum = useComputed(() => a.value + b.value, { name: "sum" }); + + return ( +
+

+ +

+

+ +

+ Sum: {sum} +
+ ); +} + +const globalCount = signal(0, { name: "global-counter" }); function GlobalCounter({ explain = true }) { return ( <> diff --git a/extension/.gitignore b/extension/.gitignore new file mode 100644 index 000000000..8b5d9750b --- /dev/null +++ b/extension/.gitignore @@ -0,0 +1,34 @@ +# Extension artifacts +*.crx +*.pem +*.zip + +# Build outputs +dist/ +build/ + +# Node modules +node_modules/ + +# Temporary files +.tmp/ +.cache/ + +# OS files +.DS_Store +Thumbs.db + +# IDE files +.vscode/ +.idea/ +*.swp +*.swo + +# Web-ext artifacts +web-ext-artifacts/ + +# Logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* diff --git a/extension/background.js b/extension/background.js new file mode 100644 index 000000000..22d329e0e --- /dev/null +++ b/extension/background.js @@ -0,0 +1,129 @@ +// Background service worker for the extension + +// Maps to store connections by tab ID +const contentConnections = new Map(); // tab ID -> content script port +const devtoolsConnections = new Map(); // tab ID -> devtools port + +chrome.runtime.onConnect.addListener(port => { + if (port.name === "content-to-background") { + handleContentScriptConnection(port); + } else if (port.name === "devtools-to-background") { + handleDevToolsConnection(port); + } else { + console.warn("Unknown connection type:", port.name); + port.disconnect(); + } +}); + +function handleContentScriptConnection(port) { + const tabId = port.sender?.tab?.id; + + if (!tabId) { + console.error("Content script connection missing tab ID"); + port.disconnect(); + return; + } + + contentConnections.set(tabId, port); + + port.onMessage.addListener(message => { + // Forward message to devtools if connected + const devtoolsPort = devtoolsConnections.get(tabId); + if (devtoolsPort) { + try { + devtoolsPort.postMessage(message); + } catch (error) { + console.error("Failed to forward message to devtools:", error); + devtoolsConnections.delete(tabId); + } + } else { + console.log( + `No devtools connection for tab ${tabId}, message queued:`, + message.type + ); + } + }); + + port.onDisconnect.addListener(() => { + contentConnections.delete(tabId); + + // Notify devtools if connected + const devtoolsPort = devtoolsConnections.get(tabId); + if (devtoolsPort) { + try { + devtoolsPort.postMessage({ type: "CONTENT_SCRIPT_DISCONNECTED" }); + } catch (error) { + console.error( + "Failed to notify devtools of content script disconnect:", + error + ); + } + } + }); +} + +function handleDevToolsConnection(port) { + let tabId = null; + let isInitialized = false; + + // Listen for the initial tab ID message + const tabIdListener = message => { + if (message.type === "DEVTOOLS_TAB_ID" && !isInitialized) { + tabId = message.tabId; + isInitialized = true; + + devtoolsConnections.set(tabId, port); + + // Remove the tab ID listener + port.onMessage.removeListener(tabIdListener); + + // Set up the main message listener + port.onMessage.addListener(message => { + // Forward message to content script if connected + const contentPort = contentConnections.get(tabId); + if (contentPort) { + try { + contentPort.postMessage(message); + } catch (error) { + console.error( + "Failed to forward message to content script:", + error + ); + contentConnections.delete(tabId); + } + } else { + console.log(`No content script connection for tab ${tabId}`); + } + }); + + // Send initial status to devtools + const contentPort = contentConnections.get(tabId); + try { + port.postMessage({ + type: "BACKGROUND_READY", + contentScriptConnected: !!contentPort, + }); + } catch (error) { + console.error("Failed to send initial status to devtools:", error); + } + } + }; + + port.onMessage.addListener(tabIdListener); + + port.onDisconnect.addListener(() => { + if (tabId) { + devtoolsConnections.delete(tabId); + } + }); +} + +chrome.action.onClicked.addListener(tab => { + chrome.tabs.sendMessage(tab.id, { type: "OPEN_DEVTOOLS_HINT" }); +}); + +// Clean up connections when tabs are closed +chrome.tabs.onRemoved.addListener(tabId => { + contentConnections.delete(tabId); + devtoolsConnections.delete(tabId); +}); diff --git a/extension/content.js b/extension/content.js new file mode 100644 index 000000000..4c844357b --- /dev/null +++ b/extension/content.js @@ -0,0 +1,213 @@ +// Content script that injects the bridge script into the page context +// and communicates with the DevTools panel + +let connectionPort = null; +let isSignalsAvailable = false; +let connectionAttempts = 0; +const MAX_CONNECTION_ATTEMPTS = 5; +let __HAS_BRIDGE_SCRIPT__ = false; + +// Inject the bridge script into the page context +function injectBridgeScript() { + try { + const script = document.createElement("script"); + script.src = chrome.runtime.getURL("inject.js"); + script.setAttribute("data-signals-devtools", "true"); + + script.onload = function () { + script.remove(); + }; + + script.onerror = function () { + console.error("Failed to load Preact Signals DevTools inject script"); + script.remove(); + }; + + // Inject into the page as early as possible + const target = document.head || document.documentElement || document; + target.appendChild(script); + __HAS_BRIDGE_SCRIPT__ = true; + } catch (error) { + console.error("Failed to inject bridge script:", error); + } +} + +// Inject the script as early as possible +if (!__HAS_BRIDGE_SCRIPT__) { + injectBridgeScript(); +} + +// Listen for messages from the injected script +window.addEventListener("message", event => { + // Only accept messages from same origin for security + if (event.source !== window || event.origin !== window.location.origin) { + return; + } + + const { type, payload } = event.data; + + switch (type) { + case "SIGNALS_UPDATE": + forwardToDevTools({ + type: "SIGNALS_UPDATE", + payload: payload, + timestamp: event.data.timestamp, + }); + break; + + case "SIGNALS_INIT_FROM_PAGE": + forwardToDevTools({ + type: "SIGNALS_INIT", + timestamp: event.data.timestamp, + }); + break; + + case "SIGNALS_AVAILABLE": + isSignalsAvailable = payload.available; + forwardToDevTools({ + type: "SIGNALS_AVAILABILITY", + payload: payload, + }); + break; + + case "SIGNALS_CONFIG_FROM_PAGE": + forwardToDevTools({ + type: "SIGNALS_CONFIG", + payload: payload, + }); + break; + } +}); + +// Forward messages to DevTools panel via background script +function forwardToDevTools(message) { + if (connectionPort) { + try { + connectionPort.postMessage(message); + } catch (error) { + console.error("Failed to send message to background:", error); + // Port might be disconnected, try to reconnect + connectionPort = null; + connectToBackground(); + if (connectionPort) { + try { + connectionPort.postMessage(message); + } catch (retryError) { + console.error( + "Failed to send message after reconnection:", + retryError + ); + } + } + } + } else { + // Try to establish connection + connectToBackground(); + if (connectionPort) { + try { + connectionPort.postMessage(message); + } catch (error) { + console.error("Failed to send message on new connection:", error); + } + } + } +} + +// Connect to background script +function connectToBackground() { + if (connectionAttempts >= MAX_CONNECTION_ATTEMPTS) { + console.error("Max connection attempts reached, giving up"); + return; + } + + try { + connectionAttempts++; + connectionPort = chrome.runtime.connect({ name: "content-to-background" }); + + connectionPort.onMessage.addListener(message => { + handleMessageFromDevTools(message); + }); + + connectionPort.onDisconnect.addListener(() => { + connectionPort = null; + + // Send disconnect message to page + window.postMessage( + { + type: "DEVTOOLS_DISCONNECTED", + }, + "*" + ); + + // Reset connection attempts after a delay + setTimeout(() => { + connectionAttempts = 0; + }, 5000); + }); + + // Reset connection attempts on successful connection + connectionAttempts = 0; + } catch (error) { + console.error("Failed to connect to background script:", error); + connectionPort = null; + } +} + +// Handle messages from DevTools panel (via background script) +function handleMessageFromDevTools(message) { + const { type, payload } = message; + + switch (type) { + case "CONFIGURE_DEBUG": + window.postMessage( + { + type: "CONFIGURE_DEBUG_FROM_EXTENSION", + payload: payload, + }, + "*" + ); + break; + + case "REQUEST_STATE": + window.postMessage( + { + type: "REQUEST_STATE_FROM_EXTENSION", + }, + "*" + ); + + // Also manually trigger a state check + setTimeout(() => { + window.postMessage( + { + type: "CONTENT_SCRIPT_READY", + }, + "*" + ); + }, 100); + break; + + case "OPEN_DEVTOOLS_HINT": + break; + + default: + console.log("Unhandled message from DevTools:", message); + } +} + +// Listen for messages from background script (for extension icon clicks, etc.) +chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { + handleMessageFromDevTools(message); + return true; // Keep the message channel open for async response +}); + +// Initial connection attempt +connectToBackground(); + +// Announce presence to the page +window.postMessage( + { + type: "CONTENT_SCRIPT_READY", + }, + "*" +); diff --git a/extension/devtools-init.js b/extension/devtools-init.js new file mode 100644 index 000000000..b7f37d11a --- /dev/null +++ b/extension/devtools-init.js @@ -0,0 +1,122 @@ +// DevTools initialization script +// This script runs in the DevTools context and creates the Signals panel + +chrome.devtools.panels.create( + "Signals", + "icons/icon16.png", + "panel.html", + panel => { + let panelWindow = null; + let devtoolsPort = null; + let isConnected = false; + + // Establish connection with background script + function connectToBackground() { + try { + // Include the inspected tab ID in the connection + devtoolsPort = chrome.runtime.connect({ + name: "devtools-to-background", + // Note: We'll send the tab ID in the first message since we can't include it in connect() + }); + isConnected = true; + + // Send the tab ID as the first message + const tabId = chrome.devtools.inspectedWindow.tabId; + devtoolsPort.postMessage({ + type: "DEVTOOLS_TAB_ID", + tabId: tabId, + }); + + devtoolsPort.onMessage.addListener(message => { + if (panelWindow) { + try { + panelWindow.postMessage(message, "*"); + } catch (error) { + console.error( + "Failed to forward message to panel window:", + error + ); + } + } else { + console.log("Panel window not available, message queued"); + } + }); + + devtoolsPort.onDisconnect.addListener(() => { + devtoolsPort = null; + isConnected = false; + + if (panelWindow) { + try { + panelWindow.postMessage({ type: "CONNECTION_LOST" }, "*"); + } catch (error) { + console.error( + "Failed to notify panel of connection loss:", + error + ); + } + } + + // Attempt to reconnect after a delay + setTimeout(() => { + if (!isConnected) { + connectToBackground(); + } + }, 1000); + }); + } catch (error) { + console.error("Failed to connect to background script:", error); + devtoolsPort = null; + isConnected = false; + } + } + + // Panel lifecycle + panel.onShown.addListener(window => { + panelWindow = window; + + // Connect to background script when panel is shown + if (!devtoolsPort) { + connectToBackground(); + } + + // Listen for messages from the panel + window.addEventListener("message", event => { + if (event.source === window && devtoolsPort) { + try { + devtoolsPort.postMessage(event.data); + } catch (error) { + console.error("Failed to send message to background:", error); + // Try to reconnect + connectToBackground(); + } + } + }); + + // Send initial connection message + setTimeout(() => { + if (devtoolsPort && panelWindow) { + try { + panelWindow.postMessage( + { + type: "DEVTOOLS_READY", + payload: { connected: isConnected }, + }, + "*" + ); + } catch (error) { + console.error("Failed to send initial status to panel:", error); + } + } + }, 100); + }); + + panel.onHidden.addListener(() => { + // Don't disconnect port when panel is hidden, just clear reference + panelWindow = null; + }); + + // Initial connection attempt + connectToBackground(); + } +); diff --git a/extension/devtools.html b/extension/devtools.html new file mode 100644 index 000000000..f14083660 --- /dev/null +++ b/extension/devtools.html @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/extension/icons/icon128.png b/extension/icons/icon128.png new file mode 100644 index 000000000..49cc8ef0e Binary files /dev/null and b/extension/icons/icon128.png differ diff --git a/extension/icons/icon16.png b/extension/icons/icon16.png new file mode 100644 index 000000000..49cc8ef0e Binary files /dev/null and b/extension/icons/icon16.png differ diff --git a/extension/icons/icon32.png b/extension/icons/icon32.png new file mode 100644 index 000000000..49cc8ef0e Binary files /dev/null and b/extension/icons/icon32.png differ diff --git a/extension/icons/icon48.png b/extension/icons/icon48.png new file mode 100644 index 000000000..49cc8ef0e Binary files /dev/null and b/extension/icons/icon48.png differ diff --git a/extension/inject.js b/extension/inject.js new file mode 100644 index 000000000..086495233 --- /dev/null +++ b/extension/inject.js @@ -0,0 +1,180 @@ +// Injected script that runs in the page context to communicate with the Preact Signals debug package +(function () { + "use strict"; + + let devtoolsAPI = null; + let isConnected = false; + let checkAttempts = 0; + const MAX_CHECK_ATTEMPTS = 100; // 10 seconds with 100ms intervals + + // Wait for the Preact Signals debug package to be available + function initSignalsDevtools() { + try { + if (window.__PREACT_SIGNALS_DEVTOOLS__) { + devtoolsAPI = window.__PREACT_SIGNALS_DEVTOOLS__; + + // Listen for signal updates + const updateUnsubscribe = devtoolsAPI.onUpdate(updates => { + window.postMessage( + { + type: "SIGNALS_UPDATE_FROM_PAGE", + payload: updates, + timestamp: Date.now(), + }, + window.location.origin + ); + }); + + // Listen for initialization + const initUnsubscribe = devtoolsAPI.onInit(() => { + window.postMessage( + { + type: "SIGNALS_INIT_FROM_PAGE", + timestamp: Date.now(), + }, + window.location.origin + ); + }); + + // Store cleanup functions + window.__PREACT_SIGNALS_DEVTOOLS_CLEANUP__ = () => { + updateUnsubscribe(); + initUnsubscribe(); + }; + + // Announce that the extension is connected + if (!isConnected) { + window.postMessage( + { + type: "DEVTOOLS_CONNECTED", + }, + window.location.origin + ); + isConnected = true; + } + + // Signal that signals are available + window.postMessage( + { + type: "SIGNALS_AVAILABLE", + payload: { available: true }, + }, + window.location.origin + ); + + return true; + } + } catch (error) { + console.error("Error initializing Signals DevTools:", error); + } + return false; + } + + // Try to initialize immediately + if (!initSignalsDevtools()) { + // If not available, keep checking + const checkInterval = setInterval(() => { + checkAttempts++; + + if (initSignalsDevtools()) { + clearInterval(checkInterval); + } else if (checkAttempts >= MAX_CHECK_ATTEMPTS) { + clearInterval(checkInterval); + window.postMessage( + { + type: "SIGNALS_AVAILABLE", + payload: { available: false }, + }, + window.location.origin + ); + } + }, 100); + } + + // Also listen for the API becoming available via postMessage + // This handles cases where the debug package loads after the extension + window.addEventListener("message", event => { + if ( + event.source === window && + event.origin === window.location.origin && + event.data.type === "SIGNALS_AVAILABLE" && + event.data.payload?.available && + !devtoolsAPI + ) { + // Try to initialize when we get the availability message + setTimeout(() => { + if (!devtoolsAPI) { + initSignalsDevtools(); + } + }, 50); + } + }); // Listen for configuration messages from the extension + window.addEventListener("message", event => { + // Only process messages from the content script (same origin) + if (event.source !== window || event.origin !== window.location.origin) { + return; + } + + const { type, payload } = event.data; + + try { + switch (type) { + case "CONFIGURE_DEBUG_FROM_EXTENSION": + // Forward configuration to the debug system + if (devtoolsAPI && devtoolsAPI.sendConfig) { + devtoolsAPI.sendConfig(payload); + } + window.postMessage( + { + type: "CONFIGURE_DEBUG", + payload: payload, + }, + window.location.origin + ); + break; + + case "REQUEST_STATE_FROM_EXTENSION": + // Request current state from the debug system + window.postMessage( + { + type: "REQUEST_STATE", + }, + window.location.origin + ); + + // Also send current availability status + window.postMessage( + { + type: "SIGNALS_AVAILABLE", + payload: { available: !!devtoolsAPI }, + }, + window.location.origin + ); + break; + + case "CONTENT_SCRIPT_READY": + window.postMessage( + { + type: "SIGNALS_AVAILABLE", + payload: { available: !!devtoolsAPI }, + }, + window.location.origin + ); + break; + } + } catch (error) { + console.error("Error handling message from extension:", error); + } + }); + + // Cleanup on page unload + window.addEventListener("beforeunload", () => { + if (window.__PREACT_SIGNALS_DEVTOOLS_CLEANUP__) { + try { + window.__PREACT_SIGNALS_DEVTOOLS_CLEANUP__(); + } catch (error) { + console.error("Error during cleanup:", error); + } + } + }); +})(); diff --git a/extension/manifest.json b/extension/manifest.json new file mode 100644 index 000000000..3ec2cf4f0 --- /dev/null +++ b/extension/manifest.json @@ -0,0 +1,39 @@ +{ + "manifest_version": 3, + "name": "Preact Signals DevTools", + "version": "1.0.0", + "description": "Debug and inspect Preact Signals in your web applications", + "permissions": ["activeTab", "scripting"], + "host_permissions": [""], + "content_scripts": [ + { + "matches": [""], + "js": ["content.js"], + "run_at": "document_start", + "all_frames": true + } + ], + "devtools_page": "devtools.html", + "background": { + "service_worker": "background.js" + }, + "icons": { + "16": "icons/icon16.png", + "32": "icons/icon32.png", + "48": "icons/icon48.png", + "128": "icons/icon128.png" + }, + "action": { + "default_popup": "popup.html", + "default_title": "Preact Signals DevTools" + }, + "web_accessible_resources": [ + { + "resources": ["inject.js"], + "matches": [""] + } + ], + "content_security_policy": { + "extension_pages": "script-src 'self'; object-src 'self';" + } +} diff --git a/extension/package.json b/extension/package.json new file mode 100644 index 000000000..7cce58b45 --- /dev/null +++ b/extension/package.json @@ -0,0 +1,35 @@ +{ + "name": "preact-signals-devtools", + "version": "1.0.0", + "description": "Chrome DevTools extension for debugging Preact Signals", + "private": true, + "type": "module", + "scripts": { + "build": "tsc && vite build", + "dev": "vite build --watch", + "pack": "web-ext build --source-dir . --artifacts-dir ../dist", + "dev:extension": "web-ext run --source-dir . --start-url about:debugging", + "lint": "web-ext lint --source-dir ." + }, + "dependencies": { + "@preact/signals": "workspace:*", + "@preact/signals-core": "workspace:*", + "preact": "^10.26.9" + }, + "devDependencies": { + "@preact/preset-vite": "^2.3.0", + "@types/chrome": "^0.0.270", + "typescript": "^5.8.3", + "vite": "^7.0.0", + "web-ext": "^7.0.0" + }, + "keywords": [ + "preact", + "signals", + "devtools", + "chrome-extension", + "debugging" + ], + "author": "Preact Team", + "license": "MIT" +} diff --git a/extension/panel.html b/extension/panel.html new file mode 100644 index 000000000..d0897d045 --- /dev/null +++ b/extension/panel.html @@ -0,0 +1,15 @@ + + + + + Preact Signals DevTools + + + +
+ +
+ + + + diff --git a/extension/popup.html b/extension/popup.html new file mode 100644 index 000000000..e08667ad0 --- /dev/null +++ b/extension/popup.html @@ -0,0 +1,115 @@ + + + + + Preact Signals DevTools + + + +
+

Preact Signals

+

DevTools Extension

+
+ +
+ + + + diff --git a/extension/src/components/Button.tsx b/extension/src/components/Button.tsx new file mode 100644 index 000000000..17a3ae346 --- /dev/null +++ b/extension/src/components/Button.tsx @@ -0,0 +1,29 @@ +interface ButtonProps { + onClick: () => void; + className?: string; + disabled?: boolean; + children: preact.ComponentChildren; + variant?: "primary" | "secondary"; + active?: boolean; +} + +export function Button({ + onClick, + className = "", + disabled = false, + children, + variant = "secondary", + active = false, +}: ButtonProps) { + const baseClass = "btn"; + const variantClass = variant === "primary" ? "btn-primary" : "btn-secondary"; + const activeClass = active ? "active" : ""; + const combinedClassName = + `${baseClass} ${variantClass} ${activeClass} ${className}`.trim(); + + return ( + + ); +} diff --git a/extension/src/components/EmptyState.tsx b/extension/src/components/EmptyState.tsx new file mode 100644 index 000000000..9b8c835f7 --- /dev/null +++ b/extension/src/components/EmptyState.tsx @@ -0,0 +1,29 @@ +import { Button } from "./Button"; + +interface EmptyStateProps { + onRefresh: () => void; + title?: string; + description?: string; + buttonText?: string; +} + +export function EmptyState({ + onRefresh, + title = "No Signals Detected", + description = "Make sure your application is using @preact/signals-debug package.", + buttonText = "Refresh Detection", +}: EmptyStateProps) { + return ( +
+
+

{title}

+

{description}

+
+ +
+
+
+ ); +} diff --git a/extension/src/components/Graph.tsx b/extension/src/components/Graph.tsx new file mode 100644 index 000000000..5882c748a --- /dev/null +++ b/extension/src/components/Graph.tsx @@ -0,0 +1,287 @@ +import { useRef } from "preact/hooks"; +import { Signal, computed } from "@preact/signals"; +import { + Divider, + GraphData, + GraphLink, + GraphNode, + ComponentGroup, + SignalUpdate, +} from "../types"; + +export function GraphVisualization({ + updates, +}: { + updates: Signal<(SignalUpdate | Divider)[]>; +}) { + console.log(updates); + const svgRef = useRef(null); + + // Build graph data from updates signal using a computed + const graphData = computed(() => { + const rawUpdates = updates.value; + if (!rawUpdates || rawUpdates.length === 0) + return { nodes: [], links: [], components: [] }; + + const nodes = new Map(); + const links = new Map(); + + // Process updates to build graph structure + const signalUpdates = rawUpdates.filter( + update => update.type !== "divider" + ) as SignalUpdate[]; + + const componentNodes = new Set(); + + for (const update of signalUpdates) { + if (!update.signalId) continue; + const type: "signal" | "computed" | "effect" = update.signalType; + const currentDepth = update.depth || 0; + + if (!nodes.has(update.signalId)) { + nodes.set(update.signalId, { + id: update.signalId, + name: update.signalName, + type, + x: 0, + y: 0, + depth: currentDepth, + componentName: update.componentName || undefined, + }); + } + + if (update.subscribedTo) { + const linkKey = `${update.subscribedTo}->${update.signalId}`; + if (!links.has(linkKey)) { + links.set(linkKey, { + source: update.subscribedTo, + target: update.signalId, + }); + } + } + + // Create component nodes for each component that will rerender + if (update.componentNames && update.componentNames.length > 0) { + for (const componentName of update.componentNames) { + const componentId = `component:${componentName}`; + componentNodes.add(componentId); + + if (!nodes.has(componentId)) { + nodes.set(componentId, { + id: componentId, + name: componentName, + type: "component", + x: 0, + y: 0, + depth: currentDepth + 1, // Components are one level deeper than the signal that triggers them + componentName: componentName, + }); + } + + // Create link from signal to component rerender + const rerenderLinkKey = `${update.signalId}->${componentId}`; + if (!links.has(rerenderLinkKey)) { + links.set(rerenderLinkKey, { + source: update.signalId, + target: componentId, + }); + } + } + } + } + + // Simple depth-based layout + const allNodes = Array.from(nodes.values()); + const nodeSpacing = 120; // More spacing between nodes + const depthSpacing = 250; // Distance between depth levels + const startX = 100; + const startY = 80; + + // Group nodes by depth + const nodesByDepth = new Map(); + allNodes.forEach(node => { + if (!nodesByDepth.has(node.depth)) { + nodesByDepth.set(node.depth, []); + } + nodesByDepth.get(node.depth)!.push(node); + }); + + // Layout nodes by depth, centering each depth level vertically + const maxDepth = Math.max(...allNodes.map(n => n.depth)); + nodesByDepth.forEach((depthNodes, depth) => { + const depthHeight = (depthNodes.length - 1) * nodeSpacing; + const depthStartY = startY + maxDepth * 100 - depthHeight / 2; // Center this depth level + + depthNodes.forEach((node, index) => { + node.x = startX + depth * depthSpacing; + node.y = depthStartY + index * nodeSpacing; + }); + }); + + const components: ComponentGroup[] = []; // Remove component grouping for now + + return { + nodes: allNodes, + links: Array.from(links.values()), + components, + }; + }); + + if (graphData.value.nodes.length === 0) { + return ( +
+
+

No Signal Dependencies

+

+ Create some signals with dependencies to see the graph + visualization. +

+
+
+ ); + } + + // Calculate SVG dimensions based on nodes + const svgWidth = Math.max(800, ...graphData.value.nodes.map(n => n.x + 100)); + const svgHeight = Math.max(600, ...graphData.value.nodes.map(n => n.y + 100)); + + return ( +
+
+ + {/* Arrow marker definition */} + + + + + + + {/* Links */} + + {graphData.value.links.map((link, index) => { + const sourceNode = graphData.value.nodes.find( + n => n.id === link.source + ); + const targetNode = graphData.value.nodes.find( + n => n.id === link.target + ); + + if (!sourceNode || !targetNode) return null; + + // Use curved paths for better visual flow + const sourceX = sourceNode.x + 25; + const sourceY = sourceNode.y; + const targetX = targetNode.x - 25; + const targetY = targetNode.y; + + const midX = sourceX + (targetX - sourceX) * 0.6; + const pathData = `M ${sourceX} ${sourceY} Q ${midX} ${sourceY} ${targetX} ${targetY}`; + + return ( + + ); + })} + + + {/* Nodes */} + + {graphData.value.nodes.map(node => { + const radius = node.type === "component" ? 35 : 25; + const displayName = + node.name.length > 10 + ? node.name.slice(0, 10) + "..." + : node.name; + + return ( + + {node.type === "component" ? ( + // Rectangular shape for components + + ) : ( + // Circular shape for signals/computed/effects + + )} + + {displayName} + + + ); + })} + + + + {/* Legend */} +
+
+
+ Signal +
+
+
+ Computed +
+
+
+ Effect +
+
+
+ Component +
+
+
+
+ ); +} diff --git a/extension/src/components/Header.tsx b/extension/src/components/Header.tsx new file mode 100644 index 000000000..6aa750de8 --- /dev/null +++ b/extension/src/components/Header.tsx @@ -0,0 +1,41 @@ +import { StatusIndicator } from "./StatusIndicator"; +import { Button } from "./Button"; +import { connectionStore } from "../models/ConnectionModel"; +import { updatesStore } from "../models/UpdatesModel"; + +interface HeaderProps { + onToggleSettings?: () => void; +} + +export function Header({ onToggleSettings }: HeaderProps) { + const onTogglePause = () => { + updatesStore.isPaused.value = !updatesStore.isPaused.value; + }; + + const onClear = () => { + updatesStore.clearUpdates(); + }; + + return ( +
+
+

Signals

+ +
+
+ {onClear && } + {onTogglePause && ( + + )} + {onToggleSettings && ( + + )} +
+
+ ); +} diff --git a/extension/src/components/SettingsPanel.tsx b/extension/src/components/SettingsPanel.tsx new file mode 100644 index 000000000..6f539279f --- /dev/null +++ b/extension/src/components/SettingsPanel.tsx @@ -0,0 +1,116 @@ +import { Signal, useSignal, useSignalEffect } from "@preact/signals"; +import { Button } from "./Button"; +import { Settings } from "../types"; + +interface SettingsPanelProps { + isVisible: Signal; + settings: Signal; + onApply: (settings: Settings) => void; + onCancel: () => void; +} + +export function SettingsPanel({ + isVisible, + settings, + onApply, + onCancel, +}: SettingsPanelProps) { + const localSettings = useSignal(settings.value); + + useSignalEffect(() => { + localSettings.value = settings.value; + }); + + const handleApply = () => { + onApply(localSettings.value); + }; + + if (!isVisible.value) { + return null; + } + + return ( +
+
+

Debug Configuration

+ +
+ +
+ +
+ +
+ +
+ + + (localSettings.value = { + ...localSettings.value, + maxUpdatesPerSecond: + parseInt((e.target as HTMLInputElement).value) || 60, + }) + } + /> +
+ +
+ +