Skip to main content

oxide_browser/
navigation.rs

1//! Navigation history stack for the Oxide browser.
2//!
3//! Manages a linear history of visited URLs with associated state, allowing
4//! users (and guest binaries) to move forward and backward between views.
5//! Pushing a new entry while in the middle of the stack truncates forward
6//! history, matching standard browser semantics.
7
8use std::time::{SystemTime, UNIX_EPOCH};
9
10/// A single entry in the navigation history.
11#[derive(Clone, Debug)]
12pub struct HistoryEntry {
13    /// The fully-resolved URL for this history point.
14    pub url: String,
15    /// An optional human-readable title.
16    pub title: String,
17    /// Opaque binary state attached by the guest via `push_state` /
18    /// `replace_state`.  The guest can read this back on re-entry.
19    pub state: Vec<u8>,
20    /// Milliseconds since the UNIX epoch when this entry was created.
21    #[allow(dead_code)]
22    pub timestamp_ms: u64,
23}
24
25impl HistoryEntry {
26    pub fn new(url: impl Into<String>) -> Self {
27        Self {
28            url: url.into(),
29            title: String::new(),
30            state: Vec::new(),
31            timestamp_ms: SystemTime::now()
32                .duration_since(UNIX_EPOCH)
33                .unwrap_or_default()
34                .as_millis() as u64,
35        }
36    }
37
38    pub fn with_title(mut self, title: impl Into<String>) -> Self {
39        self.title = title.into();
40        self
41    }
42
43    pub fn with_state(mut self, state: Vec<u8>) -> Self {
44        self.state = state;
45        self
46    }
47}
48
49/// A linear stack of [`HistoryEntry`] items with a movable cursor.
50#[derive(Clone, Debug)]
51pub struct NavigationStack {
52    entries: Vec<HistoryEntry>,
53    /// Points to the current entry.  `-1` when the stack is empty.
54    index: isize,
55}
56
57impl NavigationStack {
58    pub fn new() -> Self {
59        Self {
60            entries: Vec::new(),
61            index: -1,
62        }
63    }
64
65    /// Push a new entry, discarding any forward history beyond the cursor.
66    pub fn push(&mut self, entry: HistoryEntry) {
67        let new_index = self.index + 1;
68        self.entries.truncate(new_index as usize);
69        self.entries.push(entry);
70        self.index = new_index;
71    }
72
73    /// Replace the current entry in-place (no forward-history truncation).
74    pub fn replace_current(&mut self, entry: HistoryEntry) {
75        if self.index >= 0 && (self.index as usize) < self.entries.len() {
76            self.entries[self.index as usize] = entry;
77        } else {
78            self.push(entry);
79        }
80    }
81
82    /// Mutate selected fields of the current entry (used by guest
83    /// `push_state` / `replace_state` when only updating metadata).
84    #[allow(dead_code)]
85    pub fn update_current(
86        &mut self,
87        title: Option<&str>,
88        state: Option<Vec<u8>>,
89        url: Option<&str>,
90    ) {
91        if let Some(entry) = self.current_mut() {
92            if let Some(t) = title {
93                entry.title = t.to_string();
94            }
95            if let Some(s) = state {
96                entry.state = s;
97            }
98            if let Some(u) = url {
99                entry.url = u.to_string();
100            }
101        }
102    }
103
104    /// Move the cursor backward.  Returns the new current entry.
105    pub fn go_back(&mut self) -> Option<&HistoryEntry> {
106        if self.index > 0 {
107            self.index -= 1;
108            Some(&self.entries[self.index as usize])
109        } else {
110            None
111        }
112    }
113
114    /// Move the cursor forward.  Returns the new current entry.
115    pub fn go_forward(&mut self) -> Option<&HistoryEntry> {
116        if self.index + 1 < self.entries.len() as isize {
117            self.index += 1;
118            Some(&self.entries[self.index as usize])
119        } else {
120            None
121        }
122    }
123
124    pub fn can_go_back(&self) -> bool {
125        self.index > 0
126    }
127
128    pub fn can_go_forward(&self) -> bool {
129        self.index + 1 < self.entries.len() as isize
130    }
131
132    pub fn current(&self) -> Option<&HistoryEntry> {
133        if self.index >= 0 && (self.index as usize) < self.entries.len() {
134            Some(&self.entries[self.index as usize])
135        } else {
136            None
137        }
138    }
139
140    #[allow(dead_code)]
141    pub fn current_mut(&mut self) -> Option<&mut HistoryEntry> {
142        if self.index >= 0 && (self.index as usize) < self.entries.len() {
143            Some(&mut self.entries[self.index as usize])
144        } else {
145            None
146        }
147    }
148
149    pub fn len(&self) -> usize {
150        self.entries.len()
151    }
152
153    #[allow(dead_code)]
154    pub fn is_empty(&self) -> bool {
155        self.entries.is_empty()
156    }
157
158    #[allow(dead_code)]
159    pub fn current_index(&self) -> isize {
160        self.index
161    }
162
163    #[allow(dead_code)]
164    pub fn entries(&self) -> &[HistoryEntry] {
165        &self.entries
166    }
167}
168
169impl Default for NavigationStack {
170    fn default() -> Self {
171        Self::new()
172    }
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178
179    #[test]
180    fn push_and_navigate() {
181        let mut stack = NavigationStack::new();
182        assert!(!stack.can_go_back());
183        assert!(!stack.can_go_forward());
184
185        stack.push(HistoryEntry::new("https://a.com"));
186        stack.push(HistoryEntry::new("https://b.com"));
187        stack.push(HistoryEntry::new("https://c.com"));
188
189        assert_eq!(stack.current().unwrap().url, "https://c.com");
190        assert!(stack.can_go_back());
191        assert!(!stack.can_go_forward());
192
193        let entry = stack.go_back().unwrap();
194        assert_eq!(entry.url, "https://b.com");
195        let entry = stack.go_back().unwrap();
196        assert_eq!(entry.url, "https://a.com");
197        assert!(!stack.can_go_back());
198        assert!(stack.can_go_forward());
199
200        let entry = stack.go_forward().unwrap();
201        assert_eq!(entry.url, "https://b.com");
202
203        stack.push(HistoryEntry::new("https://d.com"));
204        assert!(!stack.can_go_forward());
205        assert_eq!(stack.len(), 3); // a, b, d
206    }
207
208    #[test]
209    fn replace_current() {
210        let mut stack = NavigationStack::new();
211        stack.push(HistoryEntry::new("https://a.com"));
212        stack.replace_current(HistoryEntry::new("https://b.com"));
213        assert_eq!(stack.current().unwrap().url, "https://b.com");
214        assert_eq!(stack.len(), 1);
215    }
216
217    #[test]
218    fn update_current_fields() {
219        let mut stack = NavigationStack::new();
220        stack.push(HistoryEntry::new("https://a.com"));
221        stack.update_current(Some("Page A"), Some(vec![1, 2, 3]), None);
222        let cur = stack.current().unwrap();
223        assert_eq!(cur.title, "Page A");
224        assert_eq!(cur.state, vec![1, 2, 3]);
225        assert_eq!(cur.url, "https://a.com");
226    }
227
228    #[test]
229    fn state_preserved_through_back_forward() {
230        let mut stack = NavigationStack::new();
231        stack.push(HistoryEntry::new("https://a.com").with_state(vec![10, 20]));
232        stack.push(HistoryEntry::new("https://b.com"));
233
234        let entry = stack.go_back().unwrap();
235        assert_eq!(entry.state, vec![10, 20]);
236    }
237}