gpui_navigator/route.rs
1//! Route definition and configuration.
2//!
3//! This module is the core of the routing system. It provides:
4//!
5//! - [`Route`] — a route with a path pattern, builder function, children, guards,
6//! middleware, lifecycle hooks, and transitions.
7//! - [`RouteConfig`] — lightweight configuration (path, name, metadata) without
8//! a builder or feature-gated fields.
9//! - [`NamedRouteRegistry`] — a registry that maps human-readable names to path
10//! patterns for reverse URL generation.
11//! - [`PageRoute`], [`NamedRoute`], and the [`IntoRoute`] trait — flexible
12//! navigation descriptors accepted by `Navigator::push`.
13//!
14//! # Route creation
15//!
16//! Routes are created via constructor methods on [`Route`]:
17//!
18//! | Constructor | Use case |
19//! |-------------|----------|
20//! | [`Route::new`] | Full control — receives `Window`, `App`, `RouteParams` |
21//! | [`Route::view`] | Stateless page — simple closure returning `AnyElement` |
22//! | [`Route::component`] | Stateful page — `Entity<T>` cached across navigations |
23//! | [`Route::component_with_params`] | Stateful page keyed by parameters |
24//!
25//! # Builder pattern
26//!
27//! After construction, routes are configured using a fluent builder API:
28//!
29//! ```no_run
30//! use gpui_navigator::{Route, render_router_outlet};
31//! use gpui::*;
32//!
33//! let route = Route::new("/dashboard", |window, cx, params| {
34//! div()
35//! .child("Dashboard")
36//! .child(render_router_outlet(window, cx, None))
37//! .into_any_element()
38//! })
39//! .name("dashboard")
40//! .meta("title", "Dashboard")
41//! .children(vec![
42//! Route::new("", |_, _cx, _p| div().child("Overview").into_any_element()).into(),
43//! Route::new("settings", |_, _cx, _p| div().child("Settings").into_any_element()).into(),
44//! ]);
45//! ```
46//!
47//! # Sharing routes
48//!
49//! [`Route`] contains non-cloneable fields (guards, middleware, lifecycle hooks).
50//! Use [`RouteRef`] (`Arc<Route>`) to share routes cheaply across the route tree.
51
52#[cfg(feature = "guard")]
53use crate::guards::RouteGuard;
54use crate::lifecycle::RouteLifecycle;
55#[cfg(feature = "middleware")]
56use crate::middleware::RouteMiddleware;
57use crate::params::RouteParams;
58#[cfg(feature = "transition")]
59use crate::transition::TransitionConfig;
60use crate::{trace_log, warn_log, RouteMatch};
61use gpui::{AnyElement, AnyView, App, AppContext, BorrowAppContext, IntoElement, Render, Window};
62use std::collections::HashMap;
63use std::sync::Arc;
64
65// ============================================================================
66// NamedRouteRegistry
67// ============================================================================
68
69/// Registry that maps human-readable route names to path patterns.
70///
71/// Enables reverse URL generation: given a name and parameters, produce the
72/// concrete path. This decouples link targets from literal paths, so renaming
73/// a path only requires updating the route definition.
74///
75/// # Example
76///
77/// ```
78/// use gpui_navigator::{NamedRouteRegistry, RouteParams};
79///
80/// let mut registry = NamedRouteRegistry::new();
81/// registry.register("user.profile", "/users/:id/profile");
82///
83/// let mut params = RouteParams::new();
84/// params.set("id".to_string(), "42".to_string());
85///
86/// assert_eq!(registry.url_for("user.profile", ¶ms), Some("/users/42/profile".to_string()));
87/// ```
88#[derive(Clone, Debug, Default)]
89pub struct NamedRouteRegistry {
90 /// Map of route names to path patterns
91 routes: HashMap<String, String>,
92}
93
94impl NamedRouteRegistry {
95 /// Create a new empty registry
96 #[must_use]
97 pub fn new() -> Self {
98 Self::default()
99 }
100
101 /// Register a named route
102 pub fn register(&mut self, name: impl Into<String>, path: impl Into<String>) {
103 self.routes.insert(name.into(), path.into());
104 }
105
106 /// Get path pattern for a named route
107 pub fn get(&self, name: &str) -> Option<&str> {
108 self.routes.get(name).map(String::as_str)
109 }
110
111 /// Check if a route name exists
112 #[must_use]
113 pub fn contains(&self, name: &str) -> bool {
114 self.routes.contains_key(name)
115 }
116
117 /// Generate URL for a named route with parameters
118 ///
119 /// # Example
120 ///
121 /// ```
122 /// use gpui_navigator::{NamedRouteRegistry, RouteParams};
123 ///
124 /// let mut registry = NamedRouteRegistry::new();
125 /// registry.register("user.detail", "/users/:id");
126 ///
127 /// let mut params = RouteParams::new();
128 /// params.set("id".to_string(), "123".to_string());
129 ///
130 /// let url = registry.url_for("user.detail", ¶ms).unwrap();
131 /// assert_eq!(url, "/users/123");
132 /// ```
133 #[must_use]
134 pub fn url_for(&self, name: &str, params: &RouteParams) -> Option<String> {
135 let pattern = self.get(name)?;
136 Some(substitute_params(pattern, params))
137 }
138
139 /// Clear all registered routes
140 pub fn clear(&mut self) {
141 self.routes.clear();
142 }
143
144 /// Get number of registered routes
145 #[must_use]
146 pub fn len(&self) -> usize {
147 self.routes.len()
148 }
149
150 /// Check if registry is empty
151 #[must_use]
152 pub fn is_empty(&self) -> bool {
153 self.routes.is_empty()
154 }
155}
156
157/// Substitute route parameters in a path pattern
158///
159/// Replaces `:param` with actual values from `RouteParams`
160fn substitute_params(pattern: &str, params: &RouteParams) -> String {
161 let mut result = pattern.to_string();
162
163 // Replace :param with actual values
164 for (key, value) in params.iter() {
165 let placeholder = format!(":{key}");
166 result = result.replace(&placeholder, value);
167 }
168
169 result
170}
171
172// ============================================================================
173// Route Validation
174// ============================================================================
175
176/// Validate a route path pattern
177///
178/// Returns an error message if the path is invalid, None otherwise.
179///
180/// # Errors
181///
182/// Returns `Err(String)` if the path violates any of the following rules:
183///
184/// - Consecutive slashes (`//`) are not allowed
185/// - Parameter names (`:name`) must be non-empty and alphanumeric
186/// - Duplicate parameter names are not allowed
187///
188/// Empty paths are valid (index routes), and trailing slashes are permitted.
189pub fn validate_route_path(path: &str) -> Result<(), String> {
190 // Empty path is allowed for index routes
191 if path.is_empty() {
192 return Ok(());
193 }
194
195 // Consecutive slashes check
196 if path.contains("//") {
197 warn_log!("Invalid route path '{}': consecutive slashes", path);
198 return Err("Route path cannot contain consecutive slashes".to_string());
199 }
200
201 // Note: Trailing slashes are allowed for compatibility
202 // They are normalized during route matching
203
204 // Extract and validate parameters
205 let mut param_names = std::collections::HashSet::new();
206 for segment in path.split('/') {
207 if let Some(param) = segment.strip_prefix(':') {
208 // Check parameter name is not empty
209 if param.is_empty() {
210 warn_log!("Invalid route path '{}': empty parameter name", path);
211 return Err("Route parameter name cannot be empty".to_string());
212 }
213
214 // Check for constraint syntax (:id{uuid})
215 let param_name = param.find('{').map_or(param, |pos| ¶m[..pos]);
216
217 // Check parameter name is alphanumeric
218 if !param_name.chars().all(|c| c.is_alphanumeric() || c == '_') {
219 warn_log!(
220 "Invalid route path '{}': parameter '{}' has invalid characters",
221 path,
222 param_name
223 );
224 return Err(format!(
225 "Route parameter '{param_name}' must contain only alphanumeric characters and underscores"
226 ));
227 }
228
229 // Check for duplicate parameters
230 if !param_names.insert(param_name.to_string()) {
231 warn_log!(
232 "Invalid route path '{}': duplicate parameter '{}'",
233 path,
234 param_name
235 );
236 return Err(format!("Duplicate route parameter: '{param_name}'"));
237 }
238 }
239 }
240
241 Ok(())
242}
243
244// ============================================================================
245// RouteConfig
246// ============================================================================
247
248/// Lightweight route configuration without a builder or feature-gated fields.
249///
250/// Carries the path pattern, optional name, child configs, and metadata.
251/// Used internally by [`Route`] and also available standalone for scenarios
252/// where you need route metadata without a render function.
253#[must_use]
254#[derive(Debug, Clone)]
255pub struct RouteConfig {
256 /// Route path pattern (e.g., "/users/:id")
257 pub path: String,
258 /// Route name (optional)
259 pub name: Option<String>,
260 /// Child routes (NOTE: For nested routing, use `Route.children()` instead)
261 pub children: Vec<Self>,
262 /// Route metadata
263 pub meta: HashMap<String, String>,
264}
265
266impl RouteConfig {
267 /// Check if this is a layout route (has children but no explicit builder)
268 #[must_use]
269 pub fn is_layout(&self) -> bool {
270 !self.children.is_empty()
271 }
272}
273
274impl RouteConfig {
275 /// Create a new route with path validation
276 ///
277 /// # Panics
278 ///
279 /// Panics if the path is invalid. Use `try_new` for non-panicking validation.
280 pub fn new(path: impl Into<String>) -> Self {
281 let path_str = path.into();
282 if let Err(e) = validate_route_path(&path_str) {
283 panic!("Invalid route path '{path_str}': {e}");
284 }
285 Self {
286 path: path_str,
287 name: None,
288 children: Vec::new(),
289 meta: HashMap::new(),
290 }
291 }
292
293 /// Create a new route with validation, returning Result
294 ///
295 /// Use this if you want to handle validation errors instead of panicking.
296 ///
297 /// # Errors
298 ///
299 /// Returns `Err(String)` if the path fails [`validate_route_path`] validation.
300 pub fn try_new(path: impl Into<String>) -> Result<Self, String> {
301 let path_str = path.into();
302 validate_route_path(&path_str)?;
303 Ok(Self {
304 path: path_str,
305 name: None,
306 children: Vec::new(),
307 meta: HashMap::new(),
308 })
309 }
310
311 /// Set route name
312 pub fn name(mut self, name: impl Into<String>) -> Self {
313 self.name = Some(name.into());
314 self
315 }
316
317 /// Add child routes
318 pub fn children(mut self, children: Vec<Self>) -> Self {
319 self.children = children;
320 self
321 }
322
323 /// Add a child route
324 pub fn child(mut self, child: Self) -> Self {
325 self.children.push(child);
326 self
327 }
328
329 /// Add metadata
330 pub fn meta(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
331 self.meta.insert(key.into(), value.into());
332 self
333 }
334}
335
336/// Type for route builder function
337///
338/// Builder receives Window, context and parameters, returns an `AnyElement`.
339/// Through context you have access to App, global state, and Navigator.
340///
341/// The `Window` parameter allows builders to use stateful GPUI features like
342/// `window.use_state()` or `window.use_keyed_state()` for caching expensive computations
343/// or creating persistent entities across renders.
344///
345/// Note: When using `Route::new()`, your builder can return any type that implements
346/// `IntoElement` - the conversion to `AnyElement` is done automatically.
347pub type RouteBuilder =
348 Arc<dyn Fn(&mut Window, &mut App, &RouteParams) -> AnyElement + Send + Sync>;
349
350/// Shared route handle.
351///
352/// A `Route` contains non-cloneable behavior (guards/middleware/lifecycle).
353/// To make route trees cheap to share and cache, the canonical way to pass
354/// routes around is via `Arc<Route>`.
355pub type RouteRef = Arc<Route>;
356
357/// Look up a cached component view by `key`, or create and cache a new one.
358///
359/// Used by [`Route::component`] and [`Route::component_with_params`] to
360/// avoid duplicating the cache-check/create/store pattern.
361fn get_or_create_cached_component<T: Render + 'static>(
362 cx: &mut App,
363 key: String,
364 create: impl FnOnce() -> T,
365) -> AnyElement {
366 // Check the global component cache first (survives across navigations)
367 if let Some(router) = cx.try_global::<crate::context::GlobalRouter>() {
368 if let Some(cached) = router.get_cached_component(&key) {
369 return cached.clone().into_any_element();
370 }
371 }
372
373 // Not cached — create a new entity and cache it
374 let entity: gpui::Entity<T> = cx.new(|_| create());
375 let view: AnyView = entity.into();
376
377 if cx.try_global::<crate::context::GlobalRouter>().is_some() {
378 cx.update_global::<crate::context::GlobalRouter, _>(
379 |router: &mut crate::context::GlobalRouter, _| {
380 router.cache_component(key, view.clone());
381 },
382 );
383 }
384
385 view.into_any_element()
386}
387
388/// A single route in the navigation tree.
389///
390/// Combines a path pattern, an optional builder function, child routes, and
391/// feature-gated behavior (guards, middleware, lifecycle hooks, transitions).
392///
393/// Create routes with [`Route::new`], [`Route::view`], [`Route::component`],
394/// or [`Route::component_with_params`], then configure them with the fluent
395/// builder methods.
396#[must_use]
397pub struct Route {
398 /// Route configuration
399 pub config: RouteConfig,
400 /// Builder function to create the view for this route
401 pub builder: Option<RouteBuilder>,
402 /// Child routes with their own builders
403 /// This is the preferred way to define nested routes (instead of RouteConfig.children)
404 pub children: Vec<RouteRef>,
405 /// Named outlets - map of outlet name to child routes
406 /// Allows multiple outlet areas in a single parent route
407 pub named_children: HashMap<String, Vec<RouteRef>>,
408 /// Guards that control access to this route
409 #[cfg(feature = "guard")]
410 pub guards: Vec<Box<dyn RouteGuard>>,
411 /// Middleware that runs before and after navigation to this route
412 #[cfg(feature = "middleware")]
413 pub middleware: Vec<Box<dyn RouteMiddleware>>,
414 /// Lifecycle hooks for this route
415 pub lifecycle: Option<Box<dyn RouteLifecycle>>,
416 /// Transition animation for this route
417 #[cfg(feature = "transition")]
418 pub transition: TransitionConfig,
419}
420
421impl Route {
422 /// Create a route with a builder function.
423 ///
424 /// The builder receives the window, app context and extracted route parameters,
425 /// and must return `AnyElement`. Use `.into_any_element()` on your element.
426 ///
427 /// # Example
428 ///
429 /// ```no_run
430 /// use gpui_navigator::Route;
431 /// use gpui::*;
432 ///
433 /// // Simple static route
434 /// Route::new("/home", |_window, _cx, _params| {
435 /// div().child("Home Page").into_any_element()
436 /// });
437 ///
438 /// // Route with dynamic parameter
439 /// Route::new("/users/:id", |_window, _cx, params| {
440 /// let id = params.get("id").unwrap();
441 /// div().child(format!("User: {}", id)).into_any_element()
442 /// });
443 /// ```
444 pub fn new<F>(path: impl Into<String>, builder: F) -> Self
445 where
446 F: Fn(&mut Window, &mut App, &RouteParams) -> AnyElement + Send + Sync + 'static,
447 {
448 Self {
449 config: RouteConfig::new(path),
450 builder: Some(Arc::new(builder)),
451 children: Vec::new(),
452 named_children: HashMap::new(),
453 #[cfg(feature = "guard")]
454 guards: Vec::new(),
455 #[cfg(feature = "middleware")]
456 middleware: Vec::new(),
457 lifecycle: None,
458 #[cfg(feature = "transition")]
459 transition: TransitionConfig::default(),
460 }
461 }
462
463 /// Create a stateless route from a simple view function.
464 ///
465 /// Use this for simple, stateless pages that don't need access to route params,
466 /// window, or context. The view function is called on every render.
467 ///
468 /// # Example
469 ///
470 /// ```no_run
471 /// use gpui_navigator::Route;
472 /// use gpui::*;
473 ///
474 /// Route::view("/about", || {
475 /// div().child("About Page").into_any_element()
476 /// });
477 /// ```
478 pub fn view(
479 path: impl Into<String>,
480 view: impl Fn() -> AnyElement + Send + Sync + 'static,
481 ) -> Self {
482 Self::new(path, move |_, _, _| view())
483 }
484
485 /// Create a stateful route with an Entity-based component
486 ///
487 /// Use this for pages that maintain internal state across navigation.
488 /// The component is cached using `window.use_keyed_state()`, so navigating
489 /// back to the route will preserve the component's state.
490 ///
491 /// # Example
492 ///
493 /// ```no_run
494 /// use gpui_navigator::Route;
495 /// use gpui::*;
496 ///
497 /// struct CounterPage {
498 /// count: i32,
499 /// }
500 ///
501 /// impl CounterPage {
502 /// fn new() -> Self {
503 /// Self { count: 0 }
504 /// }
505 /// }
506 ///
507 /// impl Render for CounterPage {
508 /// fn render(&mut self, _window: &mut Window, _cx: &mut Context<'_, Self>) -> impl IntoElement {
509 /// div().child(format!("Count: {}", self.count))
510 /// }
511 /// }
512 ///
513 /// Route::component("/counter", CounterPage::new);
514 /// ```
515 pub fn component<T, F>(path: impl Into<String>, create: F) -> Self
516 where
517 T: Render + 'static,
518 F: Fn() -> T + Send + Sync + 'static + Clone,
519 {
520 let path_str = path.into();
521 let key_path = path_str.clone();
522 let type_id = std::any::TypeId::of::<T>();
523
524 Self::new(path_str, move |_window, cx, _| {
525 let key = format!("route:{key_path}:{type_id:?}");
526 let create_fn = create.clone();
527 get_or_create_cached_component(cx, key, create_fn)
528 })
529 }
530
531 /// Create a stateful route with parameters
532 ///
533 /// Like `component()`, but the create function receives route parameters.
534 /// The component is cached per unique set of parameter values, so navigating
535 /// to `/user/1` and `/user/2` will create two separate component instances.
536 ///
537 /// # Example
538 ///
539 /// ```no_run
540 /// use gpui_navigator::{Route, RouteParams};
541 /// use gpui::*;
542 ///
543 /// struct UserPage {
544 /// user_id: String,
545 /// }
546 ///
547 /// impl UserPage {
548 /// fn new(user_id: String) -> Self {
549 /// Self { user_id }
550 /// }
551 /// }
552 ///
553 /// impl Render for UserPage {
554 /// fn render(&mut self, _window: &mut Window, _cx: &mut Context<'_, Self>) -> impl IntoElement {
555 /// div().child(format!("User: {}", self.user_id))
556 /// }
557 /// }
558 ///
559 /// Route::component_with_params("/user/:id", |params| {
560 /// let id = params.get("id").unwrap().to_string();
561 /// UserPage::new(id)
562 /// });
563 /// ```
564 pub fn component_with_params<T, F>(path: impl Into<String>, create: F) -> Self
565 where
566 T: Render + 'static,
567 F: Fn(&RouteParams) -> T + Send + Sync + 'static + Clone,
568 {
569 let path_str = path.into();
570 let key_path = path_str.clone();
571 let type_id = std::any::TypeId::of::<T>();
572
573 Self::new(path_str, move |_window, cx, params| {
574 let params_key = params
575 .iter()
576 .map(|(k, v)| format!("{k}={v}"))
577 .collect::<Vec<_>>()
578 .join("&");
579 let key = format!("route:{key_path}:{type_id:?}?{params_key}");
580 let params_clone = params.clone();
581 let create_fn = create.clone();
582 get_or_create_cached_component(cx, key, || create_fn(¶ms_clone))
583 })
584 }
585
586 /// Add child routes to this route
587 ///
588 /// Child routes will be rendered in a `RouterOutlet` within the parent's layout.
589 ///
590 /// # T038: Index Route Validation
591 ///
592 /// This method validates child routes and warns if there's an ambiguous default:
593 /// - If a parent has multiple children and no index route (path=""), users must
594 /// explicitly navigate to a child path. Without an index, navigating to the
595 /// parent path alone will show nothing.
596 ///
597 /// # Example
598 ///
599 /// ```no_run
600 /// use gpui_navigator::{Route, render_router_outlet};
601 /// use gpui::*;
602 ///
603 /// // Good: Has index route for default content
604 /// Route::new("/dashboard", |window, cx, params| {
605 /// div()
606 /// .child("Dashboard Header")
607 /// .child(render_router_outlet(window, cx, None))
608 /// .into_any_element()
609 /// })
610 /// .children(vec![
611 /// Route::new("", |_, _cx, _params| { // Index route
612 /// div().child("Overview (Default)").into_any_element()
613 /// })
614 /// .into(),
615 /// Route::new("settings", |_, _cx, _params| {
616 /// div().child("Settings").into_any_element()
617 /// })
618 /// .into(),
619 /// ]);
620 ///
621 /// // Warning: No index route - navigating to "/dashboard" shows nothing
622 /// Route::new("/dashboard", |window, cx, params| {
623 /// div()
624 /// .child("Dashboard Header")
625 /// .child(render_router_outlet(window, cx, None))
626 /// .into_any_element()
627 /// })
628 /// .children(vec![
629 /// Route::new("overview", |_, _cx, _params| {
630 /// div().child("Overview").into_any_element()
631 /// })
632 /// .into(),
633 /// Route::new("settings", |_, _cx, _params| {
634 /// div().child("Settings").into_any_element()
635 /// })
636 /// .into(),
637 /// ]);
638 /// ```
639 pub fn children(mut self, children: Vec<RouteRef>) -> Self {
640 // T038: Validate index routes - warn if ambiguous default
641 if children.len() > 1 {
642 let has_index = children.iter().any(|child| {
643 let path = child.config.path.trim_matches('/');
644 path.is_empty() || path == "index"
645 });
646
647 if !has_index && !self.config.path.is_empty() {
648 warn_log!(
649 "Route '{}' has {} children but no index route (path=\"\" or \"index\"). \
650 Navigating to '{}' alone will show no content.",
651 self.config.path,
652 children.len(),
653 self.config.path
654 );
655 }
656 }
657
658 self.children = children;
659 self
660 }
661
662 /// Add a single child route
663 ///
664 /// # Example
665 ///
666 /// ```no_run
667 /// use gpui_navigator::Route;
668 /// use gpui::*;
669 ///
670 /// Route::new("/dashboard", |_, _cx, _params| div().into_any_element())
671 /// .child(Route::new("overview", |_, _cx, _params| div().into_any_element()).into())
672 /// .child(Route::new("settings", |_, _cx, _params| div().into_any_element()).into());
673 /// ```
674 pub fn child(mut self, child: RouteRef) -> Self {
675 self.children.push(child);
676 self
677 }
678
679 /// Set route name
680 ///
681 /// Named routes can be referenced by name instead of path.
682 pub fn name(mut self, name: impl Into<String>) -> Self {
683 self.config.name = Some(name.into());
684 self
685 }
686
687 /// Add metadata to the route
688 ///
689 /// Metadata can be used for guards, analytics, titles, etc.
690 ///
691 /// # Example
692 ///
693 /// ```no_run
694 /// use gpui_navigator::Route;
695 /// use gpui::*;
696 ///
697 /// Route::new("/admin", |_, _cx, _params| div().into_any_element())
698 /// .meta("requiresAuth", "true")
699 /// .meta("requiredRole", "admin")
700 /// .meta("title", "Admin Panel");
701 /// ```
702 pub fn meta(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
703 self.config.meta.insert(key.into(), value.into());
704 self
705 }
706
707 /// Add routes for a named outlet
708 ///
709 /// Named outlets allow you to have multiple content areas in a single parent route.
710 /// For example, a main content area and a sidebar.
711 ///
712 /// # Example
713 ///
714 /// ```no_run
715 /// use gpui_navigator::{Route, render_router_outlet};
716 /// use gpui::*;
717 ///
718 /// Route::new("/dashboard", |window, cx, _params| {
719 /// div()
720 /// .child(render_router_outlet(window, cx, None)) // Main content
721 /// .child(render_router_outlet(window, cx, Some("sidebar"))) // Sidebar
722 /// .into_any_element()
723 /// })
724 /// .children(vec![
725 /// Route::new("analytics", |_, _cx, _params| div().into_any_element()).into(),
726 /// ])
727 /// .named_outlet("sidebar", vec![
728 /// Route::new("stats", |_, _cx, _params| div().into_any_element()).into(),
729 /// ]);
730 /// ```
731 pub fn named_outlet(mut self, name: impl Into<String>, children: Vec<RouteRef>) -> Self {
732 self.named_children.insert(name.into(), children);
733 self
734 }
735
736 /// Add a guard to this route
737 ///
738 /// Guards control access to routes. If any guard denies access, navigation is blocked.
739 ///
740 /// # Example
741 ///
742 /// ```no_run
743 /// use gpui_navigator::{Route, AuthGuard, RoleGuard};
744 /// use gpui::*;
745 ///
746 /// fn is_authenticated(_cx: &App) -> bool { true }
747 /// fn get_role(_cx: &App) -> Option<String> { Some("user".into()) }
748 ///
749 /// Route::new("/dashboard", |_, _cx, _params| div().into_any_element())
750 /// .guard(AuthGuard::new(is_authenticated, "/login"))
751 /// .guard(RoleGuard::new(get_role, "user", Some("/forbidden")));
752 /// ```
753 #[cfg(feature = "guard")]
754 pub fn guard<G: crate::guards::RouteGuard>(mut self, guard: G) -> Self {
755 self.guards.push(Box::new(guard));
756 self
757 }
758
759 /// Add multiple guards at once (pre-boxed).
760 #[cfg(feature = "guard")]
761 pub fn guards(mut self, guards: Vec<Box<dyn crate::guards::RouteGuard>>) -> Self {
762 self.guards.extend(guards);
763 self
764 }
765
766 /// Add middleware to this route
767 ///
768 /// Middleware runs before and after navigation.
769 ///
770 /// # Example
771 ///
772 /// ```no_run
773 /// use gpui_navigator::Route;
774 /// use gpui::*;
775 ///
776 /// // Route::new("/dashboard", |_, _cx, _params| div().into_any_element())
777 /// // .middleware(LoggingMiddleware::new());
778 /// ```
779 #[cfg(feature = "middleware")]
780 pub fn middleware<M: crate::middleware::RouteMiddleware>(mut self, middleware: M) -> Self {
781 self.middleware.push(Box::new(middleware));
782 self
783 }
784
785 /// Add multiple middleware at once (pre-boxed).
786 #[cfg(feature = "middleware")]
787 pub fn middlewares(
788 mut self,
789 middleware: Vec<Box<dyn crate::middleware::RouteMiddleware>>,
790 ) -> Self {
791 self.middleware.extend(middleware);
792 self
793 }
794
795 /// Add lifecycle hooks to this route
796 ///
797 /// Lifecycle hooks allow you to run code when entering/exiting routes.
798 ///
799 /// # Example
800 ///
801 /// ```no_run
802 /// use gpui_navigator::{Route, RouteLifecycle, NavigationRequest};
803 /// use gpui::*;
804 /// use std::pin::Pin;
805 /// use std::future::Future;
806 ///
807 /// // Lifecycle hooks allow running code when entering/exiting routes
808 /// // Implement RouteLifecycle trait for custom behavior
809 /// ```
810 pub fn lifecycle<L: crate::lifecycle::RouteLifecycle>(mut self, lifecycle: L) -> Self {
811 self.lifecycle = Some(Box::new(lifecycle));
812 self
813 }
814
815 /// Set the transition animation for this route
816 ///
817 /// # Example
818 /// ```no_run
819 /// use gpui_navigator::{Route, Transition};
820 /// use gpui::*;
821 ///
822 /// Route::new("/page", |_, _cx, _params| div().into_any_element())
823 /// .transition(Transition::fade(200));
824 /// ```
825 #[cfg(feature = "transition")]
826 pub const fn transition(mut self, transition: crate::transition::Transition) -> Self {
827 self.transition = TransitionConfig::new(transition);
828 self
829 }
830
831 /// Get child routes for a named outlet
832 ///
833 /// Returns None if the outlet doesn't exist
834 pub fn get_named_children(&self, name: &str) -> Option<&[RouteRef]> {
835 self.named_children.get(name).map(Vec::as_slice)
836 }
837
838 /// Check if this route has a named outlet
839 #[must_use]
840 pub fn has_named_outlet(&self, name: &str) -> bool {
841 self.named_children.contains_key(name)
842 }
843
844 /// Get all named outlet names
845 pub fn named_outlet_names(&self) -> Vec<&str> {
846 self.named_children.keys().map(String::as_str).collect()
847 }
848
849 /// Match a path against this route
850 #[must_use]
851 pub fn matches(&self, path: &str) -> Option<RouteMatch> {
852 match_path(&self.config.path, path)
853 }
854
855 /// Build the view for this route
856 pub fn build(
857 &self,
858 window: &mut Window,
859 cx: &mut App,
860 params: &RouteParams,
861 ) -> Option<AnyElement> {
862 trace_log!(
863 "Building route '{}' with {} params",
864 self.config.path,
865 params.len()
866 );
867 self.builder.as_ref().map(|b| b(window, cx, params))
868 }
869
870 /// Find a child route by path segment
871 ///
872 /// Used internally by `RouterOutlet` to resolve child routes.
873 #[must_use]
874 pub fn find_child(&self, segment: &str) -> Option<&RouteRef> {
875 self.children.iter().find(|child| {
876 child.config.path == segment || child.config.path.trim_start_matches('/') == segment
877 })
878 }
879
880 /// Get all child routes
881 #[must_use]
882 pub fn get_children(&self) -> &[RouteRef] {
883 &self.children
884 }
885}
886
887impl std::fmt::Debug for Route {
888 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
889 f.debug_struct("Route")
890 .field("config", &self.config)
891 .field("builder", &self.builder.is_some())
892 .field("children", &self.children.len())
893 .field(
894 "named_children",
895 &self.named_children.keys().collect::<Vec<_>>(),
896 )
897 .finish_non_exhaustive()
898 }
899}
900
901/// Match a path pattern against an actual path
902///
903/// Supports:
904/// - Static paths: `/users`
905/// - Dynamic segments: `/users/:id`
906/// - Wildcard: `/files/*`
907fn match_path(pattern: &str, path: &str) -> Option<RouteMatch> {
908 let pattern_iter = pattern.split('/').filter(|s| !s.is_empty());
909 let mut path_iter = path.split('/').filter(|s| !s.is_empty());
910
911 let mut route_match = RouteMatch::new(path.to_string());
912
913 for pattern_seg in pattern_iter {
914 if pattern_seg == "*" {
915 // Wildcard matches rest of path
916 return Some(route_match);
917 }
918
919 let Some(path_seg) = path_iter.next() else {
920 // Path exhausted before pattern — no match
921 return None;
922 };
923
924 if let Some(param_name) = pattern_seg.strip_prefix(':') {
925 route_match
926 .params
927 .insert(param_name.to_string(), path_seg.to_string());
928 } else if pattern_seg != path_seg {
929 return None;
930 }
931 }
932
933 // All pattern segments consumed — path must also be exhausted
934 if path_iter.next().is_some() {
935 return None;
936 }
937
938 Some(route_match)
939}
940
941// ============================================================================
942// Route Builder Utilities
943// ============================================================================
944
945//
946// This module provides a flexible routing system that can accept both simple
947// string paths and route builders with parameters.
948
949/// Trait for types that can be converted into a route
950///
951/// This allows `Navigator.push()` to accept both strings and route builders:
952/// ```ignore
953/// use gpui_navigator::{Navigator, PageRoute};
954///
955/// // String path
956/// Navigator::push(cx, "/users");
957///
958/// // Route with builder
959/// Navigator::push(cx, PageRoute::builder("/profile", |_, _cx, _params| {
960/// gpui::div()
961/// }));
962/// ```
963pub trait IntoRoute {
964 /// Convert this type into a route path and optional builder
965 fn into_route(self) -> RouteDescriptor;
966}
967
968/// Type for route builder function
969pub type BuilderFn = Arc<dyn Fn(&mut Window, &mut App, &RouteParams) -> AnyElement + Send + Sync>;
970
971/// A route descriptor containing path, parameters, and optional builder
972pub struct RouteDescriptor {
973 /// The route path (e.g., "/users/:id")
974 pub path: String,
975
976 /// Parameters to pass to the route
977 pub params: RouteParams,
978
979 /// Optional builder function to create the view
980 pub builder: Option<BuilderFn>,
981}
982
983// RouteParams is now imported from crate::params::RouteParams
984
985// Implement IntoRoute for String (simple path navigation)
986impl IntoRoute for String {
987 fn into_route(self) -> RouteDescriptor {
988 RouteDescriptor {
989 path: self,
990 params: RouteParams::new(),
991 builder: None,
992 }
993 }
994}
995
996// Implement IntoRoute for &str
997impl IntoRoute for &str {
998 fn into_route(self) -> RouteDescriptor {
999 RouteDescriptor {
1000 path: self.to_string(),
1001 params: RouteParams::new(),
1002 builder: None,
1003 }
1004 }
1005}
1006
1007/// A page route with optional builder function
1008///
1009/// # Example
1010///
1011/// ```ignore
1012/// use gpui_navigator::{Navigator, PageRoute};
1013///
1014/// // Simple path (no builder)
1015/// Navigator::push(cx, PageRoute::new("/users"));
1016///
1017/// // With parameters
1018/// Navigator::push(
1019/// cx,
1020/// PageRoute::new("/users/:id")
1021/// .with_param("id".into(), "123".into())
1022/// );
1023///
1024/// // With builder function
1025/// Navigator::push(
1026/// cx,
1027/// PageRoute::builder("/profile", |_window, cx, params| {
1028/// div().child("Profile Page")
1029/// })
1030/// );
1031/// ```
1032#[must_use]
1033pub struct PageRoute {
1034 path: String,
1035 params: RouteParams,
1036 builder: Option<BuilderFn>,
1037}
1038
1039impl PageRoute {
1040 /// Create a new `PageRoute` with a path (no builder)
1041 pub fn new(path: impl Into<String>) -> Self {
1042 Self {
1043 path: path.into(),
1044 params: RouteParams::new(),
1045 builder: None,
1046 }
1047 }
1048
1049 /// Create a `PageRoute` with a builder function.
1050 ///
1051 /// The builder must return `AnyElement`.
1052 pub fn builder(
1053 path: impl Into<String>,
1054 builder: impl Fn(&mut Window, &mut App, &RouteParams) -> AnyElement + Send + Sync + 'static,
1055 ) -> Self {
1056 Self {
1057 path: path.into(),
1058 params: RouteParams::new(),
1059 builder: Some(Arc::new(builder)),
1060 }
1061 }
1062
1063 /// Set the builder function for this route.
1064 ///
1065 /// The builder must return `AnyElement`.
1066 pub fn with_builder(
1067 mut self,
1068 builder: impl Fn(&mut Window, &mut App, &RouteParams) -> AnyElement + Send + Sync + 'static,
1069 ) -> Self {
1070 self.builder = Some(Arc::new(builder));
1071 self
1072 }
1073
1074 /// Add a parameter to this route
1075 pub fn with_param(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
1076 self.params.insert(key.into(), value.into());
1077 self
1078 }
1079
1080 /// Add multiple parameters
1081 pub fn with_params(mut self, params: HashMap<String, String>) -> Self {
1082 self.params = RouteParams::from_map(params);
1083 self
1084 }
1085}
1086
1087impl IntoRoute for PageRoute {
1088 fn into_route(self) -> RouteDescriptor {
1089 RouteDescriptor {
1090 path: self.path,
1091 params: self.params,
1092 builder: self.builder,
1093 }
1094 }
1095}
1096
1097/// A named route for predefined routes
1098///
1099/// # Example
1100///
1101/// ```ignore
1102/// use gpui_navigator::{Navigator, NamedRoute, RouteParams};
1103///
1104/// let mut params = RouteParams::new();
1105/// params.set("userId".to_string(), "123".to_string());
1106/// Navigator::push_named(cx, "user_profile", ¶ms);
1107/// ```
1108#[must_use]
1109pub struct NamedRoute {
1110 name: String,
1111 params: RouteParams,
1112}
1113
1114impl NamedRoute {
1115 /// Create a new named route
1116 pub fn new(name: impl Into<String>) -> Self {
1117 Self {
1118 name: name.into(),
1119 params: RouteParams::new(),
1120 }
1121 }
1122
1123 /// Add a parameter
1124 pub fn with_param(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
1125 self.params.insert(key.into(), value.into());
1126 self
1127 }
1128
1129 /// Add multiple parameters
1130 pub fn with_params(mut self, params: HashMap<String, String>) -> Self {
1131 self.params = RouteParams::from_map(params);
1132 self
1133 }
1134}
1135
1136impl IntoRoute for NamedRoute {
1137 fn into_route(self) -> RouteDescriptor {
1138 RouteDescriptor {
1139 path: self.name,
1140 params: self.params,
1141 builder: None,
1142 }
1143 }
1144}
1145
1146#[cfg(test)]
1147mod tests {
1148 use super::*;
1149
1150 // NamedRouteRegistry tests
1151
1152 #[test]
1153 fn test_registry_register_and_get() {
1154 let mut registry = NamedRouteRegistry::new();
1155 registry.register("home", "/");
1156 registry.register("user.detail", "/users/:id");
1157
1158 assert_eq!(registry.get("home"), Some("/"));
1159 assert_eq!(registry.get("user.detail"), Some("/users/:id"));
1160 assert_eq!(registry.get("unknown"), None);
1161 }
1162
1163 #[test]
1164 fn test_registry_contains() {
1165 let mut registry = NamedRouteRegistry::new();
1166 registry.register("home", "/");
1167
1168 assert!(registry.contains("home"));
1169 assert!(!registry.contains("unknown"));
1170 }
1171
1172 #[test]
1173 fn test_url_for_simple() {
1174 let mut registry = NamedRouteRegistry::new();
1175 registry.register("home", "/");
1176
1177 let params = RouteParams::new();
1178 assert_eq!(registry.url_for("home", ¶ms), Some("/".to_string()));
1179 }
1180
1181 #[test]
1182 fn test_url_for_with_params() {
1183 let mut registry = NamedRouteRegistry::new();
1184 registry.register("user.detail", "/users/:id");
1185
1186 let mut params = RouteParams::new();
1187 params.set("id".to_string(), "123".to_string());
1188
1189 assert_eq!(
1190 registry.url_for("user.detail", ¶ms),
1191 Some("/users/123".to_string())
1192 );
1193 }
1194
1195 #[test]
1196 fn test_url_for_multiple_params() {
1197 let mut registry = NamedRouteRegistry::new();
1198 registry.register("post.comment", "/posts/:postId/comments/:commentId");
1199
1200 let mut params = RouteParams::new();
1201 params.set("postId".to_string(), "42".to_string());
1202 params.set("commentId".to_string(), "99".to_string());
1203
1204 assert_eq!(
1205 registry.url_for("post.comment", ¶ms),
1206 Some("/posts/42/comments/99".to_string())
1207 );
1208 }
1209
1210 #[test]
1211 fn test_url_for_unknown_route() {
1212 let registry = NamedRouteRegistry::new();
1213 let params = RouteParams::new();
1214
1215 assert_eq!(registry.url_for("unknown", ¶ms), None);
1216 }
1217
1218 #[test]
1219 fn test_registry_clear() {
1220 let mut registry = NamedRouteRegistry::new();
1221 registry.register("home", "/");
1222 registry.register("about", "/about");
1223
1224 assert_eq!(registry.len(), 2);
1225
1226 registry.clear();
1227
1228 assert_eq!(registry.len(), 0);
1229 assert!(registry.is_empty());
1230 }
1231
1232 #[test]
1233 fn test_substitute_params() {
1234 let mut params = RouteParams::new();
1235 params.set("id".to_string(), "123".to_string());
1236 params.set("action".to_string(), "edit".to_string());
1237
1238 let result = substitute_params("/users/:id/:action", ¶ms);
1239 assert_eq!(result, "/users/123/edit");
1240 }
1241
1242 // Route tests
1243
1244 #[test]
1245 fn test_static_route() {
1246 let result = match_path("/users", "/users");
1247 assert!(result.is_some());
1248
1249 let result = match_path("/users", "/posts");
1250 assert!(result.is_none());
1251 }
1252
1253 #[test]
1254 fn test_dynamic_route() {
1255 let result = match_path("/users/:id", "/users/123");
1256 assert!(result.is_some());
1257
1258 let route_match = result.unwrap();
1259 assert_eq!(route_match.params.get("id"), Some(&"123".to_string()));
1260 }
1261
1262 #[test]
1263 fn test_wildcard_route() {
1264 let result = match_path("/files/*", "/files/documents/report.pdf");
1265 assert!(result.is_some());
1266
1267 let result = match_path("/files/*", "/other/path");
1268 assert!(result.is_none());
1269 }
1270
1271 #[test]
1272 fn test_string_into_route() {
1273 let route = "/users".into_route();
1274 assert_eq!(route.path, "/users");
1275 assert!(route.params.all().is_empty());
1276 }
1277
1278 #[test]
1279 fn test_material_route_with_params() {
1280 let route = PageRoute::new("/users/:id")
1281 .with_param("id", "123")
1282 .into_route();
1283
1284 assert_eq!(route.path, "/users/:id");
1285 assert_eq!(route.params.get("id"), Some(&"123".to_string()));
1286 }
1287
1288 #[test]
1289 fn test_named_route() {
1290 let route = NamedRoute::new("user_profile")
1291 .with_param("userId", "456")
1292 .into_route();
1293
1294 assert_eq!(route.path, "user_profile");
1295 assert_eq!(route.params.get("userId"), Some(&"456".to_string()));
1296 }
1297
1298 // Validation tests
1299
1300 #[test]
1301 fn test_validate_valid_paths() {
1302 assert!(validate_route_path("/").is_ok());
1303 assert!(validate_route_path("/users").is_ok());
1304 assert!(validate_route_path("/users/:id").is_ok());
1305 assert!(validate_route_path("/posts/:postId/comments/:commentId").is_ok());
1306 assert!(validate_route_path("/users/:id{uuid}").is_ok());
1307 assert!(validate_route_path("/api/v1/users").is_ok());
1308 assert!(validate_route_path("settings").is_ok()); // relative path
1309 assert!(validate_route_path("").is_ok()); // empty path (index route)
1310 assert!(validate_route_path("/users/").is_ok()); // trailing slash allowed
1311 }
1312
1313 #[test]
1314 fn test_validate_consecutive_slashes() {
1315 let result = validate_route_path("/users//profile");
1316 assert!(result.is_err());
1317 assert!(result.unwrap_err().contains("consecutive slashes"));
1318 }
1319
1320 #[test]
1321 fn test_validate_empty_parameter() {
1322 let result = validate_route_path("/users/:");
1323 assert!(result.is_err());
1324 assert!(result
1325 .unwrap_err()
1326 .contains("parameter name cannot be empty"));
1327 }
1328
1329 #[test]
1330 fn test_validate_invalid_parameter_name() {
1331 let result = validate_route_path("/users/:user-id");
1332 assert!(result.is_err());
1333 assert!(result.unwrap_err().contains("alphanumeric"));
1334 }
1335
1336 #[test]
1337 fn test_validate_duplicate_parameters() {
1338 let result = validate_route_path("/users/:id/posts/:id");
1339 assert!(result.is_err());
1340 assert!(result.unwrap_err().contains("Duplicate"));
1341 }
1342
1343 #[test]
1344 fn test_route_config_try_new_valid() {
1345 let result = RouteConfig::try_new("/users/:id");
1346 assert!(result.is_ok());
1347 assert_eq!(result.unwrap().path, "/users/:id");
1348 }
1349
1350 #[test]
1351 fn test_route_config_try_new_invalid() {
1352 let result = RouteConfig::try_new("/users//profile");
1353 assert!(result.is_err());
1354 }
1355
1356 #[test]
1357 #[should_panic(expected = "Invalid route path")]
1358 fn test_route_config_new_panics_on_invalid() {
1359 let _ = RouteConfig::new("/users//profile");
1360 }
1361}