Skip to main content

gpui_navigator/
state.rs

1//! Router state management.
2//!
3//! This module contains [`RouterState`] — the core data structure that holds
4//! navigation history, registered routes, and current match cache.
5//!
6//! [`RouterState`] is the low-level engine behind navigation. Higher-level APIs
7//! like [`GlobalRouter`](crate::context::GlobalRouter) and
8//! [`Navigator`](crate::Navigator) delegate to it for history bookkeeping.
9//!
10//! # Navigation model
11//!
12//! The state maintains a linear history stack with a cursor (`current`).
13//! [`push`](RouterState::push) truncates any forward entries before appending,
14//! while [`back`](RouterState::back) / [`forward`](RouterState::forward) move
15//! the cursor without modifying the stack.
16//!
17//! # Navigation cancellation (T009)
18//!
19//! An atomic navigation ID counter allows async guard checks to detect that a
20//! newer navigation has started and the current one should be discarded.
21//! Call [`start_navigation`](RouterState::start_navigation) to obtain an ID,
22//! then periodically check [`is_navigation_current`](RouterState::is_navigation_current).
23
24use crate::history::{History, HistoryEntry, HistoryState};
25use crate::route::Route;
26use crate::{debug_log, trace_log, RouteChangeEvent, RouteMatch, RouteParams};
27use std::collections::HashMap;
28use std::sync::atomic::{AtomicUsize, Ordering};
29use std::sync::Arc;
30
31/// Core navigation state that tracks history, registered routes, and match cache.
32///
33/// This struct owns the navigation history stack and provides methods for
34/// pushing, replacing, and traversing entries. Route matching results are
35/// cached in a [`HashMap`] to avoid repeated tree walks within a single path.
36///
37/// # Examples
38///
39/// ```
40/// use gpui_navigator::RouterState;
41///
42/// let mut state = RouterState::new();
43/// assert_eq!(state.current_path(), "/");
44///
45/// state.push("/users".to_string());
46/// assert_eq!(state.current_path(), "/users");
47///
48/// state.back();
49/// assert_eq!(state.current_path(), "/");
50/// ```
51#[derive(Debug)]
52pub struct RouterState {
53    /// Navigation history — delegates to the standalone [`History`] struct.
54    history: History,
55    /// Registered routes
56    routes: Vec<Arc<Route>>,
57    /// Route cache
58    cache: HashMap<String, RouteMatch>,
59    /// Current route parameters (for parameter inheritance in nested routing)
60    current_params: RouteParams,
61    /// Navigation ID counter for cancellation tracking (T009)
62    /// Each navigation increments this, allowing detection of stale navigations
63    navigation_id: Arc<AtomicUsize>,
64}
65
66impl RouterState {
67    /// Create a new router state with the initial path set to `"/"`.
68    #[must_use]
69    pub fn new() -> Self {
70        Self {
71            history: History::new("/".to_string()),
72            routes: Vec::new(),
73            cache: HashMap::new(),
74            current_params: RouteParams::new(),
75            navigation_id: Arc::new(AtomicUsize::new(0)),
76        }
77    }
78
79    /// Return the current navigation ID.
80    ///
81    /// The value is monotonically increasing and is shared across clones of
82    /// this state (via `Arc<AtomicUsize>`).
83    #[must_use]
84    pub fn navigation_id(&self) -> usize {
85        self.navigation_id.load(Ordering::SeqCst)
86    }
87
88    /// Start a new navigation and return the new navigation ID
89    ///
90    /// This increments the navigation counter, allowing previous navigations
91    /// to detect they've been superseded and should be cancelled.
92    #[must_use]
93    pub fn start_navigation(&self) -> usize {
94        self.navigation_id.fetch_add(1, Ordering::SeqCst) + 1
95    }
96
97    /// Check if a navigation is still current (not cancelled by newer navigation)
98    #[must_use]
99    pub fn is_navigation_current(&self, nav_id: usize) -> bool {
100        self.navigation_id() == nav_id
101    }
102
103    /// Register a route and invalidate the match cache.
104    ///
105    /// Routes are stored in registration order. The first route whose pattern
106    /// matches the current path wins during [`current_match`](Self::current_match).
107    pub fn add_route(&mut self, route: Route) {
108        trace_log!("RouterState: registered route '{}'", route.config.path);
109        self.routes.push(Arc::new(route));
110        self.cache.clear();
111    }
112
113    /// Return the current path in the history stack.
114    #[must_use]
115    pub fn current_path(&self) -> &str {
116        self.history.current_path()
117    }
118
119    /// Return a slice of all registered routes (in registration order).
120    #[must_use]
121    pub fn routes(&self) -> &[Arc<Route>] {
122        &self.routes
123    }
124
125    /// Return the current route parameters (used for parameter inheritance in nested routing).
126    #[must_use]
127    pub const fn current_params(&self) -> &RouteParams {
128        &self.current_params
129    }
130
131    /// Update current route parameters
132    pub fn set_current_params(&mut self, params: RouteParams) {
133        self.current_params = params;
134    }
135
136    /// Find the [`RouteMatch`] for the current path, caching the result.
137    ///
138    /// On a cache miss the registered routes are iterated in order and the
139    /// first match is stored. Subsequent calls with the same path return
140    /// the cached value in O(1).
141    pub fn current_match(&mut self) -> Option<RouteMatch> {
142        let path = self.current_path();
143
144        // Check cache first
145        if let Some(cached) = self.cache.get(path) {
146            return Some(cached.clone());
147        }
148
149        // Find matching route
150        for route in &self.routes {
151            if let Some(route_match) = route.matches(path) {
152                self.cache.insert(path.to_string(), route_match.clone());
153                return Some(route_match);
154            }
155        }
156
157        None
158    }
159
160    /// Get current route match without caching (immutable)
161    ///
162    /// Use this when you need to access the current route from a non-mutable context,
163    /// such as in a GPUI Render implementation.
164    #[must_use]
165    pub fn current_match_immutable(&self) -> Option<RouteMatch> {
166        let path = self.current_path();
167
168        // Check cache first
169        if let Some(cached) = self.cache.get(path) {
170            return Some(cached.clone());
171        }
172
173        // Find matching route without caching
174        for route in &self.routes {
175            if let Some(route_match) = route.matches(path) {
176                return Some(route_match);
177            }
178        }
179
180        None
181    }
182
183    /// Get the first top-level Route that matches the current path.
184    ///
185    /// With `MatchStack` architecture, rendering uses `GlobalRouter::match_stack()`.
186    /// This method is kept for compatibility — it returns the first registered
187    /// route whose pattern matches the current path (exact or prefix).
188    #[must_use]
189    pub fn current_route(&self) -> Option<&Arc<Route>> {
190        let path = self.current_path();
191        for route in &self.routes {
192            if route.matches(path).is_some() {
193                return Some(route);
194            }
195            // Also check if path is under this route (prefix match for nested routes)
196            let route_path = route.config.path.trim_matches('/');
197            let path_trimmed = path.trim_matches('/');
198            if !route_path.is_empty()
199                && path_trimmed.starts_with(route_path)
200                && (path_trimmed.len() == route_path.len()
201                    || path_trimmed[route_path.len()..].starts_with('/'))
202            {
203                return Some(route);
204            }
205            // Root route matches everything if it has children
206            if route_path.is_empty() && !route.children.is_empty() {
207                return Some(route);
208            }
209        }
210        None
211    }
212
213    /// Push a new path onto the history stack.
214    ///
215    /// Any forward history (entries after the current cursor) is truncated
216    /// before appending, mirroring browser `pushState` semantics.
217    ///
218    /// Returns a [`RouteChangeEvent`] describing the transition.
219    pub fn push(&mut self, path: String) -> RouteChangeEvent {
220        let event = self.history.push(path);
221        debug_log!(
222            "History push: '{}' → '{}' (stack size: {})",
223            event.from.as_deref().unwrap_or(""),
224            event.to,
225            self.history.len()
226        );
227        event
228    }
229
230    /// Push a new path with associated [`HistoryState`] data.
231    ///
232    /// Allows attaching arbitrary key-value state (scroll position, form data, etc.)
233    /// to the history entry.
234    pub fn push_with_state(&mut self, path: String, state: HistoryState) -> RouteChangeEvent {
235        let event = self.history.push_with_state(path, state);
236        debug_log!(
237            "History push (with state): '{}' → '{}'",
238            event.from.as_deref().unwrap_or(""),
239            event.to,
240        );
241        event
242    }
243
244    /// Replace the current history entry in-place without adding a new one.
245    ///
246    /// Useful for redirects where the intermediate path should not appear in
247    /// the back-button history.
248    pub fn replace(&mut self, path: String) -> RouteChangeEvent {
249        let event = self.history.replace(path);
250        debug_log!(
251            "History replace: '{}' → '{}'",
252            event.from.as_deref().unwrap_or(""),
253            event.to,
254        );
255        event
256    }
257
258    /// Replace the current history entry with associated [`HistoryState`] data.
259    pub fn replace_with_state(&mut self, path: String, state: HistoryState) -> RouteChangeEvent {
260        let event = self.history.replace_with_state(path, state);
261        debug_log!(
262            "History replace (with state): '{}' → '{}'",
263            event.from.as_deref().unwrap_or(""),
264            event.to,
265        );
266        event
267    }
268
269    /// Move the cursor one step back in the history stack.
270    ///
271    /// Returns `None` if already at the oldest entry.
272    pub fn back(&mut self) -> Option<RouteChangeEvent> {
273        let event = self.history.back()?;
274        debug_log!(
275            "History back: '{}' → '{}' (position {}/{})",
276            event.from.as_deref().unwrap_or(""),
277            event.to,
278            self.history.current_index(),
279            self.history.len()
280        );
281        Some(event)
282    }
283
284    /// Move the cursor one step forward in the history stack.
285    ///
286    /// Returns `None` if already at the newest entry.
287    pub fn forward(&mut self) -> Option<RouteChangeEvent> {
288        let event = self.history.forward()?;
289        debug_log!(
290            "History forward: '{}' → '{}' (position {}/{})",
291            event.from.as_deref().unwrap_or(""),
292            event.to,
293            self.history.current_index(),
294            self.history.len()
295        );
296        Some(event)
297    }
298
299    /// Return `true` if [`back`](Self::back) would succeed.
300    #[must_use]
301    pub const fn can_go_back(&self) -> bool {
302        self.history.can_go_back()
303    }
304
305    /// Return `true` if [`forward`](Self::forward) would succeed.
306    #[must_use]
307    pub fn can_go_forward(&self) -> bool {
308        self.history.can_go_forward()
309    }
310
311    /// Peek at the path we would navigate to on `back()`, without actually navigating.
312    #[must_use]
313    pub fn peek_back_path(&self) -> Option<&str> {
314        self.history.peek_back_path()
315    }
316
317    /// Peek at the path we would navigate to on `forward()`, without actually navigating.
318    #[must_use]
319    pub fn peek_forward_path(&self) -> Option<&str> {
320        self.history.peek_forward_path()
321    }
322
323    /// Return a reference to the current [`HistoryEntry`] (path + optional state).
324    #[must_use]
325    pub fn current_entry(&self) -> &HistoryEntry {
326        self.history.current_entry()
327    }
328
329    /// Reset the history stack to a single `"/"` entry, clearing the match cache.
330    pub fn clear(&mut self) {
331        self.history.clear("/".to_string());
332        self.cache.clear();
333    }
334}
335
336impl Default for RouterState {
337    fn default() -> Self {
338        Self::new()
339    }
340}
341
342impl Clone for RouterState {
343    fn clone(&self) -> Self {
344        Self {
345            history: self.history.clone(),
346            routes: self.routes.clone(),
347            cache: self.cache.clone(),
348            current_params: self.current_params.clone(),
349            // Clone Arc, not the AtomicUsize value - share navigation_id across clones
350            navigation_id: Arc::clone(&self.navigation_id),
351        }
352    }
353}
354
355#[cfg(test)]
356mod tests {
357    use super::*;
358
359    #[test]
360    fn test_navigation() {
361        let mut state = RouterState::new();
362
363        assert_eq!(state.current_path(), "/");
364
365        state.push("/users".to_string());
366        assert_eq!(state.current_path(), "/users");
367
368        state.push("/users/123".to_string());
369        assert_eq!(state.current_path(), "/users/123");
370
371        state.back();
372        assert_eq!(state.current_path(), "/users");
373
374        state.forward();
375        assert_eq!(state.current_path(), "/users/123");
376    }
377
378    #[test]
379    fn test_replace() {
380        let mut state = RouterState::new();
381
382        state.push("/users".to_string());
383        state.replace("/posts".to_string());
384
385        assert_eq!(state.current_path(), "/posts");
386        assert_eq!(state.history.len(), 2);
387    }
388}