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}