1use std::fmt;
20
21use tokio::sync::watch;
22
23#[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#[derive(Debug, Clone, PartialEq)]
37pub struct TabInfo {
38 pub id: TabId,
39 pub url: String,
40 pub title: String,
41 pub active: bool,
44 pub can_go_back: bool,
45 pub can_go_forward: bool,
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum TabEvent {
52 Opened(TabId),
53 Closed(TabId),
69 ActiveChanged {
73 previous: Option<TabId>,
74 current: Option<TabId>,
75 },
76 NavStateChanged(TabId),
77}
78
79#[derive(Debug, Clone, PartialEq)]
83pub struct TabsSnapshot {
84 pub tabs: Vec<TabInfo>,
85 pub active: Option<TabId>,
86 pub event: Option<TabEvent>,
89}
90
91#[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
107pub struct TabRegistry<P> {
112 entries: Vec<TabEntry<P>>,
113 active: Option<TabId>,
114 next_id: u64,
115 tx: watch::Sender<TabsSnapshot>,
116 _anchor_rx: watch::Receiver<TabsSnapshot>,
123}
124
125impl<P: Clone> TabRegistry<P> {
126 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 pub fn subscribe(&self) -> watch::Receiver<TabsSnapshot> {
150 self.tx.subscribe()
151 }
152
153 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 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 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 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 pub fn active_id(&self) -> Option<TabId> {
228 self.active
229 }
230
231 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 pub fn active_page(&self) -> Option<P> {
242 self.active.and_then(|id| self.page(id))
243 }
244
245 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 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 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 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 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 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 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 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 = ®.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 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 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 let (mut reg, initial_rx) = registry();
600 drop(initial_rx);
601
602 let id = reg.open("page-a", "http://a", "A");
603
604 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}