Skip to main content

gpui_navigator/
widgets.rs

1//! RouterOutlet component for rendering nested routes
2//!
3//! The `RouterOutlet` acts as a placeholder where child routes are rendered.
4//! When a parent route contains child routes, the outlet determines where
5//! the matched child's content appears within the parent's layout.
6
7use crate::context::GlobalRouter;
8use crate::nested::resolve_child_route;
9#[cfg(feature = "transition")]
10use crate::transition::{SlideDirection, Transition};
11use crate::{debug_log, error_log, trace_log, warn_log};
12use gpui::{div, AnyElement, App, Div, IntoElement, ParentElement, SharedString, Styled, Window};
13
14#[cfg(feature = "transition")]
15use gpui::{relative, Animation, AnimationExt};
16
17#[cfg(feature = "transition")]
18use gpui::prelude::FluentBuilder;
19
20#[cfg(feature = "transition")]
21use std::time::Duration;
22
23/// RouterOutlet component that renders the active child route
24///
25/// RouterOutlet is a special element that dynamically renders child routes
26/// based on the current route match. It accesses the GlobalRouter to resolve
27/// which child should be displayed.
28///
29/// # Example
30///
31/// ```ignore
32/// use gpui_navigator::{Route, RouterOutlet, RouteParams};
33/// use gpui::*;
34///
35/// // Parent layout component
36/// fn dashboard_layout(_cx: &mut App, _params: &RouteParams) -> AnyElement {
37///     div()
38///         .child("Dashboard Header")
39///         .child(RouterOutlet::new()) // Child routes render here
40///         .into_any_element()
41/// }
42///
43/// // Configure nested routes
44/// Route::new("/dashboard", dashboard_layout)
45///     .children(vec![
46///         Route::new("overview", |_, _cx, _params| div().into_any_element()),
47///         Route::new("settings", |_, _cx, _params| div().into_any_element()),
48///     ]);
49/// ```
50#[derive(Clone)]
51pub struct RouterOutlet {
52    /// Optional name for named outlets
53    /// Default outlet has no name
54    name: Option<String>,
55}
56
57impl RouterOutlet {
58    /// Create a new default outlet
59    pub fn new() -> Self {
60        Self { name: None }
61    }
62
63    /// Create a named outlet
64    ///
65    /// Named outlets allow multiple outlet locations in a single parent route.
66    ///
67    /// # Example
68    ///
69    /// ```ignore
70    /// use gpui_navigator::{RouterOutlet, RouteParams};
71    /// use gpui::*;
72    ///
73    /// // Parent layout with multiple outlets
74    /// fn app_layout(_cx: &mut App, _params: &RouteParams) -> AnyElement {
75    ///     div()
76    ///         .child(RouterOutlet::new()) // Main content
77    ///         .child(RouterOutlet::named("sidebar")) // Sidebar content
78    ///         .into_any_element()
79    /// }
80    /// ```
81    pub fn named(name: impl Into<String>) -> Self {
82        Self {
83            name: Some(name.into()),
84        }
85    }
86}
87
88impl Default for RouterOutlet {
89    fn default() -> Self {
90        Self::new()
91    }
92}
93
94use gpui::{Context, Render};
95
96/// State for RouterOutlet animation tracking
97#[derive(Clone)]
98struct OutletState {
99    current_path: String,
100    animation_counter: u32,
101    // Current route data (will become previous on next transition)
102    current_params: crate::RouteParams,
103    current_builder: Option<crate::route::RouteBuilder>,
104    #[cfg(feature = "transition")]
105    current_transition: crate::transition::Transition,
106    // Previous route info for exit animation
107    previous_route: Option<PreviousRoute>,
108}
109
110#[derive(Clone)]
111struct PreviousRoute {
112    path: String,
113    params: crate::RouteParams,
114    builder: Option<crate::route::RouteBuilder>,
115}
116
117impl Default for OutletState {
118    fn default() -> Self {
119        Self {
120            current_path: String::new(),
121            animation_counter: 0,
122            current_params: crate::RouteParams::new(),
123            current_builder: None,
124            #[cfg(feature = "transition")]
125            current_transition: crate::transition::Transition::None,
126            previous_route: None,
127        }
128    }
129}
130
131impl Render for RouterOutlet {
132    fn render(&mut self, window: &mut Window, cx: &mut Context<'_, Self>) -> impl IntoElement {
133        trace_log!("🔄 RouterOutlet::render() called");
134
135        // Use keyed state to persist animation counter and content across renders
136        let state_key = SharedString::from(format!("outlet_{:?}", self.name));
137        let state = window.use_keyed_state(state_key.clone(), cx, |_, _| OutletState::default());
138
139        let (prev_path, animation_counter) = {
140            let guard = state.read(cx);
141            (guard.current_path.clone(), guard.animation_counter)
142        };
143
144        // Get current router info
145        #[cfg(feature = "transition")]
146        let (router_path, route_params, route_transition, builder_opt) = cx
147            .try_global::<crate::context::GlobalRouter>()
148            .map(|router| {
149                let path = router.current_path().to_string();
150
151                let params = router
152                    .current_match_immutable()
153                    .map(|m| {
154                        let mut rp = crate::RouteParams::new();
155                        for (k, v) in m.params {
156                            rp.insert(k, v);
157                        }
158                        rp
159                    })
160                    .unwrap_or_else(crate::RouteParams::new);
161
162                let transition = router
163                    .current_route()
164                    .map(|route| route.transition.default.clone())
165                    .unwrap_or(Transition::None);
166
167                let builder = router
168                    .current_route()
169                    .and_then(|route| route.builder.clone());
170
171                (path, params, transition, builder)
172            })
173            .unwrap_or_else(|| {
174                (
175                    "/".to_string(),
176                    crate::RouteParams::new(),
177                    Transition::None,
178                    None,
179                )
180            });
181
182        #[cfg(not(feature = "transition"))]
183        let (router_path, route_params, builder_opt) = cx
184            .try_global::<crate::context::GlobalRouter>()
185            .map(|router| {
186                let path = router.current_path().to_string();
187
188                let params = router
189                    .current_match_immutable()
190                    .map(|m| {
191                        let mut rp = crate::RouteParams::new();
192                        for (k, v) in m.params {
193                            rp.insert(k, v);
194                        }
195                        rp
196                    })
197                    .unwrap_or_else(crate::RouteParams::new);
198
199                let builder = router
200                    .current_route()
201                    .and_then(|route| route.builder.clone());
202
203                (path, params, builder)
204            })
205            .unwrap_or_else(|| ("/".to_string(), crate::RouteParams::new(), None));
206
207        // Check if path actually changed (not just first render)
208        let path_changed = router_path != prev_path;
209
210        // Update state if path changed
211        #[cfg_attr(not(feature = "transition"), allow(unused_variables))]
212        let animation_counter = if path_changed {
213            #[cfg_attr(not(feature = "transition"), allow(unused_variables))]
214            let is_initial = prev_path.is_empty();
215
216            #[cfg(feature = "transition")]
217            let new_counter = if is_initial {
218                debug_log!("Initial route: '{}', no animation", router_path);
219                animation_counter
220            } else {
221                let counter = animation_counter.wrapping_add(1);
222                debug_log!(
223                    "Route changed: '{}' -> '{}', transition={:?}, animation_counter={}",
224                    prev_path,
225                    router_path,
226                    route_transition,
227                    counter
228                );
229                counter
230            };
231
232            #[cfg(not(feature = "transition"))]
233            let new_counter = {
234                debug_log!(
235                    "Route changed: '{}' -> '{}' (no transition)",
236                    prev_path,
237                    router_path
238                );
239                animation_counter
240            };
241
242            // Update state and save previous route for exit animation
243            state.update(cx, |s, _| {
244                // When path changes, replace previous_route with current route data
245                // This way, the old previous_route (from a previous transition) is discarded
246                // and we only keep the immediately previous route for the current transition
247                if !is_initial {
248                    s.previous_route = Some(PreviousRoute {
249                        path: s.current_path.clone(),
250                        params: s.current_params.clone(),
251                        builder: s.current_builder.clone(),
252                    });
253                } else {
254                    // Initial navigation - no previous route
255                    s.previous_route = None;
256                }
257                // Update state with NEW route data
258                s.current_path = router_path.clone();
259                s.current_params = route_params.clone();
260                s.current_builder = builder_opt.clone();
261                #[cfg(feature = "transition")]
262                {
263                    s.current_transition = route_transition.clone();
264                }
265                s.animation_counter = new_counter;
266            });
267
268            new_counter
269        } else {
270            trace_log!("Route unchanged: '{}'", router_path);
271            animation_counter
272        };
273
274        #[cfg(feature = "transition")]
275        {
276            // Determine animation duration based on transition type
277            // Don't zero out duration on subsequent renders - let animation complete!
278            let duration_ms = match &route_transition {
279                Transition::Fade { duration_ms, .. } => *duration_ms,
280                Transition::Slide { duration_ms, .. } => *duration_ms,
281                Transition::None => 0,
282            };
283
284            debug_log!(
285                "Rendering route '{}' with animation_counter={}, duration={}ms",
286                router_path,
287                animation_counter,
288                duration_ms
289            );
290
291            // Get previous route info for exit animation
292            // Show it if it exists and its path differs from current path
293            // (if paths are same, no transition is needed)
294            let previous_route = state
295                .read(cx)
296                .previous_route
297                .as_ref()
298                .filter(|prev| prev.path != router_path)
299                .cloned();
300
301            debug_log!(
302                "Previous route exists: {}, path: {:?}",
303                previous_route.is_some(),
304                previous_route.as_ref().map(|p| &p.path)
305            );
306
307            // Build OLD and NEW content ONCE before match to avoid multiple builder() calls per render
308            let old_content_opt = previous_route.map(|prev| {
309                if let Some(builder) = prev.builder.as_ref() {
310                    builder(window, cx, &prev.params)
311                } else {
312                    not_found_page().into_any_element()
313                }
314            });
315
316            let new_content = if let Some(builder) = builder_opt.as_ref() {
317                builder(window, cx, &route_params)
318            } else {
319                not_found_page().into_any_element()
320            };
321
322            // Build container with both old (exiting) and new (entering) content
323            // For SLIDE transitions, use a different approach
324            match &route_transition {
325                Transition::Slide { direction, .. } => {
326                    // Create animated container that holds BOTH elements side-by-side
327                    let animation_id = SharedString::from(format!(
328                        "outlet_slide_{:?}_{}",
329                        self.name, animation_counter
330                    ));
331
332                    match direction {
333                        SlideDirection::Left | SlideDirection::Right => {
334                            // Horizontal slide: use absolute positioning for proper side-by-side layout
335                            let is_left = matches!(direction, SlideDirection::Left);
336
337                            div()
338                                .relative()
339                                .w_full()
340                                .h_full()
341                                .overflow_hidden()
342                                // Old content (exits to left for SlideLeft, or stays for SlideRight)
343                                .when_some(old_content_opt, |container, old| {
344                                    container.child(
345                                        div()
346                                            .absolute()
347                                            .w_full()
348                                            .h_full()
349                                            .child(old)
350                                            .left(relative(0.0)) // Starts at normal position
351                                            .with_animation(
352                                                animation_id.clone(),
353                                                Animation::new(Duration::from_millis(duration_ms)),
354                                                move |this, delta| {
355                                                    let progress = delta.clamp(0.0, 1.0);
356                                                    // Old content exits
357                                                    let offset = if is_left {
358                                                        -progress // SlideLeft: old goes left (-1.0)
359                                                    } else {
360                                                        progress // SlideRight: old goes right (+1.0)
361                                                    };
362                                                    this.left(relative(offset))
363                                                },
364                                            ),
365                                    )
366                                })
367                                // New content (enters from right for SlideLeft, from left for SlideRight)
368                                .child(
369                                    div()
370                                        .absolute()
371                                        .w_full()
372                                        .h_full()
373                                        .child(new_content)
374                                        .left(relative(if is_left { 1.0 } else { -1.0 })) // Starts off-screen
375                                        .with_animation(
376                                            animation_id.clone(),
377                                            Animation::new(Duration::from_millis(duration_ms)),
378                                            move |this, delta| {
379                                                let progress = delta.clamp(0.0, 1.0);
380                                                // New content enters
381                                                let start = if is_left { 1.0 } else { -1.0 };
382                                                let offset = start * (1.0 - progress);
383                                                this.left(relative(offset))
384                                            },
385                                        ),
386                                )
387                                .into_any_element()
388                        }
389                        SlideDirection::Up | SlideDirection::Down => {
390                            // Vertical slide: use absolute positioning for proper stacked layout
391                            let is_up = matches!(direction, SlideDirection::Up);
392
393                            div()
394                                .relative()
395                                .w_full()
396                                .h_full()
397                                .overflow_hidden()
398                                // Old content (exits up for SlideUp, or down for SlideDown)
399                                .when_some(old_content_opt, |container, old| {
400                                    container.child(
401                                        div()
402                                            .absolute()
403                                            .w_full()
404                                            .h_full()
405                                            .child(old)
406                                            .top(relative(0.0)) // Starts at normal position
407                                            .with_animation(
408                                                animation_id.clone(),
409                                                Animation::new(Duration::from_millis(duration_ms)),
410                                                move |this, delta| {
411                                                    let progress = delta.clamp(0.0, 1.0);
412                                                    // Old content exits
413                                                    let offset = if is_up {
414                                                        -progress // SlideUp: old goes up (-1.0)
415                                                    } else {
416                                                        progress // SlideDown: old goes down (+1.0)
417                                                    };
418                                                    this.top(relative(offset))
419                                                },
420                                            ),
421                                    )
422                                })
423                                // New content (enters from bottom for SlideUp, from top for SlideDown)
424                                .child(
425                                    div()
426                                        .absolute()
427                                        .w_full()
428                                        .h_full()
429                                        .child(new_content)
430                                        .top(relative(if is_up { 1.0 } else { -1.0 })) // Starts off-screen
431                                        .with_animation(
432                                            animation_id.clone(),
433                                            Animation::new(Duration::from_millis(duration_ms)),
434                                            move |this, delta| {
435                                                let progress = delta.clamp(0.0, 1.0);
436                                                // New content enters
437                                                let start = if is_up { 1.0 } else { -1.0 };
438                                                let offset = start * (1.0 - progress);
439                                                this.top(relative(offset))
440                                            },
441                                        ),
442                                )
443                                .into_any_element()
444                        }
445                    }
446                }
447                Transition::Fade { .. } => {
448                    div()
449                        .relative()
450                        .w_full()
451                        .h_full()
452                        .overflow_hidden()
453                        // Old content (fades out)
454                        .when_some(old_content_opt, |container, old| {
455                            container.child(
456                                div()
457                                    .absolute()
458                                    .w_full()
459                                    .h_full()
460                                    .child(old)
461                                    .with_animation(
462                                        SharedString::from(format!(
463                                            "outlet_fade_exit_{:?}_{}",
464                                            self.name, animation_counter
465                                        )),
466                                        Animation::new(Duration::from_millis(duration_ms)),
467                                        |this, delta| {
468                                            let progress = delta.clamp(0.0, 1.0);
469                                            this.opacity(1.0 - progress)
470                                        },
471                                    ),
472                            )
473                        })
474                        // New content (fades in)
475                        .child(
476                            div()
477                                .absolute()
478                                .w_full()
479                                .h_full()
480                                .child(new_content)
481                                .opacity(0.0)
482                                .with_animation(
483                                    SharedString::from(format!(
484                                        "outlet_fade_enter_{:?}_{}",
485                                        self.name, animation_counter
486                                    )),
487                                    Animation::new(Duration::from_millis(duration_ms)),
488                                    |this, delta| {
489                                        let progress = delta.clamp(0.0, 1.0);
490                                        this.opacity(progress)
491                                    },
492                                ),
493                        )
494                        .into_any_element()
495                }
496                _ => {
497                    // No transition or unsupported - just show new content
498                    div()
499                        .relative()
500                        .w_full()
501                        .h_full()
502                        .child(new_content)
503                        .into_any_element()
504                }
505            }
506        }
507
508        #[cfg(not(feature = "transition"))]
509        {
510            // No animation support - just build content directly
511            let animation_id = SharedString::from(format!("outlet_{:?}", self.name));
512            build_animated_route_content(
513                cx,
514                window,
515                builder_opt.as_ref(),
516                &route_params,
517                animation_id,
518                &(),
519                0,
520            )
521        }
522    }
523}
524
525/// Convenience function to create a default router outlet
526///
527/// **DEPRECATED**: This function is deprecated. Use `RouterOutlet` entity instead.
528///
529/// # Example
530///
531/// ```ignore
532/// use gpui_navigator::{RouterOutlet, RouteParams};
533/// use gpui::*;
534///
535/// struct Layout {
536///     outlet: Entity<RouterOutlet>,
537/// }
538///
539/// impl Layout {
540///     fn new(cx: &mut Context<'_, Self>) -> Self {
541///         Self {
542///             outlet: cx.new(|_| RouterOutlet::new()),
543///         }
544///     }
545/// }
546/// ```
547#[deprecated(since = "0.1.3", note = "Use RouterOutlet entity instead")]
548pub fn router_outlet(window: &mut Window, cx: &mut App) -> impl IntoElement {
549    render_router_outlet(window, cx, None)
550}
551
552/// Convenience function to create a named router outlet
553///
554/// **DEPRECATED**: This function is deprecated. Use `RouterOutlet::named()` entity instead.
555///
556/// # Example
557///
558/// ```ignore
559/// use gpui_navigator::{RouterOutlet, RouteParams};
560/// use gpui::*;
561///
562/// struct Layout {
563///     main_outlet: Entity<RouterOutlet>,
564///     sidebar_outlet: Entity<RouterOutlet>,
565/// }
566///
567/// impl Layout {
568///     fn new(cx: &mut Context<'_, Self>) -> Self {
569///         Self {
570///             main_outlet: cx.new(|_| RouterOutlet::new()),
571///             sidebar_outlet: cx.new(|_| RouterOutlet::named("sidebar")),
572///         }
573///     }
574/// }
575/// ```
576#[deprecated(since = "0.1.3", note = "Use RouterOutlet::named() entity instead")]
577pub fn router_outlet_named(
578    window: &mut Window,
579    cx: &mut App,
580    name: impl Into<String>,
581) -> impl IntoElement {
582    render_router_outlet(window, cx, Some(&name.into()))
583}
584
585/// RouterOutletElement - a function-based element that can access App context
586///
587/// This is the functional approach that actually works with route builders.
588/// It returns a function that will be called with App context to render child routes.
589///
590/// # Example
591///
592/// ```ignore
593/// use gpui_navigator::{render_router_outlet, RouteParams};
594/// use gpui::*;
595///
596/// fn layout(cx: &mut App, _params: &RouteParams) -> AnyElement {
597///     div()
598///         .child("Header")
599///         .child(render_router_outlet(cx, None)) // Pass cx explicitly
600///         .into_any_element()
601/// }
602/// ```
603pub fn render_router_outlet(window: &mut Window, cx: &mut App, name: Option<&str>) -> AnyElement {
604    trace_log!("render_router_outlet called with name: {:?}", name);
605
606    // Access GlobalRouter
607    let router = cx.try_global::<GlobalRouter>();
608
609    let Some(router) = router else {
610        error_log!("No global router found - call init_router() first");
611        return div()
612            .child("RouterOutlet: No global router found. Call init_router() first.")
613            .into_any_element();
614    };
615
616    let current_path = router.current_path();
617    trace_log!("Current path: '{}'", current_path);
618
619    // Find the parent route that has children and matches the current path
620    // This searches through the route tree to find the correct parent
621    let parent_route = find_parent_route_for_path(router.state().routes(), current_path);
622
623    let Some(parent_route) = parent_route else {
624        warn_log!(
625            "No parent route with children found for path '{}'",
626            current_path
627        );
628        return div()
629            .child(format!(
630                "RouterOutlet: No parent route with children found for path '{}'",
631                current_path
632            ))
633            .into_any_element();
634    };
635
636    trace_log!(
637        "Found parent route: '{}' with {} children",
638        parent_route.config.path,
639        parent_route.get_children().len()
640    );
641
642    // Check if parent route has children
643    if parent_route.get_children().is_empty() {
644        return div()
645            .child(format!(
646                "RouterOutlet: Route '{}' has no child routes",
647                parent_route.config.path
648            ))
649            .into_any_element();
650    }
651
652    // Resolve which child route should be rendered.
653    // We pass the current parent params; the resolver returns (route, merged_params).
654    let route_params = crate::RouteParams::new();
655
656    let resolved = resolve_child_route(parent_route, current_path, &route_params, name);
657
658    let Some((child_route, child_params)) = resolved else {
659        warn_log!("No child route matched for path '{}'", current_path);
660        return div()
661            .child(format!(
662                "RouterOutlet: No child route matched for path '{}'",
663                current_path
664            ))
665            .into_any_element();
666    };
667
668    trace_log!("Matched child route: '{}'", child_route.config.path);
669
670    // Render the child route
671    if let Some(builder) = &child_route.builder {
672        // Call the builder with window, cx and parameters
673        builder(window, cx, &child_params)
674    } else {
675        div()
676            .child(format!(
677                "RouterOutlet: Child route '{}' has no builder",
678                child_route.config.path
679            ))
680            .into_any_element()
681    }
682}
683
684/// Find the deepest parent route that should render in this outlet
685///
686/// This function performs a depth-first search through the route tree to find
687/// the most specific route that:
688/// 1. Has children (can contain a RouterOutlet)
689/// 2. Matches (or is a parent of) the current path
690///
691/// # Algorithm
692///
693/// Uses depth-first search to find the deepest matching parent route.
694/// For path `/dashboard/analytics`:
695/// - Searches routes for one matching `/dashboard` with children
696/// - If that route's children also have children matching the path, prefers the deeper one
697/// - Returns the most specific parent route
698///
699/// # Time Complexity
700///
701/// O(n) where n is the total number of routes in the tree.
702/// Early exits when routes don't have children.
703///
704/// # Example
705///
706/// ```text
707/// Routes:
708///   /dashboard (has children) -> matches /dashboard/analytics
709///     /analytics (no children)
710///     /settings (no children)
711///
712/// For path "/dashboard/analytics":
713///   Returns: /dashboard route (has children)
714/// ```
715fn find_parent_route_for_path<'a>(
716    routes: &'a [std::sync::Arc<crate::route::Route>],
717    current_path: &str,
718) -> Option<&'a std::sync::Arc<crate::route::Route>> {
719    find_parent_route_internal(routes, current_path, "")
720}
721
722fn find_parent_route_internal<'a>(
723    routes: &'a [std::sync::Arc<crate::route::Route>],
724    current_path: &str,
725    accumulated_path: &str,
726) -> Option<&'a std::sync::Arc<crate::route::Route>> {
727    let current_normalized = current_path.trim_start_matches('/').trim_end_matches('/');
728
729    for route in routes {
730        // Early exit: skip routes without children (can't be parent routes)
731        if route.get_children().is_empty() {
732            continue;
733        }
734
735        let route_segment = route
736            .config
737            .path
738            .trim_start_matches('/')
739            .trim_end_matches('/');
740
741        // Build full path for this route
742        let full_route_path = if accumulated_path.is_empty() {
743            if route_segment.is_empty() || route_segment == "/" {
744                String::new()
745            } else {
746                route_segment.to_string()
747            }
748        } else if route_segment.is_empty() || route_segment == "/" {
749            accumulated_path.to_string()
750        } else {
751            format!("{}/{}", accumulated_path, route_segment)
752        };
753
754        // Check if current path is under this route's subtree
755        let is_under = if full_route_path.is_empty() {
756            !current_normalized.is_empty()
757        } else {
758            current_normalized.starts_with(&full_route_path)
759                && (current_normalized.len() == full_route_path.len()
760                    || current_normalized[full_route_path.len()..].starts_with('/'))
761        };
762
763        if is_under {
764            // Depth-first: check children first for a deeper matching parent
765            if let Some(deeper) =
766                find_parent_route_internal(route.get_children(), current_path, &full_route_path)
767            {
768                return Some(deeper);
769            }
770
771            // No deeper parent found - check if any direct child matches or contains the current path
772            // This route is the parent if current path matches or is under one of its children
773            for child in route.get_children() {
774                let child_segment = child
775                    .config
776                    .path
777                    .trim_start_matches('/')
778                    .trim_end_matches('/');
779                let child_full_path = if full_route_path.is_empty() {
780                    child_segment.to_string()
781                } else {
782                    format!("{}/{}", full_route_path, child_segment)
783                };
784
785                // Check if current path matches this child or is under it
786                if current_normalized == child_full_path
787                    || current_normalized.starts_with(&format!("{}/", child_full_path))
788                {
789                    return Some(route);
790                }
791            }
792
793            // If path exactly matches this route and no children matched,
794            // return this route as parent (for rendering outlet when on the route itself)
795            // Only do this if we're at the top level (accumulated_path is empty or this is the root)
796            if current_normalized == full_route_path && accumulated_path.is_empty() {
797                return Some(route);
798            }
799        }
800    }
801
802    None
803}
804
805// ============================================================================
806// RouterLink - Navigation Link Component
807// ============================================================================
808//
809// Provides a clickable link that navigates to a route when clicked.
810// Similar to:
811
812use crate::Navigator;
813use gpui::*;
814
815/// A clickable link component for router navigation
816///
817/// # Example
818///
819/// ```ignore
820/// use gpui_navigator::RouterLink;
821///
822/// RouterLink::new("/products")
823///     .child("View Products")
824///     .build(cx)
825/// ```
826pub struct RouterLink {
827    /// Target route path
828    path: SharedString,
829    /// Optional custom styling when link is active
830    active_class: Option<Box<dyn Fn(Div) -> Div>>,
831    /// Child elements
832    children: Vec<AnyElement>,
833}
834
835impl RouterLink {
836    /// Create a new RouterLink to the specified path
837    pub fn new(path: impl Into<SharedString>) -> Self {
838        Self {
839            path: path.into(),
840            active_class: None,
841            children: Vec::new(),
842        }
843    }
844
845    /// Add a child element
846    pub fn child(mut self, child: impl IntoElement) -> Self {
847        self.children.push(child.into_any_element());
848        self
849    }
850
851    /// Set custom styling for when this link is active (current route)
852    pub fn active_class(mut self, style: impl Fn(Div) -> Div + 'static) -> Self {
853        self.active_class = Some(Box::new(style));
854        self
855    }
856
857    /// Build the link element with the given context
858    pub fn build<V: 'static>(self, cx: &mut Context<'_, V>) -> Div {
859        let path = self.path.clone();
860        let current_path = Navigator::current_path(cx);
861        let is_active = current_path == path.as_ref();
862
863        let mut link = div().cursor_pointer().on_mouse_down(
864            MouseButton::Left,
865            cx.listener(move |_view, _event, _window, cx| {
866                Navigator::push(cx, path.to_string());
867                cx.notify();
868            }),
869        );
870
871        // Apply active styling if provided and link is active
872        if is_active {
873            if let Some(active_fn) = self.active_class {
874                link = active_fn(link);
875            }
876        }
877
878        // Add children
879        for child in self.children {
880            link = link.child(child);
881        }
882
883        link
884    }
885}
886
887/// Helper function to create a simple text link
888pub fn router_link<V: 'static>(
889    cx: &mut Context<'_, V>,
890    path: impl Into<SharedString>,
891    label: impl Into<SharedString>,
892) -> Div {
893    let path_str: SharedString = path.into();
894    let label_str: SharedString = label.into();
895    let current_path = Navigator::current_path(cx);
896    let is_active = current_path == path_str.as_ref();
897
898    div()
899        .cursor_pointer()
900        .text_color(if is_active {
901            rgb(0x2196f3)
902        } else {
903            rgb(0x333333)
904        })
905        .hover(|this| this.text_color(rgb(0x2196f3)))
906        .child(label_str)
907        .on_mouse_down(
908            MouseButton::Left,
909            cx.listener(move |_view, _event, _window, cx| {
910                Navigator::push(cx, path_str.to_string());
911                cx.notify();
912            }),
913        )
914}
915
916// ============================================================================
917// Default Pages System
918// ============================================================================
919
920/// Configuration for default router pages (404, loading, error, etc.)
921pub struct DefaultPages {
922    /// Custom 404 not found page builder
923    pub not_found: Option<Box<dyn Fn() -> AnyElement + Send + Sync>>,
924    /// Custom loading page builder
925    pub loading: Option<Box<dyn Fn() -> AnyElement + Send + Sync>>,
926    /// Custom error page builder
927    pub error: Option<Box<dyn Fn(&str) -> AnyElement + Send + Sync>>,
928}
929
930impl DefaultPages {
931    /// Create new default pages configuration with built-in defaults
932    pub fn new() -> Self {
933        Self {
934            not_found: None,
935            loading: None,
936            error: None,
937        }
938    }
939
940    /// Set custom 404 not found page
941    pub fn with_not_found<F>(mut self, builder: F) -> Self
942    where
943        F: Fn() -> AnyElement + Send + Sync + 'static,
944    {
945        self.not_found = Some(Box::new(builder));
946        self
947    }
948
949    /// Set custom loading page
950    pub fn with_loading<F>(mut self, builder: F) -> Self
951    where
952        F: Fn() -> AnyElement + Send + Sync + 'static,
953    {
954        self.loading = Some(Box::new(builder));
955        self
956    }
957
958    /// Set custom error page
959    pub fn with_error<F>(mut self, builder: F) -> Self
960    where
961        F: Fn(&str) -> AnyElement + Send + Sync + 'static,
962    {
963        self.error = Some(Box::new(builder));
964        self
965    }
966
967    /// Render 404 not found page (custom or default)
968    pub fn render_not_found(&self) -> AnyElement {
969        if let Some(builder) = &self.not_found {
970            builder()
971        } else {
972            default_not_found_page().into_any_element()
973        }
974    }
975
976    /// Render loading page (custom or default)
977    pub fn render_loading(&self) -> AnyElement {
978        if let Some(builder) = &self.loading {
979            builder()
980        } else {
981            default_loading_page().into_any_element()
982        }
983    }
984
985    /// Render error page (custom or default)
986    pub fn render_error(&self, message: &str) -> AnyElement {
987        if let Some(builder) = &self.error {
988            builder(message)
989        } else {
990            default_error_page(message).into_any_element()
991        }
992    }
993}
994
995impl Default for DefaultPages {
996    fn default() -> Self {
997        Self::new()
998    }
999}
1000
1001// ============================================================================
1002// Built-in Default Pages
1003// ============================================================================
1004
1005/// Default 404 Not Found page
1006fn not_found_page() -> impl IntoElement {
1007    // For now, use the static default
1008    // In the future, this could check a global DefaultPages config
1009    default_not_found_page()
1010}
1011
1012/// Built-in minimalist 404 page
1013fn default_not_found_page() -> impl IntoElement {
1014    use gpui::{div, relative, rgb, ParentElement, Styled};
1015
1016    div()
1017        .flex()
1018        .flex_col()
1019        .items_center()
1020        .justify_center()
1021        .size_full()
1022        .bg(rgb(0x1e1e1e))
1023        .p_8()
1024        .gap_6()
1025        .child(
1026            div()
1027                .flex()
1028                .items_center()
1029                .justify_center()
1030                .w(px(140.))
1031                .h(px(140.))
1032                .rounded(px(24.))
1033                .bg(rgb(0xf44336))
1034                .shadow_lg()
1035                .child(
1036                    div()
1037                        .text_color(rgb(0xffffff))
1038                        .text_size(px(64.))
1039                        .child("404"),
1040                ),
1041        )
1042        .child(
1043            div()
1044                .text_3xl()
1045                .font_weight(FontWeight::BOLD)
1046                .text_color(rgb(0xffffff))
1047                .child("Page Not Found"),
1048        )
1049        .child(
1050            div()
1051                .text_base()
1052                .text_color(rgb(0xcccccc))
1053                .text_center()
1054                .max_w(px(500.))
1055                .line_height(relative(1.6))
1056                .child("The page you're looking for doesn't exist or has been moved."),
1057        )
1058        .child(
1059            div()
1060                .mt_4()
1061                .p_6()
1062                .bg(rgb(0x252526))
1063                .rounded(px(12.))
1064                .border_1()
1065                .border_color(rgb(0x3e3e3e))
1066                .max_w(px(600.))
1067                .child(
1068                    div()
1069                        .flex()
1070                        .flex_col()
1071                        .gap_3()
1072                        .child(
1073                            div()
1074                                .text_sm()
1075                                .font_weight(FontWeight::BOLD)
1076                                .text_color(rgb(0xf44336))
1077                                .mb_2()
1078                                .child("What happened?"),
1079                        )
1080                        .child(not_found_item("•", "The route doesn't exist in the router"))
1081                        .child(not_found_item("•", "The URL might be mistyped"))
1082                        .child(not_found_item("•", "The page may have been removed")),
1083                ),
1084        )
1085}
1086
1087fn not_found_item(bullet: &str, text: &str) -> impl IntoElement {
1088    use gpui::{div, rgb, ParentElement, Styled};
1089
1090    div()
1091        .flex()
1092        .items_start()
1093        .gap_3()
1094        .child(
1095            div()
1096                .text_sm()
1097                .text_color(rgb(0xf44336))
1098                .child(bullet.to_string()),
1099        )
1100        .child(
1101            div()
1102                .text_sm()
1103                .text_color(rgb(0xcccccc))
1104                .line_height(relative(1.5))
1105                .child(text.to_string()),
1106        )
1107}
1108
1109/// Built-in minimalist loading page
1110fn default_loading_page() -> impl IntoElement {
1111    use gpui::{div, rgb, ParentElement, Styled};
1112
1113    div()
1114        .flex()
1115        .flex_col()
1116        .items_center()
1117        .justify_center()
1118        .size_full()
1119        .bg(rgb(0x1e1e1e))
1120        .gap_4()
1121        .child(
1122            div()
1123                .flex()
1124                .items_center()
1125                .justify_center()
1126                .w(px(80.))
1127                .h(px(80.))
1128                .rounded(px(16.))
1129                .bg(rgb(0x2196f3))
1130                .shadow_lg()
1131                .child(
1132                    div()
1133                        .text_color(rgb(0xffffff))
1134                        .text_size(px(36.))
1135                        .child("⏳"),
1136                ),
1137        )
1138        .child(
1139            div()
1140                .text_xl()
1141                .font_weight(FontWeight::MEDIUM)
1142                .text_color(rgb(0xffffff))
1143                .child("Loading..."),
1144        )
1145        .child(
1146            div()
1147                .text_sm()
1148                .text_color(rgb(0x888888))
1149                .child("Please wait"),
1150        )
1151}
1152
1153/// Built-in minimalist error page
1154fn default_error_page(message: &str) -> impl IntoElement {
1155    use gpui::{div, relative, rgb, ParentElement, Styled};
1156
1157    div()
1158        .flex()
1159        .flex_col()
1160        .items_center()
1161        .justify_center()
1162        .size_full()
1163        .bg(rgb(0x1e1e1e))
1164        .p_8()
1165        .gap_6()
1166        .child(
1167            div()
1168                .flex()
1169                .items_center()
1170                .justify_center()
1171                .w(px(120.))
1172                .h(px(120.))
1173                .rounded(px(20.))
1174                .bg(rgb(0xff9800))
1175                .shadow_lg()
1176                .child(
1177                    div()
1178                        .text_color(rgb(0xffffff))
1179                        .text_size(px(48.))
1180                        .child("⚠️"),
1181                ),
1182        )
1183        .child(
1184            div()
1185                .text_2xl()
1186                .font_weight(FontWeight::BOLD)
1187                .text_color(rgb(0xffffff))
1188                .child("Something Went Wrong"),
1189        )
1190        .child(
1191            div()
1192                .text_base()
1193                .text_color(rgb(0xcccccc))
1194                .text_center()
1195                .max_w(px(500.))
1196                .line_height(relative(1.6))
1197                .child(message.to_string()),
1198        )
1199        .child(
1200            div()
1201                .mt_2()
1202                .px_6()
1203                .py_3()
1204                .bg(rgb(0x252526))
1205                .rounded(px(8.))
1206                .border_1()
1207                .border_color(rgb(0x3e3e3e))
1208                .child(
1209                    div()
1210                        .text_sm()
1211                        .text_color(rgb(0x888888))
1212                        .child("Try refreshing the page or contact support"),
1213                ),
1214        )
1215}
1216
1217#[cfg(test)]
1218mod tests {
1219    use super::{find_parent_route_for_path, RouterOutlet};
1220    use crate::route::Route;
1221    use gpui::{div, IntoElement, ParentElement};
1222    use std::sync::Arc;
1223
1224    #[test]
1225    fn test_outlet_creation() {
1226        let outlet = RouterOutlet::default();
1227        assert!(outlet.name.is_none());
1228
1229        let named = RouterOutlet::named("sidebar");
1230        assert_eq!(named.name.as_deref(), Some("sidebar"));
1231    }
1232
1233    #[test]
1234    fn test_outlet_name() {
1235        let outlet = RouterOutlet::new();
1236        assert!(outlet.name.is_none());
1237
1238        let named = RouterOutlet::named("main");
1239        assert_eq!(named.name, Some("main".to_string()));
1240    }
1241
1242    // Helper to create a dummy builder
1243    fn dummy_builder(
1244        _window: &mut gpui::Window,
1245        _cx: &mut gpui::App,
1246        _params: &crate::RouteParams,
1247    ) -> gpui::AnyElement {
1248        div().child("test").into_any_element()
1249    }
1250
1251    #[test]
1252    fn test_find_parent_route_simple() {
1253        // Create route tree:
1254        // /dashboard (has children)
1255        //   /overview
1256        //   /analytics
1257        let routes = vec![Arc::new(Route::new("/dashboard", dummy_builder).children(
1258            vec![
1259                Arc::new(Route::new("overview", dummy_builder)),
1260                Arc::new(Route::new("analytics", dummy_builder)),
1261            ],
1262        ))];
1263
1264        // Should find dashboard for /dashboard/analytics
1265        let result = find_parent_route_for_path(&routes, "/dashboard/analytics");
1266        assert!(result.is_some());
1267        assert_eq!(result.unwrap().config.path, "/dashboard");
1268    }
1269
1270    #[test]
1271    fn test_find_parent_route_exact_match() {
1272        let routes = vec![Arc::new(
1273            Route::new("/dashboard", dummy_builder)
1274                .children(vec![Arc::new(Route::new("settings", dummy_builder))]),
1275        )];
1276
1277        // Should find dashboard even when path is exactly /dashboard
1278        let result = find_parent_route_for_path(&routes, "/dashboard");
1279        assert!(result.is_some());
1280        assert_eq!(result.unwrap().config.path, "/dashboard");
1281    }
1282
1283    #[test]
1284    fn test_find_parent_route_no_children() {
1285        // Route without children
1286        let routes = vec![Arc::new(Route::new("/about", dummy_builder))];
1287
1288        // Should return None (no parent with children)
1289        let result = find_parent_route_for_path(&routes, "/about");
1290        assert!(result.is_none());
1291    }
1292
1293    #[test]
1294    fn test_find_parent_route_nested_parents() {
1295        // Create deeply nested route tree:
1296        // /dashboard (has children)
1297        //   /settings (has children)
1298        //     /profile
1299        let routes = vec![Arc::new(Route::new("/dashboard", dummy_builder).children(
1300            vec![Arc::new(Route::new("settings", dummy_builder).children(
1301                vec![Arc::new(Route::new("profile", dummy_builder))],
1302            ))],
1303        ))];
1304
1305        // Should find the deepest parent with children (settings)
1306        let result = find_parent_route_for_path(&routes, "/dashboard/settings/profile");
1307        assert!(result.is_some());
1308        assert_eq!(result.unwrap().config.path, "settings");
1309    }
1310
1311    #[test]
1312    fn test_find_parent_route_root() {
1313        // Root route with children
1314        let routes = vec![Arc::new(Route::new("/", dummy_builder).children(vec![
1315            Arc::new(Route::new("home", dummy_builder)),
1316            Arc::new(Route::new("about", dummy_builder)),
1317        ]))];
1318
1319        // Root route should match any path
1320        let result = find_parent_route_for_path(&routes, "/home");
1321        assert!(result.is_some());
1322        assert_eq!(result.unwrap().config.path, "/");
1323
1324        let result = find_parent_route_for_path(&routes, "/about");
1325        assert!(result.is_some());
1326        assert_eq!(result.unwrap().config.path, "/");
1327    }
1328
1329    #[test]
1330    fn test_find_parent_route_multiple_top_level() {
1331        // Multiple top-level routes
1332        let routes = vec![
1333            Arc::new(
1334                Route::new("/dashboard", dummy_builder)
1335                    .children(vec![Arc::new(Route::new("overview", dummy_builder))]),
1336            ),
1337            Arc::new(
1338                Route::new("/settings", dummy_builder)
1339                    .children(vec![Arc::new(Route::new("profile", dummy_builder))]),
1340            ),
1341        ];
1342
1343        // Should find correct parent
1344        let result = find_parent_route_for_path(&routes, "/dashboard/overview");
1345        assert!(result.is_some());
1346        assert_eq!(result.unwrap().config.path, "/dashboard");
1347
1348        let result = find_parent_route_for_path(&routes, "/settings/profile");
1349        assert!(result.is_some());
1350        assert_eq!(result.unwrap().config.path, "/settings");
1351    }
1352
1353    #[test]
1354    fn test_find_parent_route_no_match() {
1355        let routes = vec![Arc::new(
1356            Route::new("/dashboard", dummy_builder)
1357                .children(vec![Arc::new(Route::new("overview", dummy_builder))]),
1358        )];
1359
1360        // Non-existent path
1361        let result = find_parent_route_for_path(&routes, "/nonexistent/path");
1362        assert!(result.is_none());
1363    }
1364
1365    #[test]
1366    fn test_find_parent_route_trailing_slash() {
1367        let routes = vec![Arc::new(
1368            Route::new("/dashboard/", dummy_builder)
1369                .children(vec![Arc::new(Route::new("settings", dummy_builder))]),
1370        )];
1371
1372        // Should handle trailing slashes
1373        let result = find_parent_route_for_path(&routes, "/dashboard/settings");
1374        assert!(result.is_some());
1375    }
1376
1377    #[test]
1378    fn test_find_parent_route_empty_child_path() {
1379        // Parent with index route (empty path child)
1380        let routes = vec![Arc::new(Route::new("/dashboard", dummy_builder).children(
1381            vec![
1382                Arc::new(Route::new("", dummy_builder)), // Index route
1383                Arc::new(Route::new("settings", dummy_builder)),
1384            ],
1385        ))];
1386
1387        // Should still find parent
1388        let result = find_parent_route_for_path(&routes, "/dashboard");
1389        assert!(result.is_some());
1390        assert_eq!(result.unwrap().config.path, "/dashboard");
1391    }
1392
1393    #[test]
1394    fn test_find_parent_prefers_deepest() {
1395        // Test that depth-first search prefers deeper parents
1396        // / (has children)
1397        //   /dashboard (has children)
1398        //     /settings (has children)
1399        //       /profile
1400        let routes = vec![Arc::new(Route::new("/", dummy_builder).children(vec![
1401            Arc::new(
1402                Route::new("dashboard", dummy_builder).children(vec![Arc::new(
1403                Route::new("settings", dummy_builder)
1404                    .children(vec![Arc::new(Route::new("profile", dummy_builder))]),
1405            )]),
1406            ),
1407        ]))];
1408
1409        // For /dashboard/settings/profile, should find settings (deepest with children)
1410        let result = find_parent_route_for_path(&routes, "/dashboard/settings/profile");
1411        assert!(result.is_some());
1412        assert_eq!(result.unwrap().config.path, "settings");
1413
1414        // For /dashboard/settings, should find dashboard (deepest with children)
1415        let result = find_parent_route_for_path(&routes, "/dashboard/settings");
1416        assert!(result.is_some());
1417        assert_eq!(result.unwrap().config.path, "dashboard");
1418    }
1419}