From 10f90597b9aec3b8249fdf4c59bad689581eb84e Mon Sep 17 00:00:00 2001 From: Anthony Shew Date: Tue, 16 Dec 2025 10:37:26 -0700 Subject: [PATCH 01/14] great progress --- Cargo.lock | 25 + crates/turborepo-devtools/Cargo.toml | 41 + crates/turborepo-devtools/src/graph.rs | 163 +++ crates/turborepo-devtools/src/lib.rs | 30 + crates/turborepo-devtools/src/server.rs | 259 ++++ crates/turborepo-devtools/src/types.rs | 100 ++ crates/turborepo-devtools/src/watcher.rs | 208 ++++ crates/turborepo-lib/Cargo.toml | 1 + crates/turborepo-lib/src/cli/error.rs | 2 + crates/turborepo-lib/src/cli/mod.rs | 16 + crates/turborepo-lib/src/commands/devtools.rs | 49 + crates/turborepo-lib/src/commands/mod.rs | 1 + .../(no-sidebar)/tools/devtools-client.tsx | 1050 +++++++++++++++++ .../(no-sidebar)/tools/floating-edge-utils.ts | 118 ++ .../app/(no-sidebar)/tools/function-icon.tsx | 23 + docs/site/app/(no-sidebar)/tools/layout.tsx | 9 + docs/site/app/(no-sidebar)/tools/page.tsx | 11 + .../app/(no-sidebar)/tools/turbo-edge.tsx | 41 + .../app/(no-sidebar)/tools/turbo-flow.css | 177 +++ .../app/(no-sidebar)/tools/turbo-node.tsx | 28 + docs/site/package.json | 4 +- pnpm-lock.yaml | 430 +++++++ 22 files changed, 2785 insertions(+), 1 deletion(-) create mode 100644 crates/turborepo-devtools/Cargo.toml create mode 100644 crates/turborepo-devtools/src/graph.rs create mode 100644 crates/turborepo-devtools/src/lib.rs create mode 100644 crates/turborepo-devtools/src/server.rs create mode 100644 crates/turborepo-devtools/src/types.rs create mode 100644 crates/turborepo-devtools/src/watcher.rs create mode 100644 crates/turborepo-lib/src/commands/devtools.rs create mode 100644 docs/site/app/(no-sidebar)/tools/devtools-client.tsx create mode 100644 docs/site/app/(no-sidebar)/tools/floating-edge-utils.ts create mode 100644 docs/site/app/(no-sidebar)/tools/function-icon.tsx create mode 100644 docs/site/app/(no-sidebar)/tools/layout.tsx create mode 100644 docs/site/app/(no-sidebar)/tools/page.tsx create mode 100644 docs/site/app/(no-sidebar)/tools/turbo-edge.tsx create mode 100644 docs/site/app/(no-sidebar)/tools/turbo-flow.css create mode 100644 docs/site/app/(no-sidebar)/tools/turbo-node.tsx diff --git a/Cargo.lock b/Cargo.lock index 859943caa2d27..efe0a5e22d7fd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6768,6 +6768,30 @@ dependencies = [ "tracing", ] +[[package]] +name = "turborepo-devtools" +version = "0.1.0" +dependencies = [ + "axum 0.7.5", + "futures", + "ignore", + "notify", + "port_scanner", + "radix_trie", + "serde", + "serde_json", + "tempfile", + "thiserror 1.0.63", + "tokio", + "tower-http", + "tracing", + "turbopath", + "turborepo-filewatch", + "turborepo-repository", + "turborepo-scm", + "webbrowser", +] + [[package]] name = "turborepo-dirs" version = "0.1.0" @@ -6976,6 +7000,7 @@ dependencies = [ "turborepo-auth", "turborepo-cache", "turborepo-ci", + "turborepo-devtools", "turborepo-dirs", "turborepo-env", "turborepo-errors", diff --git a/crates/turborepo-devtools/Cargo.toml b/crates/turborepo-devtools/Cargo.toml new file mode 100644 index 0000000000000..53cf1bd56816a --- /dev/null +++ b/crates/turborepo-devtools/Cargo.toml @@ -0,0 +1,41 @@ +[package] +name = "turborepo-devtools" +version = "0.1.0" +edition = "2021" +license = "MIT" + +[lints] +workspace = true + +[dependencies] +# Async runtime +tokio = { workspace = true, features = ["full", "sync"] } +futures = { workspace = true } + +# Web server + WebSocket +axum = { workspace = true, features = ["ws"] } +tower-http = { version = "0.5.2", features = ["cors"] } + +# Serialization +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } + +# File watching +notify = { workspace = true } +radix_trie = { workspace = true } +ignore = "0.4.22" + +# Utilities +thiserror = { workspace = true } +tracing = { workspace = true } +port_scanner = { workspace = true } +webbrowser = { workspace = true } + +# Internal crates +turbopath = { workspace = true } +turborepo-filewatch = { path = "../turborepo-filewatch" } +turborepo-repository = { path = "../turborepo-repository" } +turborepo-scm = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } diff --git a/crates/turborepo-devtools/src/graph.rs b/crates/turborepo-devtools/src/graph.rs new file mode 100644 index 0000000000000..d870011eec2f3 --- /dev/null +++ b/crates/turborepo-devtools/src/graph.rs @@ -0,0 +1,163 @@ +//! Graph conversion utilities. +//! +//! Converts the internal PackageGraph (petgraph-based) to our +//! serializable PackageGraphData format for sending over WebSocket. + +use std::collections::HashSet; + +use turborepo_repository::package_graph::{ + PackageGraph, PackageName, PackageNode as RepoPackageNode, +}; + +use crate::types::{GraphEdge, PackageGraphData, PackageNode, TaskGraphData, TaskNode}; + +/// Identifier used for the root package in the graph +pub const ROOT_PACKAGE_ID: &str = "__ROOT__"; + +/// Converts a PackageGraph to our serializable PackageGraphData format. +pub fn package_graph_to_data(pkg_graph: &PackageGraph) -> PackageGraphData { + let mut nodes = Vec::new(); + let mut edges = Vec::new(); + + // Iterate over all packages + for (name, info) in pkg_graph.packages() { + let (id, display_name, is_root) = match name { + PackageName::Root => (ROOT_PACKAGE_ID.to_string(), "(root)".to_string(), true), + PackageName::Other(n) => (n.clone(), n.clone(), false), + }; + + // Get available scripts from package.json + let scripts: Vec = info.package_json.scripts.keys().cloned().collect(); + + // Get the package path (directory containing package.json) + let path = info.package_path().to_string(); + + nodes.push(PackageNode { + id: id.clone(), + name: display_name, + path, + scripts, + is_root, + }); + + // Get dependencies for this package and create edges + // Note: All packages (including root) are stored as Workspace nodes in the graph. + // PackageNode::Root is a separate synthetic node that all workspace packages depend on. + let pkg_node = RepoPackageNode::Workspace(name.clone()); + + if let Some(deps) = pkg_graph.immediate_dependencies(&pkg_node) { + for dep in deps { + // Skip the synthetic Root node - it's not a real package, just a graph anchor + if matches!(dep, RepoPackageNode::Root) { + continue; + } + + let dep_id = match dep { + RepoPackageNode::Root => unreachable!("filtered above"), + RepoPackageNode::Workspace(dep_name) => match dep_name { + PackageName::Root => ROOT_PACKAGE_ID.to_string(), + PackageName::Other(n) => n.clone(), + }, + }; + edges.push(GraphEdge { + source: id.clone(), + target: dep_id, + }); + } + } + } + + PackageGraphData { nodes, edges } +} + +/// Converts a PackageGraph to a task-level graph. +/// +/// Creates a node for each package#script combination found in the monorepo. +/// Edges are created based on package dependencies - if package A depends on +/// package B, then for common tasks (like "build"), A#task depends on B#task. +pub fn task_graph_to_data(pkg_graph: &PackageGraph) -> TaskGraphData { + let mut nodes = Vec::new(); + let mut edges = Vec::new(); + + // Common tasks that typically have cross-package dependencies + let common_tasks: HashSet<&str> = ["build", "test", "lint", "typecheck", "dev"] + .into_iter() + .collect(); + + // First pass: collect all tasks and create nodes + for (name, info) in pkg_graph.packages() { + let package_id = match name { + PackageName::Root => ROOT_PACKAGE_ID.to_string(), + PackageName::Other(n) => n.clone(), + }; + + for script in info.package_json.scripts.keys() { + let task_id = format!("{}#{}", package_id, script); + nodes.push(TaskNode { + id: task_id, + package: package_id.clone(), + task: script.clone(), + }); + } + } + + // Second pass: create edges based on package dependencies + // For common tasks, if package A depends on package B, then A#task -> B#task + for (name, info) in pkg_graph.packages() { + let package_id = match name { + PackageName::Root => ROOT_PACKAGE_ID.to_string(), + PackageName::Other(n) => n.clone(), + }; + + let pkg_node = RepoPackageNode::Workspace(name.clone()); + + if let Some(deps) = pkg_graph.immediate_dependencies(&pkg_node) { + for dep in deps { + // Skip the synthetic Root node + if matches!(dep, RepoPackageNode::Root) { + continue; + } + + let dep_id = match dep { + RepoPackageNode::Root => continue, + RepoPackageNode::Workspace(dep_name) => match dep_name { + PackageName::Root => ROOT_PACKAGE_ID.to_string(), + PackageName::Other(n) => n.clone(), + }, + }; + + // Get scripts from the dependency package + let dep_info = match dep { + RepoPackageNode::Root => continue, + RepoPackageNode::Workspace(dep_name) => pkg_graph.package_info(dep_name), + }; + + if let Some(dep_info) = dep_info { + // For common tasks that exist in both packages, create edges + for script in info.package_json.scripts.keys() { + if common_tasks.contains(script.as_str()) + && dep_info.package_json.scripts.contains_key(script) + { + edges.push(GraphEdge { + source: format!("{}#{}", package_id, script), + target: format!("{}#{}", dep_id, script), + }); + } + } + } + } + } + } + + TaskGraphData { nodes, edges } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_root_package_id() { + assert_eq!(ROOT_PACKAGE_ID, "__ROOT__"); + } +} diff --git a/crates/turborepo-devtools/src/lib.rs b/crates/turborepo-devtools/src/lib.rs new file mode 100644 index 0000000000000..c75a49b2fc01d --- /dev/null +++ b/crates/turborepo-devtools/src/lib.rs @@ -0,0 +1,30 @@ +//! Turborepo Devtools +//! +//! A WebSocket-based devtools server that allows visualization of package +//! and task graphs in real-time. Changes to the repository are detected +//! via file watching and pushed to connected clients. + +#![deny(clippy::all)] + +mod graph; +mod server; +mod types; +mod watcher; + +pub use server::{DevtoolsServer, ServerError}; +pub use types::*; +pub use watcher::{DevtoolsWatcher, WatchError, WatchEvent}; + +/// Default port for the devtools WebSocket server +pub const DEFAULT_PORT: u16 = 9876; + +/// Find an available port, starting from the requested port. +/// If the requested port is in use, finds an open one. +pub fn find_available_port(requested: u16) -> u16 { + if port_scanner::scan_port(requested) { + // Port is in use, find another + port_scanner::request_open_port().unwrap_or(requested + 1) + } else { + requested + } +} diff --git a/crates/turborepo-devtools/src/server.rs b/crates/turborepo-devtools/src/server.rs new file mode 100644 index 0000000000000..2e56df6adecb8 --- /dev/null +++ b/crates/turborepo-devtools/src/server.rs @@ -0,0 +1,259 @@ +//! WebSocket server for devtools. +//! +//! Provides a WebSocket endpoint that clients can connect to receive +//! real-time graph updates as the repository changes. + +use std::sync::Arc; + +use axum::{ + extract::{ + ws::{Message, WebSocket}, + State, WebSocketUpgrade, + }, + http::Method, + response::IntoResponse, + routing::get, + Router, +}; +use futures::{SinkExt, StreamExt}; +use thiserror::Error; +use tokio::{ + net::TcpListener, + sync::{broadcast, RwLock}, +}; +use tower_http::cors::{Any, CorsLayer}; +use tracing::{debug, error, info, warn}; +use turbopath::AbsoluteSystemPathBuf; +use turborepo_repository::{package_graph::PackageGraphBuilder, package_json::PackageJson}; + +use crate::{ + graph::{package_graph_to_data, task_graph_to_data}, + types::{GraphState, ServerMessage}, + watcher::{DevtoolsWatcher, WatchEvent}, +}; + +/// Errors that can occur in the devtools server +#[derive(Debug, Error)] +pub enum ServerError { + #[error("Failed to bind to port {port}: {source}")] + Bind { + port: u16, + #[source] + source: std::io::Error, + }, + + #[error("Server error: {0}")] + Server(#[from] std::io::Error), + + #[error("Failed to build package graph: {0}")] + PackageGraph(String), + + #[error("Failed to load package.json: {0}")] + PackageJson(String), + + #[error("File watcher error: {0}")] + Watcher(#[from] crate::watcher::WatchError), +} + +/// Shared state for the WebSocket server +#[derive(Clone)] +struct AppState { + /// Current graph state + graph_state: Arc>, + /// Channel to notify clients of updates + update_tx: broadcast::Sender<()>, +} + +/// The devtools WebSocket server +pub struct DevtoolsServer { + repo_root: AbsoluteSystemPathBuf, + port: u16, +} + +impl DevtoolsServer { + /// Creates a new devtools server for the given repository + pub fn new(repo_root: AbsoluteSystemPathBuf, port: u16) -> Self { + Self { repo_root, port } + } + + /// Returns the port the server will listen on + pub fn port(&self) -> u16 { + self.port + } + + /// Run the server until shutdown + pub async fn run(self) -> Result<(), ServerError> { + // Build initial graph state + let initial_state = build_graph_state(&self.repo_root).await?; + let graph_state = Arc::new(RwLock::new(initial_state)); + let (update_tx, _) = broadcast::channel::<()>(16); + + // Start file watcher + let watcher = DevtoolsWatcher::new(self.repo_root.clone())?; + let mut watch_rx = watcher.subscribe(); + + // Spawn task to handle file changes and rebuild graph + let graph_state_clone = graph_state.clone(); + let update_tx_clone = update_tx.clone(); + let repo_root_clone = self.repo_root.clone(); + tokio::spawn(async move { + while let Ok(event) = watch_rx.recv().await { + match event { + WatchEvent::FilesChanged => { + info!("Files changed, rebuilding graph..."); + match build_graph_state(&repo_root_clone).await { + Ok(new_state) => { + *graph_state_clone.write().await = new_state; + // Notify all connected clients + let _ = update_tx_clone.send(()); + info!("Graph rebuilt successfully"); + } + Err(e) => { + warn!("Failed to rebuild graph: {}", e); + } + } + } + } + } + debug!("File watcher task ended"); + }); + + // Set up CORS + let cors = CorsLayer::new() + .allow_methods([Method::GET]) + .allow_headers(Any) + .allow_origin(Any); + + // Create app state + let app_state = AppState { + graph_state, + update_tx, + }; + + // Build router + let app = Router::new() + .route("/", get(ws_handler)) + .layer(cors) + .with_state(app_state); + + // Bind and serve + let addr = format!("127.0.0.1:{}", self.port); + let listener = TcpListener::bind(&addr).await.map_err(|e| ServerError::Bind { + port: self.port, + source: e, + })?; + + info!("Devtools server listening on ws://{}", addr); + + axum::serve(listener, app).await?; + + Ok(()) + } +} + +/// WebSocket upgrade handler +async fn ws_handler(ws: WebSocketUpgrade, State(state): State) -> impl IntoResponse { + ws.on_upgrade(|socket| handle_socket(socket, state)) +} + +/// Handle a WebSocket connection +async fn handle_socket(socket: WebSocket, state: AppState) { + let (mut sender, mut receiver) = socket.split(); + + // Send initial state + let init_state = state.graph_state.read().await.clone(); + let init_msg = ServerMessage::Init { data: init_state }; + + if let Err(e) = sender + .send(Message::Text(serde_json::to_string(&init_msg).unwrap())) + .await + { + error!("Failed to send initial state: {}", e); + return; + } + + debug!("Client connected, sent initial state"); + + // Subscribe to updates + let mut update_rx = state.update_tx.subscribe(); + + loop { + tokio::select! { + // Handle incoming messages from client + msg = receiver.next() => { + match msg { + Some(Ok(Message::Text(_text))) => { + // Currently we only expect pong messages, which we can ignore + // Future: handle RequestTaskGraph here + } + Some(Ok(Message::Close(_))) => { + debug!("Client disconnected"); + break; + } + Some(Ok(Message::Ping(data))) => { + if sender.send(Message::Pong(data)).await.is_err() { + break; + } + } + Some(Err(e)) => { + warn!("WebSocket error: {}", e); + break; + } + None => { + debug!("Client connection closed"); + break; + } + _ => {} + } + } + + // Handle graph updates + result = update_rx.recv() => { + if result.is_err() { + // Channel closed + break; + } + + let new_state = state.graph_state.read().await.clone(); + let update_msg = ServerMessage::Update { data: new_state }; + + if let Err(e) = sender + .send(Message::Text(serde_json::to_string(&update_msg).unwrap())) + .await + { + warn!("Failed to send update: {}", e); + break; + } + + debug!("Sent graph update to client"); + } + } + } +} + +/// Build the current graph state from the repository +async fn build_graph_state(repo_root: &AbsoluteSystemPathBuf) -> Result { + // Load root package.json + let root_package_json_path = repo_root.join_component("package.json"); + let root_package_json = PackageJson::load(&root_package_json_path) + .map_err(|e| ServerError::PackageJson(e.to_string()))?; + + // Build package graph using local discovery (no daemon) + // We use allow_no_package_manager to be more permissive about package manager detection + let pkg_graph = PackageGraphBuilder::new(repo_root, root_package_json) + .with_allow_no_package_manager(true) + .build() + .await + .map_err(|e| ServerError::PackageGraph(e.to_string()))?; + + // Convert to our serializable formats + let package_graph = package_graph_to_data(&pkg_graph); + let task_graph = task_graph_to_data(&pkg_graph); + + Ok(GraphState { + package_graph, + task_graph, + repo_root: repo_root.to_string(), + turbo_version: env!("CARGO_PKG_VERSION").to_string(), + }) +} diff --git a/crates/turborepo-devtools/src/types.rs b/crates/turborepo-devtools/src/types.rs new file mode 100644 index 0000000000000..9ca6fee340083 --- /dev/null +++ b/crates/turborepo-devtools/src/types.rs @@ -0,0 +1,100 @@ +//! Serializable types for the devtools WebSocket protocol. +//! +//! These types define the messages exchanged between the CLI server +//! and the web client, as well as the graph data structures. + +use serde::{Deserialize, Serialize}; + +/// Messages sent from CLI server to web client +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum ServerMessage { + /// Initial state on connection + Init { data: GraphState }, + /// Updated state after file changes + Update { data: GraphState }, + /// Keep-alive ping + Ping, + /// Error message + Error { message: String }, +} + +/// Messages sent from web client to CLI server +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum ClientMessage { + /// Pong response to ping + Pong, +} + +/// Full graph state sent to clients +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GraphState { + /// The package dependency graph + pub package_graph: PackageGraphData, + /// The task dependency graph (tasks and their dependencies based on turbo.json) + pub task_graph: TaskGraphData, + /// Absolute path to the repository root + pub repo_root: String, + /// Version of turbo running the devtools + pub turbo_version: String, +} + +/// Package dependency graph in a serializable format +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PackageGraphData { + /// All packages in the monorepo + pub nodes: Vec, + /// Dependency edges between packages + pub edges: Vec, +} + +/// A package in the dependency graph +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PackageNode { + /// Unique identifier (package name, or "__ROOT__" for root) + pub id: String, + /// Display name + pub name: String, + /// Path relative to repo root + pub path: String, + /// Available npm scripts + pub scripts: Vec, + /// Is this the root package? + pub is_root: bool, +} + +/// An edge in the graph representing a dependency relationship +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GraphEdge { + /// Source node ID (the dependent package) + pub source: String, + /// Target node ID (the dependency) + pub target: String, +} + +/// Task dependency graph in a serializable format +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TaskGraphData { + /// All task nodes in the graph + pub nodes: Vec, + /// Dependency edges between tasks + pub edges: Vec, +} + +/// A task node in the task graph +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TaskNode { + /// Unique identifier (package#task format) + pub id: String, + /// Package name this task belongs to + pub package: String, + /// Task name (e.g., "build", "test") + pub task: String, +} diff --git a/crates/turborepo-devtools/src/watcher.rs b/crates/turborepo-devtools/src/watcher.rs new file mode 100644 index 0000000000000..63b2bcf193abb --- /dev/null +++ b/crates/turborepo-devtools/src/watcher.rs @@ -0,0 +1,208 @@ +//! File watching for devtools. +//! +//! Watches the repository for changes to relevant files (package.json, +//! turbo.json, etc.) and emits events when changes are detected. + +use std::path::Path; +use std::time::Duration; + +use notify::Event; +use thiserror::Error; +use tokio::sync::{broadcast, oneshot}; +use tracing::{debug, trace, warn}; +use turbopath::AbsoluteSystemPathBuf; +use turborepo_filewatch::{FileSystemWatcher, NotifyError, OptionalWatch}; + +/// Errors that can occur during file watching +#[derive(Debug, Error)] +pub enum WatchError { + #[error("Failed to initialize file watcher: {0}")] + FileWatcher(#[from] turborepo_filewatch::WatchError), + #[error("File watching stopped unexpectedly")] + WatchingStopped, +} + +/// Events emitted by the devtools watcher +#[derive(Clone, Debug)] +pub enum WatchEvent { + /// Files changed that require a graph rebuild + FilesChanged, +} + +/// File names that trigger a rebuild when changed +const RELEVANT_FILES: &[&str] = &[ + "package.json", + "turbo.json", + "turbo.jsonc", + "pnpm-workspace.yaml", + "pnpm-workspace.yml", + "package-lock.json", + "yarn.lock", + "pnpm-lock.yaml", + "bun.lockb", +]; + +/// Directories to ignore entirely +const IGNORED_DIRS: &[&str] = &[".git", "node_modules", ".turbo", ".next", "dist", "build"]; + +/// Watches for file changes in the repository and emits events +pub struct DevtoolsWatcher { + _exit_tx: oneshot::Sender<()>, + event_rx: broadcast::Receiver, + // Keep the file watcher alive for the lifetime of the DevtoolsWatcher + _file_watcher: FileSystemWatcher, +} + +impl DevtoolsWatcher { + /// Creates a new devtools watcher for the given repository root. + pub fn new(repo_root: AbsoluteSystemPathBuf) -> Result { + // Create file system watcher + let file_watcher = FileSystemWatcher::new_with_default_cookie_dir(&repo_root)?; + + // Set up channels + let (exit_tx, exit_rx) = oneshot::channel(); + let (event_tx, event_rx) = broadcast::channel(16); + + // Spawn watcher task + tokio::spawn(watch_loop( + repo_root, + file_watcher.watch(), + event_tx, + exit_rx, + )); + + Ok(Self { + _exit_tx: exit_tx, + event_rx, + _file_watcher: file_watcher, + }) + } + + /// Subscribe to watch events + pub fn subscribe(&self) -> broadcast::Receiver { + self.event_rx.resubscribe() + } +} + +/// Check if a path is in an ignored directory +fn is_in_ignored_dir(path: &Path) -> bool { + path.components().any(|c| { + c.as_os_str() + .to_str() + .map(|s| IGNORED_DIRS.contains(&s)) + .unwrap_or(false) + }) +} + +/// Check if a file is relevant for triggering a rebuild +fn is_relevant_file(path: &Path) -> bool { + path.file_name() + .and_then(|n| n.to_str()) + .map(|name| RELEVANT_FILES.contains(&name)) + .unwrap_or(false) +} + +/// Main watch loop that processes file events +async fn watch_loop( + _repo_root: AbsoluteSystemPathBuf, + mut file_events_lazy: OptionalWatch>>, + event_tx: broadcast::Sender, + exit_rx: oneshot::Receiver<()>, +) { + // Get the receiver and immediately resubscribe to drop the SomeRef + // (which is not Send) before entering the select loop + let Ok(mut file_events) = file_events_lazy.get().await.map(|r| r.resubscribe()) else { + warn!("File watching not available"); + return; + }; + let mut pending_rebuild = false; + let mut debounce_interval = tokio::time::interval(Duration::from_millis(100)); + debounce_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + + tokio::pin!(exit_rx); + + loop { + tokio::select! { + biased; + + // Exit signal received + _ = &mut exit_rx => { + debug!("Devtools watcher shutting down"); + break; + } + + // Debounce tick - send event if pending + _ = debounce_interval.tick() => { + if pending_rebuild { + pending_rebuild = false; + debug!("Sending FilesChanged event"); + let _ = event_tx.send(WatchEvent::FilesChanged); + } + } + + // File event received + result = file_events.recv() => { + match result { + Ok(Ok(event)) => { + // Check if any of the changed files are relevant + let has_relevant_change = event.paths.iter().any(|path| { + // Skip ignored directories + if is_in_ignored_dir(path) { + return false; + } + + // Check if it's a relevant file + if is_relevant_file(path) { + trace!("Relevant file changed: {:?}", path); + return true; + } + + false + }); + + if has_relevant_change { + pending_rebuild = true; + } + } + Ok(Err(e)) => { + warn!("File watch error: {:?}", e); + } + Err(broadcast::error::RecvError::Lagged(n)) => { + warn!("File watcher lagged by {} events, triggering rebuild", n); + pending_rebuild = true; + } + Err(broadcast::error::RecvError::Closed) => { + debug!("File event channel closed"); + break; + } + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + #[test] + fn test_is_relevant_file() { + assert!(is_relevant_file(Path::new("package.json"))); + assert!(is_relevant_file(Path::new("/some/path/package.json"))); + assert!(is_relevant_file(Path::new("turbo.json"))); + assert!(is_relevant_file(Path::new("turbo.jsonc"))); + assert!(is_relevant_file(Path::new("pnpm-workspace.yaml"))); + assert!(!is_relevant_file(Path::new("index.ts"))); + assert!(!is_relevant_file(Path::new("README.md"))); + } + + #[test] + fn test_is_in_ignored_dir() { + assert!(is_in_ignored_dir(Path::new(".git/config"))); + assert!(is_in_ignored_dir(Path::new("node_modules/foo/package.json"))); + assert!(is_in_ignored_dir(Path::new("/repo/.turbo/cache"))); + assert!(!is_in_ignored_dir(Path::new("/repo/packages/app/package.json"))); + assert!(!is_in_ignored_dir(Path::new("turbo.json"))); + } +} diff --git a/crates/turborepo-lib/Cargo.toml b/crates/turborepo-lib/Cargo.toml index 41dd0af80da88..d0cdc68e954bd 100644 --- a/crates/turborepo-lib/Cargo.toml +++ b/crates/turborepo-lib/Cargo.toml @@ -127,6 +127,7 @@ turborepo-api-client = { workspace = true } turborepo-auth = { path = "../turborepo-auth" } turborepo-cache = { workspace = true } turborepo-ci = { workspace = true } +turborepo-devtools = { path = "../turborepo-devtools" } turborepo-dirs = { path = "../turborepo-dirs" } turborepo-env = { workspace = true } turborepo-errors = { workspace = true } diff --git a/crates/turborepo-lib/src/cli/error.rs b/crates/turborepo-lib/src/cli/error.rs index 1241d09168062..21a786d0599a2 100644 --- a/crates/turborepo-lib/src/cli/error.rs +++ b/crates/turborepo-lib/src/cli/error.rs @@ -72,6 +72,8 @@ pub enum Error { #[error(transparent)] #[diagnostic(transparent)] Watch(#[from] watch::Error), + #[error("Devtools error: {0}")] + Devtools(Box), #[error(transparent)] Opts(#[from] crate::opts::Error), #[error(transparent)] diff --git a/crates/turborepo-lib/src/cli/mod.rs b/crates/turborepo-lib/src/cli/mod.rs index ff2e963e7cb86..52319f9d4c62e 100644 --- a/crates/turborepo-lib/src/cli/mod.rs +++ b/crates/turborepo-lib/src/cli/mod.rs @@ -610,6 +610,15 @@ pub enum Command { #[clap(subcommand)] command: Option, }, + /// Visualize your monorepo's package graph in the browser + Devtools { + /// Port for the WebSocket server + #[clap(long, default_value_t = turborepo_devtools::DEFAULT_PORT)] + port: u16, + /// Don't automatically open the browser + #[clap(long)] + no_open: bool, + }, /// Generate a new app / package #[clap(aliases = ["g", "gen"])] Generate { @@ -1449,6 +1458,13 @@ pub async fn run( Ok(0) } + Command::Devtools { port, no_open } => { + let event = CommandEventBuilder::new("devtools").with_parent(&root_telemetry); + event.track_call(); + + crate::commands::devtools::run(repo_root, *port, *no_open).await?; + Ok(0) + } Command::Generate { tag, generator_name, diff --git a/crates/turborepo-lib/src/commands/devtools.rs b/crates/turborepo-lib/src/commands/devtools.rs new file mode 100644 index 0000000000000..3b168caa6fff8 --- /dev/null +++ b/crates/turborepo-lib/src/commands/devtools.rs @@ -0,0 +1,49 @@ +//! `turbo devtools` command implementation. +//! +//! Starts a WebSocket server that serves package graph data +//! and watches for file changes to push updates. + +use turbopath::AbsoluteSystemPathBuf; +use turborepo_devtools::{find_available_port, DevtoolsServer}; + +use crate::cli; + +// In production, use the hosted devtools UI +// For local development, set TURBO_DEVTOOLS_LOCAL=1 to use localhost:3000 +const DEVTOOLS_URL: &str = if cfg!(debug_assertions) { + "http://localhost:3000/tools" +} else { + "https://turbo.build/tools" +}; + +/// Run the devtools server. +pub async fn run(repo_root: AbsoluteSystemPathBuf, port: u16, no_open: bool) -> Result<(), cli::Error> { + // Find available port + let port = find_available_port(port); + + // Create server + let server = DevtoolsServer::new(repo_root, port); + + let url = format!("{}?port={}", DEVTOOLS_URL, port); + + println!(); + println!(" Turbo Devtools"); + println!(" ──────────────────────────────────────"); + println!(" WebSocket: ws://localhost:{}", port); + println!(" Browser: {}", url); + println!(); + println!(" Press Ctrl+C to stop"); + println!(); + + // Open browser + if !no_open { + if let Err(e) = webbrowser::open(&url) { + eprintln!(" Warning: Could not open browser: {}", e); + } + } + + // Run server + server.run().await.map_err(|e| cli::Error::Devtools(Box::new(e)))?; + + Ok(()) +} diff --git a/crates/turborepo-lib/src/commands/mod.rs b/crates/turborepo-lib/src/commands/mod.rs index 534bda2cb7ee0..60224aafae21c 100644 --- a/crates/turborepo-lib/src/commands/mod.rs +++ b/crates/turborepo-lib/src/commands/mod.rs @@ -21,6 +21,7 @@ pub(crate) mod boundaries; pub(crate) mod clone; pub(crate) mod config; pub(crate) mod daemon; +pub(crate) mod devtools; pub(crate) mod generate; pub(crate) mod get_mfe_port; pub(crate) mod info; diff --git a/docs/site/app/(no-sidebar)/tools/devtools-client.tsx b/docs/site/app/(no-sidebar)/tools/devtools-client.tsx new file mode 100644 index 0000000000000..9ed2461c87dcc --- /dev/null +++ b/docs/site/app/(no-sidebar)/tools/devtools-client.tsx @@ -0,0 +1,1050 @@ +"use client"; + +import { useSearchParams } from "next/navigation"; +import { + useEffect, + useState, + useRef, + useCallback, + Suspense, + useMemo, +} from "react"; +import { + ReactFlow, + ReactFlowProvider, + Controls, + useNodesState, + useEdgesState, + useReactFlow, + type Node, + type Edge, + type NodeMouseHandler, +} from "reactflow"; +import ELK from "elkjs/lib/elk.bundled.js"; +import { Package } from "lucide-react"; + +import "reactflow/dist/base.css"; +import "./turbo-flow.css"; + +import TurboNode, { type TurboNodeData } from "./turbo-node"; +import TurboEdge from "./turbo-edge"; +import FunctionIcon from "./function-icon"; + +// Types matching Rust server +interface PackageNode { + id: string; + name: string; + path: string; + scripts: Array; + isRoot: boolean; +} + +interface TaskNode { + id: string; + package: string; + task: string; +} + +interface GraphEdge { + source: string; + target: string; +} + +interface PackageGraphData { + nodes: Array; + edges: Array; +} + +interface TaskGraphData { + nodes: Array; + edges: Array; +} + +interface GraphState { + packageGraph: PackageGraphData; + taskGraph: TaskGraphData; + repoRoot: string; + turboVersion: string; +} + +interface ServerMessage { + type: "init" | "update" | "ping" | "error"; + data?: GraphState; + message?: string; +} + +type GraphView = "packages" | "tasks"; + +// Selection mode: none -> direct (first click) -> affected (second click) -> none (third click) +type SelectionMode = "none" | "direct" | "affected"; + +const elk = new ELK(); + +// Turbo node and edge types +const nodeTypes = { + turbo: TurboNode, +}; + +const edgeTypes = { + turbo: TurboEdge, +}; + +const defaultEdgeOptions = { + type: "turbo", + markerEnd: "edge-circle", +}; + +// Constants for node sizing +const NODE_HEIGHT = 70; +const NODE_PADDING_X = 60; // Padding for icon, margins, and handle areas +const MIN_NODE_WIDTH = 150; +const CHAR_WIDTH = 9.6; // Approximate character width for "Fira Mono" at 16px +const SUBTITLE_CHAR_WIDTH = 7.2; // Approximate character width at 12px +const NODE_SPACING = 50; // Consistent spacing between nodes + +// Calculate node width based on content +function calculateNodeWidth(data: TurboNodeData): number { + const titleWidth = data.title.length * CHAR_WIDTH; + const subtitleWidth = (data.subtitle?.length ?? 0) * SUBTITLE_CHAR_WIDTH; + const contentWidth = Math.max(titleWidth, subtitleWidth); + return Math.max(MIN_NODE_WIDTH, contentWidth + NODE_PADDING_X); +} + +// ELK layout function +async function getLayoutedElements( + nodes: Array>, + edges: Array +): Promise<{ nodes: Array; edges: Array }> { + if (nodes.length === 0) { + return { nodes: [], edges: [] }; + } + + // Calculate width for each node based on its content + const nodeWidths = new Map(); + for (const node of nodes) { + nodeWidths.set(node.id, calculateNodeWidth(node.data)); + } + + const graph = { + id: "root", + layoutOptions: { + "elk.algorithm": "layered", + "elk.direction": "DOWN", + "elk.spacing.nodeNode": String(NODE_SPACING), + "elk.layered.spacing.nodeNodeBetweenLayers": "150", + "elk.spacing.componentComponent": "150", + "elk.layered.spacing.edgeNodeBetweenLayers": "50", + "elk.layered.nodePlacement.strategy": "NETWORK_SIMPLEX", + }, + children: nodes.map((node) => ({ + id: node.id, + width: nodeWidths.get(node.id) ?? MIN_NODE_WIDTH, + height: NODE_HEIGHT, + })), + edges: edges.map((edge, i) => ({ + id: `e${i}`, + sources: [edge.source], + targets: [edge.target], + })), + }; + + const layoutedGraph = await elk.layout(graph); + + return { + nodes: nodes.map((node) => { + const layoutedNode = layoutedGraph.children?.find( + (n: { id: string; x?: number; y?: number }) => n.id === node.id + ); + return { + ...node, + position: { x: layoutedNode?.x ?? 0, y: layoutedNode?.y ?? 0 }, + }; + }), + edges, + }; +} + +// Get direct dependencies (nodes directly connected to the selected node) +function getDirectDependencies( + nodeId: string, + edges: Array +): Set { + const connected = new Set(); + connected.add(nodeId); + + for (const edge of edges) { + if (edge.source === nodeId) { + connected.add(edge.target); + } + if (edge.target === nodeId) { + connected.add(edge.source); + } + } + + return connected; +} + +// Get affected nodes (packages/tasks whose hash would change if the selected node changes) +// If package A changes, then all packages that depend on A (directly or transitively) are affected. +// In the edge model: edge.source depends on edge.target (arrow points from dependent to dependency) +// So we traverse "upstream" - following edges backwards from target to source +function getAffectedNodes( + nodeId: string, + edges: Array +): Set { + const affected = new Set(); + affected.add(nodeId); + + // Build an adjacency list for reverse traversal (dependency -> dependents) + const dependentsMap = new Map>(); + for (const edge of edges) { + // edge.source depends on edge.target + // So edge.target has edge.source as a dependent + const dependents = dependentsMap.get(edge.target) || []; + dependents.push(edge.source); + dependentsMap.set(edge.target, dependents); + } + + // BFS to find all transitively affected nodes + const queue = [nodeId]; + while (queue.length > 0) { + const current = queue.shift()!; + const dependents = dependentsMap.get(current) || []; + + for (const dependent of dependents) { + if (!affected.has(dependent)) { + affected.add(dependent); + queue.push(dependent); + } + } + } + + return affected; +} + +// Get edges that connect the visible nodes +function getConnectedEdges( + visibleNodes: Set, + edges: Array +): Set { + const connectedEdges = new Set(); + + edges.forEach((edge, i) => { + if (visibleNodes.has(edge.source) && visibleNodes.has(edge.target)) { + connectedEdges.add(`e${i}`); + } + }); + + return connectedEdges; +} + +function SetupInstructions() { + return ( +
+
+

+ Turbo Devtools +

+

+ Run the following command in your Turborepo to start the devtools + server: +

+
+          turbo devtools
+        
+

+ This will automatically open this page with the correct connection + parameters. +

+
+
+ ); +} + +function DisconnectedOverlay({ port }: { port: string }) { + return ( +
+
+

+ Disconnected +

+

+ The connection to turbo devtools was lost. Run the command below to + reconnect: +

+
+          turbo devtools --port {port}
+        
+
+
+ ); +} + +function ConnectionStatus({ isConnected }: { isConnected: boolean }) { + return ( +
+
+ + {isConnected ? "Connected" : "Disconnected"} + +
+ ); +} + +function GraphViewToggle({ + view, + onViewChange, +}: { + view: GraphView; + onViewChange: (view: GraphView) => void; +}) { + return ( +
+ + +
+ ); +} + +function SelectionIndicator({ + selectedNode, + selectionMode, + onClear, +}: { + selectedNode: string | null; + selectionMode: SelectionMode; + onClear: () => void; +}) { + if (!selectedNode || selectionMode === "none") return null; + + const getModeLabel = () => { + switch (selectionMode) { + case "direct": + return "Direct deps of"; + case "affected": + return "Affected by"; + default: + return ""; + } + }; + + return ( +
+ + {getModeLabel()} {selectedNode} + + +
+ ); +} + +function DevtoolsContent() { + const searchParams = useSearchParams(); + const port = searchParams.get("port"); + const { fitBounds, getNodes } = useReactFlow(); + + const [graphState, setGraphState] = useState(null); + const [isConnected, setIsConnected] = useState(false); + const [error, setError] = useState(null); + const [view, setView] = useState("packages"); + const [selectedNode, setSelectedNode] = useState(null); + const [selectionMode, setSelectionMode] = useState("none"); + const [showDisconnected, setShowDisconnected] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + const wsRef = useRef(null); + + // Store the base (unlayouted) nodes and edges for the current view + const [baseNodes, setBaseNodes] = useState>([]); + const [baseEdges, setBaseEdges] = useState>([]); + const [rawEdges, setRawEdges] = useState>([]); + + const [nodes, setNodes, onNodesChange] = useNodesState([]); + const [edges, setEdges, onEdgesChange] = useEdgesState([]); + + // Calculate which nodes/edges should be highlighted based on selection + const { highlightedNodes, highlightedEdges } = useMemo(() => { + if (!selectedNode || selectionMode === "none") { + return { highlightedNodes: null, highlightedEdges: null }; + } + + const visibleNodes = + selectionMode === "direct" + ? getDirectDependencies(selectedNode, rawEdges) + : getAffectedNodes(selectedNode, rawEdges); + + const visibleEdges = getConnectedEdges(visibleNodes, rawEdges); + + return { highlightedNodes: visibleNodes, highlightedEdges: visibleEdges }; + }, [selectedNode, selectionMode, rawEdges]); + + // Apply highlighting to nodes and edges + useEffect(() => { + if (baseNodes.length === 0) return; + + const updatedNodes = baseNodes.map((node) => { + const isHighlighted = !highlightedNodes || highlightedNodes.has(node.id); + const isSelected = node.id === selectedNode; + + return { + ...node, + selected: isSelected, + style: { + ...node.style, + opacity: isHighlighted ? 1 : 0.2, + }, + }; + }); + + const updatedEdges = baseEdges.map((edge) => { + const isHighlighted = !highlightedEdges || highlightedEdges.has(edge.id); + + return { + ...edge, + style: { + ...edge.style, + opacity: isHighlighted ? 1 : 0.1, + }, + }; + }); + + setNodes(updatedNodes); + setEdges(updatedEdges); + }, [ + baseNodes, + baseEdges, + highlightedNodes, + highlightedEdges, + selectedNode, + setNodes, + setEdges, + ]); + + // Handle node click + const handleNodeClick: NodeMouseHandler = useCallback( + (_, node) => { + if (selectedNode === node.id) { + // Clicking the same node - cycle through modes: direct -> affected -> none + if (selectionMode === "direct") { + setSelectionMode("affected"); + } else if (selectionMode === "affected") { + setSelectionMode("none"); + setSelectedNode(null); + } + } else { + // Clicking a different node - start with direct dependencies + setSelectedNode(node.id); + setSelectionMode("direct"); + } + }, + [selectedNode, selectionMode] + ); + + // Clear selection + const clearSelection = useCallback(() => { + setSelectedNode(null); + setSelectionMode("none"); + }, []); + + // Handle clicking on the background to clear selection + const handlePaneClick = useCallback(() => { + clearSelection(); + }, [clearSelection]); + + // Get set of node IDs that have at least one edge connection + const getConnectedNodeIds = useCallback((edges: Array) => { + const connected = new Set(); + for (const edge of edges) { + connected.add(edge.source); + connected.add(edge.target); + } + return connected; + }, []); + + // Convert package graph to React Flow elements + const updatePackageGraphElements = useCallback( + async (state: GraphState) => { + // Filter to only nodes that have connections + const connectedIds = getConnectedNodeIds(state.packageGraph.edges); + const connectedPackages = state.packageGraph.nodes.filter((pkg) => + connectedIds.has(pkg.id) + ); + + const flowNodes: Array> = connectedPackages.map( + (pkg) => ({ + id: pkg.id, + type: "turbo", + data: { + icon: , + title: pkg.name, + subtitle: pkg.path || ".", + }, + position: { x: 0, y: 0 }, + }) + ); + + const flowEdges: Array = state.packageGraph.edges.map( + (edge, i) => ({ + id: `e${i}`, + source: edge.source, + target: edge.target, + type: "turbo", + markerEnd: "edge-circle", + }) + ); + + const { nodes: layoutedNodes, edges: layoutedEdges } = + await getLayoutedElements(flowNodes, flowEdges); + + setBaseNodes(layoutedNodes); + setBaseEdges(layoutedEdges); + setRawEdges(state.packageGraph.edges); + setNodes(layoutedNodes); + setEdges(layoutedEdges); + }, + [setNodes, setEdges, getConnectedNodeIds] + ); + + // Convert task graph to React Flow elements + const updateTaskGraphElements = useCallback( + async (state: GraphState) => { + // Filter to only nodes that have connections + const connectedIds = getConnectedNodeIds(state.taskGraph.edges); + const connectedTasks = state.taskGraph.nodes.filter((task) => + connectedIds.has(task.id) + ); + + const flowNodes: Array> = connectedTasks.map( + (task) => ({ + id: task.id, + type: "turbo", + data: { + icon: , + title: task.task, + subtitle: task.package, + }, + position: { x: 0, y: 0 }, + }) + ); + + const flowEdges: Array = state.taskGraph.edges.map((edge, i) => ({ + id: `e${i}`, + source: edge.source, + target: edge.target, + type: "turbo", + markerEnd: "edge-circle", + })); + + const { nodes: layoutedNodes, edges: layoutedEdges } = + await getLayoutedElements(flowNodes, flowEdges); + + setBaseNodes(layoutedNodes); + setBaseEdges(layoutedEdges); + setRawEdges(state.taskGraph.edges); + setNodes(layoutedNodes); + setEdges(layoutedEdges); + }, + [setNodes, setEdges, getConnectedNodeIds] + ); + + // Update flow elements when view or graph state changes + const updateFlowElements = useCallback( + async (state: GraphState, currentView: GraphView) => { + // Clear selection when switching views or updating + clearSelection(); + + if (currentView === "packages") { + await updatePackageGraphElements(state); + } else { + await updateTaskGraphElements(state); + } + }, + [updatePackageGraphElements, updateTaskGraphElements, clearSelection] + ); + + // Handle view change + const handleViewChange = useCallback( + async (newView: GraphView) => { + setView(newView); + if (graphState) { + await updateFlowElements(graphState, newView); + } + }, + [graphState, updateFlowElements] + ); + + // Store latest graphState in a ref for WebSocket handler + const graphStateRef = useRef(null); + useEffect(() => { + graphStateRef.current = graphState; + }, [graphState]); + + // WebSocket connection - only reconnect when port changes + useEffect(() => { + if (!port) return; + + const connect = () => { + const ws = new WebSocket(`ws://localhost:${port}`); + wsRef.current = ws; + + ws.onopen = () => { + setIsConnected(true); + setError(null); + }; + + ws.onmessage = (event) => { + try { + const message: ServerMessage = JSON.parse(event.data); + switch (message.type) { + case "init": + case "update": + if (message.data) { + setGraphState(message.data); + } + break; + case "ping": + ws.send(JSON.stringify({ type: "pong" })); + break; + case "error": + setError(message.message ?? "Unknown error"); + break; + } + } catch (e) { + console.error("Failed to parse message:", e); + } + }; + + ws.onclose = () => { + setIsConnected(false); + wsRef.current = null; + }; + + ws.onerror = () => { + setError("Connection failed"); + setIsConnected(false); + }; + }; + + connect(); + + return () => { + if (wsRef.current) { + wsRef.current.close(); + wsRef.current = null; + } + }; + }, [port]); + + // Update flow elements when graphState or view changes + useEffect(() => { + if (graphState) { + updateFlowElements(graphState, view); + } + }, [graphState, view, updateFlowElements]); + + const nodeLabel = view === "packages" ? "packages" : "tasks"; + + // Get nodes that have at least one connection (edge) + const connectedNodeIds = useMemo(() => { + const connected = new Set(); + for (const edge of rawEdges) { + connected.add(edge.source); + connected.add(edge.target); + } + return connected; + }, [rawEdges]); + + // Get the list of nodes for the sidebar, split into connected and disconnected + const { connectedNodes, disconnectedNodes } = useMemo(() => { + if (!graphState) return { connectedNodes: [], disconnectedNodes: [] }; + + const allNodes = + view === "packages" + ? graphState.packageGraph.nodes.map((pkg) => ({ + id: pkg.id, + name: pkg.name, + subtitle: pkg.path, + })) + : graphState.taskGraph.nodes.map((task) => ({ + id: task.id, + name: task.task, + subtitle: task.package, + })); + + const connected: typeof allNodes = []; + const disconnected: typeof allNodes = []; + + for (const node of allNodes) { + if (connectedNodeIds.has(node.id)) { + connected.push(node); + } else { + disconnected.push(node); + } + } + + // Sort both lists + const sortFn = (a: (typeof allNodes)[0], b: (typeof allNodes)[0]) => + a.name.localeCompare(b.name) || a.subtitle.localeCompare(b.subtitle); + + return { + connectedNodes: connected.sort(sortFn), + disconnectedNodes: disconnected.sort(sortFn), + }; + }, [graphState, view, connectedNodeIds]); + + // Filter nodes based on search query + const filteredConnectedNodes = useMemo(() => { + if (!searchQuery.trim()) return connectedNodes; + const query = searchQuery.toLowerCase(); + return connectedNodes.filter( + (node) => + node.name.toLowerCase().includes(query) || + node.subtitle.toLowerCase().includes(query) + ); + }, [connectedNodes, searchQuery]); + + const filteredDisconnectedNodes = useMemo(() => { + if (!searchQuery.trim()) return disconnectedNodes; + const query = searchQuery.toLowerCase(); + return disconnectedNodes.filter( + (node) => + node.name.toLowerCase().includes(query) || + node.subtitle.toLowerCase().includes(query) + ); + }, [disconnectedNodes, searchQuery]); + + const nodeCount = connectedNodes.length + disconnectedNodes.length; + + // Focus the viewport on a set of nodes + const focusOnNodes = useCallback( + (nodeIds: Set) => { + const flowNodes = getNodes() as Array>; + const targetNodes = flowNodes.filter((n) => nodeIds.has(n.id)); + + if (targetNodes.length === 0) return; + + // Calculate bounding box of all target nodes + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + + for (const node of targetNodes) { + const nodeWidth = calculateNodeWidth(node.data); + minX = Math.min(minX, node.position.x); + minY = Math.min(minY, node.position.y); + maxX = Math.max(maxX, node.position.x + nodeWidth); + maxY = Math.max(maxY, node.position.y + NODE_HEIGHT); + } + + // Add padding + const padding = 50; + fitBounds( + { + x: minX - padding, + y: minY - padding, + width: maxX - minX + padding * 2, + height: maxY - minY + padding * 2, + }, + { duration: 0 } + ); + }, + [fitBounds, getNodes] + ); + + // Handle sidebar node click + const handleSidebarNodeClick = useCallback( + (nodeId: string) => { + if (selectedNode === nodeId) { + // Clicking the same node - cycle through modes + if (selectionMode === "direct") { + setSelectionMode("affected"); + // Focus on affected nodes + const affected = getAffectedNodes(nodeId, rawEdges); + focusOnNodes(affected); + } else if (selectionMode === "affected") { + setSelectionMode("none"); + setSelectedNode(null); + } + } else { + setSelectedNode(nodeId); + setSelectionMode("direct"); + // Focus on direct dependencies + const direct = getDirectDependencies(nodeId, rawEdges); + focusOnNodes(direct); + } + }, + [selectedNode, selectionMode, rawEdges, focusOnNodes] + ); + + // No port provided - show instructions + if (!port) { + return ; + } + + return ( +
+ {/* Disconnected overlay */} + {!isConnected && graphState && } + + {/* Error display */} + {error && ( +
+ Error: {error} +
+ )} + + {/* Sidebar */} + + + {/* Graph */} +
+ + + + + + + + + + + + + + + +
+
+ ); +} + +export function DevtoolsClientComponent() { + return ( + +
Loading...
+
+ } + > + + + + + ); +} diff --git a/docs/site/app/(no-sidebar)/tools/floating-edge-utils.ts b/docs/site/app/(no-sidebar)/tools/floating-edge-utils.ts new file mode 100644 index 0000000000000..0fa4f8e41f724 --- /dev/null +++ b/docs/site/app/(no-sidebar)/tools/floating-edge-utils.ts @@ -0,0 +1,118 @@ +import { Position } from "reactflow"; + +// Type for node structure from reactflow v11 nodeInternals +export interface InternalNodeType { + id: string; + width?: number; + height?: number; + positionAbsolute?: { + x: number; + y: number; + }; + position: { + x: number; + y: number; + }; +} + +// Get the center point of a node +function getNodeCenter(node: InternalNodeType) { + const width = node.width ?? 0; + const height = node.height ?? 0; + const pos = node.positionAbsolute ?? node.position; + const x = pos.x + width / 2; + const y = pos.y + height / 2; + + return { x, y }; +} + +// Get the intersection point of the line from center to target with the node border +function getNodeIntersection( + intersectionNode: InternalNodeType, + targetNode: InternalNodeType +) { + const width = intersectionNode.width ?? 1; + const height = intersectionNode.height ?? 1; + const nodeCenter = getNodeCenter(intersectionNode); + const targetCenter = getNodeCenter(targetNode); + + const w = width / 2; + const h = height / 2; + + const dx = targetCenter.x - nodeCenter.x; + const dy = targetCenter.y - nodeCenter.y; + + // Prevent division by zero + if (dx === 0 && dy === 0) { + return { x: nodeCenter.x, y: nodeCenter.y }; + } + + const slope = Math.abs(dy / dx); + const nodeSlope = h / w; + + let x: number; + let y: number; + + if (slope <= nodeSlope) { + // Intersects left or right edge + x = dx > 0 ? nodeCenter.x + w : nodeCenter.x - w; + y = nodeCenter.y + (dy * w) / Math.abs(dx); + } else { + // Intersects top or bottom edge + x = nodeCenter.x + (dx * h) / Math.abs(dy); + y = dy > 0 ? nodeCenter.y + h : nodeCenter.y - h; + } + + return { x, y }; +} + +// Get the position (TOP, RIGHT, BOTTOM, LEFT) based on intersection point +function getEdgePosition( + node: InternalNodeType, + intersectionPoint: { x: number; y: number } +): Position { + const width = node.width ?? 0; + const height = node.height ?? 0; + const pos = node.positionAbsolute ?? node.position; + const nx = pos.x; + const ny = pos.y; + + const px = Math.round(intersectionPoint.x); + const py = Math.round(intersectionPoint.y); + + if (px <= Math.round(nx + 1)) { + return Position.Left; + } + if (px >= Math.round(nx + width - 1)) { + return Position.Right; + } + if (py <= Math.round(ny + 1)) { + return Position.Top; + } + if (py >= Math.round(ny + height - 1)) { + return Position.Bottom; + } + + return Position.Top; +} + +// Get all params needed to draw an edge between two nodes +export function getEdgeParams( + source: InternalNodeType, + target: InternalNodeType +) { + const sourceIntersection = getNodeIntersection(source, target); + const targetIntersection = getNodeIntersection(target, source); + + const sourcePos = getEdgePosition(source, sourceIntersection); + const targetPos = getEdgePosition(target, targetIntersection); + + return { + sx: sourceIntersection.x, + sy: sourceIntersection.y, + tx: targetIntersection.x, + ty: targetIntersection.y, + sourcePos, + targetPos, + }; +} diff --git a/docs/site/app/(no-sidebar)/tools/function-icon.tsx b/docs/site/app/(no-sidebar)/tools/function-icon.tsx new file mode 100644 index 0000000000000..451d28984882b --- /dev/null +++ b/docs/site/app/(no-sidebar)/tools/function-icon.tsx @@ -0,0 +1,23 @@ +export default function FunctionIcon() { + return ( + + + + + + ); +} diff --git a/docs/site/app/(no-sidebar)/tools/layout.tsx b/docs/site/app/(no-sidebar)/tools/layout.tsx new file mode 100644 index 0000000000000..38ea821ebbc8d --- /dev/null +++ b/docs/site/app/(no-sidebar)/tools/layout.tsx @@ -0,0 +1,9 @@ +import type { ReactNode } from "react"; + +export default function ToolsLayout({ + children, +}: { + children: ReactNode; +}): JSX.Element { + return <>{children}; +} diff --git a/docs/site/app/(no-sidebar)/tools/page.tsx b/docs/site/app/(no-sidebar)/tools/page.tsx new file mode 100644 index 0000000000000..afabe8f2919c3 --- /dev/null +++ b/docs/site/app/(no-sidebar)/tools/page.tsx @@ -0,0 +1,11 @@ +import type { Metadata } from "next"; +import { DevtoolsClientComponent } from "./devtools-client"; + +export const metadata: Metadata = { + title: "Turbo Devtools", + description: "Visualize your Turborepo package and task graphs", +}; + +export default function ToolsPage() { + return ; +} diff --git a/docs/site/app/(no-sidebar)/tools/turbo-edge.tsx b/docs/site/app/(no-sidebar)/tools/turbo-edge.tsx new file mode 100644 index 0000000000000..e3f01b958356c --- /dev/null +++ b/docs/site/app/(no-sidebar)/tools/turbo-edge.tsx @@ -0,0 +1,41 @@ +import { getBezierPath, useStore, type EdgeProps } from "reactflow"; +import { getEdgeParams, type InternalNodeType } from "./floating-edge-utils"; + +export default function TurboEdge({ + id, + source, + target, + style = {}, + markerEnd, +}: EdgeProps) { + const sourceNode = useStore((store) => store.nodeInternals.get(source)); + const targetNode = useStore((store) => store.nodeInternals.get(target)); + + if (!sourceNode || !targetNode) { + return null; + } + + const { sx, sy, tx, ty, sourcePos, targetPos } = getEdgeParams( + sourceNode as unknown as InternalNodeType, + targetNode as unknown as InternalNodeType + ); + + const [edgePath] = getBezierPath({ + sourceX: sx, + sourceY: sy, + sourcePosition: sourcePos, + targetPosition: targetPos, + targetX: tx, + targetY: ty, + }); + + return ( + + ); +} diff --git a/docs/site/app/(no-sidebar)/tools/turbo-flow.css b/docs/site/app/(no-sidebar)/tools/turbo-flow.css new file mode 100644 index 0000000000000..a1d2b48fee8ad --- /dev/null +++ b/docs/site/app/(no-sidebar)/tools/turbo-flow.css @@ -0,0 +1,177 @@ +.turbo-flow.react-flow { + --bg-color: rgb(17, 17, 17); + --text-color: rgb(243, 244, 246); + --node-border-radius: 10px; + --node-box-shadow: 10px 0 15px rgba(42, 138, 246, 0.3), + -10px 0 15px rgba(233, 42, 103, 0.3); + background-color: var(--bg-color); + color: var(--text-color); +} + +.turbo-flow .react-flow__node-turbo { + border-radius: var(--node-border-radius); + display: flex; + height: 70px; + min-width: 150px; + font-family: "Fira Mono", Monospace; + font-weight: 500; + letter-spacing: -0.2px; + box-shadow: var(--node-box-shadow); +} + +.turbo-flow .react-flow__node-turbo .wrapper { + overflow: hidden; + display: flex; + padding: 2px; + position: relative; + border-radius: var(--node-border-radius); + flex-grow: 1; +} + +.turbo-flow .gradient:before { + content: ""; + position: absolute; + padding-bottom: calc(100% * 1.41421356237); + width: calc(100% * 1.41421356237); + background: conic-gradient( + from -160deg at 50% 50%, + #e92a67 0deg, + #a853ba 120deg, + #2a8af6 240deg, + #e92a67 360deg + ); + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + border-radius: 100%; +} + +.turbo-flow .react-flow__node-turbo.selected .wrapper.gradient:before { + content: ""; + background: conic-gradient( + from -160deg at 50% 50%, + #e92a67 0deg, + #a853ba 120deg, + #2a8af6 240deg, + rgba(42, 138, 246, 0) 360deg + ); + animation: spinner 4s linear infinite; + transform: translate(-50%, -50%) rotate(0deg); + z-index: -1; +} + +@keyframes spinner { + 100% { + transform: translate(-50%, -50%) rotate(-360deg); + } +} + +.turbo-flow .react-flow__node-turbo .inner { + background: var(--bg-color); + padding: 16px 20px; + border-radius: var(--node-border-radius); + display: flex; + flex-direction: column; + justify-content: center; + flex-grow: 1; + position: relative; +} + +.turbo-flow .react-flow__node-turbo .icon { + margin-right: 8px; +} + +.turbo-flow .react-flow__node-turbo .body { + display: flex; +} + +.turbo-flow .react-flow__node-turbo .title { + font-size: 16px; + margin-bottom: 2px; + line-height: 1; +} + +.turbo-flow .react-flow__node-turbo .subtitle { + font-size: 12px; + color: #777; +} + +.turbo-flow .react-flow__node-turbo .cloud { + border-radius: 100%; + width: 30px; + height: 30px; + right: 0; + position: absolute; + top: 0; + transform: translate(50%, -50%); + display: flex; + transform-origin: center center; + padding: 2px; + overflow: hidden; + box-shadow: var(--node-box-shadow); + z-index: 1; +} + +.turbo-flow .react-flow__node-turbo .cloud div { + background-color: var(--bg-color); + flex-grow: 1; + border-radius: 100%; + display: flex; + justify-content: center; + align-items: center; + position: relative; +} + +.turbo-flow .react-flow__handle { + opacity: 0; +} + +.turbo-flow .react-flow__handle.source { + right: -10px; +} + +.turbo-flow .react-flow__handle.target { + left: -10px; +} + +.turbo-flow .react-flow__node:focus { + outline: none; +} + +.turbo-flow .react-flow__edge .react-flow__edge-path { + stroke: url(#edge-gradient); + stroke-width: 2; + stroke-opacity: 0.75; +} + +.turbo-flow .react-flow__controls button { + background-color: var(--bg-color); + color: var(--text-color); + border: 1px solid #95679e; + border-bottom: none; +} + +.turbo-flow .react-flow__controls button:hover { + background-color: rgb(37, 37, 37); +} + +.turbo-flow .react-flow__controls button:first-child { + border-radius: 5px 5px 0 0; +} + +.turbo-flow .react-flow__controls button:last-child { + border-bottom: 1px solid #95679e; + border-radius: 0 0 5px 5px; +} + +.turbo-flow .react-flow__controls button path { + fill: var(--text-color); +} + +.turbo-flow .react-flow__attribution { + background: rgba(200, 200, 200, 0.2); +} + +.turbo-flow .react-flow__attribution a { + color: #95679e; +} diff --git a/docs/site/app/(no-sidebar)/tools/turbo-node.tsx b/docs/site/app/(no-sidebar)/tools/turbo-node.tsx new file mode 100644 index 0000000000000..56cf3cc798470 --- /dev/null +++ b/docs/site/app/(no-sidebar)/tools/turbo-node.tsx @@ -0,0 +1,28 @@ +import { memo, type ReactNode } from "react"; +import { Handle, Position, type NodeProps } from "reactflow"; + +export type TurboNodeData = { + title: string; + icon?: ReactNode; + subtitle?: string; +}; + +function TurboNode({ data }: NodeProps) { + return ( +
+
+
+ {data.icon &&
{data.icon}
} +
+
{data.title}
+ {data.subtitle &&
{data.subtitle}
} +
+
+ + +
+
+ ); +} + +export default memo(TurboNode); diff --git a/docs/site/package.json b/docs/site/package.json index ff2b1dfc9ad84..50079317b20aa 100644 --- a/docs/site/package.json +++ b/docs/site/package.json @@ -58,7 +58,9 @@ "shiki": "3.1.0", "swr": "2.2.6-beta.0", "tailwind-merge": "3.2.0", - "zod": "3.24.2" + "zod": "3.24.2", + "reactflow": "11.11.4", + "elkjs": "0.9.3" }, "devDependencies": { "@next/env": "15.4.0-canary.81", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7410a06cafa4e..ee75d9ac3000f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -126,6 +126,9 @@ importers: copy-to-clipboard: specifier: 3.3.3 version: 3.3.3 + elkjs: + specifier: 0.9.3 + version: 0.9.3 fast-glob: specifier: 3.3.3 version: 3.3.3 @@ -165,6 +168,9 @@ importers: react-dom: specifier: 19.0.0 version: 19.0.0(react@19.0.0) + reactflow: + specifier: 11.11.4 + version: 11.11.4(@types/react@18.3.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) remark: specifier: 15.0.1 version: 15.0.1 @@ -2951,6 +2957,42 @@ packages: react: ^16.8.0 || ^17.0.0 || ^18.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 + '@reactflow/background@11.3.14': + resolution: {integrity: sha512-Gewd7blEVT5Lh6jqrvOgd4G6Qk17eGKQfsDXgyRSqM+CTwDqRldG2LsWN4sNeno6sbqVIC2fZ+rAUBFA9ZEUDA==} + peerDependencies: + react: '>=17' + react-dom: '>=17' + + '@reactflow/controls@11.2.14': + resolution: {integrity: sha512-MiJp5VldFD7FrqaBNIrQ85dxChrG6ivuZ+dcFhPQUwOK3HfYgX2RHdBua+gx+40p5Vw5It3dVNp/my4Z3jF0dw==} + peerDependencies: + react: '>=17' + react-dom: '>=17' + + '@reactflow/core@11.11.4': + resolution: {integrity: sha512-H4vODklsjAq3AMq6Np4LE12i1I4Ta9PrDHuBR9GmL8uzTt2l2jh4CiQbEMpvMDcp7xi4be0hgXj+Ysodde/i7Q==} + peerDependencies: + react: '>=17' + react-dom: '>=17' + + '@reactflow/minimap@11.7.14': + resolution: {integrity: sha512-mpwLKKrEAofgFJdkhwR5UQ1JYWlcAAL/ZU/bctBkuNTT1yqV+y0buoNVImsRehVYhJwffSWeSHaBR5/GJjlCSQ==} + peerDependencies: + react: '>=17' + react-dom: '>=17' + + '@reactflow/node-resizer@2.2.14': + resolution: {integrity: sha512-fwqnks83jUlYr6OHcdFEedumWKChTHRGw/kbCxj0oqBd+ekfs+SIp4ddyNU0pdx96JIm5iNFS0oNrmEiJbbSaA==} + peerDependencies: + react: '>=17' + react-dom: '>=17' + + '@reactflow/node-toolbar@1.3.14': + resolution: {integrity: sha512-rbynXQnH/xFNu4P9H+hVqlEUafDCkEoCy0Dg9mG22Sg+rY/0ck6KkrAQrYrTgXusd+cEJOMK0uOOFCK2/5rSGQ==} + peerDependencies: + react: '>=17' + react-dom: '>=17' + '@rollup/plugin-commonjs@28.0.2': resolution: {integrity: sha512-BEFI2EDqzl+vA1rl97IDRZ61AIwGH093d9nz8+dThxJNH8oSoB7MjWvPCX3dkaK1/RCJ/1v/R1XB15FuSs0fQw==} engines: {node: '>=16.0.0 || 14 >= 14.17'} @@ -3374,6 +3416,99 @@ packages: '@types/cacheable-request@6.0.3': resolution: {integrity: sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==} + '@types/d3-array@3.2.2': + resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} + + '@types/d3-axis@3.0.6': + resolution: {integrity: sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==} + + '@types/d3-brush@3.0.6': + resolution: {integrity: sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==} + + '@types/d3-chord@3.0.6': + resolution: {integrity: sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==} + + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-contour@3.0.6': + resolution: {integrity: sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==} + + '@types/d3-delaunay@6.0.4': + resolution: {integrity: sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==} + + '@types/d3-dispatch@3.0.7': + resolution: {integrity: sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==} + + '@types/d3-drag@3.0.7': + resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==} + + '@types/d3-dsv@3.0.7': + resolution: {integrity: sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==} + + '@types/d3-ease@3.0.2': + resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} + + '@types/d3-fetch@3.0.7': + resolution: {integrity: sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==} + + '@types/d3-force@3.0.10': + resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==} + + '@types/d3-format@3.0.4': + resolution: {integrity: sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==} + + '@types/d3-geo@3.1.0': + resolution: {integrity: sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==} + + '@types/d3-hierarchy@3.1.7': + resolution: {integrity: sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + + '@types/d3-path@3.1.1': + resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} + + '@types/d3-polygon@3.0.2': + resolution: {integrity: sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==} + + '@types/d3-quadtree@3.0.6': + resolution: {integrity: sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==} + + '@types/d3-random@3.0.3': + resolution: {integrity: sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==} + + '@types/d3-scale-chromatic@3.1.0': + resolution: {integrity: sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==} + + '@types/d3-scale@4.0.9': + resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} + + '@types/d3-selection@3.0.11': + resolution: {integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==} + + '@types/d3-shape@3.1.7': + resolution: {integrity: sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==} + + '@types/d3-time-format@4.0.3': + resolution: {integrity: sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==} + + '@types/d3-time@3.0.4': + resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} + + '@types/d3-timer@3.0.2': + resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + + '@types/d3-transition@3.0.9': + resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==} + + '@types/d3-zoom@3.0.8': + resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==} + + '@types/d3@7.4.3': + resolution: {integrity: sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==} + '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} @@ -4224,6 +4359,9 @@ packages: class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + classcat@5.0.5: + resolution: {integrity: sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==} + clean-css@5.3.3: resolution: {integrity: sha512-D5J+kHaVb/wKSFcyyV75uCn8fiY4sV38XJoe4CUyGQ+mOU/fMVYUdH1hJC+CJQ5uY3EnW27SbJYS4X8BiLrAFg==} engines: {node: '>= 10.0'} @@ -4472,11 +4610,19 @@ packages: resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} engines: {node: '>=12'} + d3-drag@3.0.0: + resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==} + engines: {node: '>=12'} + d3-dsv@3.0.1: resolution: {integrity: sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==} engines: {node: '>=12'} hasBin: true + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + d3-force@3.0.0: resolution: {integrity: sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==} engines: {node: '>=12'} @@ -4514,6 +4660,10 @@ packages: resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} engines: {node: '>=12'} + d3-selection@3.0.0: + resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==} + engines: {node: '>=12'} + d3-shape@3.2.0: resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} engines: {node: '>=12'} @@ -4530,6 +4680,16 @@ packages: resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} engines: {node: '>=12'} + d3-transition@3.0.1: + resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==} + engines: {node: '>=12'} + peerDependencies: + d3-selection: 2 - 3 + + d3-zoom@3.0.0: + resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==} + engines: {node: '>=12'} + damerau-levenshtein@1.0.8: resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} @@ -4762,6 +4922,9 @@ packages: electron-to-chromium@1.5.114: resolution: {integrity: sha512-DFptFef3iktoKlFQK/afbo274/XNWD00Am0xa7M8FZUepHlHT8PEuiNBoRfFHbH1okqN58AlhbJ4QTkcnXorjA==} + elkjs@0.9.3: + resolution: {integrity: sha512-f/ZeWvW/BCXbhGEf1Ujp29EASo/lk1FDnETgNKwJrsVvGZhUWCZyg3xLJjAsxfOmt8KjswHmI5EwCQcPMpOYhQ==} + emittery@0.13.1: resolution: {integrity: sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==} engines: {node: '>=12'} @@ -7822,6 +7985,12 @@ packages: resolution: {integrity: sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==} engines: {node: '>=0.10.0'} + reactflow@11.11.4: + resolution: {integrity: sha512-70FOtJkUWH3BAOsN+LU9lCrKoKbtOPnz2uq0CV2PLdNSwxTXOhCbsZr50GmZ+Rtw3jx8Uv7/vBFtCGixLfd4Og==} + peerDependencies: + react: '>=17' + react-dom: '>=17' + read-cache@1.0.0: resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==} @@ -9199,6 +9368,21 @@ packages: zod@3.24.2: resolution: {integrity: sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==} + zustand@4.5.7: + resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==} + engines: {node: '>=12.7.0'} + peerDependencies: + '@types/react': '>=16.8' + immer: '>=9.0.6' + react: '>=16.8' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} @@ -10886,6 +11070,84 @@ snapshots: react: 19.0.0 react-dom: 19.0.0(react@19.0.0) + '@reactflow/background@11.3.14(@types/react@18.3.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': + dependencies: + '@reactflow/core': 11.11.4(@types/react@18.3.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + classcat: 5.0.5 + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + zustand: 4.5.7(@types/react@18.3.1)(react@19.0.0) + transitivePeerDependencies: + - '@types/react' + - immer + + '@reactflow/controls@11.2.14(@types/react@18.3.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': + dependencies: + '@reactflow/core': 11.11.4(@types/react@18.3.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + classcat: 5.0.5 + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + zustand: 4.5.7(@types/react@18.3.1)(react@19.0.0) + transitivePeerDependencies: + - '@types/react' + - immer + + '@reactflow/core@11.11.4(@types/react@18.3.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': + dependencies: + '@types/d3': 7.4.3 + '@types/d3-drag': 3.0.7 + '@types/d3-selection': 3.0.11 + '@types/d3-zoom': 3.0.8 + classcat: 5.0.5 + d3-drag: 3.0.0 + d3-selection: 3.0.0 + d3-zoom: 3.0.0 + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + zustand: 4.5.7(@types/react@18.3.1)(react@19.0.0) + transitivePeerDependencies: + - '@types/react' + - immer + + '@reactflow/minimap@11.7.14(@types/react@18.3.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': + dependencies: + '@reactflow/core': 11.11.4(@types/react@18.3.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@types/d3-selection': 3.0.11 + '@types/d3-zoom': 3.0.8 + classcat: 5.0.5 + d3-selection: 3.0.0 + d3-zoom: 3.0.0 + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + zustand: 4.5.7(@types/react@18.3.1)(react@19.0.0) + transitivePeerDependencies: + - '@types/react' + - immer + + '@reactflow/node-resizer@2.2.14(@types/react@18.3.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': + dependencies: + '@reactflow/core': 11.11.4(@types/react@18.3.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + classcat: 5.0.5 + d3-drag: 3.0.0 + d3-selection: 3.0.0 + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + zustand: 4.5.7(@types/react@18.3.1)(react@19.0.0) + transitivePeerDependencies: + - '@types/react' + - immer + + '@reactflow/node-toolbar@1.3.14(@types/react@18.3.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': + dependencies: + '@reactflow/core': 11.11.4(@types/react@18.3.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + classcat: 5.0.5 + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + zustand: 4.5.7(@types/react@18.3.1)(react@19.0.0) + transitivePeerDependencies: + - '@types/react' + - immer + '@rollup/plugin-commonjs@28.0.2(rollup@4.34.7)': dependencies: '@rollup/pluginutils': 5.1.4(rollup@4.34.7) @@ -11317,6 +11579,123 @@ snapshots: '@types/node': 22.15.3 '@types/responselike': 1.0.0 + '@types/d3-array@3.2.2': {} + + '@types/d3-axis@3.0.6': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-brush@3.0.6': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-chord@3.0.6': {} + + '@types/d3-color@3.1.3': {} + + '@types/d3-contour@3.0.6': + dependencies: + '@types/d3-array': 3.2.2 + '@types/geojson': 7946.0.4 + + '@types/d3-delaunay@6.0.4': {} + + '@types/d3-dispatch@3.0.7': {} + + '@types/d3-drag@3.0.7': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-dsv@3.0.7': {} + + '@types/d3-ease@3.0.2': {} + + '@types/d3-fetch@3.0.7': + dependencies: + '@types/d3-dsv': 3.0.7 + + '@types/d3-force@3.0.10': {} + + '@types/d3-format@3.0.4': {} + + '@types/d3-geo@3.1.0': + dependencies: + '@types/geojson': 7946.0.4 + + '@types/d3-hierarchy@3.1.7': {} + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-path@3.1.1': {} + + '@types/d3-polygon@3.0.2': {} + + '@types/d3-quadtree@3.0.6': {} + + '@types/d3-random@3.0.3': {} + + '@types/d3-scale-chromatic@3.1.0': {} + + '@types/d3-scale@4.0.9': + dependencies: + '@types/d3-time': 3.0.4 + + '@types/d3-selection@3.0.11': {} + + '@types/d3-shape@3.1.7': + dependencies: + '@types/d3-path': 3.1.1 + + '@types/d3-time-format@4.0.3': {} + + '@types/d3-time@3.0.4': {} + + '@types/d3-timer@3.0.2': {} + + '@types/d3-transition@3.0.9': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-zoom@3.0.8': + dependencies: + '@types/d3-interpolate': 3.0.4 + '@types/d3-selection': 3.0.11 + + '@types/d3@7.4.3': + dependencies: + '@types/d3-array': 3.2.2 + '@types/d3-axis': 3.0.6 + '@types/d3-brush': 3.0.6 + '@types/d3-chord': 3.0.6 + '@types/d3-color': 3.1.3 + '@types/d3-contour': 3.0.6 + '@types/d3-delaunay': 6.0.4 + '@types/d3-dispatch': 3.0.7 + '@types/d3-drag': 3.0.7 + '@types/d3-dsv': 3.0.7 + '@types/d3-ease': 3.0.2 + '@types/d3-fetch': 3.0.7 + '@types/d3-force': 3.0.10 + '@types/d3-format': 3.0.4 + '@types/d3-geo': 3.1.0 + '@types/d3-hierarchy': 3.1.7 + '@types/d3-interpolate': 3.0.4 + '@types/d3-path': 3.1.1 + '@types/d3-polygon': 3.0.2 + '@types/d3-quadtree': 3.0.6 + '@types/d3-random': 3.0.3 + '@types/d3-scale': 4.0.9 + '@types/d3-scale-chromatic': 3.1.0 + '@types/d3-selection': 3.0.11 + '@types/d3-shape': 3.1.7 + '@types/d3-time': 3.0.4 + '@types/d3-time-format': 4.0.3 + '@types/d3-timer': 3.0.2 + '@types/d3-transition': 3.0.9 + '@types/d3-zoom': 3.0.8 + '@types/debug@4.1.12': dependencies: '@types/ms': 0.7.34 @@ -12281,6 +12660,8 @@ snapshots: dependencies: clsx: 2.1.1 + classcat@5.0.5: {} + clean-css@5.3.3: dependencies: source-map: 0.6.1 @@ -12542,12 +12923,19 @@ snapshots: d3-dispatch@3.0.1: {} + d3-drag@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-selection: 3.0.0 + d3-dsv@3.0.1: dependencies: commander: 7.2.0 iconv-lite: 0.6.3 rw: 1.3.3 + d3-ease@3.0.1: {} + d3-force@3.0.0: dependencies: d3-dispatch: 3.0.1 @@ -12584,6 +12972,8 @@ snapshots: d3-time: 3.1.0 d3-time-format: 4.1.0 + d3-selection@3.0.0: {} + d3-shape@3.2.0: dependencies: d3-path: 3.1.0 @@ -12598,6 +12988,23 @@ snapshots: d3-timer@3.0.1: {} + d3-transition@3.0.1(d3-selection@3.0.0): + dependencies: + d3-color: 3.1.0 + d3-dispatch: 3.0.1 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-timer: 3.0.1 + + d3-zoom@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + damerau-levenshtein@1.0.8: {} data-uri-to-buffer@5.0.1: {} @@ -12795,6 +13202,8 @@ snapshots: electron-to-chromium@1.5.114: {} + elkjs@0.9.3: {} + emittery@0.13.1: {} emoji-regex-xs@1.0.0: {} @@ -16992,6 +17401,20 @@ snapshots: react@19.0.0: {} + reactflow@11.11.4(@types/react@18.3.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0): + dependencies: + '@reactflow/background': 11.3.14(@types/react@18.3.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@reactflow/controls': 11.2.14(@types/react@18.3.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@reactflow/core': 11.11.4(@types/react@18.3.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@reactflow/minimap': 11.7.14(@types/react@18.3.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@reactflow/node-resizer': 2.2.14(@types/react@18.3.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@reactflow/node-toolbar': 1.3.14(@types/react@18.3.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + transitivePeerDependencies: + - '@types/react' + - immer + read-cache@1.0.0: dependencies: pify: 2.3.0 @@ -18921,4 +19344,11 @@ snapshots: zod@3.24.2: {} + zustand@4.5.7(@types/react@18.3.1)(react@19.0.0): + dependencies: + use-sync-external-store: 1.4.0(react@19.0.0) + optionalDependencies: + '@types/react': 18.3.1 + react: 19.0.0 + zwitch@2.0.4: {} From e149342cb9eebf86829c86f82b821a270001ce56 Mon Sep 17 00:00:00 2001 From: Anthony Shew Date: Tue, 16 Dec 2025 11:16:06 -0700 Subject: [PATCH 02/14] continuing --- .../(no-sidebar)/tools/devtools-client.tsx | 43 +++++++++++-------- 1 file changed, 25 insertions(+), 18 deletions(-) diff --git a/docs/site/app/(no-sidebar)/tools/devtools-client.tsx b/docs/site/app/(no-sidebar)/tools/devtools-client.tsx index 9ed2461c87dcc..28c9a7602ae05 100644 --- a/docs/site/app/(no-sidebar)/tools/devtools-client.tsx +++ b/docs/site/app/(no-sidebar)/tools/devtools-client.tsx @@ -379,7 +379,7 @@ function SelectionIndicator({ function DevtoolsContent() { const searchParams = useSearchParams(); const port = searchParams.get("port"); - const { fitBounds, getNodes } = useReactFlow(); + const { fitBounds, fitView, getNodes } = useReactFlow(); const [graphState, setGraphState] = useState(null); const [isConnected, setIsConnected] = useState(false); @@ -457,6 +457,21 @@ function DevtoolsContent() { setEdges, ]); + // Clear selection and reset viewport to show all nodes + const clearSelection = useCallback( + (hadFilter: boolean) => { + setSelectedNode(null); + setSelectionMode("none"); + // Reset viewport to show all nodes only if we had a filter active + if (hadFilter) { + setTimeout(() => { + fitView(); + }, 50); + } + }, + [fitView] + ); + // Handle node click const handleNodeClick: NodeMouseHandler = useCallback( (_, node) => { @@ -465,8 +480,7 @@ function DevtoolsContent() { if (selectionMode === "direct") { setSelectionMode("affected"); } else if (selectionMode === "affected") { - setSelectionMode("none"); - setSelectedNode(null); + clearSelection(true); } } else { // Clicking a different node - start with direct dependencies @@ -474,19 +488,13 @@ function DevtoolsContent() { setSelectionMode("direct"); } }, - [selectedNode, selectionMode] + [selectedNode, selectionMode, clearSelection] ); - // Clear selection - const clearSelection = useCallback(() => { - setSelectedNode(null); - setSelectionMode("none"); - }, []); - // Handle clicking on the background to clear selection const handlePaneClick = useCallback(() => { - clearSelection(); - }, [clearSelection]); + clearSelection(selectionMode !== "none"); + }, [clearSelection, selectionMode]); // Get set of node IDs that have at least one edge connection const getConnectedNodeIds = useCallback((edges: Array) => { @@ -587,8 +595,8 @@ function DevtoolsContent() { // Update flow elements when view or graph state changes const updateFlowElements = useCallback( async (state: GraphState, currentView: GraphView) => { - // Clear selection when switching views or updating - clearSelection(); + // Clear selection when switching views or updating (don't reset viewport, layout will handle it) + clearSelection(false); if (currentView === "packages") { await updatePackageGraphElements(state); @@ -800,8 +808,7 @@ function DevtoolsContent() { const affected = getAffectedNodes(nodeId, rawEdges); focusOnNodes(affected); } else if (selectionMode === "affected") { - setSelectionMode("none"); - setSelectedNode(null); + clearSelection(true); } } else { setSelectedNode(nodeId); @@ -811,7 +818,7 @@ function DevtoolsContent() { focusOnNodes(direct); } }, - [selectedNode, selectionMode, rawEdges, focusOnNodes] + [selectedNode, selectionMode, rawEdges, focusOnNodes, clearSelection] ); // No port provided - show instructions @@ -854,7 +861,7 @@ function DevtoolsContent() { clearSelection(true)} /> From 93a560d9081e466f1face284bfe05824425cf609 Mon Sep 17 00:00:00 2001 From: Anthony Shew Date: Tue, 16 Dec 2025 11:50:01 -0700 Subject: [PATCH 03/14] arrows --- .../(no-sidebar)/tools/devtools-client.tsx | 110 ++++++++++++++++-- .../app/(no-sidebar)/tools/turbo-edge.tsx | 33 +++++- 2 files changed, 129 insertions(+), 14 deletions(-) diff --git a/docs/site/app/(no-sidebar)/tools/devtools-client.tsx b/docs/site/app/(no-sidebar)/tools/devtools-client.tsx index 28c9a7602ae05..82b055109a239 100644 --- a/docs/site/app/(no-sidebar)/tools/devtools-client.tsx +++ b/docs/site/app/(no-sidebar)/tools/devtools-client.tsx @@ -75,8 +75,8 @@ interface ServerMessage { type GraphView = "packages" | "tasks"; -// Selection mode: none -> direct (first click) -> affected (second click) -> none (third click) -type SelectionMode = "none" | "direct" | "affected"; +// Selection mode: none -> direct (first click) -> affected (second click) -> affects (third click) -> none (fourth click) +type SelectionMode = "none" | "direct" | "affected" | "affects"; const elk = new ELK(); @@ -222,6 +222,40 @@ function getAffectedNodes( return affected; } +// Get nodes that affect the selected node (transitive dependencies) +// These are the packages that, if changed, would cause the selected node's hash to change. +// In the edge model: edge.source depends on edge.target +// So we traverse "downstream" - following edges from source to target +function getAffectsNodes(nodeId: string, edges: Array): Set { + const affects = new Set(); + affects.add(nodeId); + + // Build an adjacency list for forward traversal (dependent -> dependencies) + const dependenciesMap = new Map>(); + for (const edge of edges) { + // edge.source depends on edge.target + const dependencies = dependenciesMap.get(edge.source) || []; + dependencies.push(edge.target); + dependenciesMap.set(edge.source, dependencies); + } + + // BFS to find all transitive dependencies + const queue = [nodeId]; + while (queue.length > 0) { + const current = queue.shift()!; + const dependencies = dependenciesMap.get(current) || []; + + for (const dependency of dependencies) { + if (!affects.has(dependency)) { + affects.add(dependency); + queue.push(dependency); + } + } + } + + return affects; +} + // Get edges that connect the visible nodes function getConnectedEdges( visibleNodes: Set, @@ -356,18 +390,29 @@ function SelectionIndicator({ const getModeLabel = () => { switch (selectionMode) { case "direct": - return "Direct deps of"; + // Direct dependencies (both directions, no arrow needed) + return { prefix: "Direct deps of", suffix: "" }; case "affected": - return "Affected by"; + // Shows packages that this node affects (dependents) - arrow points outward + return { prefix: "", suffix: "→ affects" }; + case "affects": + // Shows packages that affect this node (dependencies) - arrow points inward + return { prefix: "affected by →", suffix: "" }; default: - return ""; + return { prefix: "", suffix: "" }; } }; + const { prefix, suffix } = getModeLabel(); + return (
- {getModeLabel()} {selectedNode} + {prefix} + {prefix && " "} + {selectedNode} + {suffix && " "} + {suffix}
@@ -1032,6 +1100,24 @@ function DevtoolsContent() { cy="0" /> + + {/* Arrow marker for directional highlighting */} + + + diff --git a/docs/site/app/(no-sidebar)/tools/turbo-edge.tsx b/docs/site/app/(no-sidebar)/tools/turbo-edge.tsx index e3f01b958356c..4f189265f7029 100644 --- a/docs/site/app/(no-sidebar)/tools/turbo-edge.tsx +++ b/docs/site/app/(no-sidebar)/tools/turbo-edge.tsx @@ -1,11 +1,23 @@ import { getBezierPath, useStore, type EdgeProps } from "reactflow"; import { getEdgeParams, type InternalNodeType } from "./floating-edge-utils"; +// Helper to resolve marker ID to url(#id) format +function resolveMarker(marker: string | undefined): string | undefined { + if (typeof marker === "string" && marker && !marker.startsWith("url(")) { + return `url(#${marker})`; + } + return marker; +} + +// Offset for arrow marker to prevent line from extending past arrowhead +const ARROW_OFFSET = 10; + export default function TurboEdge({ id, source, target, style = {}, + markerStart, markerEnd, }: EdgeProps) { const sourceNode = useStore((store) => store.nodeInternals.get(source)); @@ -15,11 +27,27 @@ export default function TurboEdge({ return null; } - const { sx, sy, tx, ty, sourcePos, targetPos } = getEdgeParams( + let { sx, sy, tx, ty, sourcePos, targetPos } = getEdgeParams( sourceNode as unknown as InternalNodeType, targetNode as unknown as InternalNodeType ); + // If there's an arrow marker, shorten the line at the source end + // so the line doesn't extend past the arrowhead + const hasArrowMarker = + typeof markerStart === "string" && markerStart.includes("edge-arrow"); + if (hasArrowMarker) { + const dx = tx - sx; + const dy = ty - sy; + const length = Math.sqrt(dx * dx + dy * dy); + if (length > 0) { + const offsetX = (dx / length) * ARROW_OFFSET; + const offsetY = (dy / length) * ARROW_OFFSET; + sx += offsetX; + sy += offsetY; + } + } + const [edgePath] = getBezierPath({ sourceX: sx, sourceY: sy, @@ -35,7 +63,8 @@ export default function TurboEdge({ style={style} className="react-flow__edge-path" d={edgePath} - markerEnd={markerEnd} + markerStart={resolveMarker(markerStart)} + markerEnd={resolveMarker(markerEnd)} /> ); } From cc48c02d668cdb4bf72a32425a4a7025dfb882f0 Mon Sep 17 00:00:00 2001 From: Anthony Shew Date: Tue, 16 Dec 2025 13:44:25 -0700 Subject: [PATCH 04/14] dankies --- crates/turborepo-lib/src/commands/devtools.rs | 2 +- .../(no-sidebar)/tools/devtools-client.tsx | 308 +++++++++++++----- docs/site/app/(no-sidebar)/tools/page.tsx | 2 +- 3 files changed, 237 insertions(+), 75 deletions(-) diff --git a/crates/turborepo-lib/src/commands/devtools.rs b/crates/turborepo-lib/src/commands/devtools.rs index 3b168caa6fff8..99ea496b13404 100644 --- a/crates/turborepo-lib/src/commands/devtools.rs +++ b/crates/turborepo-lib/src/commands/devtools.rs @@ -27,7 +27,7 @@ pub async fn run(repo_root: AbsoluteSystemPathBuf, port: u16, no_open: bool) -> let url = format!("{}?port={}", DEVTOOLS_URL, port); println!(); - println!(" Turbo Devtools"); + println!(" Turborepo Devtools"); println!(" ──────────────────────────────────────"); println!(" WebSocket: ws://localhost:{}", port); println!(" Browser: {}", url); diff --git a/docs/site/app/(no-sidebar)/tools/devtools-client.tsx b/docs/site/app/(no-sidebar)/tools/devtools-client.tsx index 82b055109a239..620e84891ee02 100644 --- a/docs/site/app/(no-sidebar)/tools/devtools-client.tsx +++ b/docs/site/app/(no-sidebar)/tools/devtools-client.tsx @@ -75,8 +75,8 @@ interface ServerMessage { type GraphView = "packages" | "tasks"; -// Selection mode: none -> direct (first click) -> affected (second click) -> affects (third click) -> none (fourth click) -type SelectionMode = "none" | "direct" | "affected" | "affects"; +// Selection mode: none -> direct (first click) -> blocks (second click) -> dependsOn (third click) -> none (fourth click) +type SelectionMode = "none" | "direct" | "blocks" | "dependsOn"; const elk = new ELK(); @@ -277,7 +277,7 @@ function SetupInstructions() {

- Turbo Devtools + Turborepo Devtools

Run the following command in your Turborepo to start the devtools @@ -376,47 +376,135 @@ function GraphViewToggle({ ); } +type ActiveSelectionMode = Exclude; + +function getModeOptions(view: GraphView): Array<{ + mode: ActiveSelectionMode; + getLabel: () => { prefix: string; suffix: string }; +}> { + if (view === "tasks") { + return [ + { + mode: "direct", + getLabel: () => ({ prefix: "Direct neighbors of", suffix: "" }), + }, + { + mode: "blocks", + getLabel: () => ({ prefix: "Blocked by", suffix: "" }), + }, + { + mode: "dependsOn", + getLabel: () => ({ prefix: "", suffix: "depends on..." }), + }, + ]; + } + // Package graph + return [ + { + mode: "direct", + getLabel: () => ({ prefix: "Direct neighbors of", suffix: "" }), + }, + { + mode: "blocks", + getLabel: () => ({ prefix: "", suffix: "affects..." }), + }, + { + mode: "dependsOn", + getLabel: () => ({ prefix: "Packages that affect", suffix: "" }), + }, + ]; +} + function SelectionIndicator({ selectedNode, selectionMode, + view, + isOpen, + onToggleOpen, + onModeChange, onClear, }: { selectedNode: string | null; selectionMode: SelectionMode; + view: GraphView; + isOpen: boolean; + onToggleOpen: () => void; + onModeChange: (mode: ActiveSelectionMode) => void; onClear: () => void; }) { if (!selectedNode || selectionMode === "none") return null; - const getModeLabel = () => { - switch (selectionMode) { - case "direct": - // Direct dependencies (both directions, no arrow needed) - return { prefix: "Direct deps of", suffix: "" }; - case "affected": - // Shows packages that this node affects (dependents) - arrow points outward - return { prefix: "", suffix: "→ affects" }; - case "affects": - // Shows packages that affect this node (dependencies) - arrow points inward - return { prefix: "affected by →", suffix: "" }; - default: - return { prefix: "", suffix: "" }; - } + const modeOptions = getModeOptions(view); + const currentOption = modeOptions.find((opt) => opt.mode === selectionMode); + const { prefix, suffix } = currentOption?.getLabel() ?? { + prefix: "", + suffix: "", }; - const { prefix, suffix } = getModeLabel(); - return ( -

- - {prefix} - {prefix && " "} - {selectedNode} - {suffix && " "} - {suffix} - - +
+
+ + +
+ + {isOpen && ( +
+ {modeOptions.map((option) => { + const { prefix: optPrefix, suffix: optSuffix } = option.getLabel(); + const isSelected = option.mode === selectionMode; + + return ( + + ); + })} +
+ )}
); } @@ -432,6 +520,7 @@ function DevtoolsContent() { const [view, setView] = useState("packages"); const [selectedNode, setSelectedNode] = useState(null); const [selectionMode, setSelectionMode] = useState("none"); + const [isDropdownOpen, setIsDropdownOpen] = useState(false); const [showDisconnected, setShowDisconnected] = useState(false); const [searchQuery, setSearchQuery] = useState(""); const wsRef = useRef(null); @@ -453,7 +542,7 @@ function DevtoolsContent() { let visibleNodes: Set; if (selectionMode === "direct") { visibleNodes = getDirectDependencies(selectedNode, rawEdges); - } else if (selectionMode === "affected") { + } else if (selectionMode === "blocks") { visibleNodes = getAffectedNodes(selectedNode, rawEdges); } else { visibleNodes = getAffectsNodes(selectedNode, rawEdges); @@ -485,12 +574,12 @@ function DevtoolsContent() { const updatedEdges = baseEdges.map((edge) => { const isHighlighted = !highlightedEdges || highlightedEdges.has(edge.id); - // Use arrow markers for directional modes (affected/affects) - // For "affected" mode: arrows point from selected node outward (shows what it affects) - // For "affects" mode: arrows point toward selected node (shows what affects it) + // Use arrow markers for directional modes (blocks/dependsOn) + // For "blocks" mode: arrows point from selected node outward (shows what it blocks) + // For "dependsOn" mode: arrows point toward selected node (shows what it depends on) const useArrow = isHighlighted && - (selectionMode === "affected" || selectionMode === "affects"); + (selectionMode === "blocks" || selectionMode === "dependsOn"); return { ...edge, @@ -534,13 +623,19 @@ function DevtoolsContent() { // Handle node click const handleNodeClick: NodeMouseHandler = useCallback( (_, node) => { + // If dropdown is open, just close it + if (isDropdownOpen) { + setIsDropdownOpen(false); + return; + } + if (selectedNode === node.id) { - // Clicking the same node - cycle through modes: direct -> affected -> affects -> none + // Clicking the same node - cycle through modes: direct -> blocks -> dependsOn -> none if (selectionMode === "direct") { - setSelectionMode("affected"); - } else if (selectionMode === "affected") { - setSelectionMode("affects"); - } else if (selectionMode === "affects") { + setSelectionMode("blocks"); + } else if (selectionMode === "blocks") { + setSelectionMode("dependsOn"); + } else if (selectionMode === "dependsOn") { clearSelection(true); } } else { @@ -549,13 +644,17 @@ function DevtoolsContent() { setSelectionMode("direct"); } }, - [selectedNode, selectionMode, clearSelection] + [selectedNode, selectionMode, clearSelection, isDropdownOpen] ); - // Handle clicking on the background to clear selection + // Handle clicking on the background to clear selection (or just close dropdown) const handlePaneClick = useCallback(() => { + if (isDropdownOpen) { + setIsDropdownOpen(false); + return; + } clearSelection(selectionMode !== "none"); - }, [clearSelection, selectionMode]); + }, [clearSelection, selectionMode, isDropdownOpen]); // Get set of node IDs that have at least one edge connection const getConnectedNodeIds = useCallback((edges: Array) => { @@ -798,16 +897,43 @@ function DevtoolsContent() { }; }, [graphState, view, connectedNodeIds]); - // Filter nodes based on search query + // Filter and sort nodes based on search query and highlighting const filteredConnectedNodes = useMemo(() => { - if (!searchQuery.trim()) return connectedNodes; - const query = searchQuery.toLowerCase(); - return connectedNodes.filter( - (node) => - node.name.toLowerCase().includes(query) || - node.subtitle.toLowerCase().includes(query) - ); - }, [connectedNodes, searchQuery]); + let filtered = connectedNodes; + + if (searchQuery.trim()) { + const query = searchQuery.toLowerCase(); + filtered = connectedNodes.filter( + (node) => + node.name.toLowerCase().includes(query) || + node.subtitle.toLowerCase().includes(query) + ); + } + + // Sort: selected node first, then highlighted nodes, then the rest + if (selectedNode || highlightedNodes) { + return [...filtered].sort((a, b) => { + // Selected node always comes first + if (a.id === selectedNode) return -1; + if (b.id === selectedNode) return 1; + + // Then sort by highlighted status + if (highlightedNodes) { + const aHighlighted = highlightedNodes.has(a.id); + const bHighlighted = highlightedNodes.has(b.id); + if (aHighlighted && !bHighlighted) return -1; + if (!aHighlighted && bHighlighted) return 1; + } + + // Within each group, sort alphabetically + return ( + a.name.localeCompare(b.name) || a.subtitle.localeCompare(b.subtitle) + ); + }); + } + + return filtered; + }, [connectedNodes, searchQuery, highlightedNodes, selectedNode]); const filteredDisconnectedNodes = useMemo(() => { if (!searchQuery.trim()) return disconnectedNodes; @@ -858,22 +984,44 @@ function DevtoolsContent() { [fitBounds, getNodes] ); + // Handle mode change from dropdown + const handleModeChange = useCallback( + (mode: ActiveSelectionMode) => { + if (!selectedNode) return; + + setSelectionMode(mode); + setIsDropdownOpen(false); + + // Focus on the appropriate nodes for the new mode + let nodesToFocus: Set; + if (mode === "direct") { + nodesToFocus = getDirectDependencies(selectedNode, rawEdges); + } else if (mode === "blocks") { + nodesToFocus = getAffectedNodes(selectedNode, rawEdges); + } else { + nodesToFocus = getAffectsNodes(selectedNode, rawEdges); + } + focusOnNodes(nodesToFocus); + }, + [selectedNode, rawEdges, focusOnNodes] + ); + // Handle sidebar node click const handleSidebarNodeClick = useCallback( (nodeId: string) => { if (selectedNode === nodeId) { // Clicking the same node - cycle through modes if (selectionMode === "direct") { - setSelectionMode("affected"); - // Focus on affected nodes - const affected = getAffectedNodes(nodeId, rawEdges); - focusOnNodes(affected); - } else if (selectionMode === "affected") { - setSelectionMode("affects"); - // Focus on nodes that affect this one - const affects = getAffectsNodes(nodeId, rawEdges); - focusOnNodes(affects); - } else if (selectionMode === "affects") { + setSelectionMode("blocks"); + // Focus on nodes that this blocks (dependents) + const blocked = getAffectedNodes(nodeId, rawEdges); + focusOnNodes(blocked); + } else if (selectionMode === "blocks") { + setSelectionMode("dependsOn"); + // Focus on nodes that this depends on + const dependencies = getAffectsNodes(nodeId, rawEdges); + focusOnNodes(dependencies); + } else if (selectionMode === "dependsOn") { clearSelection(true); } } else { @@ -918,19 +1066,12 @@ function DevtoolsContent() { backgroundColor: "var(--ds-background-100)", }} > - {/* Toggle and selection indicator */} + {/* Toggle */}
- { - clearSelection(true); - }} - />
{/* Search input */} @@ -1057,7 +1198,22 @@ function DevtoolsContent() { {/* Graph */} -
+
+ {/* Selection indicator overlay */} + { + setIsDropdownOpen(!isDropdownOpen); + }} + onModeChange={handleModeChange} + onClear={() => { + clearSelection(true); + setIsDropdownOpen(false); + }} + /> - + diff --git a/docs/site/app/(no-sidebar)/tools/page.tsx b/docs/site/app/(no-sidebar)/tools/page.tsx index afabe8f2919c3..c0241e1d59960 100644 --- a/docs/site/app/(no-sidebar)/tools/page.tsx +++ b/docs/site/app/(no-sidebar)/tools/page.tsx @@ -2,7 +2,7 @@ import type { Metadata } from "next"; import { DevtoolsClientComponent } from "./devtools-client"; export const metadata: Metadata = { - title: "Turbo Devtools", + title: "Turborepo Devtools", description: "Visualize your Turborepo package and task graphs", }; From 6a263776b993142ec16888c795a7c8ecb0cd0e10 Mon Sep 17 00:00:00 2001 From: Anthony Shew Date: Tue, 16 Dec 2025 14:21:59 -0700 Subject: [PATCH 05/14] feat: Turborepo Devtools --- crates/turborepo-devtools/src/server.rs | 4 +- crates/turborepo-lib/src/commands/devtools.rs | 15 +- .../{tools => devtools}/devtools-client.tsx | 0 .../floating-edge-utils.ts | 0 .../{tools => devtools}/function-icon.tsx | 0 .../{tools => devtools}/layout.tsx | 0 .../(no-sidebar)/{tools => devtools}/page.tsx | 10 +- .../{tools => devtools}/turbo-edge.tsx | 0 .../{tools => devtools}/turbo-flow.css | 0 .../{tools => devtools}/turbo-node.tsx | 0 docs/site/flags.ts | 7 + docs/site/package.json | 8 +- pnpm-lock.yaml | 364 +++++++++++------- 13 files changed, 265 insertions(+), 143 deletions(-) rename docs/site/app/(no-sidebar)/{tools => devtools}/devtools-client.tsx (100%) rename docs/site/app/(no-sidebar)/{tools => devtools}/floating-edge-utils.ts (100%) rename docs/site/app/(no-sidebar)/{tools => devtools}/function-icon.tsx (100%) rename docs/site/app/(no-sidebar)/{tools => devtools}/layout.tsx (100%) rename docs/site/app/(no-sidebar)/{tools => devtools}/page.tsx (53%) rename docs/site/app/(no-sidebar)/{tools => devtools}/turbo-edge.tsx (100%) rename docs/site/app/(no-sidebar)/{tools => devtools}/turbo-flow.css (100%) rename docs/site/app/(no-sidebar)/{tools => devtools}/turbo-node.tsx (100%) create mode 100644 docs/site/flags.ts diff --git a/crates/turborepo-devtools/src/server.rs b/crates/turborepo-devtools/src/server.rs index 2e56df6adecb8..7c7b9767bc519 100644 --- a/crates/turborepo-devtools/src/server.rs +++ b/crates/turborepo-devtools/src/server.rs @@ -196,7 +196,9 @@ async fn handle_socket(socket: WebSocket, state: AppState) { } } Some(Err(e)) => { - warn!("WebSocket error: {}", e); + // Connection resets without closing handshake are expected when + // clients disconnect abruptly (laptop sleep, network drop, etc.) + debug!("WebSocket error: {}", e); break; } None => { diff --git a/crates/turborepo-lib/src/commands/devtools.rs b/crates/turborepo-lib/src/commands/devtools.rs index 99ea496b13404..85ab9b2bd7190 100644 --- a/crates/turborepo-lib/src/commands/devtools.rs +++ b/crates/turborepo-lib/src/commands/devtools.rs @@ -11,13 +11,17 @@ use crate::cli; // In production, use the hosted devtools UI // For local development, set TURBO_DEVTOOLS_LOCAL=1 to use localhost:3000 const DEVTOOLS_URL: &str = if cfg!(debug_assertions) { - "http://localhost:3000/tools" + "http://localhost:3000/devtools" } else { - "https://turbo.build/tools" + "https://turborepo.dev/devtools" }; /// Run the devtools server. -pub async fn run(repo_root: AbsoluteSystemPathBuf, port: u16, no_open: bool) -> Result<(), cli::Error> { +pub async fn run( + repo_root: AbsoluteSystemPathBuf, + port: u16, + no_open: bool, +) -> Result<(), cli::Error> { // Find available port let port = find_available_port(port); @@ -43,7 +47,10 @@ pub async fn run(repo_root: AbsoluteSystemPathBuf, port: u16, no_open: bool) -> } // Run server - server.run().await.map_err(|e| cli::Error::Devtools(Box::new(e)))?; + server + .run() + .await + .map_err(|e| cli::Error::Devtools(Box::new(e)))?; Ok(()) } diff --git a/docs/site/app/(no-sidebar)/tools/devtools-client.tsx b/docs/site/app/(no-sidebar)/devtools/devtools-client.tsx similarity index 100% rename from docs/site/app/(no-sidebar)/tools/devtools-client.tsx rename to docs/site/app/(no-sidebar)/devtools/devtools-client.tsx diff --git a/docs/site/app/(no-sidebar)/tools/floating-edge-utils.ts b/docs/site/app/(no-sidebar)/devtools/floating-edge-utils.ts similarity index 100% rename from docs/site/app/(no-sidebar)/tools/floating-edge-utils.ts rename to docs/site/app/(no-sidebar)/devtools/floating-edge-utils.ts diff --git a/docs/site/app/(no-sidebar)/tools/function-icon.tsx b/docs/site/app/(no-sidebar)/devtools/function-icon.tsx similarity index 100% rename from docs/site/app/(no-sidebar)/tools/function-icon.tsx rename to docs/site/app/(no-sidebar)/devtools/function-icon.tsx diff --git a/docs/site/app/(no-sidebar)/tools/layout.tsx b/docs/site/app/(no-sidebar)/devtools/layout.tsx similarity index 100% rename from docs/site/app/(no-sidebar)/tools/layout.tsx rename to docs/site/app/(no-sidebar)/devtools/layout.tsx diff --git a/docs/site/app/(no-sidebar)/tools/page.tsx b/docs/site/app/(no-sidebar)/devtools/page.tsx similarity index 53% rename from docs/site/app/(no-sidebar)/tools/page.tsx rename to docs/site/app/(no-sidebar)/devtools/page.tsx index c0241e1d59960..847de935ded32 100644 --- a/docs/site/app/(no-sidebar)/tools/page.tsx +++ b/docs/site/app/(no-sidebar)/devtools/page.tsx @@ -1,4 +1,6 @@ import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { enableDevtools } from "../../../flags.ts"; import { DevtoolsClientComponent } from "./devtools-client"; export const metadata: Metadata = { @@ -6,6 +8,12 @@ export const metadata: Metadata = { description: "Visualize your Turborepo package and task graphs", }; -export default function ToolsPage() { +export default async function ToolsPage() { + const showDevtools = await enableDevtools(); + + if (!showDevtools) { + return notFound(); + } + return ; } diff --git a/docs/site/app/(no-sidebar)/tools/turbo-edge.tsx b/docs/site/app/(no-sidebar)/devtools/turbo-edge.tsx similarity index 100% rename from docs/site/app/(no-sidebar)/tools/turbo-edge.tsx rename to docs/site/app/(no-sidebar)/devtools/turbo-edge.tsx diff --git a/docs/site/app/(no-sidebar)/tools/turbo-flow.css b/docs/site/app/(no-sidebar)/devtools/turbo-flow.css similarity index 100% rename from docs/site/app/(no-sidebar)/tools/turbo-flow.css rename to docs/site/app/(no-sidebar)/devtools/turbo-flow.css diff --git a/docs/site/app/(no-sidebar)/tools/turbo-node.tsx b/docs/site/app/(no-sidebar)/devtools/turbo-node.tsx similarity index 100% rename from docs/site/app/(no-sidebar)/tools/turbo-node.tsx rename to docs/site/app/(no-sidebar)/devtools/turbo-node.tsx diff --git a/docs/site/flags.ts b/docs/site/flags.ts new file mode 100644 index 0000000000000..cb8181b816a21 --- /dev/null +++ b/docs/site/flags.ts @@ -0,0 +1,7 @@ +import { flag } from "flags/next"; +import { vercelAdapter } from "@flags-sdk/vercel"; + +export const enableDevtools = flag({ + key: "enable-devtools", + adapter: vercelAdapter(), +}); diff --git a/docs/site/package.json b/docs/site/package.json index 50079317b20aa..6fa309ba55f05 100644 --- a/docs/site/package.json +++ b/docs/site/package.json @@ -22,6 +22,7 @@ "collect-examples-data": "node --experimental-strip-types ./scripts/collect-examples-data.ts" }, "dependencies": { + "@flags-sdk/vercel": "^0.1.8", "@heroicons/react": "1.0.6", "@radix-ui/react-collapsible": "1.1.3", "@radix-ui/react-dialog": "1.1.6", @@ -38,7 +39,9 @@ "class-variance-authority": "0.7.1", "clsx": "2.1.1", "copy-to-clipboard": "3.3.3", + "elkjs": "0.9.3", "fast-glob": "3.3.3", + "flags": "^4.0.2", "framer-motion": "12.2.0", "fumadocs-core": "14.7.7", "fumadocs-mdx": "11.5.8", @@ -51,6 +54,7 @@ "next-themes": "0.4.6", "react": "19.0.0", "react-dom": "19.0.0", + "reactflow": "11.11.4", "remark": "15.0.1", "remark-mdx": "3.1.0", "remark-stringify": "11.0.0", @@ -58,9 +62,7 @@ "shiki": "3.1.0", "swr": "2.2.6-beta.0", "tailwind-merge": "3.2.0", - "zod": "3.24.2", - "reactflow": "11.11.4", - "elkjs": "0.9.3" + "zod": "3.24.2" }, "devDependencies": { "@next/env": "15.4.0-canary.81", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ee75d9ac3000f..0b77a880d890b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -78,6 +78,9 @@ importers: docs/site: dependencies: + '@flags-sdk/vercel': + specifier: ^0.1.8 + version: 0.1.8(flags@4.0.2(next@15.5.9(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(next@15.5.9(react-dom@19.0.0(react@19.0.0))(react@19.0.0)) '@heroicons/react': specifier: 1.0.6 version: 1.0.6(react@19.0.0) @@ -132,6 +135,9 @@ importers: fast-glob: specifier: 3.3.3 version: 3.3.3 + flags: + specifier: ^4.0.2 + version: 4.0.2(next@15.5.9(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0) framer-motion: specifier: 12.2.0 version: 12.2.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0) @@ -325,7 +331,7 @@ importers: version: 29.7.0(@types/node@18.17.4)(ts-node@10.9.2(@swc/core@1.10.16(@swc/helpers@0.5.15))(@types/node@18.17.4)(typescript@5.5.4)) ts-jest: specifier: 29.2.5 - version: 29.2.5(@babel/core@7.26.10)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.26.10))(esbuild@0.17.18)(jest@29.7.0(@types/node@18.17.4)(ts-node@10.9.2(@swc/core@1.10.16(@swc/helpers@0.5.15))(@types/node@18.17.4)(typescript@5.5.4)))(typescript@5.5.4) + version: 29.2.5(@babel/core@7.26.10)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.26.10))(esbuild@0.14.49)(jest@29.7.0(@types/node@18.17.4)(ts-node@10.9.2(@swc/core@1.10.16(@swc/helpers@0.5.15))(@types/node@18.17.4)(typescript@5.5.4)))(typescript@5.5.4) tsup: specifier: 6.7.0 version: 6.7.0(@swc/core@1.10.16(@swc/helpers@0.5.15))(postcss@8.5.3)(ts-node@10.9.2(@swc/core@1.10.16(@swc/helpers@0.5.15))(@types/node@18.17.4)(typescript@5.5.4))(typescript@5.5.4) @@ -337,7 +343,7 @@ importers: devDependencies: '@vercel/style-guide': specifier: 5.1.0 - version: 5.1.0(eslint@8.57.0)(jest@29.7.0(@types/node@22.15.3)(ts-node@10.9.2(@swc/core@1.10.16(@swc/helpers@0.5.15))(@types/node@22.15.3)(typescript@5.6.3)))(prettier@3.5.3)(typescript@5.6.3) + version: 5.1.0(eslint@8.55.0)(jest@29.7.0(@types/node@22.15.3)(ts-node@10.9.2(@swc/core@1.10.16(@swc/helpers@0.5.15))(@types/node@22.15.3)(typescript@5.5.4)))(prettier@3.5.3)(typescript@5.5.4) packages/eslint-config-turbo: dependencies: @@ -368,7 +374,7 @@ importers: version: 20.11.30 bunchee: specifier: 6.3.4 - version: 6.3.4(typescript@5.6.3) + version: 6.3.4(typescript@5.5.4) packages/eslint-plugin-turbo: dependencies: @@ -1462,6 +1468,10 @@ packages: resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} + '@edge-runtime/cookies@5.0.2': + resolution: {integrity: sha512-Sd8LcWpZk/SWEeKGE8LT6gMm5MGfX/wm+GPnh1eBEtCpya3vYqn37wYknwAHw92ONoyyREl1hJwxV/Qx2DWNOg==} + engines: {node: '>=16'} + '@emnapi/runtime@0.45.0': resolution: {integrity: sha512-Txumi3td7J4A/xTTwlssKieHKTGl3j4A1tglBx72auZ49YK7ePY6XZricgIg9mnZT4xPfA+UPCUdnhRuEFDL+w==} @@ -1941,6 +1951,11 @@ packages: '@fastify/deepmerge@1.3.0': resolution: {integrity: sha512-J8TOSBq3SoZbDhM9+R/u77hP93gz/rajSA+K2kGyijPpORPWUXHUpTaleoj+92As0S9uPRP7Oi8IqMf0u+ro6A==} + '@flags-sdk/vercel@0.1.8': + resolution: {integrity: sha512-V4Ek26i7Kuv2EMjZ0S4hqWsiZoybdfz0Kww0Nsr3cr/m76Z1IVrU6wWuaYFduLUzc40pcDqhTpMHGZB6+xYqDw==} + peerDependencies: + flags: '*' + '@floating-ui/core@1.6.9': resolution: {integrity: sha512-uMXCuQ3BItDUbAMhIXw7UPXRfAlOAvZzdK9BWpE60MCn+Svt3aLn9jsPTi/WNGlRUu2uI0v5S7JiIUsbsvh3fw==} @@ -3804,6 +3819,30 @@ packages: resolution: {integrity: sha512-LtHmiYAdJhiSAfBP+5hHXtVyqZUND2G+ild/XVY0SOiB46ab7VUrQctwUMGcVx+yZyXZ2lXPT1HvRJtXFnKvHA==} engines: {node: '>=16.14'} + '@vercel/edge-config-fs@0.1.0': + resolution: {integrity: sha512-NRIBwfcS0bUoUbRWlNGetqjvLSwgYH/BqKqDN7vK1g32p7dN96k0712COgaz6VFizAm9b0g6IG6hR6+hc0KCPg==} + + '@vercel/edge-config@1.4.3': + resolution: {integrity: sha512-8vTDATodRrH49wMzKEjZ8/5H2qs1aPkD0uRK585f/Fx4YN2wfHfY/3td9OFrh+gdnCq07z8A5f0hoY6xhBcPkg==} + engines: {node: '>=14.6'} + peerDependencies: + '@opentelemetry/api': ^1.7.0 + next: '>=1' + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + next: + optional: true + + '@vercel/flags-core@0.1.8': + resolution: {integrity: sha512-tFdHFpvlrKvfXdxjM5Xyi6vpDg6y23tqQgnqfvleGuw+T5NZz2LX+WYh3D/DUJzGGWAxZt1/aZ+0SKlIjdG0lA==} + peerDependencies: + '@openfeature/server-sdk': 1.18.0 + flags: '*' + peerDependenciesMeta: + '@openfeature/server-sdk': + optional: true + '@vercel/speed-insights@1.2.0': resolution: {integrity: sha512-y9GVzrUJ2xmgtQlzFP2KhVRoCglwfRQgjyfY607aU0hh0Un6d0OUyrJkjuAlsV18qR4zfoFPs/BiIj9YDS6Wzw==} peerDependencies: @@ -5650,6 +5689,26 @@ packages: resolution: {integrity: sha512-Gq/a6YCi8zexmGHMuJwahTGzXlAZAOsbCVKduWXC6TlLCjjFRlExMJc4GC2NYPYZ0r/brw9P7CpRgQmlPVeOoA==} engines: {node: '>= 10.13.0'} + flags@4.0.2: + resolution: {integrity: sha512-qNMPc10TKe/5pA0evWxS0vDm3AMpicB7uf6e/+828chNAWUoVziRJfyOcBWwadl4zBhmTfPz+hxTVpNKF7+/PA==} + peerDependencies: + '@opentelemetry/api': ^1.7.0 + '@sveltejs/kit': '*' + next: '*' + react: '*' + react-dom: '*' + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + '@sveltejs/kit': + optional: true + next: + optional: true + react: + optional: true + react-dom: + optional: true + flat-cache@3.0.4: resolution: {integrity: sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg==} engines: {node: ^10.12.0 || >=12.0.0} @@ -6696,6 +6755,12 @@ packages: jju@1.4.0: resolution: {integrity: sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA==} + jose@5.10.0: + resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==} + + jose@5.2.1: + resolution: {integrity: sha512-qiaQhtQRw6YrOaOj0v59h3R6hUY9NvxBmmnMfKemkqYmBB0tEc97NbLP7ix44VP5p9/0YHG8Vyhzuo5YBNwviA==} + joycon@3.1.1: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} @@ -6703,6 +6768,10 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + js-xxhash@4.0.0: + resolution: {integrity: sha512-3Q2eIqG6s1KEBBmkj9tGM9lef8LJbuRyTVBdI3GpTnrvtytunjLPO0wqABp5qhtMzfA32jYn1FlnIV7GH1RAHQ==} + engines: {node: '>=18.0.0'} + js-yaml@3.14.1: resolution: {integrity: sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==} hasBin: true @@ -9473,11 +9542,11 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/eslint-parser@7.22.11(@babel/core@7.26.10)(eslint@8.57.0)': + '@babel/eslint-parser@7.22.11(@babel/core@7.26.10)(eslint@8.55.0)': dependencies: '@babel/core': 7.26.10 '@nicolo-ribaudo/eslint-scope-5-internals': 5.1.1-v1 - eslint: 8.57.0 + eslint: 8.55.0 eslint-visitor-keys: 2.1.0 semver: 6.3.1 @@ -9643,6 +9712,8 @@ snapshots: dependencies: '@jridgewell/trace-mapping': 0.3.9 + '@edge-runtime/cookies@5.0.2': {} + '@emnapi/runtime@0.45.0': dependencies: tslib: 2.8.1 @@ -9926,6 +9997,18 @@ snapshots: '@fastify/deepmerge@1.3.0': {} + '@flags-sdk/vercel@0.1.8(flags@4.0.2(next@15.5.9(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(next@15.5.9(react-dom@19.0.0(react@19.0.0))(react@19.0.0))': + dependencies: + '@vercel/edge-config': 1.4.3(next@15.5.9(react-dom@19.0.0(react@19.0.0))(react@19.0.0)) + '@vercel/flags-core': 0.1.8(flags@4.0.2(next@15.5.9(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(next@15.5.9(react-dom@19.0.0(react@19.0.0))(react@19.0.0)) + flags: 4.0.2(next@15.5.9(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + jose: 5.2.1 + js-xxhash: 4.0.0 + transitivePeerDependencies: + - '@openfeature/server-sdk' + - '@opentelemetry/api' + - next + '@floating-ui/core@1.6.9': dependencies: '@floating-ui/utils': 0.2.9 @@ -10218,7 +10301,7 @@ snapshots: - supports-color - ts-node - '@jest/core@29.7.0(ts-node@10.9.2(@swc/core@1.10.16(@swc/helpers@0.5.15))(@types/node@22.15.3)(typescript@5.6.3))': + '@jest/core@29.7.0(ts-node@10.9.2(@swc/core@1.10.16(@swc/helpers@0.5.15))(@types/node@22.15.3)(typescript@5.5.4))': dependencies: '@jest/console': 29.7.0 '@jest/reporters': 29.7.0 @@ -10232,7 +10315,7 @@ snapshots: exit: 0.1.2 graceful-fs: 4.2.11 jest-changed-files: 29.7.0 - jest-config: 29.7.0(@types/node@22.15.3)(ts-node@10.9.2(@swc/core@1.10.16(@swc/helpers@0.5.15))(@types/node@22.15.3)(typescript@5.6.3)) + jest-config: 29.7.0(@types/node@22.15.3)(ts-node@10.9.2(@swc/core@1.10.16(@swc/helpers@0.5.15))(@types/node@22.15.3)(typescript@5.5.4)) jest-haste-map: 29.7.0 jest-message-util: 29.7.0 jest-regex-util: 29.6.3 @@ -11877,36 +11960,36 @@ snapshots: dependencies: '@types/yargs-parser': 21.0.0 - '@typescript-eslint/eslint-plugin@6.18.1(@typescript-eslint/parser@6.18.1(eslint@8.57.0)(typescript@5.6.3))(eslint@8.57.0)(typescript@5.6.3)': + '@typescript-eslint/eslint-plugin@6.18.1(@typescript-eslint/parser@6.18.1(eslint@8.55.0)(typescript@5.5.4))(eslint@8.55.0)(typescript@5.5.4)': dependencies: '@eslint-community/regexpp': 4.10.0 - '@typescript-eslint/parser': 6.18.1(eslint@8.57.0)(typescript@5.6.3) + '@typescript-eslint/parser': 6.18.1(eslint@8.55.0)(typescript@5.5.4) '@typescript-eslint/scope-manager': 6.18.1 - '@typescript-eslint/type-utils': 6.18.1(eslint@8.57.0)(typescript@5.6.3) - '@typescript-eslint/utils': 6.18.1(eslint@8.57.0)(typescript@5.6.3) + '@typescript-eslint/type-utils': 6.18.1(eslint@8.55.0)(typescript@5.5.4) + '@typescript-eslint/utils': 6.18.1(eslint@8.55.0)(typescript@5.5.4) '@typescript-eslint/visitor-keys': 6.18.1 debug: 4.4.0 - eslint: 8.57.0 + eslint: 8.55.0 graphemer: 1.4.0 ignore: 5.3.0 natural-compare: 1.4.0 semver: 7.6.2 - ts-api-utils: 1.0.2(typescript@5.6.3) + ts-api-utils: 1.0.2(typescript@5.5.4) optionalDependencies: - typescript: 5.6.3 + typescript: 5.5.4 transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@6.18.1(eslint@8.57.0)(typescript@5.6.3)': + '@typescript-eslint/parser@6.18.1(eslint@8.55.0)(typescript@5.5.4)': dependencies: '@typescript-eslint/scope-manager': 6.18.1 '@typescript-eslint/types': 6.18.1 - '@typescript-eslint/typescript-estree': 6.18.1(typescript@5.6.3) + '@typescript-eslint/typescript-estree': 6.18.1(typescript@5.5.4) '@typescript-eslint/visitor-keys': 6.18.1 debug: 4.4.0 - eslint: 8.57.0 + eslint: 8.55.0 optionalDependencies: - typescript: 5.6.3 + typescript: 5.5.4 transitivePeerDependencies: - supports-color @@ -11920,15 +12003,15 @@ snapshots: '@typescript-eslint/types': 6.18.1 '@typescript-eslint/visitor-keys': 6.18.1 - '@typescript-eslint/type-utils@6.18.1(eslint@8.57.0)(typescript@5.6.3)': + '@typescript-eslint/type-utils@6.18.1(eslint@8.55.0)(typescript@5.5.4)': dependencies: - '@typescript-eslint/typescript-estree': 6.18.1(typescript@5.6.3) - '@typescript-eslint/utils': 6.18.1(eslint@8.57.0)(typescript@5.6.3) + '@typescript-eslint/typescript-estree': 6.18.1(typescript@5.5.4) + '@typescript-eslint/utils': 6.18.1(eslint@8.55.0)(typescript@5.5.4) debug: 4.4.0 - eslint: 8.57.0 - ts-api-utils: 1.0.2(typescript@5.6.3) + eslint: 8.55.0 + ts-api-utils: 1.0.2(typescript@5.5.4) optionalDependencies: - typescript: 5.6.3 + typescript: 5.5.4 transitivePeerDependencies: - supports-color @@ -11936,7 +12019,7 @@ snapshots: '@typescript-eslint/types@6.18.1': {} - '@typescript-eslint/typescript-estree@5.62.0(typescript@5.6.3)': + '@typescript-eslint/typescript-estree@5.62.0(typescript@5.5.4)': dependencies: '@typescript-eslint/types': 5.62.0 '@typescript-eslint/visitor-keys': 5.62.0 @@ -11944,13 +12027,13 @@ snapshots: globby: 11.1.0 is-glob: 4.0.3 semver: 7.6.2 - tsutils: 3.21.0(typescript@5.6.3) + tsutils: 3.21.0(typescript@5.5.4) optionalDependencies: - typescript: 5.6.3 + typescript: 5.5.4 transitivePeerDependencies: - supports-color - '@typescript-eslint/typescript-estree@6.18.1(typescript@5.6.3)': + '@typescript-eslint/typescript-estree@6.18.1(typescript@5.5.4)': dependencies: '@typescript-eslint/types': 6.18.1 '@typescript-eslint/visitor-keys': 6.18.1 @@ -11959,36 +12042,36 @@ snapshots: is-glob: 4.0.3 minimatch: 9.0.3 semver: 7.6.2 - ts-api-utils: 1.0.2(typescript@5.6.3) + ts-api-utils: 1.0.2(typescript@5.5.4) optionalDependencies: - typescript: 5.6.3 + typescript: 5.5.4 transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@5.62.0(eslint@8.57.0)(typescript@5.6.3)': + '@typescript-eslint/utils@5.62.0(eslint@8.55.0)(typescript@5.5.4)': dependencies: - '@eslint-community/eslint-utils': 4.4.0(eslint@8.57.0) + '@eslint-community/eslint-utils': 4.4.0(eslint@8.55.0) '@types/json-schema': 7.0.15 '@types/semver': 7.5.8 '@typescript-eslint/scope-manager': 5.62.0 '@typescript-eslint/types': 5.62.0 - '@typescript-eslint/typescript-estree': 5.62.0(typescript@5.6.3) - eslint: 8.57.0 + '@typescript-eslint/typescript-estree': 5.62.0(typescript@5.5.4) + eslint: 8.55.0 eslint-scope: 5.1.1 semver: 7.6.2 transitivePeerDependencies: - supports-color - typescript - '@typescript-eslint/utils@6.18.1(eslint@8.57.0)(typescript@5.6.3)': + '@typescript-eslint/utils@6.18.1(eslint@8.55.0)(typescript@5.5.4)': dependencies: - '@eslint-community/eslint-utils': 4.4.0(eslint@8.57.0) + '@eslint-community/eslint-utils': 4.4.0(eslint@8.55.0) '@types/json-schema': 7.0.15 '@types/semver': 7.5.8 '@typescript-eslint/scope-manager': 6.18.1 '@typescript-eslint/types': 6.18.1 - '@typescript-eslint/typescript-estree': 6.18.1(typescript@5.6.3) - eslint: 8.57.0 + '@typescript-eslint/typescript-estree': 6.18.1(typescript@5.5.4) + eslint: 8.55.0 semver: 7.6.2 transitivePeerDependencies: - supports-color @@ -12022,36 +12105,54 @@ snapshots: is-buffer: 2.0.5 undici: 5.28.3 + '@vercel/edge-config-fs@0.1.0': {} + + '@vercel/edge-config@1.4.3(next@15.5.9(react-dom@19.0.0(react@19.0.0))(react@19.0.0))': + dependencies: + '@vercel/edge-config-fs': 0.1.0 + optionalDependencies: + next: 15.5.9(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + + '@vercel/flags-core@0.1.8(flags@4.0.2(next@15.5.9(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(next@15.5.9(react-dom@19.0.0(react@19.0.0))(react@19.0.0))': + dependencies: + '@vercel/edge-config': 1.4.3(next@15.5.9(react-dom@19.0.0(react@19.0.0))(react@19.0.0)) + flags: 4.0.2(next@15.5.9(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + jose: 5.2.1 + js-xxhash: 4.0.0 + transitivePeerDependencies: + - '@opentelemetry/api' + - next + '@vercel/speed-insights@1.2.0(next@15.5.9(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)': optionalDependencies: next: 15.5.9(react-dom@19.0.0(react@19.0.0))(react@19.0.0) react: 19.0.0 - '@vercel/style-guide@5.1.0(eslint@8.57.0)(jest@29.7.0(@types/node@22.15.3)(ts-node@10.9.2(@swc/core@1.10.16(@swc/helpers@0.5.15))(@types/node@22.15.3)(typescript@5.6.3)))(prettier@3.5.3)(typescript@5.6.3)': + '@vercel/style-guide@5.1.0(eslint@8.55.0)(jest@29.7.0(@types/node@22.15.3)(ts-node@10.9.2(@swc/core@1.10.16(@swc/helpers@0.5.15))(@types/node@22.15.3)(typescript@5.5.4)))(prettier@3.5.3)(typescript@5.5.4)': dependencies: '@babel/core': 7.26.10 - '@babel/eslint-parser': 7.22.11(@babel/core@7.26.10)(eslint@8.57.0) + '@babel/eslint-parser': 7.22.11(@babel/core@7.26.10)(eslint@8.55.0) '@rushstack/eslint-patch': 1.3.3 - '@typescript-eslint/eslint-plugin': 6.18.1(@typescript-eslint/parser@6.18.1(eslint@8.57.0)(typescript@5.6.3))(eslint@8.57.0)(typescript@5.6.3) - '@typescript-eslint/parser': 6.18.1(eslint@8.57.0)(typescript@5.6.3) - eslint-config-prettier: 9.0.0(eslint@8.57.0) + '@typescript-eslint/eslint-plugin': 6.18.1(@typescript-eslint/parser@6.18.1(eslint@8.55.0)(typescript@5.5.4))(eslint@8.55.0)(typescript@5.5.4) + '@typescript-eslint/parser': 6.18.1(eslint@8.55.0)(typescript@5.5.4) + eslint-config-prettier: 9.0.0(eslint@8.55.0) eslint-import-resolver-alias: 1.1.2(eslint-plugin-import@2.28.1) - eslint-import-resolver-typescript: 3.6.0(@typescript-eslint/parser@6.18.1(eslint@8.57.0)(typescript@5.6.3))(eslint-plugin-import@2.28.1)(eslint@8.57.0) - eslint-plugin-eslint-comments: 3.2.0(eslint@8.57.0) - eslint-plugin-import: 2.28.1(@typescript-eslint/parser@6.18.1(eslint@8.57.0)(typescript@5.6.3))(eslint-import-resolver-typescript@3.6.0)(eslint@8.57.0) - eslint-plugin-jest: 27.2.3(@typescript-eslint/eslint-plugin@6.18.1(@typescript-eslint/parser@6.18.1(eslint@8.57.0)(typescript@5.6.3))(eslint@8.57.0)(typescript@5.6.3))(eslint@8.57.0)(jest@29.7.0(@types/node@22.15.3)(ts-node@10.9.2(@swc/core@1.10.16(@swc/helpers@0.5.15))(@types/node@22.15.3)(typescript@5.6.3)))(typescript@5.6.3) - eslint-plugin-jsx-a11y: 6.7.1(eslint@8.57.0) - eslint-plugin-playwright: 0.16.0(eslint-plugin-jest@27.2.3(@typescript-eslint/eslint-plugin@6.18.1(@typescript-eslint/parser@6.18.1(eslint@8.57.0)(typescript@5.6.3))(eslint@8.57.0)(typescript@5.6.3))(eslint@8.57.0)(jest@29.7.0(@types/node@22.15.3)(ts-node@10.9.2(@swc/core@1.10.16(@swc/helpers@0.5.15))(@types/node@22.15.3)(typescript@5.6.3)))(typescript@5.6.3))(eslint@8.57.0) - eslint-plugin-react: 7.33.2(eslint@8.57.0) - eslint-plugin-react-hooks: 4.6.0(eslint@8.57.0) - eslint-plugin-testing-library: 6.0.1(eslint@8.57.0)(typescript@5.6.3) + eslint-import-resolver-typescript: 3.6.0(@typescript-eslint/parser@6.18.1(eslint@8.55.0)(typescript@5.5.4))(eslint-plugin-import@2.28.1)(eslint@8.55.0) + eslint-plugin-eslint-comments: 3.2.0(eslint@8.55.0) + eslint-plugin-import: 2.28.1(@typescript-eslint/parser@6.18.1(eslint@8.55.0)(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.0)(eslint@8.55.0) + eslint-plugin-jest: 27.2.3(@typescript-eslint/eslint-plugin@6.18.1(@typescript-eslint/parser@6.18.1(eslint@8.55.0)(typescript@5.5.4))(eslint@8.55.0)(typescript@5.5.4))(eslint@8.55.0)(jest@29.7.0(@types/node@22.15.3)(ts-node@10.9.2(@swc/core@1.10.16(@swc/helpers@0.5.15))(@types/node@22.15.3)(typescript@5.5.4)))(typescript@5.5.4) + eslint-plugin-jsx-a11y: 6.7.1(eslint@8.55.0) + eslint-plugin-playwright: 0.16.0(eslint-plugin-jest@27.2.3(@typescript-eslint/eslint-plugin@6.18.1(@typescript-eslint/parser@6.18.1(eslint@8.55.0)(typescript@5.5.4))(eslint@8.55.0)(typescript@5.5.4))(eslint@8.55.0)(jest@29.7.0(@types/node@22.15.3)(ts-node@10.9.2(@swc/core@1.10.16(@swc/helpers@0.5.15))(@types/node@22.15.3)(typescript@5.5.4)))(typescript@5.5.4))(eslint@8.55.0) + eslint-plugin-react: 7.33.2(eslint@8.55.0) + eslint-plugin-react-hooks: 4.6.0(eslint@8.55.0) + eslint-plugin-testing-library: 6.0.1(eslint@8.55.0)(typescript@5.5.4) eslint-plugin-tsdoc: 0.2.17 - eslint-plugin-unicorn: 48.0.1(eslint@8.57.0) + eslint-plugin-unicorn: 48.0.1(eslint@8.55.0) prettier-plugin-packagejson: 2.4.5(prettier@3.5.3) optionalDependencies: - eslint: 8.57.0 + eslint: 8.55.0 prettier: 3.5.3 - typescript: 5.6.3 + typescript: 5.5.4 transitivePeerDependencies: - eslint-import-resolver-node - eslint-import-resolver-webpack @@ -12447,7 +12548,7 @@ snapshots: dependencies: semver: 7.6.2 - bunchee@6.3.4(typescript@5.6.3): + bunchee@6.3.4(typescript@5.5.4): dependencies: '@rollup/plugin-commonjs': 28.0.2(rollup@4.34.7) '@rollup/plugin-json': 6.1.0(rollup@4.34.7) @@ -12464,13 +12565,13 @@ snapshots: picomatch: 4.0.2 pretty-bytes: 5.6.0 rollup: 4.34.7 - rollup-plugin-dts: 6.1.1(rollup@4.34.7)(typescript@5.6.3) + rollup-plugin-dts: 6.1.1(rollup@4.34.7)(typescript@5.5.4) rollup-plugin-swc3: 0.11.2(@swc/core@1.10.16(@swc/helpers@0.5.15))(rollup@4.34.7) rollup-preserve-directives: 1.1.3(rollup@4.34.7) tslib: 2.8.1 yargs: 17.7.2 optionalDependencies: - typescript: 5.6.3 + typescript: 5.5.4 bundle-name@3.0.0: dependencies: @@ -12854,13 +12955,13 @@ snapshots: - supports-color - ts-node - create-jest@29.7.0(@types/node@22.15.3)(ts-node@10.9.2(@swc/core@1.10.16(@swc/helpers@0.5.15))(@types/node@22.15.3)(typescript@5.6.3)): + create-jest@29.7.0(@types/node@22.15.3)(ts-node@10.9.2(@swc/core@1.10.16(@swc/helpers@0.5.15))(@types/node@22.15.3)(typescript@5.5.4)): dependencies: '@jest/types': 29.6.3 chalk: 4.1.2 exit: 0.1.2 graceful-fs: 4.2.11 - jest-config: 29.7.0(@types/node@22.15.3)(ts-node@10.9.2(@swc/core@1.10.16(@swc/helpers@0.5.15))(@types/node@22.15.3)(typescript@5.6.3)) + jest-config: 29.7.0(@types/node@22.15.3)(ts-node@10.9.2(@swc/core@1.10.16(@swc/helpers@0.5.15))(@types/node@22.15.3)(typescript@5.5.4)) jest-util: 29.7.0 prompts: 2.4.2 transitivePeerDependencies: @@ -13605,13 +13706,13 @@ snapshots: optionalDependencies: source-map: 0.6.1 - eslint-config-prettier@9.0.0(eslint@8.57.0): + eslint-config-prettier@9.0.0(eslint@8.55.0): dependencies: - eslint: 8.57.0 + eslint: 8.55.0 eslint-import-resolver-alias@1.1.2(eslint-plugin-import@2.28.1): dependencies: - eslint-plugin-import: 2.28.1(@typescript-eslint/parser@6.18.1(eslint@8.57.0)(typescript@5.6.3))(eslint-import-resolver-typescript@3.6.0)(eslint@8.57.0) + eslint-plugin-import: 2.28.1(@typescript-eslint/parser@6.18.1(eslint@8.55.0)(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.0)(eslint@8.55.0) eslint-import-resolver-node@0.3.9: dependencies: @@ -13621,13 +13722,13 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.6.0(@typescript-eslint/parser@6.18.1(eslint@8.57.0)(typescript@5.6.3))(eslint-plugin-import@2.28.1)(eslint@8.57.0): + eslint-import-resolver-typescript@3.6.0(@typescript-eslint/parser@6.18.1(eslint@8.55.0)(typescript@5.5.4))(eslint-plugin-import@2.28.1)(eslint@8.55.0): dependencies: debug: 4.4.0 enhanced-resolve: 5.12.0 - eslint: 8.57.0 - eslint-module-utils: 2.8.0(@typescript-eslint/parser@6.18.1(eslint@8.57.0)(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.0)(eslint@8.57.0) - eslint-plugin-import: 2.28.1(@typescript-eslint/parser@6.18.1(eslint@8.57.0)(typescript@5.6.3))(eslint-import-resolver-typescript@3.6.0)(eslint@8.57.0) + eslint: 8.55.0 + eslint-module-utils: 2.8.0(@typescript-eslint/parser@6.18.1(eslint@8.55.0)(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.0)(eslint@8.55.0) + eslint-plugin-import: 2.28.1(@typescript-eslint/parser@6.18.1(eslint@8.55.0)(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.0)(eslint@8.55.0) fast-glob: 3.3.3 get-tsconfig: 4.7.6 is-core-module: 2.13.0 @@ -13638,24 +13739,24 @@ snapshots: - eslint-import-resolver-webpack - supports-color - eslint-module-utils@2.8.0(@typescript-eslint/parser@6.18.1(eslint@8.57.0)(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.0)(eslint@8.57.0): + eslint-module-utils@2.8.0(@typescript-eslint/parser@6.18.1(eslint@8.55.0)(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.0)(eslint@8.55.0): dependencies: debug: 3.2.7 optionalDependencies: - '@typescript-eslint/parser': 6.18.1(eslint@8.57.0)(typescript@5.6.3) - eslint: 8.57.0 + '@typescript-eslint/parser': 6.18.1(eslint@8.55.0)(typescript@5.5.4) + eslint: 8.55.0 eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.6.0(@typescript-eslint/parser@6.18.1(eslint@8.57.0)(typescript@5.6.3))(eslint-plugin-import@2.28.1)(eslint@8.57.0) + eslint-import-resolver-typescript: 3.6.0(@typescript-eslint/parser@6.18.1(eslint@8.55.0)(typescript@5.5.4))(eslint-plugin-import@2.28.1)(eslint@8.55.0) transitivePeerDependencies: - supports-color - eslint-plugin-eslint-comments@3.2.0(eslint@8.57.0): + eslint-plugin-eslint-comments@3.2.0(eslint@8.55.0): dependencies: escape-string-regexp: 1.0.5 - eslint: 8.57.0 + eslint: 8.55.0 ignore: 5.3.0 - eslint-plugin-import@2.28.1(@typescript-eslint/parser@6.18.1(eslint@8.57.0)(typescript@5.6.3))(eslint-import-resolver-typescript@3.6.0)(eslint@8.57.0): + eslint-plugin-import@2.28.1(@typescript-eslint/parser@6.18.1(eslint@8.55.0)(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.0)(eslint@8.55.0): dependencies: array-includes: 3.1.6 array.prototype.findlastindex: 1.2.2 @@ -13663,9 +13764,9 @@ snapshots: array.prototype.flatmap: 1.3.1 debug: 3.2.7 doctrine: 2.1.0 - eslint: 8.57.0 + eslint: 8.55.0 eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.8.0(@typescript-eslint/parser@6.18.1(eslint@8.57.0)(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.0)(eslint@8.57.0) + eslint-module-utils: 2.8.0(@typescript-eslint/parser@6.18.1(eslint@8.55.0)(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.0)(eslint@8.55.0) has: 1.0.3 is-core-module: 2.13.0 is-glob: 4.0.3 @@ -13676,24 +13777,24 @@ snapshots: semver: 6.3.1 tsconfig-paths: 3.14.2 optionalDependencies: - '@typescript-eslint/parser': 6.18.1(eslint@8.57.0)(typescript@5.6.3) + '@typescript-eslint/parser': 6.18.1(eslint@8.55.0)(typescript@5.5.4) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack - supports-color - eslint-plugin-jest@27.2.3(@typescript-eslint/eslint-plugin@6.18.1(@typescript-eslint/parser@6.18.1(eslint@8.57.0)(typescript@5.6.3))(eslint@8.57.0)(typescript@5.6.3))(eslint@8.57.0)(jest@29.7.0(@types/node@22.15.3)(ts-node@10.9.2(@swc/core@1.10.16(@swc/helpers@0.5.15))(@types/node@22.15.3)(typescript@5.6.3)))(typescript@5.6.3): + eslint-plugin-jest@27.2.3(@typescript-eslint/eslint-plugin@6.18.1(@typescript-eslint/parser@6.18.1(eslint@8.55.0)(typescript@5.5.4))(eslint@8.55.0)(typescript@5.5.4))(eslint@8.55.0)(jest@29.7.0(@types/node@22.15.3)(ts-node@10.9.2(@swc/core@1.10.16(@swc/helpers@0.5.15))(@types/node@22.15.3)(typescript@5.5.4)))(typescript@5.5.4): dependencies: - '@typescript-eslint/utils': 5.62.0(eslint@8.57.0)(typescript@5.6.3) - eslint: 8.57.0 + '@typescript-eslint/utils': 5.62.0(eslint@8.55.0)(typescript@5.5.4) + eslint: 8.55.0 optionalDependencies: - '@typescript-eslint/eslint-plugin': 6.18.1(@typescript-eslint/parser@6.18.1(eslint@8.57.0)(typescript@5.6.3))(eslint@8.57.0)(typescript@5.6.3) - jest: 29.7.0(@types/node@22.15.3)(ts-node@10.9.2(@swc/core@1.10.16(@swc/helpers@0.5.15))(@types/node@22.15.3)(typescript@5.6.3)) + '@typescript-eslint/eslint-plugin': 6.18.1(@typescript-eslint/parser@6.18.1(eslint@8.55.0)(typescript@5.5.4))(eslint@8.55.0)(typescript@5.5.4) + jest: 29.7.0(@types/node@22.15.3)(ts-node@10.9.2(@swc/core@1.10.16(@swc/helpers@0.5.15))(@types/node@22.15.3)(typescript@5.5.4)) transitivePeerDependencies: - supports-color - typescript - eslint-plugin-jsx-a11y@6.7.1(eslint@8.57.0): + eslint-plugin-jsx-a11y@6.7.1(eslint@8.55.0): dependencies: '@babel/runtime': 7.22.11 aria-query: 5.3.0 @@ -13704,7 +13805,7 @@ snapshots: axobject-query: 3.2.1 damerau-levenshtein: 1.0.8 emoji-regex: 9.2.2 - eslint: 8.57.0 + eslint: 8.55.0 has: 1.0.3 jsx-ast-utils: 3.3.3 language-tags: 1.0.5 @@ -13713,24 +13814,24 @@ snapshots: object.fromentries: 2.0.6 semver: 6.3.1 - eslint-plugin-playwright@0.16.0(eslint-plugin-jest@27.2.3(@typescript-eslint/eslint-plugin@6.18.1(@typescript-eslint/parser@6.18.1(eslint@8.57.0)(typescript@5.6.3))(eslint@8.57.0)(typescript@5.6.3))(eslint@8.57.0)(jest@29.7.0(@types/node@22.15.3)(ts-node@10.9.2(@swc/core@1.10.16(@swc/helpers@0.5.15))(@types/node@22.15.3)(typescript@5.6.3)))(typescript@5.6.3))(eslint@8.57.0): + eslint-plugin-playwright@0.16.0(eslint-plugin-jest@27.2.3(@typescript-eslint/eslint-plugin@6.18.1(@typescript-eslint/parser@6.18.1(eslint@8.55.0)(typescript@5.5.4))(eslint@8.55.0)(typescript@5.5.4))(eslint@8.55.0)(jest@29.7.0(@types/node@22.15.3)(ts-node@10.9.2(@swc/core@1.10.16(@swc/helpers@0.5.15))(@types/node@22.15.3)(typescript@5.5.4)))(typescript@5.5.4))(eslint@8.55.0): dependencies: - eslint: 8.57.0 + eslint: 8.55.0 optionalDependencies: - eslint-plugin-jest: 27.2.3(@typescript-eslint/eslint-plugin@6.18.1(@typescript-eslint/parser@6.18.1(eslint@8.57.0)(typescript@5.6.3))(eslint@8.57.0)(typescript@5.6.3))(eslint@8.57.0)(jest@29.7.0(@types/node@22.15.3)(ts-node@10.9.2(@swc/core@1.10.16(@swc/helpers@0.5.15))(@types/node@22.15.3)(typescript@5.6.3)))(typescript@5.6.3) + eslint-plugin-jest: 27.2.3(@typescript-eslint/eslint-plugin@6.18.1(@typescript-eslint/parser@6.18.1(eslint@8.55.0)(typescript@5.5.4))(eslint@8.55.0)(typescript@5.5.4))(eslint@8.55.0)(jest@29.7.0(@types/node@22.15.3)(ts-node@10.9.2(@swc/core@1.10.16(@swc/helpers@0.5.15))(@types/node@22.15.3)(typescript@5.5.4)))(typescript@5.5.4) - eslint-plugin-react-hooks@4.6.0(eslint@8.57.0): + eslint-plugin-react-hooks@4.6.0(eslint@8.55.0): dependencies: - eslint: 8.57.0 + eslint: 8.55.0 - eslint-plugin-react@7.33.2(eslint@8.57.0): + eslint-plugin-react@7.33.2(eslint@8.55.0): dependencies: array-includes: 3.1.6 array.prototype.flatmap: 1.3.1 array.prototype.tosorted: 1.1.1 doctrine: 2.1.0 es-iterator-helpers: 1.0.14 - eslint: 8.57.0 + eslint: 8.55.0 estraverse: 5.3.0 jsx-ast-utils: 3.3.3 minimatch: 3.1.2 @@ -13743,10 +13844,10 @@ snapshots: semver: 6.3.1 string.prototype.matchall: 4.0.8 - eslint-plugin-testing-library@6.0.1(eslint@8.57.0)(typescript@5.6.3): + eslint-plugin-testing-library@6.0.1(eslint@8.55.0)(typescript@5.5.4): dependencies: - '@typescript-eslint/utils': 5.62.0(eslint@8.57.0)(typescript@5.6.3) - eslint: 8.57.0 + '@typescript-eslint/utils': 5.62.0(eslint@8.55.0)(typescript@5.5.4) + eslint: 8.55.0 transitivePeerDependencies: - supports-color - typescript @@ -13756,13 +13857,13 @@ snapshots: '@microsoft/tsdoc': 0.14.2 '@microsoft/tsdoc-config': 0.16.2 - eslint-plugin-unicorn@48.0.1(eslint@8.57.0): + eslint-plugin-unicorn@48.0.1(eslint@8.55.0): dependencies: '@babel/helper-validator-identifier': 7.25.9 - '@eslint-community/eslint-utils': 4.4.0(eslint@8.57.0) + '@eslint-community/eslint-utils': 4.4.0(eslint@8.55.0) ci-info: 3.8.0 clean-regexp: 1.0.0 - eslint: 8.57.0 + eslint: 8.55.0 esquery: 1.5.0 indent-string: 4.0.0 is-builtin-module: 3.2.1 @@ -14193,6 +14294,15 @@ snapshots: flagged-respawn@2.0.0: {} + flags@4.0.2(next@15.5.9(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0): + dependencies: + '@edge-runtime/cookies': 5.0.2 + jose: 5.10.0 + optionalDependencies: + next: 15.5.9(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + flat-cache@3.0.4: dependencies: flatted: 3.2.7 @@ -15336,16 +15446,16 @@ snapshots: - supports-color - ts-node - jest-cli@29.7.0(@types/node@22.15.3)(ts-node@10.9.2(@swc/core@1.10.16(@swc/helpers@0.5.15))(@types/node@22.15.3)(typescript@5.6.3)): + jest-cli@29.7.0(@types/node@22.15.3)(ts-node@10.9.2(@swc/core@1.10.16(@swc/helpers@0.5.15))(@types/node@22.15.3)(typescript@5.5.4)): dependencies: - '@jest/core': 29.7.0(ts-node@10.9.2(@swc/core@1.10.16(@swc/helpers@0.5.15))(@types/node@22.15.3)(typescript@5.6.3)) + '@jest/core': 29.7.0(ts-node@10.9.2(@swc/core@1.10.16(@swc/helpers@0.5.15))(@types/node@22.15.3)(typescript@5.5.4)) '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 chalk: 4.1.2 - create-jest: 29.7.0(@types/node@22.15.3)(ts-node@10.9.2(@swc/core@1.10.16(@swc/helpers@0.5.15))(@types/node@22.15.3)(typescript@5.6.3)) + create-jest: 29.7.0(@types/node@22.15.3)(ts-node@10.9.2(@swc/core@1.10.16(@swc/helpers@0.5.15))(@types/node@22.15.3)(typescript@5.5.4)) exit: 0.1.2 import-local: 3.1.0 - jest-config: 29.7.0(@types/node@22.15.3)(ts-node@10.9.2(@swc/core@1.10.16(@swc/helpers@0.5.15))(@types/node@22.15.3)(typescript@5.6.3)) + jest-config: 29.7.0(@types/node@22.15.3)(ts-node@10.9.2(@swc/core@1.10.16(@swc/helpers@0.5.15))(@types/node@22.15.3)(typescript@5.5.4)) jest-util: 29.7.0 jest-validate: 29.7.0 yargs: 17.7.2 @@ -15418,7 +15528,7 @@ snapshots: - babel-plugin-macros - supports-color - jest-config@29.7.0(@types/node@22.15.3)(ts-node@10.9.2(@swc/core@1.10.16(@swc/helpers@0.5.15))(@types/node@22.15.3)(typescript@5.6.3)): + jest-config@29.7.0(@types/node@22.15.3)(ts-node@10.9.2(@swc/core@1.10.16(@swc/helpers@0.5.15))(@types/node@22.15.3)(typescript@5.5.4)): dependencies: '@babel/core': 7.26.10 '@jest/test-sequencer': 29.7.0 @@ -15444,7 +15554,7 @@ snapshots: strip-json-comments: 3.1.1 optionalDependencies: '@types/node': 22.15.3 - ts-node: 10.9.2(@swc/core@1.10.16(@swc/helpers@0.5.15))(@types/node@22.15.3)(typescript@5.6.3) + ts-node: 10.9.2(@swc/core@1.10.16(@swc/helpers@0.5.15))(@types/node@22.15.3)(typescript@5.5.4) transitivePeerDependencies: - babel-plugin-macros - supports-color @@ -15677,12 +15787,12 @@ snapshots: - supports-color - ts-node - jest@29.7.0(@types/node@22.15.3)(ts-node@10.9.2(@swc/core@1.10.16(@swc/helpers@0.5.15))(@types/node@22.15.3)(typescript@5.6.3)): + jest@29.7.0(@types/node@22.15.3)(ts-node@10.9.2(@swc/core@1.10.16(@swc/helpers@0.5.15))(@types/node@22.15.3)(typescript@5.5.4)): dependencies: - '@jest/core': 29.7.0(ts-node@10.9.2(@swc/core@1.10.16(@swc/helpers@0.5.15))(@types/node@22.15.3)(typescript@5.6.3)) + '@jest/core': 29.7.0(ts-node@10.9.2(@swc/core@1.10.16(@swc/helpers@0.5.15))(@types/node@22.15.3)(typescript@5.5.4)) '@jest/types': 29.6.3 import-local: 3.1.0 - jest-cli: 29.7.0(@types/node@22.15.3)(ts-node@10.9.2(@swc/core@1.10.16(@swc/helpers@0.5.15))(@types/node@22.15.3)(typescript@5.6.3)) + jest-cli: 29.7.0(@types/node@22.15.3)(ts-node@10.9.2(@swc/core@1.10.16(@swc/helpers@0.5.15))(@types/node@22.15.3)(typescript@5.5.4)) transitivePeerDependencies: - '@types/node' - babel-plugin-macros @@ -15694,10 +15804,16 @@ snapshots: jju@1.4.0: {} + jose@5.10.0: {} + + jose@5.2.1: {} + joycon@3.1.1: {} js-tokens@4.0.0: {} + js-xxhash@4.0.0: {} + js-yaml@3.14.1: dependencies: argparse: 1.0.10 @@ -17700,11 +17816,11 @@ snapshots: robust-predicates@3.0.2: {} - rollup-plugin-dts@6.1.1(rollup@4.34.7)(typescript@5.6.3): + rollup-plugin-dts@6.1.1(rollup@4.34.7)(typescript@5.5.4): dependencies: magic-string: 0.30.17 rollup: 4.34.7 - typescript: 5.6.3 + typescript: 5.5.4 optionalDependencies: '@babel/code-frame': 7.26.2 @@ -18407,9 +18523,9 @@ snapshots: trough@2.2.0: {} - ts-api-utils@1.0.2(typescript@5.6.3): + ts-api-utils@1.0.2(typescript@5.5.4): dependencies: - typescript: 5.6.3 + typescript: 5.5.4 ts-interface-checker@0.1.13: {} @@ -18453,26 +18569,6 @@ snapshots: babel-jest: 29.7.0(@babel/core@7.26.10) esbuild: 0.15.6 - ts-jest@29.2.5(@babel/core@7.26.10)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.26.10))(esbuild@0.17.18)(jest@29.7.0(@types/node@18.17.4)(ts-node@10.9.2(@swc/core@1.10.16(@swc/helpers@0.5.15))(@types/node@18.17.4)(typescript@5.5.4)))(typescript@5.5.4): - dependencies: - bs-logger: 0.2.6 - ejs: 3.1.10 - fast-json-stable-stringify: 2.1.0 - jest: 29.7.0(@types/node@18.17.4)(ts-node@10.9.2(@swc/core@1.10.16(@swc/helpers@0.5.15))(@types/node@18.17.4)(typescript@5.5.4)) - jest-util: 29.7.0 - json5: 2.2.3 - lodash.memoize: 4.1.2 - make-error: 1.3.6 - semver: 7.7.3 - typescript: 5.5.4 - yargs-parser: 21.1.1 - optionalDependencies: - '@babel/core': 7.26.10 - '@jest/transform': 29.7.0 - '@jest/types': 29.6.3 - babel-jest: 29.7.0(@babel/core@7.26.10) - esbuild: 0.17.18 - ts-json-schema-generator@2.3.0: dependencies: '@types/json-schema': 7.0.15 @@ -18546,7 +18642,7 @@ snapshots: '@swc/core': 1.10.16(@swc/helpers@0.5.15) optional: true - ts-node@10.9.2(@swc/core@1.10.16(@swc/helpers@0.5.15))(@types/node@22.15.3)(typescript@5.6.3): + ts-node@10.9.2(@swc/core@1.10.16(@swc/helpers@0.5.15))(@types/node@22.15.3)(typescript@5.5.4): dependencies: '@cspotcode/source-map-support': 0.8.1 '@tsconfig/node10': 1.0.9 @@ -18560,7 +18656,7 @@ snapshots: create-require: 1.1.1 diff: 4.0.2 make-error: 1.3.6 - typescript: 5.6.3 + typescript: 5.5.4 v8-compile-cache-lib: 3.0.1 yn: 3.1.1 optionalDependencies: @@ -18652,10 +18748,10 @@ snapshots: - supports-color - ts-node - tsutils@3.21.0(typescript@5.6.3): + tsutils@3.21.0(typescript@5.5.4): dependencies: tslib: 1.14.1 - typescript: 5.6.3 + typescript: 5.5.4 tsx@4.19.1: dependencies: From 582335c61b386c3ed98f44fcb572ab715651361a Mon Sep 17 00:00:00 2001 From: Anthony Shew Date: Tue, 16 Dec 2025 14:22:08 -0700 Subject: [PATCH 06/14] feat: Turborepo Devtools --- crates/turborepo-devtools/src/graph.rs | 5 +++-- crates/turborepo-devtools/src/server.rs | 13 ++++++++----- crates/turborepo-devtools/src/types.rs | 3 ++- crates/turborepo-devtools/src/watcher.rs | 14 +++++++++----- 4 files changed, 22 insertions(+), 13 deletions(-) diff --git a/crates/turborepo-devtools/src/graph.rs b/crates/turborepo-devtools/src/graph.rs index d870011eec2f3..37f47fbea9493 100644 --- a/crates/turborepo-devtools/src/graph.rs +++ b/crates/turborepo-devtools/src/graph.rs @@ -41,8 +41,9 @@ pub fn package_graph_to_data(pkg_graph: &PackageGraph) -> PackageGraphData { }); // Get dependencies for this package and create edges - // Note: All packages (including root) are stored as Workspace nodes in the graph. - // PackageNode::Root is a separate synthetic node that all workspace packages depend on. + // Note: All packages (including root) are stored as Workspace nodes in the + // graph. PackageNode::Root is a separate synthetic node that all + // workspace packages depend on. let pkg_node = RepoPackageNode::Workspace(name.clone()); if let Some(deps) = pkg_graph.immediate_dependencies(&pkg_node) { diff --git a/crates/turborepo-devtools/src/server.rs b/crates/turborepo-devtools/src/server.rs index 7c7b9767bc519..2de74dc882cd5 100644 --- a/crates/turborepo-devtools/src/server.rs +++ b/crates/turborepo-devtools/src/server.rs @@ -138,10 +138,12 @@ impl DevtoolsServer { // Bind and serve let addr = format!("127.0.0.1:{}", self.port); - let listener = TcpListener::bind(&addr).await.map_err(|e| ServerError::Bind { - port: self.port, - source: e, - })?; + let listener = TcpListener::bind(&addr) + .await + .map_err(|e| ServerError::Bind { + port: self.port, + source: e, + })?; info!("Devtools server listening on ws://{}", addr); @@ -241,7 +243,8 @@ async fn build_graph_state(repo_root: &AbsoluteSystemPathBuf) -> Result Date: Tue, 16 Dec 2025 16:02:57 -0700 Subject: [PATCH 07/14] cool --- crates/turborepo-devtools/src/graph.rs | 7 ++-- crates/turborepo-devtools/src/types.rs | 2 ++ .../(no-sidebar)/devtools/devtools-client.tsx | 35 ++++--------------- .../app/.well-known/vercel/flags/route.ts | 4 +++ 4 files changed, 17 insertions(+), 31 deletions(-) create mode 100644 docs/site/app/.well-known/vercel/flags/route.ts diff --git a/crates/turborepo-devtools/src/graph.rs b/crates/turborepo-devtools/src/graph.rs index 37f47fbea9493..c0aae08d1bf9c 100644 --- a/crates/turborepo-devtools/src/graph.rs +++ b/crates/turborepo-devtools/src/graph.rs @@ -92,12 +92,13 @@ pub fn task_graph_to_data(pkg_graph: &PackageGraph) -> TaskGraphData { PackageName::Other(n) => n.clone(), }; - for script in info.package_json.scripts.keys() { - let task_id = format!("{}#{}", package_id, script); + for (script_name, script_cmd) in info.package_json.scripts.iter() { + let task_id = format!("{}#{}", package_id, script_name); nodes.push(TaskNode { id: task_id, package: package_id.clone(), - task: script.clone(), + task: script_name.clone(), + script: script_cmd.value.clone(), }); } } diff --git a/crates/turborepo-devtools/src/types.rs b/crates/turborepo-devtools/src/types.rs index d43209d7f8566..c5dd9b3da23a3 100644 --- a/crates/turborepo-devtools/src/types.rs +++ b/crates/turborepo-devtools/src/types.rs @@ -98,4 +98,6 @@ pub struct TaskNode { pub package: String, /// Task name (e.g., "build", "test") pub task: String, + /// The script command from package.json (e.g., "tsc --build") + pub script: String, } diff --git a/docs/site/app/(no-sidebar)/devtools/devtools-client.tsx b/docs/site/app/(no-sidebar)/devtools/devtools-client.tsx index 620e84891ee02..da4a605a3f66b 100644 --- a/docs/site/app/(no-sidebar)/devtools/devtools-client.tsx +++ b/docs/site/app/(no-sidebar)/devtools/devtools-client.tsx @@ -43,6 +43,7 @@ interface TaskNode { id: string; package: string; task: string; + script: string; } interface GraphEdge { @@ -91,7 +92,6 @@ const edgeTypes = { const defaultEdgeOptions = { type: "turbo", - markerEnd: "edge-circle", }; // Constants for node sizing @@ -353,7 +353,7 @@ function GraphViewToggle({ color: view === "packages" ? "var(--ds-gray-1000)" : "var(--ds-gray-900)", backgroundColor: - view === "packages" ? "var(--ds-gray-400)" : "transparent", + view === "packages" ? "var(--ds-background-100)" : "transparent", }} > Packages @@ -367,7 +367,7 @@ function GraphViewToggle({ color: view === "tasks" ? "var(--ds-gray-1000)" : "var(--ds-gray-900)", backgroundColor: - view === "tasks" ? "var(--ds-gray-400)" : "transparent", + view === "tasks" ? "var(--ds-background-100)" : "transparent", }} > Tasks @@ -584,7 +584,7 @@ function DevtoolsContent() { return { ...edge, markerStart: useArrow ? "edge-arrow" : undefined, - markerEnd: useArrow ? undefined : edge.markerEnd, + markerEnd: undefined, style: { ...edge.style, opacity: isHighlighted ? 1 : 0.1, @@ -694,7 +694,6 @@ function DevtoolsContent() { source: edge.source, target: edge.target, type: "turbo", - markerEnd: "edge-circle", }) ); @@ -737,7 +736,6 @@ function DevtoolsContent() { source: edge.source, target: edge.target, type: "turbo", - markerEnd: "edge-circle", })); const { nodes: layoutedNodes, edges: layoutedEdges } = @@ -872,8 +870,8 @@ function DevtoolsContent() { })) : graphState.taskGraph.nodes.map((task) => ({ id: task.id, - name: task.task, - subtitle: task.package, + name: task.id, // package#task format + subtitle: task.script, })); const connected: typeof allNodes = []; @@ -1086,7 +1084,7 @@ function DevtoolsContent() { onChange={(e) => { setSearchQuery(e.target.value); }} - className="w-full px-2 py-1.5 text-sm rounded focus:outline-none" + className="w-full px-2 py-1.5 text-sm rounded focus:outline-none placeholder:text-[var(--ds-gray-900)]" style={{ backgroundColor: "var(--ds-gray-200)", border: "1px solid var(--ds-gray-400)", @@ -1244,25 +1242,6 @@ function DevtoolsContent() { - - - - {/* Arrow marker for directional highlighting */} getProviderData(flags)); From 312d894e539f36dcf1b68b92ee54aae0c3a50138 Mon Sep 17 00:00:00 2001 From: Anthony Shew Date: Tue, 16 Dec 2025 16:05:56 -0700 Subject: [PATCH 08/14] line drawing bug --- .../site/app/(no-sidebar)/devtools/floating-edge-utils.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/site/app/(no-sidebar)/devtools/floating-edge-utils.ts b/docs/site/app/(no-sidebar)/devtools/floating-edge-utils.ts index 0fa4f8e41f724..423fe914ee360 100644 --- a/docs/site/app/(no-sidebar)/devtools/floating-edge-utils.ts +++ b/docs/site/app/(no-sidebar)/devtools/floating-edge-utils.ts @@ -47,6 +47,14 @@ function getNodeIntersection( return { x: nodeCenter.x, y: nodeCenter.y }; } + // Handle perfectly vertical lines (dx === 0) + if (dx === 0) { + return { + x: nodeCenter.x, + y: dy > 0 ? nodeCenter.y + h : nodeCenter.y - h, + }; + } + const slope = Math.abs(dy / dx); const nodeSlope = h / w; From 6a1f0aa04efda9c656ca3920ad11d041872a9d7d Mon Sep 17 00:00:00 2001 From: Anthony Shew Date: Tue, 16 Dec 2025 16:31:49 -0700 Subject: [PATCH 09/14] start polish --- .../(no-sidebar)/devtools/devtools-client.tsx | 139 ++++++++++++++---- .../(no-sidebar)/devtools/function-icon.tsx | 27 ++-- .../app/(no-sidebar)/devtools/turbo-flow.css | 65 ++++++-- 3 files changed, 180 insertions(+), 51 deletions(-) diff --git a/docs/site/app/(no-sidebar)/devtools/devtools-client.tsx b/docs/site/app/(no-sidebar)/devtools/devtools-client.tsx index da4a605a3f66b..22bd065877305 100644 --- a/docs/site/app/(no-sidebar)/devtools/devtools-client.tsx +++ b/docs/site/app/(no-sidebar)/devtools/devtools-client.tsx @@ -13,6 +13,7 @@ import { ReactFlow, ReactFlowProvider, Controls, + MiniMap, useNodesState, useEdgesState, useReactFlow, @@ -22,14 +23,23 @@ import { } from "reactflow"; import ELK from "elkjs/lib/elk.bundled.js"; import { Package } from "lucide-react"; +import { DynamicCodeBlock } from "fumadocs-ui/components/dynamic-codeblock"; +import { createCssVariablesTheme } from "shiki"; import "reactflow/dist/base.css"; import "./turbo-flow.css"; +import { Callout } from "#components/callout.tsx"; import TurboNode, { type TurboNodeData } from "./turbo-node"; import TurboEdge from "./turbo-edge"; import FunctionIcon from "./function-icon"; +const theme = createCssVariablesTheme({ + name: "css-variables", + variablePrefix: "--shiki-", + variableDefaults: {}, +}); + // Types matching Rust server interface PackageNode { id: string; @@ -274,22 +284,61 @@ function getConnectedEdges( function SetupInstructions() { return ( -
-
-

+
+
+

Turborepo Devtools

-

+

Run the following command in your Turborepo to start the devtools server:

-
-          turbo devtools
-        
-

+ [0]["options"] + } + /> +

This will automatically open this page with the correct connection parameters.

+ +

+ Already ran it? Add{" "} + + ?port=<your-port> + {" "} + to the URL to get connected. +

+
); @@ -297,16 +346,28 @@ function SetupInstructions() { function DisconnectedOverlay({ port }: { port: string }) { return ( -
-
-

+
+
+

Disconnected

-

+

The connection to turbo devtools was lost. Run the command below to reconnect:

-
+        
           turbo devtools --port {port}
         
@@ -446,7 +507,8 @@ function SelectionIndicator({
-
{isOpen && ( -
+
{modeOptions.map((option) => { const { prefix: optPrefix, suffix: optSuffix } = option.getLabel(); const isSelected = option.mode === selectionMode; @@ -491,9 +553,10 @@ function SelectionIndicator({ onClick={() => { onModeChange(option.mode); }} - className={`w-full px-3 py-1.5 text-left text-sm hover:bg-[#2a8af6]/20 ${ - isSelected ? "text-[#2a8af6]" : "text-[rgb(243,244,246)]" - }`} + className="w-full px-3 py-1.5 text-left text-sm hover:bg-[#2a8af6]/20" + style={{ + color: isSelected ? "#2a8af6" : "var(--ds-gray-1000)", + }} > {optPrefix} {optPrefix && " "} @@ -523,6 +586,7 @@ function DevtoolsContent() { const [isDropdownOpen, setIsDropdownOpen] = useState(false); const [showDisconnected, setShowDisconnected] = useState(false); const [searchQuery, setSearchQuery] = useState(""); + const [showMinimap, setShowMinimap] = useState(false); const wsRef = useRef(null); // Store the base (unlayouted) nodes and edges for the current view @@ -1228,7 +1292,29 @@ function DevtoolsContent() { defaultEdgeOptions={defaultEdgeOptions} className="turbo-flow" > - + + + + {showMinimap && } -
Loading...
+
+
Loading...
} > diff --git a/docs/site/app/(no-sidebar)/devtools/function-icon.tsx b/docs/site/app/(no-sidebar)/devtools/function-icon.tsx index 451d28984882b..492f6fcaf5c6a 100644 --- a/docs/site/app/(no-sidebar)/devtools/function-icon.tsx +++ b/docs/site/app/(no-sidebar)/devtools/function-icon.tsx @@ -1,22 +1,17 @@ export default function FunctionIcon() { return ( - - + - ); diff --git a/docs/site/app/(no-sidebar)/devtools/turbo-flow.css b/docs/site/app/(no-sidebar)/devtools/turbo-flow.css index a1d2b48fee8ad..e19682e50546b 100644 --- a/docs/site/app/(no-sidebar)/devtools/turbo-flow.css +++ b/docs/site/app/(no-sidebar)/devtools/turbo-flow.css @@ -1,11 +1,31 @@ .turbo-flow.react-flow { + /* Light mode defaults */ + --bg-color: rgb(255, 255, 255); + --text-color: rgb(23, 23, 23); + --node-inner-bg: rgb(250, 250, 250); + --node-border-radius: 10px; + --node-box-shadow: 10px 0 15px rgba(42, 138, 246, 0.15), + -10px 0 15px rgba(233, 42, 103, 0.15); + --control-border-color: #95679e; + --control-hover-bg: rgb(240, 240, 240); + --subtitle-color: #666; + --minimap-mask-color: rgba(255, 255, 255, 0.8); + --attribution-bg: rgba(200, 200, 200, 0.2); + + background-color: var(--bg-color); + color: var(--text-color); +} + +/* Dark mode overrides */ +.dark .turbo-flow.react-flow { --bg-color: rgb(17, 17, 17); --text-color: rgb(243, 244, 246); - --node-border-radius: 10px; + --node-inner-bg: rgb(17, 17, 17); --node-box-shadow: 10px 0 15px rgba(42, 138, 246, 0.3), -10px 0 15px rgba(233, 42, 103, 0.3); - background-color: var(--bg-color); - color: var(--text-color); + --control-hover-bg: rgb(37, 37, 37); + --subtitle-color: #777; + --minimap-mask-color: rgba(17, 17, 17, 0.8); } .turbo-flow .react-flow__node-turbo { @@ -67,7 +87,7 @@ } .turbo-flow .react-flow__node-turbo .inner { - background: var(--bg-color); + background: var(--node-inner-bg); padding: 16px 20px; border-radius: var(--node-border-radius); display: flex; @@ -93,7 +113,7 @@ .turbo-flow .react-flow__node-turbo .subtitle { font-size: 12px; - color: #777; + color: var(--subtitle-color); } .turbo-flow .react-flow__node-turbo .cloud { @@ -113,7 +133,7 @@ } .turbo-flow .react-flow__node-turbo .cloud div { - background-color: var(--bg-color); + background-color: var(--node-inner-bg); flex-grow: 1; border-radius: 100%; display: flex; @@ -147,12 +167,12 @@ .turbo-flow .react-flow__controls button { background-color: var(--bg-color); color: var(--text-color); - border: 1px solid #95679e; + border: 1px solid var(--control-border-color); border-bottom: none; } .turbo-flow .react-flow__controls button:hover { - background-color: rgb(37, 37, 37); + background-color: var(--control-hover-bg); } .turbo-flow .react-flow__controls button:first-child { @@ -160,7 +180,7 @@ } .turbo-flow .react-flow__controls button:last-child { - border-bottom: 1px solid #95679e; + border-bottom: 1px solid var(--control-border-color); border-radius: 0 0 5px 5px; } @@ -168,10 +188,35 @@ fill: var(--text-color); } +.turbo-flow .react-flow__controls button svg { + width: 16px; + height: 16px; +} + +.turbo-flow .react-flow__controls button rect { + stroke: var(--text-color); +} + .turbo-flow .react-flow__attribution { - background: rgba(200, 200, 200, 0.2); + background: var(--attribution-bg); } .turbo-flow .react-flow__attribution a { color: #95679e; } + +/* MiniMap styles */ +.turbo-flow .react-flow__minimap { + background-color: var(--bg-color); + border: 1px solid var(--control-border-color); + border-radius: 5px; +} + +.turbo-flow .react-flow__minimap-mask { + fill: var(--minimap-mask-color); +} + +.turbo-flow .react-flow__minimap-node { + fill: #95679e; + stroke: #2a8af6; +} From 067c3f0fccf6fcc8c0f3ffd6230f47185746bfed Mon Sep 17 00:00:00 2001 From: Anthony Shew Date: Wed, 17 Dec 2025 06:47:40 -0700 Subject: [PATCH 10/14] remove useless token strategy --- crates/turborepo-devtools/Cargo.toml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/turborepo-devtools/Cargo.toml b/crates/turborepo-devtools/Cargo.toml index 53cf1bd56816a..6dfce83b0c759 100644 --- a/crates/turborepo-devtools/Cargo.toml +++ b/crates/turborepo-devtools/Cargo.toml @@ -9,8 +9,8 @@ workspace = true [dependencies] # Async runtime -tokio = { workspace = true, features = ["full", "sync"] } futures = { workspace = true } +tokio = { workspace = true, features = ["full", "sync"] } # Web server + WebSocket axum = { workspace = true, features = ["ws"] } @@ -21,14 +21,14 @@ serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } # File watching +ignore = "0.4.22" notify = { workspace = true } radix_trie = { workspace = true } -ignore = "0.4.22" # Utilities +port_scanner = { workspace = true } thiserror = { workspace = true } tracing = { workspace = true } -port_scanner = { workspace = true } webbrowser = { workspace = true } # Internal crates From 91be33be131491d60789ca3245bd0b6f31af8588 Mon Sep 17 00:00:00 2001 From: Anthony Shew Date: Wed, 17 Dec 2025 07:12:37 -0700 Subject: [PATCH 11/14] feels good --- Cargo.lock | 2 + crates/turborepo-devtools/Cargo.toml | 4 + crates/turborepo-devtools/src/graph.rs | 241 ++++++++++++++++++++++- crates/turborepo-devtools/src/server.rs | 7 +- crates/turborepo-devtools/src/watcher.rs | 2 - 5 files changed, 242 insertions(+), 14 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index efe0a5e22d7fd..9d15ac769b3fb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6773,6 +6773,8 @@ name = "turborepo-devtools" version = "0.1.0" dependencies = [ "axum 0.7.5", + "biome_json_parser", + "biome_json_syntax", "futures", "ignore", "notify", diff --git a/crates/turborepo-devtools/Cargo.toml b/crates/turborepo-devtools/Cargo.toml index 6dfce83b0c759..3bfd66ad89589 100644 --- a/crates/turborepo-devtools/Cargo.toml +++ b/crates/turborepo-devtools/Cargo.toml @@ -20,6 +20,10 @@ tower-http = { version = "0.5.2", features = ["cors"] } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } +# JSON/JSONC parsing +biome_json_parser = { workspace = true } +biome_json_syntax = { workspace = true } + # File watching ignore = "0.4.22" notify = { workspace = true } diff --git a/crates/turborepo-devtools/src/graph.rs b/crates/turborepo-devtools/src/graph.rs index c0aae08d1bf9c..2e836626e001b 100644 --- a/crates/turborepo-devtools/src/graph.rs +++ b/crates/turborepo-devtools/src/graph.rs @@ -5,6 +5,10 @@ use std::collections::HashSet; +use biome_json_parser::JsonParserOptions; +use biome_json_syntax::JsonRoot; +use tracing::debug; +use turbopath::AbsoluteSystemPath; use turborepo_repository::package_graph::{ PackageGraph, PackageName, PackageNode as RepoPackageNode, }; @@ -14,6 +18,114 @@ use crate::types::{GraphEdge, PackageGraphData, PackageNode, TaskGraphData, Task /// Identifier used for the root package in the graph pub const ROOT_PACKAGE_ID: &str = "__ROOT__"; +/// Reads task names from turbo.json at the repository root. +/// Returns a set of task names (without package prefixes like "build", not +/// "pkg#build"). Returns an empty set if turbo.json cannot be read or parsed. +pub fn read_pipeline_tasks(repo_root: &AbsoluteSystemPath) -> HashSet { + let turbo_json_path = repo_root.join_component("turbo.json"); + let turbo_jsonc_path = repo_root.join_component("turbo.jsonc"); + + // Try turbo.json first, then turbo.jsonc + let contents = turbo_json_path + .read_to_string() + .or_else(|_| turbo_jsonc_path.read_to_string()); + + match contents { + Ok(contents) => parse_pipeline_tasks(&contents), + Err(e) => { + debug!("Could not read turbo.json: {}", e); + HashSet::new() + } + } +} + +/// Parses turbo.json content and extracts task names. +/// Task names like "build" or "pkg#build" are normalized to just the task part. +fn parse_pipeline_tasks(contents: &str) -> HashSet { + // Use Biome's JSONC parser which handles comments natively + let parse_result = + biome_json_parser::parse_json(contents, JsonParserOptions::default().with_allow_comments()); + + if parse_result.has_errors() { + debug!( + "Failed to parse turbo.json: {:?}", + parse_result.diagnostics() + ); + return HashSet::new(); + } + + let root: JsonRoot = parse_result.tree(); + + // Navigate to the "tasks" object and extract its keys + extract_task_keys_from_json(&root) +} + +/// Extracts task keys from a parsed JSON root. +/// Returns task names normalized (without package prefixes). +fn extract_task_keys_from_json(root: &JsonRoot) -> HashSet { + use biome_json_syntax::AnyJsonValue; + + // Get the root value (should be an object) + let Some(value) = root.value().ok() else { + return HashSet::new(); + }; + + let AnyJsonValue::JsonObjectValue(obj) = value else { + return HashSet::new(); + }; + + // Find the "tasks" member + for member in obj.json_member_list() { + let Ok(member) = member else { continue }; + let Ok(name) = member.name() else { continue }; + + if get_member_name_text(&name) == "tasks" { + let Ok(tasks_value) = member.value() else { + continue; + }; + + if let AnyJsonValue::JsonObjectValue(tasks_obj) = tasks_value { + let mut task_names = HashSet::new(); + extract_keys_from_object(&tasks_obj, &mut task_names); + return task_names; + } + } + } + + HashSet::new() +} + +/// Helper to get the text content of a JSON member name +fn get_member_name_text(name: &biome_json_syntax::JsonMemberName) -> String { + // The name is a string literal, we need to extract the text without quotes + name.inner_string_text() + .map(|t| t.to_string()) + .unwrap_or_default() +} + +/// Extracts keys from a JSON object and normalizes task names +fn extract_keys_from_object( + obj: &biome_json_syntax::JsonObjectValue, + task_names: &mut HashSet, +) { + for member in obj.json_member_list() { + let Ok(member) = member else { continue }; + let Ok(name) = member.name() else { continue }; + + let task_name = get_member_name_text(&name); + + // Strip package prefix if present (e.g., "pkg#build" -> "build") + // Also handle root tasks like "//#build" -> "build" + let normalized = if let Some(pos) = task_name.find('#') { + task_name[pos + 1..].to_string() + } else { + task_name + }; + + task_names.insert(normalized); + } +} + /// Converts a PackageGraph to our serializable PackageGraphData format. pub fn package_graph_to_data(pkg_graph: &PackageGraph) -> PackageGraphData { let mut nodes = Vec::new(); @@ -75,16 +187,19 @@ pub fn package_graph_to_data(pkg_graph: &PackageGraph) -> PackageGraphData { /// /// Creates a node for each package#script combination found in the monorepo. /// Edges are created based on package dependencies - if package A depends on -/// package B, then for common tasks (like "build"), A#task depends on B#task. -pub fn task_graph_to_data(pkg_graph: &PackageGraph) -> TaskGraphData { +/// package B, then for tasks defined in `pipeline_tasks`, A#task depends on +/// B#task. +/// +/// The `pipeline_tasks` parameter should contain task names from turbo.json's +/// tasks configuration. Use `read_pipeline_tasks` to obtain these from the +/// repository's turbo.json file. +pub fn task_graph_to_data( + pkg_graph: &PackageGraph, + pipeline_tasks: &HashSet, +) -> TaskGraphData { let mut nodes = Vec::new(); let mut edges = Vec::new(); - // Common tasks that typically have cross-package dependencies - let common_tasks: HashSet<&str> = ["build", "test", "lint", "typecheck", "dev"] - .into_iter() - .collect(); - // First pass: collect all tasks and create nodes for (name, info) in pkg_graph.packages() { let package_id = match name { @@ -104,7 +219,8 @@ pub fn task_graph_to_data(pkg_graph: &PackageGraph) -> TaskGraphData { } // Second pass: create edges based on package dependencies - // For common tasks, if package A depends on package B, then A#task -> B#task + // For tasks defined in turbo.json, if package A depends on package B, + // then A#task -> B#task for (name, info) in pkg_graph.packages() { let package_id = match name { PackageName::Root => ROOT_PACKAGE_ID.to_string(), @@ -135,9 +251,9 @@ pub fn task_graph_to_data(pkg_graph: &PackageGraph) -> TaskGraphData { }; if let Some(dep_info) = dep_info { - // For common tasks that exist in both packages, create edges + // For pipeline tasks that exist in both packages, create edges for script in info.package_json.scripts.keys() { - if common_tasks.contains(script.as_str()) + if pipeline_tasks.contains(script) && dep_info.package_json.scripts.contains_key(script) { edges.push(GraphEdge { @@ -162,4 +278,109 @@ mod tests { fn test_root_package_id() { assert_eq!(ROOT_PACKAGE_ID, "__ROOT__"); } + + #[test] + fn test_parse_pipeline_tasks_basic() { + let turbo_json = r#" + { + "tasks": { + "build": {}, + "test": {}, + "lint": {} + } + } + "#; + let tasks = parse_pipeline_tasks(turbo_json); + assert!(tasks.contains("build")); + assert!(tasks.contains("test")); + assert!(tasks.contains("lint")); + assert_eq!(tasks.len(), 3); + } + + #[test] + fn test_parse_pipeline_tasks_with_package_prefix() { + let turbo_json = r#" + { + "tasks": { + "build": {}, + "web#build": {}, + "//#test": {} + } + } + "#; + let tasks = parse_pipeline_tasks(turbo_json); + // Both "build" and "web#build" should normalize to "build" + assert!(tasks.contains("build")); + assert!(tasks.contains("test")); + // Should only have 2 unique task names after normalization + assert_eq!(tasks.len(), 2); + } + + #[test] + fn test_parse_pipeline_tasks_with_comments() { + let turbo_json = r#" + { + // This is a comment + "tasks": { + "build": {}, /* inline comment */ + "compile": {} + } + } + "#; + let tasks = parse_pipeline_tasks(turbo_json); + assert!(tasks.contains("build")); + assert!(tasks.contains("compile")); + assert_eq!(tasks.len(), 2); + } + + #[test] + fn test_parse_pipeline_tasks_empty() { + let turbo_json = r#" + { + "tasks": {} + } + "#; + let tasks = parse_pipeline_tasks(turbo_json); + // Empty tasks object should return empty set + assert!(tasks.is_empty()); + } + + #[test] + fn test_parse_pipeline_tasks_no_tasks_key() { + let turbo_json = r#" + { + "globalEnv": ["NODE_ENV"] + } + "#; + let tasks = parse_pipeline_tasks(turbo_json); + // No tasks key should return empty set + assert!(tasks.is_empty()); + } + + #[test] + fn test_parse_pipeline_tasks_invalid_json() { + let turbo_json = r#"{ invalid json }"#; + let tasks = parse_pipeline_tasks(turbo_json); + // Invalid JSON should return empty set + assert!(tasks.is_empty()); + } + + #[test] + fn test_parse_pipeline_tasks_custom_tasks() { + let turbo_json = r#" + { + "tasks": { + "compile": {}, + "bundle": {}, + "deploy": {} + } + } + "#; + let tasks = parse_pipeline_tasks(turbo_json); + assert!(tasks.contains("compile")); + assert!(tasks.contains("bundle")); + assert!(tasks.contains("deploy")); + // Should NOT contain defaults since we found tasks + assert!(!tasks.contains("lint")); + } } diff --git a/crates/turborepo-devtools/src/server.rs b/crates/turborepo-devtools/src/server.rs index 2de74dc882cd5..443b957a8a8f1 100644 --- a/crates/turborepo-devtools/src/server.rs +++ b/crates/turborepo-devtools/src/server.rs @@ -27,7 +27,7 @@ use turbopath::AbsoluteSystemPathBuf; use turborepo_repository::{package_graph::PackageGraphBuilder, package_json::PackageJson}; use crate::{ - graph::{package_graph_to_data, task_graph_to_data}, + graph::{package_graph_to_data, read_pipeline_tasks, task_graph_to_data}, types::{GraphState, ServerMessage}, watcher::{DevtoolsWatcher, WatchEvent}, }; @@ -251,9 +251,12 @@ async fn build_graph_state(repo_root: &AbsoluteSystemPathBuf) -> Result Date: Wed, 17 Dec 2025 07:38:08 -0700 Subject: [PATCH 12/14] cleanup --- .../(no-sidebar)/devtools/devtools-client.tsx | 75 +++++++++++-------- .../(no-sidebar)/devtools/function-icon.tsx | 2 +- .../app/(no-sidebar)/devtools/turbo-edge.tsx | 19 ++++- .../app/(no-sidebar)/devtools/turbo-node.tsx | 8 +- .../integration/tests/other/no-args.t | 1 + .../integration/tests/other/turbo-help.t | 2 + 6 files changed, 67 insertions(+), 40 deletions(-) diff --git a/docs/site/app/(no-sidebar)/devtools/devtools-client.tsx b/docs/site/app/(no-sidebar)/devtools/devtools-client.tsx index 22bd065877305..9bbaf5bac304d 100644 --- a/docs/site/app/(no-sidebar)/devtools/devtools-client.tsx +++ b/docs/site/app/(no-sidebar)/devtools/devtools-client.tsx @@ -21,18 +21,16 @@ import { type Edge, type NodeMouseHandler, } from "reactflow"; -import ELK from "elkjs/lib/elk.bundled.js"; +import Elk from "elkjs/lib/elk.bundled.js"; import { Package } from "lucide-react"; import { DynamicCodeBlock } from "fumadocs-ui/components/dynamic-codeblock"; import { createCssVariablesTheme } from "shiki"; - import "reactflow/dist/base.css"; import "./turbo-flow.css"; - import { Callout } from "#components/callout.tsx"; -import TurboNode, { type TurboNodeData } from "./turbo-node"; -import TurboEdge from "./turbo-edge"; -import FunctionIcon from "./function-icon"; +import { TurboNode, type TurboNodeData } from "./turbo-node"; +import { TurboEdge } from "./turbo-edge"; +import { FunctionIcon } from "./function-icon"; const theme = createCssVariablesTheme({ name: "css-variables", @@ -89,7 +87,7 @@ type GraphView = "packages" | "tasks"; // Selection mode: none -> direct (first click) -> blocks (second click) -> dependsOn (third click) -> none (fourth click) type SelectionMode = "none" | "direct" | "blocks" | "dependsOn"; -const elk = new ELK(); +const elk = new Elk(); // Turbo node and edge types const nodeTypes = { @@ -218,7 +216,8 @@ function getAffectedNodes( // BFS to find all transitively affected nodes const queue = [nodeId]; while (queue.length > 0) { - const current = queue.shift()!; + const current = queue.shift(); + if (current === undefined) continue; const dependents = dependentsMap.get(current) || []; for (const dependent of dependents) { @@ -252,7 +251,8 @@ function getAffectsNodes(nodeId: string, edges: Array): Set { // BFS to find all transitive dependencies const queue = [nodeId]; while (queue.length > 0) { - const current = queue.shift()!; + const current = queue.shift(); + if (current === undefined) continue; const dependencies = dependenciesMap.get(current) || []; for (const dependency of dependencies) { @@ -594,8 +594,12 @@ function DevtoolsContent() { const [baseEdges, setBaseEdges] = useState>([]); const [rawEdges, setRawEdges] = useState>([]); - const [nodes, setNodes, onNodesChange] = useNodesState([]); - const [edges, setEdges, onEdgesChange] = useEdgesState([]); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- reactflow types are imperfect + const [nodes, setNodes, onNodesChange] = useNodesState>( + [] + ); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- reactflow types are imperfect + const [edges, setEdges, onEdgesChange] = useEdgesState([]); // Calculate which nodes/edges should be highlighted based on selection const { highlightedNodes, highlightedEdges } = useMemo(() => { @@ -621,7 +625,7 @@ function DevtoolsContent() { useEffect(() => { if (baseNodes.length === 0) return; - const updatedNodes = baseNodes.map((node) => { + const updatedNodes: Array = baseNodes.map((node) => { const isHighlighted = !highlightedNodes || highlightedNodes.has(node.id); const isSelected = node.id === selectedNode; @@ -629,13 +633,13 @@ function DevtoolsContent() { ...node, selected: isSelected, style: { - ...node.style, + ...(node.style as React.CSSProperties), opacity: isHighlighted ? 1 : 0.2, }, }; }); - const updatedEdges = baseEdges.map((edge) => { + const updatedEdges: Array = baseEdges.map((edge) => { const isHighlighted = !highlightedEdges || highlightedEdges.has(edge.id); // Use arrow markers for directional modes (blocks/dependsOn) @@ -650,13 +654,15 @@ function DevtoolsContent() { markerStart: useArrow ? "edge-arrow" : undefined, markerEnd: undefined, style: { - ...edge.style, + ...(edge.style as React.CSSProperties), opacity: isHighlighted ? 1 : 0.1, }, }; }); + // eslint-disable-next-line @typescript-eslint/no-unsafe-call -- reactflow types are imperfect setNodes(updatedNodes); + // eslint-disable-next-line @typescript-eslint/no-unsafe-call -- reactflow types are imperfect setEdges(updatedEdges); }, [ baseNodes, @@ -721,9 +727,9 @@ function DevtoolsContent() { }, [clearSelection, selectionMode, isDropdownOpen]); // Get set of node IDs that have at least one edge connection - const getConnectedNodeIds = useCallback((edges: Array) => { + const getConnectedNodeIds = useCallback((graphEdges: Array) => { const connected = new Set(); - for (const edge of edges) { + for (const edge of graphEdges) { connected.add(edge.source); connected.add(edge.target); } @@ -767,7 +773,9 @@ function DevtoolsContent() { setBaseNodes(layoutedNodes); setBaseEdges(layoutedEdges); setRawEdges(state.packageGraph.edges); + // eslint-disable-next-line @typescript-eslint/no-unsafe-call -- reactflow types are imperfect setNodes(layoutedNodes); + // eslint-disable-next-line @typescript-eslint/no-unsafe-call -- reactflow types are imperfect setEdges(layoutedEdges); }, [setNodes, setEdges, getConnectedNodeIds] @@ -808,7 +816,9 @@ function DevtoolsContent() { setBaseNodes(layoutedNodes); setBaseEdges(layoutedEdges); setRawEdges(state.taskGraph.edges); + // eslint-disable-next-line @typescript-eslint/no-unsafe-call -- reactflow types are imperfect setNodes(layoutedNodes); + // eslint-disable-next-line @typescript-eslint/no-unsafe-call -- reactflow types are imperfect setEdges(layoutedEdges); }, [setNodes, setEdges, getConnectedNodeIds] @@ -831,10 +841,10 @@ function DevtoolsContent() { // Handle view change const handleViewChange = useCallback( - async (newView: GraphView) => { + (newView: GraphView) => { setView(newView); if (graphState) { - await updateFlowElements(graphState, newView); + void updateFlowElements(graphState, newView); } }, [graphState, updateFlowElements] @@ -859,9 +869,11 @@ function DevtoolsContent() { setError(null); }; - ws.onmessage = (event) => { + ws.onmessage = (event: MessageEvent) => { try { - const message: ServerMessage = JSON.parse(event.data); + const message: ServerMessage = JSON.parse( + event.data + ) as ServerMessage; switch (message.type) { case "init": case "update": @@ -876,8 +888,8 @@ function DevtoolsContent() { setError(message.message ?? "Unknown error"); break; } - } catch (e) { - console.error("Failed to parse message:", e); + } catch { + // Failed to parse message - ignore invalid messages } }; @@ -905,7 +917,7 @@ function DevtoolsContent() { // Update flow elements when graphState or view changes useEffect(() => { if (graphState) { - updateFlowElements(graphState, view); + void updateFlowElements(graphState, view); } }, [graphState, view, updateFlowElements]); @@ -1165,19 +1177,20 @@ function DevtoolsContent() { const isHighlighted = !highlightedNodes || highlightedNodes.has(node.id); + let selectionClass = ""; + if (isSelected) { + selectionClass = "border-l-2 border-l-[#2a8af6]"; + } else if (!isHighlighted) { + selectionClass = "opacity-40"; + } + return (
@@ -25,4 +25,4 @@ function TurboNode({ data }: NodeProps) { ); } -export default memo(TurboNode); +export const TurboNode = memo(TurboNodeComponent); diff --git a/turborepo-tests/integration/tests/other/no-args.t b/turborepo-tests/integration/tests/other/no-args.t index cd6f13b67afac..b083bf4d0d77e 100644 --- a/turborepo-tests/integration/tests/other/no-args.t +++ b/turborepo-tests/integration/tests/other/no-args.t @@ -12,6 +12,7 @@ Make sure exit code is 2 when no args are passed get-mfe-port Get the port assigned to the current microfrontend completion Generate the autocompletion script for the specified shell daemon Runs the Turborepo background daemon + devtools Visualize your monorepo's package graph in the browser generate Generate a new app / package telemetry Enable or disable anonymous telemetry scan Turbo your monorepo by running a number of 'repo lints' to identify common issues, suggest fixes, and improve performance diff --git a/turborepo-tests/integration/tests/other/turbo-help.t b/turborepo-tests/integration/tests/other/turbo-help.t index 71df343af280d..f88da8a210b7d 100644 --- a/turborepo-tests/integration/tests/other/turbo-help.t +++ b/turborepo-tests/integration/tests/other/turbo-help.t @@ -12,6 +12,7 @@ Test help flag get-mfe-port Get the port assigned to the current microfrontend completion Generate the autocompletion script for the specified shell daemon Runs the Turborepo background daemon + devtools Visualize your monorepo's package graph in the browser generate Generate a new app / package telemetry Enable or disable anonymous telemetry scan Turbo your monorepo by running a number of 'repo lints' to identify common issues, suggest fixes, and improve performance @@ -137,6 +138,7 @@ Test help flag get-mfe-port Get the port assigned to the current microfrontend completion Generate the autocompletion script for the specified shell daemon Runs the Turborepo background daemon + devtools Visualize your monorepo's package graph in the browser generate Generate a new app / package telemetry Enable or disable anonymous telemetry scan Turbo your monorepo by running a number of 'repo lints' to identify common issues, suggest fixes, and improve performance From ef44937aae527d2646723e1d42b1417e4a7d19dc Mon Sep 17 00:00:00 2001 From: Anthony Shew Date: Wed, 17 Dec 2025 07:40:14 -0700 Subject: [PATCH 13/14] WIP e2b4a --- docs/site/turbo.json | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/site/turbo.json b/docs/site/turbo.json index b879e863b1439..1b16aee8bfd99 100644 --- a/docs/site/turbo.json +++ b/docs/site/turbo.json @@ -10,6 +10,7 @@ "ENABLE_EXPERIMENTAL_COREPACK", "TRAY_URL", "BLOB_READ_WRITE_TOKEN", + "FLAGS", "FLAGS_SECRET" ], "inputs": ["$TURBO_DEFAULT$", "content/**"], From e933d1da0f51f7beadc94d5c6fedd3b30cb010ae Mon Sep 17 00:00:00 2001 From: Anthony Shew Date: Wed, 17 Dec 2025 07:48:43 -0700 Subject: [PATCH 14/14] Update crates/turborepo-devtools/src/lib.rs Co-authored-by: vercel[bot] <35613825+vercel[bot]@users.noreply.github.com> --- crates/turborepo-devtools/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/turborepo-devtools/src/lib.rs b/crates/turborepo-devtools/src/lib.rs index c75a49b2fc01d..4c2b1e6d466ec 100644 --- a/crates/turborepo-devtools/src/lib.rs +++ b/crates/turborepo-devtools/src/lib.rs @@ -23,7 +23,7 @@ pub const DEFAULT_PORT: u16 = 9876; pub fn find_available_port(requested: u16) -> u16 { if port_scanner::scan_port(requested) { // Port is in use, find another - port_scanner::request_open_port().unwrap_or(requested + 1) + port_scanner::request_open_port().unwrap_or(requested.saturating_add(1)) } else { requested }