Skip to main content

gpui_navigator/
lifecycle.rs

1//! Route lifecycle hooks and navigation action types.
2//!
3//! This module defines two key abstractions:
4//!
5//! - [`NavigationAction`] — the unified result type returned by guards, lifecycle
6//!   hooks, and middleware. It describes whether navigation should continue, be
7//!   denied, or be redirected.
8//! - [`RouteLifecycle`] — a trait for running code at key points in the navigation
9//!   process: entering a route, exiting a route, and checking whether the user
10//!   can leave (e.g. unsaved changes prompt).
11//!
12//! # Navigation pipeline
13//!
14//! When a navigation request is made, the router executes steps in this order:
15//!
16//! 1. **Guards** — decide if navigation is allowed (see [`guards`](crate::guards))
17//! 2. **`can_deactivate`** — current route's lifecycle check
18//! 3. **Middleware `before`** — cross-cutting pre-navigation logic
19//! 4. **`on_exit`** — current route's cleanup
20//! 5. **Navigation** — the route change itself
21//! 6. **`on_enter`** — new route's setup
22//! 7. **Middleware `after`** — cross-cutting post-navigation logic
23
24use crate::NavigationRequest;
25use gpui::App;
26
27// ============================================================================
28// NavigationAction — unified result for guards, lifecycle, middleware
29// ============================================================================
30
31/// Result of a navigation check (guard, lifecycle, or middleware).
32///
33/// Used by guards to allow/deny navigation, by lifecycle hooks to
34/// continue/abort, and as a general "what should the router do?" answer.
35///
36/// # Example
37///
38/// ```
39/// use gpui_navigator::NavigationAction;
40///
41/// let action = NavigationAction::deny("Not authorized");
42/// assert!(action.is_deny());
43///
44/// let action = NavigationAction::redirect("/login");
45/// assert_eq!(action.redirect_path(), Some("/login"));
46/// ```
47#[derive(Debug, Clone, PartialEq)]
48#[non_exhaustive]
49pub enum NavigationAction {
50    /// Allow navigation to proceed.
51    Continue,
52
53    /// Deny navigation with a reason.
54    Deny {
55        /// Human-readable reason for denying navigation.
56        reason: String,
57    },
58
59    /// Redirect to a different path.
60    Redirect {
61        /// Path to redirect to.
62        to: String,
63        /// Optional human-readable reason for redirecting.
64        reason: Option<String>,
65    },
66}
67
68impl NavigationAction {
69    /// Create a result that allows navigation to proceed (alias for [`Continue`](Self::Continue)).
70    #[must_use]
71    pub const fn allow() -> Self {
72        Self::Continue
73    }
74
75    /// Create a result that blocks navigation with a human-readable reason.
76    pub fn deny(reason: impl Into<String>) -> Self {
77        Self::Deny {
78            reason: reason.into(),
79        }
80    }
81
82    /// Create a result that redirects navigation to a different path.
83    pub fn redirect(to: impl Into<String>) -> Self {
84        Self::Redirect {
85            to: to.into(),
86            reason: None,
87        }
88    }
89
90    /// Create a redirect result with a human-readable reason.
91    pub fn redirect_with_reason(to: impl Into<String>, reason: impl Into<String>) -> Self {
92        Self::Redirect {
93            to: to.into(),
94            reason: Some(reason.into()),
95        }
96    }
97
98    /// Check if this action allows navigation to continue.
99    #[must_use]
100    pub const fn is_continue(&self) -> bool {
101        matches!(self, Self::Continue)
102    }
103
104    /// Check if this action denies navigation.
105    #[must_use]
106    pub const fn is_deny(&self) -> bool {
107        matches!(self, Self::Deny { .. })
108    }
109
110    /// Check if this action redirects navigation.
111    #[must_use]
112    pub const fn is_redirect(&self) -> bool {
113        matches!(self, Self::Redirect { .. })
114    }
115
116    /// Get the redirect path, if this is a redirect action.
117    #[must_use]
118    pub fn redirect_path(&self) -> Option<&str> {
119        match self {
120            Self::Redirect { to, .. } => Some(to.as_str()),
121            _ => None,
122        }
123    }
124}
125
126// Backward-compatibility aliases
127
128/// Deprecated alias for [`NavigationAction`].
129#[deprecated(since = "0.2.0", note = "Use NavigationAction instead")]
130pub type GuardResult = NavigationAction;
131
132/// Deprecated alias for [`NavigationAction`].
133#[deprecated(since = "0.2.0", note = "Use NavigationAction instead")]
134pub type LifecycleResult = NavigationAction;
135
136// ============================================================================
137// RouteLifecycle trait
138// ============================================================================
139
140/// Route lifecycle hooks.
141///
142/// Lifecycle hooks allow you to run code at key points in the navigation process:
143/// - [`on_enter`](RouteLifecycle::on_enter): Called when entering a route (for data loading, setup)
144/// - [`on_exit`](RouteLifecycle::on_exit): Called when leaving a route (for cleanup, saving state)
145/// - [`can_deactivate`](RouteLifecycle::can_deactivate): Called to check if user can leave (for unsaved changes warning)
146///
147/// All methods are **synchronous** because GPUI is a single-threaded desktop framework.
148///
149/// # Example
150///
151/// ```no_run
152/// use gpui_navigator::{RouteLifecycle, NavigationAction, NavigationRequest};
153///
154/// struct FormLifecycle {
155///     has_unsaved_changes: bool,
156/// }
157///
158/// impl RouteLifecycle for FormLifecycle {
159///     fn on_enter(&self, _cx: &gpui::App, _request: &NavigationRequest) -> NavigationAction {
160///         NavigationAction::Continue
161///     }
162///
163///     fn on_exit(&self, _cx: &gpui::App) -> NavigationAction {
164///         NavigationAction::Continue
165///     }
166///
167///     fn can_deactivate(&self, _cx: &gpui::App) -> NavigationAction {
168///         if self.has_unsaved_changes {
169///             NavigationAction::deny("Unsaved changes")
170///         } else {
171///             NavigationAction::Continue
172///         }
173///     }
174/// }
175/// ```
176pub trait RouteLifecycle: Send + Sync + 'static {
177    /// Called when entering the route.
178    ///
179    /// Use this to load data, set up subscriptions, or validate navigation parameters.
180    /// Return [`NavigationAction::deny`] to prevent navigation
181    /// or [`NavigationAction::redirect`] to navigate elsewhere.
182    fn on_enter(&self, cx: &App, request: &NavigationRequest) -> NavigationAction;
183
184    /// Called when exiting the route.
185    ///
186    /// Use this to save state, clean up subscriptions, or cancel pending operations.
187    /// Return [`NavigationAction::deny`] to prevent navigation away.
188    fn on_exit(&self, cx: &App) -> NavigationAction;
189
190    /// Check if the route can be deactivated.
191    ///
192    /// Use this to check for unsaved changes or confirm navigation away.
193    /// Return [`NavigationAction::deny`] to prevent navigation.
194    fn can_deactivate(&self, cx: &App) -> NavigationAction;
195}
196
197// ============================================================================
198// Tests
199// ============================================================================
200
201#[cfg(test)]
202#[allow(clippy::needless_pass_by_ref_mut)]
203mod tests {
204    use super::*;
205
206    // --- NavigationAction tests ---
207
208    #[test]
209    fn test_navigation_action_continue() {
210        let action = NavigationAction::Continue;
211        assert!(action.is_continue());
212        assert!(!action.is_deny());
213        assert!(!action.is_redirect());
214        assert_eq!(action.redirect_path(), None);
215    }
216
217    #[test]
218    fn test_navigation_action_allow_alias() {
219        let action = NavigationAction::allow();
220        assert!(action.is_continue());
221    }
222
223    #[test]
224    fn test_navigation_action_deny() {
225        let action = NavigationAction::deny("Not authorized");
226        assert!(!action.is_continue());
227        assert!(action.is_deny());
228        assert!(!action.is_redirect());
229
230        match action {
231            NavigationAction::Deny { reason } => assert_eq!(reason, "Not authorized"),
232            _ => panic!("Expected Deny"),
233        }
234    }
235
236    #[test]
237    fn test_navigation_action_redirect() {
238        let action = NavigationAction::redirect("/login");
239        assert!(!action.is_continue());
240        assert!(!action.is_deny());
241        assert!(action.is_redirect());
242        assert_eq!(action.redirect_path(), Some("/login"));
243    }
244
245    #[test]
246    fn test_navigation_action_redirect_with_reason() {
247        let action = NavigationAction::redirect_with_reason("/login", "Auth required");
248        match action {
249            NavigationAction::Redirect { to, reason } => {
250                assert_eq!(to, "/login");
251                assert_eq!(reason, Some("Auth required".to_string()));
252            }
253            _ => panic!("Expected Redirect"),
254        }
255    }
256
257    #[test]
258    fn test_navigation_action_equality() {
259        assert_eq!(NavigationAction::Continue, NavigationAction::Continue);
260        assert_ne!(NavigationAction::Continue, NavigationAction::deny("x"));
261    }
262
263    // --- RouteLifecycle tests ---
264
265    struct TestLifecycle {
266        should_abort: bool,
267        should_redirect: bool,
268    }
269
270    impl RouteLifecycle for TestLifecycle {
271        fn on_enter(&self, _cx: &App, _request: &NavigationRequest) -> NavigationAction {
272            if self.should_abort {
273                NavigationAction::deny("Test abort")
274            } else if self.should_redirect {
275                NavigationAction::redirect("/redirect")
276            } else {
277                NavigationAction::Continue
278            }
279        }
280
281        fn on_exit(&self, _cx: &App) -> NavigationAction {
282            NavigationAction::Continue
283        }
284
285        fn can_deactivate(&self, _cx: &App) -> NavigationAction {
286            if self.should_abort {
287                NavigationAction::deny("Cannot leave")
288            } else {
289                NavigationAction::Continue
290            }
291        }
292    }
293
294    #[gpui::test]
295    fn test_lifecycle_on_enter_continue(cx: &mut gpui::TestAppContext) {
296        let lifecycle = TestLifecycle {
297            should_abort: false,
298            should_redirect: false,
299        };
300        let request = NavigationRequest::new("/test".to_string());
301        let result = cx.update(|cx| lifecycle.on_enter(cx, &request));
302        assert_eq!(result, NavigationAction::Continue);
303    }
304
305    #[gpui::test]
306    fn test_lifecycle_on_enter_abort(cx: &mut gpui::TestAppContext) {
307        let lifecycle = TestLifecycle {
308            should_abort: true,
309            should_redirect: false,
310        };
311        let request = NavigationRequest::new("/test".to_string());
312        let result = cx.update(|cx| lifecycle.on_enter(cx, &request));
313        assert!(result.is_deny());
314    }
315
316    #[gpui::test]
317    fn test_lifecycle_on_enter_redirect(cx: &mut gpui::TestAppContext) {
318        let lifecycle = TestLifecycle {
319            should_abort: false,
320            should_redirect: true,
321        };
322        let request = NavigationRequest::new("/test".to_string());
323        let result = cx.update(|cx| lifecycle.on_enter(cx, &request));
324        assert!(result.is_redirect());
325        assert_eq!(result.redirect_path(), Some("/redirect"));
326    }
327
328    #[gpui::test]
329    fn test_lifecycle_can_deactivate_allow(cx: &mut gpui::TestAppContext) {
330        let lifecycle = TestLifecycle {
331            should_abort: false,
332            should_redirect: false,
333        };
334        let result = cx.update(|cx| lifecycle.can_deactivate(cx));
335        assert_eq!(result, NavigationAction::Continue);
336    }
337
338    #[gpui::test]
339    fn test_lifecycle_can_deactivate_block(cx: &mut gpui::TestAppContext) {
340        let lifecycle = TestLifecycle {
341            should_abort: true,
342            should_redirect: false,
343        };
344        let result = cx.update(|cx| lifecycle.can_deactivate(cx));
345        assert!(result.is_deny());
346    }
347}