Skip to main content

gpui_navigator/
context.rs

1//! Router context integration for GPUI.
2//!
3//! This module provides the global router state management through GPUI's
4//! context system. It contains three key types:
5//!
6//! - [`GlobalRouter`] — the central routing object stored as a GPUI `Global`.
7//!   It owns the [`RouterState`](crate::RouterState), the route registry,
8//!   and orchestrates the full navigation pipeline (guards → middleware →
9//!   navigation → middleware).
10//!
11//! - [`Navigator`] — a convenience API with static methods
12//!   (`Navigator::push`, `Navigator::pop`, …) that read/write the
13//!   `GlobalRouter` through `cx`.
14//!
15//! - [`NavigatorHandle`] — returned by [`Navigator::of(cx)`](Navigator::of),
16//!   enables fluent chained navigation calls.
17//!
18//! # Initialization
19//!
20//! Use [`init_router`] to set up the global router before any navigation:
21//!
22//! ```ignore
23//! use gpui_navigator::{init_router, Route};
24//!
25//! init_router(cx, |router| {
26//!     router.add_route(Route::new("/", |_, _cx, _params| gpui::div()));
27//! });
28//! ```
29
30#[cfg(feature = "cache")]
31use crate::cache::{CacheStats, RouteCache};
32use crate::error::{ErrorHandlers, NavigationResult};
33use crate::history::{HistoryEntry, HistoryState};
34use crate::lifecycle::NavigationAction;
35use crate::nested::trim_slashes;
36use crate::resolve::{resolve_match_stack, MatchStack};
37use crate::route::NamedRouteRegistry;
38#[cfg(feature = "transition")]
39use crate::transition::Transition;
40use crate::{
41    debug_log, error_log, info_log, trace_log, warn_log, IntoRoute, Route, RouteParams, RouterState,
42};
43use gpui::{AnyView, App, BorrowAppContext, Global};
44use std::borrow::BorrowMut;
45use std::collections::HashMap;
46use std::sync::Arc;
47
48/// Maximum redirect depth to prevent infinite redirect loops.
49const MAX_REDIRECT_DEPTH: usize = 5;
50
51/// Maximum number of cached component views before FIFO eviction kicks in.
52const MAX_COMPONENT_CACHE: usize = 128;
53
54// ============================================================================
55// NavigationRequest
56// ============================================================================
57
58/// Request for navigation.
59///
60/// Contains information about the navigation being performed, passed to guards
61/// and middleware so they can inspect the source and destination.
62///
63/// # Example
64///
65/// ```
66/// use gpui_navigator::NavigationRequest;
67///
68/// let request = NavigationRequest::new("/dashboard".to_string());
69/// assert_eq!(request.to, "/dashboard");
70/// ```
71#[must_use]
72pub struct NavigationRequest {
73    /// The path we're navigating from (if any)
74    pub from: Option<String>,
75
76    /// The path we're navigating to
77    pub to: String,
78
79    /// Route parameters extracted from the path
80    pub params: RouteParams,
81}
82
83impl NavigationRequest {
84    /// Create a new navigation request.
85    pub fn new(to: String) -> Self {
86        Self {
87            from: None,
88            to,
89            params: RouteParams::new(),
90        }
91    }
92
93    /// Create a navigation request with a source path.
94    pub fn with_from(to: String, from: String) -> Self {
95        Self {
96            from: Some(from),
97            to,
98            params: RouteParams::new(),
99        }
100    }
101
102    /// Set route parameters.
103    pub fn with_params(mut self, params: RouteParams) -> Self {
104        self.params = params;
105        self
106    }
107}
108
109impl std::fmt::Debug for NavigationRequest {
110    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
111        f.debug_struct("NavigationRequest")
112            .field("from", &self.from)
113            .field("to", &self.to)
114            .field("params", &self.params)
115            .finish_non_exhaustive()
116    }
117}
118
119// ============================================================================
120// GlobalRouter
121// ============================================================================
122
123/// Global router state accessible from any component.
124///
125/// This is the central routing object stored as a GPUI global. It holds the
126/// navigation state, route registry, and orchestrates the navigation pipeline
127/// (guards -> middleware -> navigation -> middleware).
128#[derive(Clone)]
129pub struct GlobalRouter {
130    state: RouterState,
131    /// Pre-resolved route chain for the current path.
132    /// Built once per navigation, consumed by outlets during render.
133    match_stack: MatchStack,
134    /// Previous match stack — used for transition exit animations.
135    #[cfg(feature = "transition")]
136    previous_stack: Option<MatchStack>,
137    #[cfg(feature = "cache")]
138    nested_cache: RouteCache,
139    named_routes: NamedRouteRegistry,
140    #[cfg(feature = "transition")]
141    next_transition: Option<Transition>,
142    /// Cache for component entities created by `Route::component()`.
143    /// Unlike `window.use_keyed_state()` which is frame-scoped, this cache
144    /// persists across navigations so that component state survives when the
145    /// user navigates away and back.
146    ///
147    /// Capped at `MAX_COMPONENT_CACHE` entries. Oldest entries are evicted
148    /// when the limit is reached (FIFO order via `VecDeque` of keys).
149    component_cache: HashMap<String, AnyView>,
150    /// Insertion-order tracking for FIFO eviction of `component_cache`.
151    component_cache_order: std::collections::VecDeque<String>,
152    /// Custom error handlers for 404 and navigation errors.
153    error_handlers: ErrorHandlers,
154}
155
156impl GlobalRouter {
157    /// Create a new global router with empty state and no registered routes.
158    #[must_use]
159    pub fn new() -> Self {
160        Self::default()
161    }
162
163    /// Get the pre-resolved match stack for the current path.
164    ///
165    /// Outlets call this during render to find their route by depth index.
166    /// The stack is built once per navigation, so this is O(1).
167    #[must_use]
168    pub const fn match_stack(&self) -> &MatchStack {
169        &self.match_stack
170    }
171
172    /// Get the previous match stack (for transition animations).
173    #[cfg(feature = "transition")]
174    #[must_use]
175    pub const fn previous_stack(&self) -> Option<&MatchStack> {
176        self.previous_stack.as_ref()
177    }
178
179    /// Re-resolve the match stack after routes change.
180    fn re_resolve(&mut self) {
181        self.match_stack = resolve_match_stack(self.state.routes(), self.state.current_path());
182    }
183
184    /// Register a route and re-resolve the match stack.
185    ///
186    /// If the route has a [`name`](crate::route::RouteConfig::name), it is
187    /// also registered in the [`NamedRouteRegistry`] for URL generation via
188    /// [`url_for`](Self::url_for).
189    pub fn add_route(&mut self, route: Route) {
190        if let Some(name) = &route.config.name {
191            info_log!(
192                "Registered route '{}' (name: '{}')",
193                route.config.path,
194                name
195            );
196            self.named_routes
197                .register(name.clone(), route.config.path.clone());
198        } else {
199            info_log!("Registered route '{}'", route.config.path);
200        }
201        self.state.add_route(route);
202        #[cfg(feature = "cache")]
203        self.nested_cache.clear();
204        // Re-resolve match stack after adding routes
205        self.re_resolve();
206    }
207
208    // ========================================================================
209    // Navigation pipeline
210    // ========================================================================
211
212    /// Navigate to a path, running the full guard/middleware pipeline.
213    ///
214    /// Pipeline:
215    /// 1. Collect guards from matched route (+ ancestors)
216    /// 2. Check guards — if any denies/redirects, navigation is blocked
217    /// 3. Run `before_navigation` middleware
218    /// 4. Perform actual navigation
219    /// 5. Run `after_navigation` middleware
220    pub fn push(&mut self, path: String, cx: &App) -> NavigationResult {
221        self.navigate_with_pipeline(path, cx, NavigateOp::Push, 0)
222    }
223
224    /// Replace current path, running the full guard/middleware pipeline.
225    pub fn replace(&mut self, path: String, cx: &App) -> NavigationResult {
226        self.navigate_with_pipeline(path, cx, NavigateOp::Replace, 0)
227    }
228
229    /// Go back in history, checking guards on the target route.
230    pub fn back(&mut self, cx: &App) -> Option<NavigationResult> {
231        let target = self.state.peek_back_path()?.to_string();
232        Some(self.navigate_with_pipeline(target, cx, NavigateOp::Back, 0))
233    }
234
235    /// Go forward in history, checking guards on the target route.
236    pub fn forward(&mut self, cx: &App) -> Option<NavigationResult> {
237        let target = self.state.peek_forward_path()?.to_string();
238        Some(self.navigate_with_pipeline(target, cx, NavigateOp::Forward, 0))
239    }
240
241    /// Push a new path with associated [`HistoryState`] data, running the full pipeline.
242    ///
243    /// Allows attaching arbitrary key-value state (scroll position, form data, etc.)
244    /// to the history entry. The pipeline (guards, middleware) runs first; state
245    /// is only attached if navigation succeeds.
246    pub fn push_with_state(
247        &mut self,
248        path: String,
249        state: HistoryState,
250        cx: &App,
251    ) -> NavigationResult {
252        // Run the pipeline first (guards, middleware, etc.)
253        // We use the normal push pipeline, then retroactively attach state
254        let result = self.navigate_with_pipeline(path, cx, NavigateOp::Push, 0);
255        if matches!(result, NavigationResult::Success { .. }) {
256            // Attach state to the current history entry
257            let current_path = self.state.current_path().to_string();
258            self.state.replace_with_state(current_path, state);
259        }
260        result
261    }
262
263    /// Replace current path with associated [`HistoryState`] data, running the full pipeline.
264    pub fn replace_with_state(
265        &mut self,
266        path: String,
267        state: HistoryState,
268        cx: &App,
269    ) -> NavigationResult {
270        let result = self.navigate_with_pipeline(path, cx, NavigateOp::Replace, 0);
271        if matches!(result, NavigationResult::Success { .. }) {
272            let current_path = self.state.current_path().to_string();
273            self.state.replace_with_state(current_path, state);
274        }
275        result
276    }
277
278    /// Return the current [`HistoryEntry`] (path + optional state data).
279    #[must_use]
280    pub fn current_entry(&self) -> &HistoryEntry {
281        self.state.current_entry()
282    }
283
284    /// Core navigation method that runs the full pipeline.
285    fn navigate_with_pipeline(
286        &mut self,
287        path: String,
288        cx: &App,
289        op: NavigateOp,
290        redirect_depth: usize,
291    ) -> NavigationResult {
292        if redirect_depth >= MAX_REDIRECT_DEPTH {
293            error_log!(
294                "Redirect loop detected (depth {}) navigating to '{}'",
295                redirect_depth,
296                path
297            );
298            return NavigationResult::Blocked {
299                reason: format!("Redirect loop detected (depth {redirect_depth}): target '{path}'"),
300                redirect: None,
301            };
302        }
303
304        let from = self.current_path().to_string();
305        info_log!("Navigation {:?}: '{}' → '{}'", op, from, path);
306
307        // Build request — used by guards, lifecycle hooks, and middleware
308        let request = NavigationRequest::with_from(path.clone(), from.clone());
309
310        // Step 1: Run guards
311        #[cfg(feature = "guard")]
312        {
313            let guard_result = self.run_guards(cx, &request);
314            match guard_result {
315                NavigationAction::Continue => {}
316                NavigationAction::Deny { reason } => {
317                    warn_log!("Navigation to '{}' blocked: {}", path, reason);
318                    return NavigationResult::Blocked {
319                        reason,
320                        redirect: None,
321                    };
322                }
323                NavigationAction::Redirect { to, reason } => {
324                    debug_log!(
325                        "Guard redirecting from '{}' to '{}': {:?}",
326                        path,
327                        to,
328                        reason
329                    );
330                    return self.navigate_with_pipeline(
331                        to,
332                        cx,
333                        NavigateOp::Push,
334                        redirect_depth + 1,
335                    );
336                }
337            }
338        }
339
340        // Step 2: Check if current route allows deactivation (lifecycle)
341        match self.run_lifecycle_can_deactivate(cx) {
342            NavigationAction::Continue => {}
343            NavigationAction::Deny { reason } => {
344                warn_log!(
345                    "Lifecycle can_deactivate blocked leaving '{}': {}",
346                    from,
347                    reason
348                );
349                return NavigationResult::Blocked {
350                    reason,
351                    redirect: None,
352                };
353            }
354            NavigationAction::Redirect { to, .. } => {
355                return self.navigate_with_pipeline(to, cx, NavigateOp::Push, redirect_depth + 1);
356            }
357        }
358
359        // Step 3: Run before middleware
360        #[cfg(feature = "middleware")]
361        self.run_middleware_before(cx, &request);
362
363        // Step 4: Run on_exit lifecycle on current route
364        if let NavigationAction::Deny { reason } = self.run_lifecycle_on_exit(cx) {
365            warn_log!("Lifecycle on_exit blocked leaving '{}': {}", from, reason);
366            return NavigationResult::Blocked {
367                reason,
368                redirect: None,
369            };
370        }
371
372        // Step 5: Perform actual navigation + resolve match stack
373        let event = match self.perform_navigation(path, op) {
374            Ok(event) => event,
375            Err(result) => return result,
376        };
377
378        // Step 6: Run on_enter lifecycle on new route
379        match self.run_lifecycle_on_enter(cx, &request) {
380            NavigationAction::Continue => {}
381            NavigationAction::Deny { reason } => {
382                // Navigation already happened — log warning but don't revert
383                warn_log!(
384                    "Lifecycle on_enter denied entry to '{}': {}",
385                    event.to,
386                    reason
387                );
388            }
389            NavigationAction::Redirect { to, .. } => {
390                return self.navigate_with_pipeline(to, cx, NavigateOp::Push, redirect_depth + 1);
391            }
392        }
393
394        // Step 7: Run after middleware
395        #[cfg(feature = "middleware")]
396        self.run_middleware_after(cx, &request);
397
398        info_log!(
399            "Navigation complete: '{}' (stack depth: {})",
400            event.to,
401            self.match_stack.len()
402        );
403        NavigationResult::Success { path: event.to }
404    }
405
406    // ========================================================================
407    // Navigation execution
408    // ========================================================================
409
410    /// Perform the actual history mutation, cache clear, and match stack resolution.
411    ///
412    /// Returns `Ok(RouteChangeEvent)` on success, `Err(NavigationResult)` if the
413    /// history operation fails unexpectedly.
414    fn perform_navigation(
415        &mut self,
416        path: String,
417        op: NavigateOp,
418    ) -> Result<crate::RouteChangeEvent, NavigationResult> {
419        #[cfg(feature = "cache")]
420        self.nested_cache.clear();
421
422        #[cfg(feature = "transition")]
423        {
424            self.previous_stack = Some(self.match_stack.clone());
425        }
426
427        let event = match op {
428            NavigateOp::Push => self.state.push(path),
429            NavigateOp::Replace => self.state.replace(path),
430            NavigateOp::Back => self.state.back().ok_or_else(|| {
431                error_log!("back() returned None after peek succeeded");
432                NavigationResult::Error(crate::error::NavigationError::NavigationFailed {
433                    message: "History back failed unexpectedly".into(),
434                })
435            })?,
436            NavigateOp::Forward => self.state.forward().ok_or_else(|| {
437                error_log!("forward() returned None after peek succeeded");
438                NavigationResult::Error(crate::error::NavigationError::NavigationFailed {
439                    message: "History forward failed unexpectedly".into(),
440                })
441            })?,
442        };
443
444        self.match_stack = resolve_match_stack(self.state.routes(), self.state.current_path());
445        Ok(event)
446    }
447
448    // ========================================================================
449    // Lifecycle hooks
450    // ========================================================================
451
452    /// Run `can_deactivate` on the current route's lifecycle (if any).
453    fn run_lifecycle_can_deactivate(&self, cx: &App) -> NavigationAction {
454        if let Some(current_route) = self.state.current_route() {
455            if let Some(ref lifecycle) = current_route.lifecycle {
456                return lifecycle.can_deactivate(cx);
457            }
458        }
459        NavigationAction::Continue
460    }
461
462    /// Run `on_exit` on the current route's lifecycle (if any).
463    fn run_lifecycle_on_exit(&self, cx: &App) -> NavigationAction {
464        if let Some(current_route) = self.state.current_route() {
465            if let Some(ref lifecycle) = current_route.lifecycle {
466                return lifecycle.on_exit(cx);
467            }
468        }
469        NavigationAction::Continue
470    }
471
472    /// Run `on_enter` on the new route's lifecycle (if any).
473    fn run_lifecycle_on_enter(&self, cx: &App, request: &NavigationRequest) -> NavigationAction {
474        if let Some(leaf) = self.match_stack.leaf() {
475            if let Some(ref lifecycle) = leaf.route.lifecycle {
476                return lifecycle.on_enter(cx, request);
477            }
478        }
479        NavigationAction::Continue
480    }
481
482    /// Collect and run guards for the target path.
483    ///
484    /// Walks the route tree to find the target route, collecting guards from
485    /// every ancestor route along the way. Guards on parent routes also protect
486    /// child routes (e.g. an `AuthGuard` on `/dashboard` also guards `/dashboard/settings`).
487    #[cfg(feature = "guard")]
488    fn run_guards(&self, cx: &App, request: &NavigationRequest) -> NavigationAction {
489        let path = trim_slashes(&request.to);
490        let mut guards: Vec<(&dyn crate::guards::RouteGuard, i32)> = Vec::new();
491
492        // Collect guards from matching routes (including ancestor routes)
493        for route in self.state.routes() {
494            Self::collect_guards_recursive(route, path, "", &mut guards);
495        }
496
497        // Sort by priority (higher first)
498        guards.sort_by_key(|(_, prio)| std::cmp::Reverse(*prio));
499
500        debug_log!("Collected {} guards for '{}'", guards.len(), path);
501
502        // Check each guard — first non-Continue result wins
503        for (guard, prio) in &guards {
504            let result = guard.check(cx, request);
505            trace_log!(
506                "Guard '{}' (priority {}) → {:?}",
507                guard.name(),
508                prio,
509                result
510            );
511            if !matches!(result, NavigationAction::Continue) {
512                debug_log!(
513                    "Guard '{}' blocked navigation to '{}'",
514                    guard.name(),
515                    request.to
516                );
517                return result;
518            }
519        }
520
521        NavigationAction::Continue
522    }
523
524    /// Recursively walk the route tree, collecting guards from routes that match
525    /// the given path (as exact match or prefix).
526    #[cfg(feature = "guard")]
527    fn collect_guards_recursive<'a>(
528        route: &'a Arc<Route>,
529        path: &str,
530        accumulated: &str,
531        out: &mut Vec<(&'a dyn crate::guards::RouteGuard, i32)>,
532    ) {
533        walk_matching_routes(route, path, accumulated, &mut |r, _full| {
534            for guard in &r.guards {
535                out.push((guard.as_ref(), guard.priority()));
536            }
537        });
538    }
539
540    /// Run `before_navigation` on all middleware attached to matching routes.
541    #[cfg(feature = "middleware")]
542    fn run_middleware_before(&self, cx: &App, request: &NavigationRequest) {
543        let path = trim_slashes(&request.to);
544        let mut middleware: Vec<(&dyn crate::middleware::RouteMiddleware, i32)> = Vec::new();
545
546        for route in self.state.routes() {
547            Self::collect_middleware_recursive(route, path, "", &mut middleware);
548        }
549
550        // Sort by priority (higher first for before)
551        middleware.sort_by_key(|(_, prio)| std::cmp::Reverse(*prio));
552
553        debug_log!(
554            "Running {} before-middleware for '{}'",
555            middleware.len(),
556            request.to
557        );
558        for (mw, _) in &middleware {
559            trace_log!(
560                "Middleware '{}' before_navigation for '{}'",
561                mw.name(),
562                request.to
563            );
564            mw.before_navigation(cx, request);
565        }
566    }
567
568    /// Run `after_navigation` on all middleware attached to matching routes.
569    #[cfg(feature = "middleware")]
570    fn run_middleware_after(&self, cx: &App, request: &NavigationRequest) {
571        let path = trim_slashes(&request.to);
572        let mut middleware: Vec<(&dyn crate::middleware::RouteMiddleware, i32)> = Vec::new();
573
574        for route in self.state.routes() {
575            Self::collect_middleware_recursive(route, path, "", &mut middleware);
576        }
577
578        // Sort by priority ascending for after (reverse of before — stack-like)
579        middleware.sort_by_key(|(_, prio)| *prio);
580
581        debug_log!(
582            "Running {} after-middleware for '{}'",
583            middleware.len(),
584            request.to
585        );
586        for (mw, _) in &middleware {
587            trace_log!(
588                "Middleware '{}' after_navigation for '{}'",
589                mw.name(),
590                request.to
591            );
592            mw.after_navigation(cx, request);
593        }
594    }
595
596    /// Recursively collect middleware from matching routes.
597    #[cfg(feature = "middleware")]
598    fn collect_middleware_recursive<'a>(
599        route: &'a Arc<Route>,
600        path: &str,
601        accumulated: &str,
602        out: &mut Vec<(&'a dyn crate::middleware::RouteMiddleware, i32)>,
603    ) {
604        walk_matching_routes(route, path, accumulated, &mut |r, _full| {
605            for mw in &r.middleware {
606                out.push((mw.as_ref(), mw.priority()));
607            }
608        });
609    }
610
611    // ========================================================================
612    // Named routes
613    // ========================================================================
614
615    /// Navigate to a named route, resolving the URL from `params`.
616    ///
617    /// Returns `None` if the name is not registered.
618    pub fn push_named(
619        &mut self,
620        name: &str,
621        params: &RouteParams,
622        cx: &App,
623    ) -> Option<NavigationResult> {
624        let url = if let Some(url) = self.named_routes.url_for(name, params) {
625            debug_log!("Named route '{}' resolved to '{}'", name, url);
626            url
627        } else {
628            warn_log!("Named route '{}' not found in registry", name);
629            return None;
630        };
631        Some(self.push(url, cx))
632    }
633
634    /// Generate a URL for a named route by substituting `params` into its pattern.
635    ///
636    /// Returns `None` if the name is not registered.
637    #[must_use]
638    pub fn url_for(&self, name: &str, params: &RouteParams) -> Option<String> {
639        self.named_routes.url_for(name, params)
640    }
641
642    // ========================================================================
643    // Accessors
644    // ========================================================================
645
646    /// Return the current navigation path.
647    #[must_use]
648    pub fn current_path(&self) -> &str {
649        self.state.current_path()
650    }
651
652    /// Get current route match (with caching, requires mutable).
653    pub fn current_match(&mut self) -> Option<crate::RouteMatch> {
654        self.state.current_match()
655    }
656
657    /// Get current route match (immutable, no caching).
658    #[must_use]
659    pub fn current_match_immutable(&self) -> Option<crate::RouteMatch> {
660        self.state.current_match_immutable()
661    }
662
663    /// Get the current matched Route.
664    #[must_use]
665    pub fn current_route(&self) -> Option<&Arc<crate::route::Route>> {
666        self.state.current_route()
667    }
668
669    /// Check if can go back.
670    #[must_use]
671    pub const fn can_go_back(&self) -> bool {
672        self.state.can_go_back()
673    }
674
675    /// Check if can go forward.
676    #[must_use]
677    pub fn can_go_forward(&self) -> bool {
678        self.state.can_go_forward()
679    }
680
681    /// Get mutable state reference.
682    pub fn state_mut(&mut self) -> &mut RouterState {
683        &mut self.state
684    }
685
686    /// Get state reference.
687    #[must_use]
688    pub const fn state(&self) -> &RouterState {
689        &self.state
690    }
691
692    /// Get nested route cache (mutable).
693    #[cfg(feature = "cache")]
694    pub fn nested_cache_mut(&mut self) -> &mut RouteCache {
695        &mut self.nested_cache
696    }
697
698    /// Get nested route cache statistics.
699    #[cfg(feature = "cache")]
700    #[must_use]
701    pub const fn cache_stats(&self) -> &CacheStats {
702        self.nested_cache.stats()
703    }
704
705    // ========================================================================
706    // Error handlers
707    // ========================================================================
708
709    /// Set custom error handlers for 404 and navigation errors.
710    pub fn set_error_handlers(&mut self, handlers: ErrorHandlers) {
711        self.error_handlers = handlers;
712    }
713
714    /// Get a reference to the current error handlers.
715    pub const fn error_handlers(&self) -> &ErrorHandlers {
716        &self.error_handlers
717    }
718
719    // ========================================================================
720    // Component cache
721    // ========================================================================
722
723    /// Get a cached component view by key.
724    #[must_use]
725    pub fn get_cached_component(&self, key: &str) -> Option<&AnyView> {
726        self.component_cache.get(key)
727    }
728
729    /// Store a component view in the cache, evicting the oldest entry if full.
730    pub fn cache_component(&mut self, key: String, view: AnyView) {
731        if !self.component_cache.contains_key(&key) {
732            // Evict oldest entries until we are under the limit
733            while self.component_cache.len() >= MAX_COMPONENT_CACHE {
734                if let Some(oldest_key) = self.component_cache_order.pop_front() {
735                    self.component_cache.remove(&oldest_key);
736                } else {
737                    break;
738                }
739            }
740            self.component_cache_order.push_back(key.clone());
741        }
742        self.component_cache.insert(key, view);
743    }
744
745    // ========================================================================
746    // Transitions
747    // ========================================================================
748
749    /// Set transition for the next navigation.
750    #[cfg(feature = "transition")]
751    pub fn set_next_transition(&mut self, transition: Transition) {
752        self.next_transition = Some(transition);
753    }
754
755    /// Get and consume the next transition override.
756    #[cfg(feature = "transition")]
757    pub fn take_next_transition(&mut self) -> Option<Transition> {
758        self.next_transition.take()
759    }
760
761    /// Check if there's a transition override set.
762    #[cfg(feature = "transition")]
763    #[must_use]
764    pub const fn has_next_transition(&self) -> bool {
765        self.next_transition.is_some()
766    }
767
768    /// Clear transition override.
769    #[cfg(feature = "transition")]
770    pub fn clear_next_transition(&mut self) {
771        self.next_transition = None;
772    }
773
774    /// Navigate with a specific transition.
775    #[cfg(feature = "transition")]
776    pub fn push_with_transition(
777        &mut self,
778        path: String,
779        transition: Transition,
780        cx: &App,
781    ) -> NavigationResult {
782        self.set_next_transition(transition);
783        self.push(path, cx)
784    }
785
786    /// Replace with a specific transition.
787    #[cfg(feature = "transition")]
788    pub fn replace_with_transition(
789        &mut self,
790        path: String,
791        transition: Transition,
792        cx: &App,
793    ) -> NavigationResult {
794        self.set_next_transition(transition);
795        self.replace(path, cx)
796    }
797}
798
799impl Default for GlobalRouter {
800    fn default() -> Self {
801        Self {
802            state: RouterState::new(),
803            match_stack: MatchStack::new(),
804            #[cfg(feature = "transition")]
805            previous_stack: None,
806            #[cfg(feature = "cache")]
807            nested_cache: RouteCache::new(),
808            named_routes: NamedRouteRegistry::new(),
809            #[cfg(feature = "transition")]
810            next_transition: None,
811            component_cache: HashMap::new(),
812            component_cache_order: std::collections::VecDeque::new(),
813            error_handlers: ErrorHandlers::new(),
814        }
815    }
816}
817
818impl Global for GlobalRouter {}
819
820// ============================================================================
821// Helper: path prefix matching with parameter support
822// ============================================================================
823
824/// Walk the route tree, calling `visitor` on each route whose accumulated path
825/// is a prefix of `target_path`. The visitor receives the route and the full
826/// accumulated path.
827///
828/// This factored-out helper avoids duplicating tree-walk logic between guard
829/// collection and middleware collection.
830fn walk_matching_routes<'a>(
831    route: &'a Arc<Route>,
832    target_path: &str,
833    accumulated: &str,
834    visitor: &mut dyn FnMut(&'a Route, &str),
835) {
836    let route_path = trim_slashes(&route.config.path);
837
838    // Avoid allocations when possible by reusing the existing string
839    let full: std::borrow::Cow<'_, str> = if accumulated.is_empty() {
840        std::borrow::Cow::Borrowed(route_path)
841    } else if route_path.is_empty() {
842        std::borrow::Cow::Borrowed(accumulated)
843    } else {
844        std::borrow::Cow::Owned(format!("{accumulated}/{route_path}"))
845    };
846
847    if !full.is_empty() && !path_matches_prefix(target_path, &full) {
848        return;
849    }
850
851    visitor(route, &full);
852
853    for child in route.get_children() {
854        walk_matching_routes(child, target_path, &full, visitor);
855    }
856}
857
858/// Check if `path` matches `prefix` as a route prefix (supports `:param` segments).
859///
860/// Uses iterators instead of collecting into `Vec`s to avoid allocation.
861///
862/// Examples:
863/// - `path_matches_prefix("dashboard/settings", "dashboard")` → true
864/// - `path_matches_prefix("dashboard", "dashboard")` → true
865/// - `path_matches_prefix("users/123", "users/:id")` → true
866/// - `path_matches_prefix("other", "dashboard")` → false
867fn path_matches_prefix(path: &str, prefix: &str) -> bool {
868    let mut path_segs = path.split('/').filter(|s| !s.is_empty());
869    let prefix_segs = prefix.split('/').filter(|s| !s.is_empty());
870
871    for pfs in prefix_segs {
872        let Some(ps) = path_segs.next() else {
873            // Path exhausted before prefix — not a prefix match
874            return false;
875        };
876        if pfs.starts_with(':') {
877            continue;
878        }
879        if ps != pfs {
880            return false;
881        }
882    }
883
884    true
885}
886
887// ============================================================================
888// Navigation operation type
889// ============================================================================
890
891/// Internal enum for the kind of navigation to perform after pipeline checks.
892#[derive(Debug, Clone, Copy)]
893enum NavigateOp {
894    Push,
895    Replace,
896    Back,
897    Forward,
898}
899
900// ============================================================================
901// UseRouter trait
902// ============================================================================
903
904/// Trait for accessing the global router from context.
905pub trait UseRouter {
906    /// Get reference to global router.
907    fn router(&self) -> &GlobalRouter;
908
909    /// Update global router.
910    fn update_router<F, R>(&mut self, f: F) -> R
911    where
912        F: FnOnce(&mut GlobalRouter, &mut App) -> R;
913}
914
915impl UseRouter for App {
916    fn router(&self) -> &GlobalRouter {
917        self.global::<GlobalRouter>()
918    }
919
920    fn update_router<F, R>(&mut self, f: F) -> R
921    where
922        F: FnOnce(&mut GlobalRouter, &mut Self) -> R,
923    {
924        self.update_global(f)
925    }
926}
927
928// ============================================================================
929// init_router
930// ============================================================================
931
932/// Initialize global router with routes.
933///
934/// # Example
935///
936/// ```ignore
937/// use gpui_navigator::{init_router, Route};
938///
939/// init_router(cx, |router| {
940///     router.add_route(Route::new("/", |_, _cx, _params| gpui::div()));
941///     router.add_route(Route::new("/users/:id", |_, _cx, _params| gpui::div()));
942/// });
943/// ```
944pub fn init_router<F>(cx: &mut App, configure: F)
945where
946    F: FnOnce(&mut GlobalRouter),
947{
948    let mut router = GlobalRouter::new();
949    configure(&mut router);
950    cx.set_global(router);
951}
952
953/// Navigate to a path using the global router and refresh all windows.
954///
955/// This is a convenience shortcut equivalent to
956/// `cx.update_global::<GlobalRouter, _>(|r, cx| r.push(path, cx))`.
957pub fn navigate(cx: &mut App, path: impl Into<String>) {
958    let path = path.into();
959    cx.update_global::<GlobalRouter, _>(|router, cx| {
960        router.push(path, cx);
961    });
962    cx.refresh_windows();
963}
964
965/// Return the current path from the global router.
966pub fn current_path(cx: &App) -> String {
967    cx.router().current_path().to_string()
968}
969
970// ============================================================================
971// NavigatorHandle
972// ============================================================================
973
974/// Handle returned by [`Navigator::of`] for fluent chained navigation.
975///
976/// Each method consumes and returns `self`, allowing patterns like:
977///
978/// ```ignore
979/// Navigator::of(cx)
980///     .push("/users")
981///     .push("/users/42");
982/// ```
983#[must_use]
984pub struct NavigatorHandle<'a, C: BorrowAppContext> {
985    cx: &'a mut C,
986}
987
988impl<C: BorrowAppContext + BorrowMut<App>> NavigatorHandle<'_, C> {
989    /// Navigate to a new path.
990    pub fn push(self, route: impl IntoRoute) -> Self {
991        let descriptor = route.into_route();
992        self.cx.update_global::<GlobalRouter, _>(|router, cx| {
993            let app: &App = cx.borrow_mut();
994            router.push(descriptor.path, app);
995        });
996        self.cx.borrow_mut().refresh_windows();
997        self
998    }
999
1000    /// Replace current path without adding to history.
1001    pub fn replace(self, route: impl IntoRoute) -> Self {
1002        let descriptor = route.into_route();
1003        self.cx.update_global::<GlobalRouter, _>(|router, cx| {
1004            let app: &App = cx.borrow_mut();
1005            router.replace(descriptor.path, app);
1006        });
1007        self.cx.borrow_mut().refresh_windows();
1008        self
1009    }
1010
1011    /// Go back to the previous route.
1012    pub fn pop(self) -> Self {
1013        self.cx.update_global::<GlobalRouter, _>(|router, cx| {
1014            let app: &App = cx.borrow_mut();
1015            router.back(app);
1016        });
1017        self.cx.borrow_mut().refresh_windows();
1018        self
1019    }
1020
1021    /// Go forward in history.
1022    pub fn forward(self) -> Self {
1023        self.cx.update_global::<GlobalRouter, _>(|router, cx| {
1024            let app: &App = cx.borrow_mut();
1025            router.forward(app);
1026        });
1027        self.cx.borrow_mut().refresh_windows();
1028        self
1029    }
1030}
1031
1032// ============================================================================
1033// Navigator
1034// ============================================================================
1035
1036/// Navigation API for convenient route navigation.
1037///
1038/// Provides static methods for navigation operations:
1039/// - `Navigator::push(cx, "/path")` — Navigate to a new page
1040/// - `Navigator::pop(cx)` — Go back to previous page
1041/// - `Navigator::replace(cx, "/path")` — Replace current page
1042///
1043/// All navigation methods run the full pipeline (guards, middleware).
1044///
1045/// # Example
1046///
1047/// ```ignore
1048/// use gpui_navigator::Navigator;
1049///
1050/// Navigator::push(cx, "/users/123");
1051/// Navigator::pop(cx);
1052/// Navigator::replace(cx, "/login");
1053/// ```
1054pub struct Navigator;
1055
1056impl Navigator {
1057    /// Get a [`NavigatorHandle`] for chained navigation calls.
1058    pub fn of<C: BorrowAppContext + BorrowMut<App>>(cx: &mut C) -> NavigatorHandle<'_, C> {
1059        NavigatorHandle { cx }
1060    }
1061
1062    /// Navigate to a new path.
1063    pub fn push(cx: &mut (impl BorrowAppContext + BorrowMut<App>), route: impl IntoRoute) {
1064        let descriptor = route.into_route();
1065        debug_log!("Navigator::push: pushing path '{}'", descriptor.path);
1066        cx.update_global::<GlobalRouter, _>(|router, cx| {
1067            let app: &App = cx.borrow_mut();
1068            router.push(descriptor.path, app);
1069        });
1070        cx.borrow_mut().refresh_windows();
1071    }
1072
1073    /// Replace current path without adding to history.
1074    pub fn replace(cx: &mut (impl BorrowAppContext + BorrowMut<App>), route: impl IntoRoute) {
1075        let descriptor = route.into_route();
1076        cx.update_global::<GlobalRouter, _>(|router, cx| {
1077            let app: &App = cx.borrow_mut();
1078            router.replace(descriptor.path, app);
1079        });
1080        cx.borrow_mut().refresh_windows();
1081    }
1082
1083    /// Push a new path with associated [`HistoryState`] data.
1084    pub fn push_with_state(
1085        cx: &mut (impl BorrowAppContext + BorrowMut<App>),
1086        route: impl IntoRoute,
1087        state: HistoryState,
1088    ) {
1089        let descriptor = route.into_route();
1090        cx.update_global::<GlobalRouter, _>(|router, cx| {
1091            let app: &App = cx.borrow_mut();
1092            router.push_with_state(descriptor.path, state, app);
1093        });
1094        cx.borrow_mut().refresh_windows();
1095    }
1096
1097    /// Replace current path with associated [`HistoryState`] data.
1098    pub fn replace_with_state(
1099        cx: &mut (impl BorrowAppContext + BorrowMut<App>),
1100        route: impl IntoRoute,
1101        state: HistoryState,
1102    ) {
1103        let descriptor = route.into_route();
1104        cx.update_global::<GlobalRouter, _>(|router, cx| {
1105            let app: &App = cx.borrow_mut();
1106            router.replace_with_state(descriptor.path, state, app);
1107        });
1108        cx.borrow_mut().refresh_windows();
1109    }
1110
1111    /// Return the current [`HistoryEntry`] (path + optional state).
1112    pub fn current_entry(cx: &App) -> HistoryEntry {
1113        cx.global::<GlobalRouter>().current_entry().clone()
1114    }
1115
1116    /// Go back to the previous route.
1117    pub fn pop(cx: &mut (impl BorrowAppContext + BorrowMut<App>)) {
1118        cx.update_global::<GlobalRouter, _>(|router, cx| {
1119            let app: &App = cx.borrow_mut();
1120            router.back(app);
1121        });
1122        cx.borrow_mut().refresh_windows();
1123    }
1124
1125    /// Alias for [`pop`](Navigator::pop).
1126    pub fn back(cx: &mut (impl BorrowAppContext + BorrowMut<App>)) {
1127        Self::pop(cx);
1128    }
1129
1130    /// Go forward in history.
1131    pub fn forward(cx: &mut (impl BorrowAppContext + BorrowMut<App>)) {
1132        cx.update_global::<GlobalRouter, _>(|router, cx| {
1133            let app: &App = cx.borrow_mut();
1134            router.forward(app);
1135        });
1136        cx.borrow_mut().refresh_windows();
1137    }
1138
1139    /// Get current path.
1140    pub fn current_path(cx: &App) -> String {
1141        cx.global::<GlobalRouter>().current_path().to_string()
1142    }
1143
1144    /// Check if can go back.
1145    pub fn can_pop(cx: &App) -> bool {
1146        cx.global::<GlobalRouter>().can_go_back()
1147    }
1148
1149    /// Alias for [`can_pop`](Navigator::can_pop).
1150    pub fn can_go_back(cx: &App) -> bool {
1151        Self::can_pop(cx)
1152    }
1153
1154    /// Check if can go forward.
1155    pub fn can_go_forward(cx: &App) -> bool {
1156        cx.global::<GlobalRouter>().can_go_forward()
1157    }
1158
1159    /// Navigate to a named route with parameters.
1160    pub fn push_named(
1161        cx: &mut (impl BorrowAppContext + BorrowMut<App>),
1162        name: &str,
1163        params: &RouteParams,
1164    ) {
1165        let name = name.to_string();
1166        let params = params.clone();
1167        cx.update_global::<GlobalRouter, _>(|router, cx| {
1168            let app: &App = cx.borrow_mut();
1169            router.push_named(&name, &params, app);
1170        });
1171        cx.borrow_mut().refresh_windows();
1172    }
1173
1174    /// Generate URL for a named route.
1175    pub fn url_for(cx: &App, name: &str, params: &RouteParams) -> Option<String> {
1176        cx.global::<GlobalRouter>().url_for(name, params)
1177    }
1178
1179    /// Set transition for the next navigation.
1180    #[cfg(feature = "transition")]
1181    pub fn set_next_transition(cx: &mut impl BorrowAppContext, transition: Transition) {
1182        cx.update_global::<GlobalRouter, _>(|router, _| {
1183            router.set_next_transition(transition);
1184        });
1185    }
1186
1187    /// Navigate with a specific transition.
1188    #[cfg(feature = "transition")]
1189    pub fn push_with_transition(
1190        cx: &mut (impl BorrowAppContext + BorrowMut<App>),
1191        route: impl IntoRoute,
1192        transition: Transition,
1193    ) {
1194        let descriptor = route.into_route();
1195        cx.update_global::<GlobalRouter, _>(|router, cx| {
1196            let app: &App = cx.borrow_mut();
1197            router.push_with_transition(descriptor.path, transition, app);
1198        });
1199        cx.borrow_mut().refresh_windows();
1200    }
1201
1202    /// Replace with a specific transition.
1203    #[cfg(feature = "transition")]
1204    pub fn replace_with_transition(
1205        cx: &mut (impl BorrowAppContext + BorrowMut<App>),
1206        route: impl IntoRoute,
1207        transition: Transition,
1208    ) {
1209        let descriptor = route.into_route();
1210        cx.update_global::<GlobalRouter, _>(|router, cx| {
1211            let app: &App = cx.borrow_mut();
1212            router.replace_with_transition(descriptor.path, transition, app);
1213        });
1214        cx.borrow_mut().refresh_windows();
1215    }
1216
1217    /// Push named route with a specific transition.
1218    #[cfg(feature = "transition")]
1219    pub fn push_named_with_transition(
1220        cx: &mut (impl BorrowAppContext + BorrowMut<App>),
1221        name: &str,
1222        params: &RouteParams,
1223        transition: Transition,
1224    ) {
1225        let name = name.to_string();
1226        let params = params.clone();
1227        cx.update_global::<GlobalRouter, _>(|router, cx| {
1228            let app: &App = cx.borrow_mut();
1229            router.set_next_transition(transition);
1230            router.push_named(&name, &params, app);
1231        });
1232        cx.borrow_mut().refresh_windows();
1233    }
1234}
1235
1236// ============================================================================
1237// Tests
1238// ============================================================================
1239
1240#[cfg(test)]
1241#[allow(clippy::needless_pass_by_ref_mut)]
1242mod tests {
1243    use super::*;
1244    use gpui::{IntoElement, TestAppContext};
1245
1246    #[gpui::test]
1247    fn test_nav_push(cx: &mut TestAppContext) {
1248        cx.update(|cx| {
1249            init_router(cx, |router| {
1250                router.add_route(Route::new("/", |_, _cx, _params| {
1251                    gpui::div().into_any_element()
1252                }));
1253                router.add_route(Route::new("/users", |_, _cx, _params| {
1254                    gpui::div().into_any_element()
1255                }));
1256                router.add_route(Route::new("/users/:id", |_, _cx, _params| {
1257                    gpui::div().into_any_element()
1258                }));
1259            });
1260        });
1261
1262        let initial_path = cx.read(Navigator::current_path);
1263        assert_eq!(initial_path, "/");
1264
1265        cx.update(|cx| Navigator::push(cx, "/users"));
1266        assert_eq!(cx.read(Navigator::current_path), "/users");
1267
1268        cx.update(|cx| Navigator::push(cx, "/users/123"));
1269        assert_eq!(cx.read(Navigator::current_path), "/users/123");
1270    }
1271
1272    #[gpui::test]
1273    fn test_nav_back_forward(cx: &mut TestAppContext) {
1274        cx.update(|cx| {
1275            init_router(cx, |router| {
1276                router.add_route(Route::new("/", |_, _cx, _params| {
1277                    gpui::div().into_any_element()
1278                }));
1279                router.add_route(Route::new("/page1", |_, _cx, _params| {
1280                    gpui::div().into_any_element()
1281                }));
1282                router.add_route(Route::new("/page2", |_, _cx, _params| {
1283                    gpui::div().into_any_element()
1284                }));
1285            });
1286        });
1287
1288        cx.update(|cx| {
1289            Navigator::push(cx, "/page1");
1290            Navigator::push(cx, "/page2");
1291        });
1292
1293        assert_eq!(cx.read(Navigator::current_path), "/page2");
1294        assert!(cx.read(Navigator::can_pop));
1295
1296        cx.update(Navigator::pop);
1297        assert_eq!(cx.read(Navigator::current_path), "/page1");
1298        assert!(cx.read(Navigator::can_pop));
1299        assert!(cx.read(Navigator::can_go_forward));
1300
1301        cx.update(Navigator::forward);
1302        assert_eq!(cx.read(Navigator::current_path), "/page2");
1303        assert!(!cx.read(Navigator::can_go_forward));
1304    }
1305
1306    #[gpui::test]
1307    fn test_nav_replace(cx: &mut TestAppContext) {
1308        cx.update(|cx| {
1309            init_router(cx, |router| {
1310                router.add_route(Route::new("/", |_, _cx, _params| {
1311                    gpui::div().into_any_element()
1312                }));
1313                router.add_route(Route::new("/login", |_, _cx, _params| {
1314                    gpui::div().into_any_element()
1315                }));
1316                router.add_route(Route::new("/home", |_, _cx, _params| {
1317                    gpui::div().into_any_element()
1318                }));
1319            });
1320        });
1321
1322        cx.update(|cx| {
1323            Navigator::push(cx, "/login");
1324            Navigator::replace(cx, "/home");
1325        });
1326
1327        assert_eq!(cx.read(Navigator::current_path), "/home");
1328
1329        cx.update(Navigator::pop);
1330        assert_eq!(cx.read(Navigator::current_path), "/");
1331    }
1332
1333    #[gpui::test]
1334    fn test_nav_can_go_back_boundaries(cx: &mut TestAppContext) {
1335        cx.update(|cx| {
1336            init_router(cx, |router| {
1337                router.add_route(Route::new("/", |_, _cx, _params| {
1338                    gpui::div().into_any_element()
1339                }));
1340            });
1341        });
1342
1343        assert!(!cx.read(Navigator::can_pop));
1344
1345        cx.update(|cx| Navigator::push(cx, "/page1"));
1346        assert!(cx.read(Navigator::can_pop));
1347
1348        cx.update(Navigator::pop);
1349        assert!(!cx.read(Navigator::can_pop));
1350    }
1351
1352    #[gpui::test]
1353    fn test_nav_multiple_pushes(cx: &mut TestAppContext) {
1354        cx.update(|cx| {
1355            init_router(cx, |router| {
1356                router.add_route(Route::new("/", |_, _cx, _params| {
1357                    gpui::div().into_any_element()
1358                }));
1359                router.add_route(Route::new("/step1", |_, _cx, _params| {
1360                    gpui::div().into_any_element()
1361                }));
1362                router.add_route(Route::new("/step2", |_, _cx, _params| {
1363                    gpui::div().into_any_element()
1364                }));
1365                router.add_route(Route::new("/step3", |_, _cx, _params| {
1366                    gpui::div().into_any_element()
1367                }));
1368            });
1369        });
1370
1371        cx.update(|cx| {
1372            Navigator::push(cx, "/step1");
1373            Navigator::push(cx, "/step2");
1374            Navigator::push(cx, "/step3");
1375        });
1376
1377        assert_eq!(cx.read(Navigator::current_path), "/step3");
1378
1379        cx.update(Navigator::pop);
1380        assert_eq!(cx.read(Navigator::current_path), "/step2");
1381
1382        cx.update(Navigator::pop);
1383        assert_eq!(cx.read(Navigator::current_path), "/step1");
1384
1385        cx.update(Navigator::pop);
1386        assert_eq!(cx.read(Navigator::current_path), "/");
1387    }
1388
1389    #[gpui::test]
1390    fn test_nav_with_route_parameters(cx: &mut TestAppContext) {
1391        cx.update(|cx| {
1392            init_router(cx, |router| {
1393                router.add_route(Route::new("/", |_, _cx, _params| {
1394                    gpui::div().into_any_element()
1395                }));
1396                router.add_route(Route::new("/users/:id", |_, _cx, _params| {
1397                    gpui::div().into_any_element()
1398                }));
1399                router.add_route(Route::new(
1400                    "/posts/:id/comments/:commentId",
1401                    |_, _cx, _params| gpui::div().into_any_element(),
1402                ));
1403            });
1404        });
1405
1406        cx.update(|cx| Navigator::push(cx, "/users/42"));
1407        assert_eq!(cx.read(Navigator::current_path), "/users/42");
1408
1409        cx.update(|cx| Navigator::push(cx, "/posts/123/comments/456"));
1410        assert_eq!(cx.read(Navigator::current_path), "/posts/123/comments/456");
1411    }
1412
1413    #[gpui::test]
1414    fn test_navigator_of_style(cx: &mut TestAppContext) {
1415        cx.update(|cx| {
1416            init_router(cx, |router| {
1417                router.add_route(Route::new("/", |_, _cx, _params| {
1418                    gpui::div().into_any_element()
1419                }));
1420                router.add_route(Route::new("/home", |_, _cx, _params| {
1421                    gpui::div().into_any_element()
1422                }));
1423                router.add_route(Route::new("/profile", |_, _cx, _params| {
1424                    gpui::div().into_any_element()
1425                }));
1426            });
1427        });
1428
1429        cx.update(|cx| {
1430            let _ = Navigator::of(cx).push("/home");
1431        });
1432        assert_eq!(cx.read(Navigator::current_path), "/home");
1433
1434        cx.update(|cx| {
1435            let _ = Navigator::of(cx).push("/profile").pop();
1436        });
1437        assert_eq!(cx.read(Navigator::current_path), "/home");
1438
1439        cx.update(|cx| {
1440            let _ = Navigator::of(cx).replace("/profile");
1441        });
1442        assert_eq!(cx.read(Navigator::current_path), "/profile");
1443
1444        assert!(cx.read(Navigator::can_pop));
1445        cx.update(|cx| {
1446            let _ = Navigator::of(cx).pop();
1447        });
1448        assert_eq!(cx.read(Navigator::current_path), "/");
1449        assert!(!cx.read(Navigator::can_pop));
1450    }
1451
1452    #[gpui::test]
1453    fn test_string_into_route(cx: &mut TestAppContext) {
1454        cx.update(|cx| {
1455            init_router(cx, |router| {
1456                router.add_route(Route::new("/", |_, _cx, _params| {
1457                    gpui::div().into_any_element()
1458                }));
1459                router.add_route(Route::new("/home", |_, _cx, _params| {
1460                    gpui::div().into_any_element()
1461                }));
1462            });
1463        });
1464
1465        cx.update(|cx| Navigator::push(cx, "/home"));
1466        assert_eq!(cx.read(Navigator::current_path), "/home");
1467
1468        cx.update(|cx| Navigator::push(cx, String::from("/home")));
1469        assert_eq!(cx.read(Navigator::current_path), "/home");
1470    }
1471
1472    // ========================================================================
1473    // Guard integration tests
1474    // ========================================================================
1475
1476    #[gpui::test]
1477    #[cfg(feature = "guard")]
1478    fn test_guard_blocks_navigation(cx: &mut TestAppContext) {
1479        use crate::AuthGuard;
1480
1481        cx.update(|cx| {
1482            init_router(cx, |router| {
1483                router.add_route(Route::new("/", |_, _cx, _params| {
1484                    gpui::div().into_any_element()
1485                }));
1486                router.add_route(
1487                    Route::new("/protected", |_, _cx, _params| {
1488                        gpui::div().into_any_element()
1489                    })
1490                    .guard(AuthGuard::new(|_| false, "/login")),
1491                );
1492                router.add_route(Route::new("/login", |_, _cx, _params| {
1493                    gpui::div().into_any_element()
1494                }));
1495            });
1496        });
1497
1498        // Guard should redirect to /login
1499        cx.update(|cx| Navigator::push(cx, "/protected"));
1500
1501        // We end up at /login (redirect), not /protected
1502        assert_eq!(cx.read(Navigator::current_path), "/login");
1503    }
1504
1505    #[gpui::test]
1506    #[cfg(feature = "guard")]
1507    fn test_guard_allows_navigation(cx: &mut TestAppContext) {
1508        use crate::AuthGuard;
1509
1510        cx.update(|cx| {
1511            init_router(cx, |router| {
1512                router.add_route(Route::new("/", |_, _cx, _params| {
1513                    gpui::div().into_any_element()
1514                }));
1515                router.add_route(
1516                    Route::new("/dashboard", |_, _cx, _params| {
1517                        gpui::div().into_any_element()
1518                    })
1519                    .guard(AuthGuard::new(|_| true, "/login")),
1520                );
1521            });
1522        });
1523
1524        cx.update(|cx| Navigator::push(cx, "/dashboard"));
1525        assert_eq!(cx.read(Navigator::current_path), "/dashboard");
1526    }
1527
1528    #[gpui::test]
1529    #[cfg(feature = "guard")]
1530    fn test_guard_denies_navigation(cx: &mut TestAppContext) {
1531        use crate::guard_fn;
1532
1533        cx.update(|cx| {
1534            init_router(cx, |router| {
1535                router.add_route(Route::new("/", |_, _cx, _params| {
1536                    gpui::div().into_any_element()
1537                }));
1538                router.add_route(
1539                    Route::new("/forbidden", |_, _cx, _params| {
1540                        gpui::div().into_any_element()
1541                    })
1542                    .guard(guard_fn(|_, _| NavigationAction::deny("No access"))),
1543                );
1544            });
1545        });
1546
1547        cx.update(|cx| Navigator::push(cx, "/forbidden"));
1548        // Navigation was blocked, path should remain at "/"
1549        assert_eq!(cx.read(Navigator::current_path), "/");
1550    }
1551
1552    #[gpui::test]
1553    #[cfg(feature = "guard")]
1554    fn test_parent_guard_blocks_child(cx: &mut TestAppContext) {
1555        use crate::AuthGuard;
1556
1557        cx.update(|cx| {
1558            init_router(cx, |router| {
1559                router.add_route(Route::new("/", |_, _cx, _params| {
1560                    gpui::div().into_any_element()
1561                }));
1562                router.add_route(
1563                    Route::new("/dashboard", |_, _cx, _params| {
1564                        gpui::div().into_any_element()
1565                    })
1566                    .guard(AuthGuard::new(|_| false, "/login"))
1567                    .child(
1568                        Route::new("settings", |_, _cx, _params| gpui::div().into_any_element())
1569                            .into(),
1570                    ),
1571                );
1572                router.add_route(Route::new("/login", |_, _cx, _params| {
1573                    gpui::div().into_any_element()
1574                }));
1575            });
1576        });
1577
1578        // Guard on /dashboard should also block /dashboard/settings
1579        cx.update(|cx| Navigator::push(cx, "/dashboard/settings"));
1580        assert_eq!(cx.read(Navigator::current_path), "/login");
1581    }
1582
1583    #[gpui::test]
1584    #[cfg(feature = "guard")]
1585    fn test_redirect_loop_protection(cx: &mut TestAppContext) {
1586        use crate::guard_fn;
1587
1588        cx.update(|cx| {
1589            init_router(cx, |router| {
1590                router.add_route(Route::new("/", |_, _cx, _params| {
1591                    gpui::div().into_any_element()
1592                }));
1593                // /a redirects to /b, /b redirects to /a — infinite loop
1594                router.add_route(
1595                    Route::new("/a", |_, _cx, _params| gpui::div().into_any_element())
1596                        .guard(guard_fn(|_, _| NavigationAction::redirect("/b"))),
1597                );
1598                router.add_route(
1599                    Route::new("/b", |_, _cx, _params| gpui::div().into_any_element())
1600                        .guard(guard_fn(|_, _| NavigationAction::redirect("/a"))),
1601                );
1602            });
1603        });
1604
1605        // Should not infinite loop — stays at "/"
1606        cx.update(|cx| Navigator::push(cx, "/a"));
1607        // Path stays at "/" because the redirect loop is detected and blocked
1608        assert_eq!(cx.read(Navigator::current_path), "/");
1609    }
1610
1611    // ========================================================================
1612    // Middleware integration tests
1613    // ========================================================================
1614
1615    #[gpui::test]
1616    #[cfg(feature = "middleware")]
1617    fn test_middleware_runs_during_navigation(cx: &mut TestAppContext) {
1618        use crate::middleware_fn;
1619        use std::sync::{Arc, Mutex};
1620
1621        let calls = Arc::new(Mutex::new(Vec::<String>::new()));
1622        let before_calls = calls.clone();
1623        let after_calls = calls.clone();
1624
1625        cx.update(|cx| {
1626            init_router(cx, |router| {
1627                router.add_route(Route::new("/", |_, _cx, _params| {
1628                    gpui::div().into_any_element()
1629                }));
1630                router.add_route(
1631                    Route::new("/page", |_, _cx, _params| gpui::div().into_any_element())
1632                        .middleware(middleware_fn(
1633                            move |_cx, req| {
1634                                before_calls
1635                                    .lock()
1636                                    .unwrap()
1637                                    .push(format!("before:{}", req.to));
1638                            },
1639                            move |_cx, req| {
1640                                after_calls
1641                                    .lock()
1642                                    .unwrap()
1643                                    .push(format!("after:{}", req.to));
1644                            },
1645                        )),
1646                );
1647            });
1648        });
1649
1650        cx.update(|cx| Navigator::push(cx, "/page"));
1651        assert_eq!(cx.read(Navigator::current_path), "/page");
1652
1653        let log = calls.lock().unwrap();
1654        assert_eq!(log.len(), 2);
1655        assert_eq!(log[0], "before:/page");
1656        assert_eq!(log[1], "after:/page");
1657        drop(log);
1658    }
1659
1660    // ========================================================================
1661    // path_matches_prefix unit tests
1662    // ========================================================================
1663
1664    #[test]
1665    fn test_path_matches_prefix_exact() {
1666        assert!(path_matches_prefix("dashboard", "dashboard"));
1667    }
1668
1669    #[test]
1670    fn test_path_matches_prefix_child() {
1671        assert!(path_matches_prefix("dashboard/settings", "dashboard"));
1672    }
1673
1674    #[test]
1675    fn test_path_matches_prefix_no_match() {
1676        assert!(!path_matches_prefix("other", "dashboard"));
1677    }
1678
1679    #[test]
1680    fn test_path_matches_prefix_with_param() {
1681        assert!(path_matches_prefix("users/123", "users/:id"));
1682        assert!(path_matches_prefix("users/123/posts", "users/:id"));
1683    }
1684
1685    #[test]
1686    fn test_path_matches_prefix_shorter_path() {
1687        assert!(!path_matches_prefix("users", "users/123"));
1688    }
1689}