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