Skip to main content

car_browser/
tabs.rs

1//! Multi-tab page registry — the tab data model behind
2//! [`crate::chromium::ChromiumBackend`].
3//!
4//! [`TabRegistry`] is generic over the page handle type (`ChromiumBackend` uses
5//! `chromiumoxide::Page`) so the open/close/switch/list state machine — including
6//! the neighbor-activation and empty-state rules a tab strip depends on — is unit
7//! testable with a cheap synthetic handle (see `tests` below), no live Chrome
8//! required. The CDP-specific glue (creating a real page, closing it, refreshing
9//! its live nav state) lives in `chromium.rs`, which owns a `TabRegistry<Page>`
10//! and drives it.
11//!
12//! Every mutation publishes a [`TabsSnapshot`] on a `tokio::sync::watch` channel —
13//! the change-notification seam a later task's `browser.view.*` RPC surface needs
14//! to keep a tab strip live, and the one a screencast pump owner needs to notice
15//! the active tab changed and move capture to the new page (see
16//! `crate::screencast`): subscribe, `changed().await`, react. No wire protocol
17//! here — that's the later task's job.
18
19use std::fmt;
20
21use tokio::sync::watch;
22
23/// Opaque identifier for one open tab. Assigned by [`TabRegistry::open`] from
24/// an internal monotonic counter — ids are never reused, so a stale id from a
25/// closed tab can never later alias a different tab.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
27pub struct TabId(u64);
28
29impl fmt::Display for TabId {
30    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
31        write!(f, "tab-{}", self.0)
32    }
33}
34
35/// One tab's navigation state, as shown in a tab strip.
36#[derive(Debug, Clone, PartialEq)]
37pub struct TabInfo {
38    pub id: TabId,
39    pub url: String,
40    pub title: String,
41    /// Whether this is the currently active tab — every existing
42    /// `BrowserBackend` method acts on the active tab's page.
43    pub active: bool,
44    pub can_go_back: bool,
45    pub can_go_forward: bool,
46}
47
48/// One tab lifecycle or navigation event, carried alongside each
49/// [`TabsSnapshot`] published to subscribers.
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum TabEvent {
52    Opened(TabId),
53    /// A BACKGROUND tab closed.
54    ///
55    /// Closing the ACTIVE tab publishes [`TabEvent::ActiveChanged`] instead,
56    /// with `previous: Some(id)` naming the tab that went — see that variant.
57    /// One mutation carries exactly one event, because this rides a `watch`,
58    /// whose `send` overwrites: two publishes from inside a single `close()`
59    /// would leave the first provably unobservable (nothing can be scheduled
60    /// between them — `close` is synchronous and holds the registry's write
61    /// lock throughout), so this is a contract to state rather than a second
62    /// event to emit.
63    ///
64    /// A consumer that needs "which tab closed" for either case reads
65    /// `previous` on `ActiveChanged` and this id here; one that needs to tell a
66    /// close from a plain switch cannot, and that is the residual — stated
67    /// here rather than left to be discovered.
68    Closed(TabId),
69    /// The active tab changed — by an explicit switch, or because the
70    /// previously active tab was closed and a neighbor took over (`current:
71    /// None` if it was the last tab — the well-defined empty state).
72    ActiveChanged {
73        previous: Option<TabId>,
74        current: Option<TabId>,
75    },
76    NavStateChanged(TabId),
77}
78
79/// The full tab list plus which one is active, published to subscribers on
80/// every mutation. `tabs`/`active` are always the resulting state, not a
81/// diff; `event` names what just happened.
82#[derive(Debug, Clone, PartialEq)]
83pub struct TabsSnapshot {
84    pub tabs: Vec<TabInfo>,
85    pub active: Option<TabId>,
86    /// `None` only for the very first value published by [`TabRegistry::new`],
87    /// before anything has happened yet.
88    pub event: Option<TabEvent>,
89}
90
91/// Error from an operation that names a tab id.
92#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
93pub enum TabError {
94    #[error("no tab with id {0}")]
95    NotFound(TabId),
96}
97
98struct TabEntry<P> {
99    id: TabId,
100    page: P,
101    url: String,
102    title: String,
103    can_go_back: bool,
104    can_go_forward: bool,
105}
106
107/// Ordered collection of open tabs plus which one is active. Generic over the
108/// page handle type `P` so the state machine is unit testable without a live
109/// CDP connection — the `tests` module below drives it with a plain `&str`
110/// standing in for `chromiumoxide::Page`.
111pub struct TabRegistry<P> {
112    entries: Vec<TabEntry<P>>,
113    active: Option<TabId>,
114    next_id: u64,
115    tx: watch::Sender<TabsSnapshot>,
116    /// Kept alive so `tx.send()` never fails for lack of a receiver — a
117    /// `watch::Sender` silently drops the value it was sending (does not
118    /// update its stored state) when the receiver count is zero, which would
119    /// otherwise mean a `subscribe()` that arrives after the FIRST mutation
120    /// but before any consumer had subscribed would see stale (pre-mutation)
121    /// state. See `tests::publish_persists_even_with_no_external_subscriber`.
122    _anchor_rx: watch::Receiver<TabsSnapshot>,
123}
124
125impl<P: Clone> TabRegistry<P> {
126    /// A registry with no tabs open and no active tab — the well-defined
127    /// empty state.
128    pub fn new() -> (Self, watch::Receiver<TabsSnapshot>) {
129        let (tx, rx) = watch::channel(TabsSnapshot {
130            tabs: Vec::new(),
131            active: None,
132            event: None,
133        });
134        let anchor = rx.clone();
135        (
136            Self {
137                entries: Vec::new(),
138                active: None,
139                next_id: 1,
140                tx,
141                _anchor_rx: anchor,
142            },
143            rx,
144        )
145    }
146
147    /// A new receiver tuned to the current state — `borrow()` sees the
148    /// latest snapshot immediately; `changed()` waits for the next one.
149    pub fn subscribe(&self) -> watch::Receiver<TabsSnapshot> {
150        self.tx.subscribe()
151    }
152
153    /// Open a new tab wrapping `page`, and make it active. Returns the new
154    /// tab's id.
155    pub fn open(&mut self, page: P, url: impl Into<String>, title: impl Into<String>) -> TabId {
156        let id = TabId(self.next_id);
157        self.next_id += 1;
158        self.entries.push(TabEntry {
159            id,
160            page,
161            url: url.into(),
162            title: title.into(),
163            can_go_back: false,
164            can_go_forward: false,
165        });
166        self.active = Some(id);
167        self.publish(TabEvent::Opened(id));
168        id
169    }
170
171    /// Close `id`. Returns its page handle (so the caller can drive the
172    /// CDP-level close) if it existed — `None` if there was no such tab, a
173    /// safe no-op rather than an error.
174    ///
175    /// Closing the active tab activates a neighbor: prefer the tab that
176    /// slides into the closed one's position (the tab that was to its
177    /// right), falling back to the tab now at the end (previously to its
178    /// left) if it was the rightmost tab, falling back to `None` if it was
179    /// the last tab open — the well-defined empty state.
180    pub fn close(&mut self, id: TabId) -> Option<P> {
181        let idx = self.entries.iter().position(|e| e.id == id)?;
182        let removed = self.entries.remove(idx);
183        if self.active == Some(id) {
184            let neighbor = self
185                .entries
186                .get(idx)
187                .or_else(|| idx.checked_sub(1).and_then(|i| self.entries.get(i)))
188                .map(|e| e.id);
189            self.active = neighbor;
190            self.publish(TabEvent::ActiveChanged {
191                previous: Some(id),
192                current: neighbor,
193            });
194        } else {
195            self.publish(TabEvent::Closed(id));
196        }
197        Some(removed.page)
198    }
199
200    /// Close every open tab. Returns their page handles in closing order, so
201    /// the caller can drive a CDP-level close for each. Equivalent to
202    /// calling [`Self::close`] on every currently-open id.
203    pub fn close_all(&mut self) -> Vec<P> {
204        let ids: Vec<TabId> = self.entries.iter().map(|e| e.id).collect();
205        ids.into_iter().filter_map(|id| self.close(id)).collect()
206    }
207
208    /// Make `id` the active tab. Errors if no tab has that id. A no-op
209    /// (still `Ok`, no event published) if `id` is already active.
210    pub fn switch(&mut self, id: TabId) -> Result<(), TabError> {
211        if !self.entries.iter().any(|e| e.id == id) {
212            return Err(TabError::NotFound(id));
213        }
214        if self.active == Some(id) {
215            return Ok(());
216        }
217        let previous = self.active;
218        self.active = Some(id);
219        self.publish(TabEvent::ActiveChanged {
220            previous,
221            current: Some(id),
222        });
223        Ok(())
224    }
225
226    /// The active tab's id, if any (`None` in the empty state).
227    pub fn active_id(&self) -> Option<TabId> {
228        self.active
229    }
230
231    /// The active tab's cached URL, if any. Used to keep a scalar
232    /// "last known URL" cache in sync with whichever tab is active, without
233    /// a live CDP round trip.
234    pub fn active_url(&self) -> Option<String> {
235        self.active
236            .and_then(|id| self.entries.iter().find(|e| e.id == id))
237            .map(|e| e.url.clone())
238    }
239
240    /// The active tab's page handle, if any.
241    pub fn active_page(&self) -> Option<P> {
242        self.active.and_then(|id| self.page(id))
243    }
244
245    /// `id`'s page handle, if it's an open tab.
246    pub fn page(&self, id: TabId) -> Option<P> {
247        self.entries
248            .iter()
249            .find(|e| e.id == id)
250            .map(|e| e.page.clone())
251    }
252
253    /// Every open tab's id + page handle, in tab-strip order. Used to drive
254    /// a live nav-state refresh across ALL tabs, not just the active one — a
255    /// background tab can navigate on its own (a clicked link, a JS
256    /// redirect) without ever going through `ChromiumBackend::navigate`.
257    pub fn pages(&self) -> Vec<(TabId, P)> {
258        self.entries
259            .iter()
260            .map(|e| (e.id, e.page.clone()))
261            .collect()
262    }
263
264    /// Current tab list in strip order, each flagged with whether it's the
265    /// active one.
266    pub fn list(&self) -> Vec<TabInfo> {
267        self.entries
268            .iter()
269            .map(|e| TabInfo {
270                id: e.id,
271                url: e.url.clone(),
272                title: e.title.clone(),
273                active: self.active == Some(e.id),
274                can_go_back: e.can_go_back,
275                can_go_forward: e.can_go_forward,
276            })
277            .collect()
278    }
279
280    /// Update `id`'s cached nav state. A no-op (including no published
281    /// event) if none of the fields actually changed, or if `id` doesn't
282    /// exist — keeps the watch channel free of churn from a caller that
283    /// refreshes on every poll regardless of whether anything actually
284    /// moved (see `ChromiumBackend::list_tabs`).
285    pub fn update_nav_state(
286        &mut self,
287        id: TabId,
288        url: impl Into<String>,
289        title: impl Into<String>,
290        can_go_back: bool,
291        can_go_forward: bool,
292    ) {
293        let url = url.into();
294        let title = title.into();
295        let mut changed = false;
296        if let Some(e) = self.entries.iter_mut().find(|e| e.id == id) {
297            if e.url != url
298                || e.title != title
299                || e.can_go_back != can_go_back
300                || e.can_go_forward != can_go_forward
301            {
302                e.url = url;
303                e.title = title;
304                e.can_go_back = can_go_back;
305                e.can_go_forward = can_go_forward;
306                changed = true;
307            }
308        }
309        if changed {
310            self.publish(TabEvent::NavStateChanged(id));
311        }
312    }
313
314    fn publish(&self, event: TabEvent) {
315        // `_anchor_rx` guarantees at least one receiver always exists, so
316        // this send cannot fail for lack of a receiver — see its field doc.
317        let _ = self.tx.send(TabsSnapshot {
318            tabs: self.list(),
319            active: self.active,
320            event: Some(event),
321        });
322    }
323}
324
325#[cfg(test)]
326mod tests {
327    use super::*;
328
329    /// A dummy page handle standing in for `chromiumoxide::Page` — cheap,
330    /// `Clone`, and carries an identifying label so assertions can tell
331    /// which tab's handle came back.
332    type FakePage = &'static str;
333
334    fn registry() -> (TabRegistry<FakePage>, watch::Receiver<TabsSnapshot>) {
335        TabRegistry::new()
336    }
337
338    #[test]
339    fn open_adds_a_tab_and_makes_it_active() {
340        let (mut reg, _rx) = registry();
341        let id = reg.open("page-a", "http://a", "A");
342        assert_eq!(reg.active_id(), Some(id));
343        let tabs = reg.list();
344        assert_eq!(tabs.len(), 1);
345        assert!(tabs[0].active);
346        assert_eq!(tabs[0].url, "http://a");
347        assert_eq!(tabs[0].title, "A");
348        assert!(!tabs[0].can_go_back);
349        assert!(!tabs[0].can_go_forward);
350    }
351
352    #[test]
353    fn open_twice_makes_the_second_active_and_keeps_the_first() {
354        let (mut reg, _rx) = registry();
355        let first = reg.open("page-a", "http://a", "A");
356        let second = reg.open("page-b", "http://b", "B");
357        assert_eq!(reg.active_id(), Some(second));
358        let tabs = reg.list();
359        assert_eq!(tabs.len(), 2);
360        assert_eq!(tabs[0].id, first);
361        assert!(!tabs[0].active);
362        assert_eq!(tabs[1].id, second);
363        assert!(tabs[1].active);
364    }
365
366    #[test]
367    fn active_page_routes_to_whichever_tab_is_active() {
368        // This is the mechanism `ChromiumBackend::get_page()` — and through
369        // it, every existing single-page BrowserBackend method — relies on
370        // to "operate on the ACTIVE page" once more than one tab exists.
371        let (mut reg, _rx) = registry();
372        let first = reg.open("page-a", "http://a", "A");
373        assert_eq!(reg.active_page(), Some("page-a"));
374
375        let second = reg.open("page-b", "http://b", "B");
376        assert_eq!(reg.active_page(), Some("page-b"));
377
378        reg.switch(first).unwrap();
379        assert_eq!(reg.active_page(), Some("page-a"));
380
381        // Sanity: `second`'s handle is still resolvable directly by id even
382        // while it isn't active.
383        assert_eq!(reg.page(second), Some("page-b"));
384    }
385
386    #[test]
387    fn close_non_active_tab_leaves_active_unchanged() {
388        let (mut reg, _rx) = registry();
389        let first = reg.open("page-a", "http://a", "A");
390        let second = reg.open("page-b", "http://b", "B");
391        assert_eq!(reg.active_id(), Some(second));
392
393        let closed_page = reg.close(first);
394        assert_eq!(closed_page, Some("page-a"));
395        assert_eq!(
396            reg.active_id(),
397            Some(second),
398            "closing a background tab must not move focus"
399        );
400        assert_eq!(reg.list().len(), 1);
401    }
402
403    #[test]
404    fn close_active_tab_activates_the_right_neighbor() {
405        let (mut reg, _rx) = registry();
406        let first = reg.open("page-a", "http://a", "A");
407        let second = reg.open("page-b", "http://b", "B");
408        let third = reg.open("page-c", "http://c", "C");
409        reg.switch(second).unwrap();
410        assert_eq!(reg.active_id(), Some(second));
411
412        reg.close(second);
413        assert_eq!(
414            reg.active_id(),
415            Some(third),
416            "closing the active middle tab should activate its right neighbor"
417        );
418        let ids: Vec<TabId> = reg.list().iter().map(|t| t.id).collect();
419        assert_eq!(ids, vec![first, third]);
420    }
421
422    #[test]
423    fn close_active_rightmost_tab_falls_back_to_left_neighbor() {
424        let (mut reg, _rx) = registry();
425        let first = reg.open("page-a", "http://a", "A");
426        let second = reg.open("page-b", "http://b", "B");
427        assert_eq!(reg.active_id(), Some(second));
428
429        reg.close(second);
430        assert_eq!(
431            reg.active_id(),
432            Some(first),
433            "closing the rightmost active tab should fall back to the left neighbor"
434        );
435    }
436
437    #[test]
438    fn close_last_tab_leaves_the_empty_state() {
439        let (mut reg, _rx) = registry();
440        let only = reg.open("page-a", "http://a", "A");
441
442        let page = reg.close(only);
443        assert_eq!(page, Some("page-a"));
444        assert_eq!(reg.active_id(), None);
445        assert!(reg.list().is_empty());
446        assert_eq!(
447            reg.active_page(),
448            None,
449            "no dangling handle in the empty state"
450        );
451    }
452
453    #[test]
454    fn close_unknown_id_is_a_safe_noop() {
455        let (mut reg, _rx) = registry();
456        let real = reg.open("page-a", "http://a", "A");
457        let bogus = TabId(9999);
458
459        assert_eq!(reg.close(bogus), None);
460        assert_eq!(
461            reg.active_id(),
462            Some(real),
463            "closing a bogus id must not disturb state"
464        );
465        assert_eq!(reg.list().len(), 1);
466    }
467
468    #[test]
469    fn close_all_closes_every_tab_and_returns_their_pages() {
470        let (mut reg, _rx) = registry();
471        reg.open("page-a", "http://a", "A");
472        reg.open("page-b", "http://b", "B");
473        reg.open("page-c", "http://c", "C");
474
475        let mut closed = reg.close_all();
476        closed.sort();
477        assert_eq!(closed, vec!["page-a", "page-b", "page-c"]);
478        assert!(reg.list().is_empty());
479        assert_eq!(reg.active_id(), None);
480    }
481
482    #[test]
483    fn switch_to_existing_tab_changes_active() {
484        let (mut reg, _rx) = registry();
485        let first = reg.open("page-a", "http://a", "A");
486        let second = reg.open("page-b", "http://b", "B");
487        assert_eq!(reg.active_id(), Some(second));
488
489        reg.switch(first).unwrap();
490        assert_eq!(reg.active_id(), Some(first));
491        assert!(reg.list()[0].active);
492        assert!(!reg.list()[1].active);
493    }
494
495    #[test]
496    fn switch_to_unknown_id_errors() {
497        let (mut reg, _rx) = registry();
498        let real = reg.open("page-a", "http://a", "A");
499        let bogus = TabId(9999);
500
501        let err = reg.switch(bogus).unwrap_err();
502        assert_eq!(err, TabError::NotFound(bogus));
503        assert_eq!(
504            reg.active_id(),
505            Some(real),
506            "a failed switch must not change active"
507        );
508    }
509
510    #[test]
511    fn switch_to_already_active_tab_is_a_noop_ok() {
512        let (mut reg, mut rx) = registry();
513        let only = reg.open("page-a", "http://a", "A");
514        rx.mark_unchanged();
515
516        reg.switch(only).unwrap();
517        assert_eq!(reg.active_id(), Some(only));
518        assert!(
519            !rx.has_changed().unwrap(),
520            "switching to the already-active tab must not publish a new snapshot"
521        );
522    }
523
524    #[test]
525    fn update_nav_state_updates_fields_and_is_reflected_in_list() {
526        let (mut reg, _rx) = registry();
527        let id = reg.open("page-a", "about:blank", "");
528
529        reg.update_nav_state(id, "http://a/2", "Page A", true, false);
530
531        let tab = &reg.list()[0];
532        assert_eq!(tab.url, "http://a/2");
533        assert_eq!(tab.title, "Page A");
534        assert!(tab.can_go_back);
535        assert!(!tab.can_go_forward);
536    }
537
538    #[test]
539    fn update_nav_state_is_a_noop_when_nothing_changed() {
540        let (mut reg, mut rx) = registry();
541        let id = reg.open("page-a", "http://a", "A");
542        rx.mark_unchanged();
543
544        // Same values as at open() time.
545        reg.update_nav_state(id, "http://a", "A", false, false);
546
547        assert!(
548            !rx.has_changed().unwrap(),
549            "re-asserting identical nav state must not publish (avoids polling churn)"
550        );
551    }
552
553    #[tokio::test]
554    async fn subscribe_sees_open_close_switch_events_in_order() {
555        let (mut reg, _initial_rx) = registry();
556        let mut rx = reg.subscribe();
557        rx.mark_unchanged();
558
559        let first = reg.open("page-a", "http://a", "A");
560        rx.changed().await.unwrap();
561        assert_eq!(rx.borrow().event, Some(TabEvent::Opened(first)));
562
563        let second = reg.open("page-b", "http://b", "B");
564        rx.changed().await.unwrap();
565        assert_eq!(rx.borrow().event, Some(TabEvent::Opened(second)));
566
567        reg.switch(first).unwrap();
568        rx.changed().await.unwrap();
569        assert_eq!(
570            rx.borrow().event,
571            Some(TabEvent::ActiveChanged {
572                previous: Some(second),
573                current: Some(first)
574            })
575        );
576
577        // Closing the active tab (`first`) with one neighbor left (`second`)
578        // activates it.
579        reg.close(first);
580        rx.changed().await.unwrap();
581        assert_eq!(
582            rx.borrow().event,
583            Some(TabEvent::ActiveChanged {
584                previous: Some(first),
585                current: Some(second)
586            })
587        );
588    }
589
590    #[test]
591    fn publish_persists_even_with_no_external_subscriber() {
592        // Regression test for the `_anchor_rx` field: without it, a
593        // `watch::Sender::send()` with a receiver count of zero silently
594        // drops the value (does not update the channel's stored state)
595        // rather than merely failing to notify. Dropping the constructor's
596        // receiver here simulates "nobody subscribed yet" — a real
597        // ChromiumBackend discards it too, since `open()` at launch happens
598        // before any caller has had a chance to call `subscribe_tabs()`.
599        let (mut reg, initial_rx) = registry();
600        drop(initial_rx);
601
602        let id = reg.open("page-a", "http://a", "A");
603
604        // A subscriber that arrives AFTER the mutation must still see it.
605        let late_rx = reg.subscribe();
606        let snapshot = late_rx.borrow();
607        assert_eq!(snapshot.active, Some(id));
608        assert_eq!(snapshot.tabs.len(), 1);
609        assert_eq!(snapshot.event, Some(TabEvent::Opened(id)));
610    }
611
612    #[test]
613    fn tab_id_display_is_stable_and_distinguishable() {
614        let (mut reg, _rx) = registry();
615        let a = reg.open("page-a", "http://a", "A");
616        let b = reg.open("page-b", "http://b", "B");
617        assert_ne!(a.to_string(), b.to_string());
618    }
619
620    #[test]
621    fn closed_tab_ids_are_never_reused() {
622        let (mut reg, _rx) = registry();
623        let closed = reg.open("page-a", "http://a", "A");
624        assert_eq!(reg.close(closed), Some("page-a"));
625
626        let reopened = reg.open("page-b", "http://b", "B");
627
628        assert_ne!(
629            reopened, closed,
630            "a stale tab id must never alias a new tab"
631        );
632        assert!(reopened > closed, "tab ids come from a monotonic counter");
633    }
634}