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