1use std::collections::{BTreeMap, HashMap, HashSet};
9use std::time::{Duration, Instant};
10
11use forge_primitives::SessionSlot;
12use forge_primitives::browser::{BrowserTabDialog, BrowserTabRow, BrowserTabState};
13use serde::Deserialize;
14
15#[derive(Clone, Debug, PartialEq, Eq, Deserialize)]
17pub struct DriverTab {
18 #[serde(rename = "i")]
19 pub index: usize,
20 pub target: String,
22 pub title: String,
23 pub url: String,
24 pub current: bool,
25}
26
27const EXPORT: &str = "FORGE-TABS ";
28
29pub fn parse_export(text: &str) -> Result<Vec<DriverTab>, String> {
31 let line = text.lines().find(|line| line.starts_with(EXPORT)).ok_or_else(|| {
32 "the driver's tab list carried no FORGE-TABS export, so no tab can be named".to_owned()
33 })?;
34 serde_json::from_str(&line[EXPORT.len()..])
35 .map_err(|why| format!("the driver's tab export did not parse: {why}"))
36}
37
38pub fn strip_export(text: &str) -> String {
40 let mut listing = String::new();
41 for line in text.lines().filter(|line| !line.starts_with(EXPORT)) {
42 listing.push_str(line);
43 listing.push('\n');
44 }
45 listing
46}
47
48pub fn orphan_targets(tabs: &TabRegistry, listing: &[DriverTab]) -> Vec<String> {
52 let known: HashSet<String> = tabs
53 .numbers()
54 .into_iter()
55 .filter_map(|number| tabs.target(number).map(str::to_owned))
56 .collect();
57 listing
58 .iter()
59 .filter(|row| !row.target.is_empty() && !known.contains(&row.target))
60 .map(|row| row.target.clone())
61 .collect()
62}
63
64#[derive(Default)]
68pub struct TabRegistry {
69 tabs: BTreeMap<u16, Tab>,
70 current: HashMap<SessionSlot, u16>,
71 retired: HashSet<u16>,
75 unresolved: HashSet<u16>,
79 closed: HashSet<u16>,
84}
85
86pub(crate) struct Tab {
87 pub(crate) owner: SessionSlot,
88 since: Instant,
90 opener: Option<u16>,
93 is_popup: bool,
97 pub(crate) target: String,
99 title: String,
100 url: String,
101 busy: bool,
102}
103
104impl TabRegistry {
105 pub fn adopt(
109 &mut self,
110 number: u16,
111 owner: SessionSlot,
112 target: &str,
113 title: &str,
114 url: &str,
115 ) -> Result<u16, String> {
116 if self.tabs.contains_key(&number) {
117 return Err(format!("tab {number} is already open"));
118 }
119 if self.retired.contains(&number) {
120 return Err(format!("tab {number} was already used by this host"));
121 }
122 self.retired.insert(number);
123 self.current.insert(owner.clone(), number);
124 self.tabs.insert(
125 number,
126 Tab {
127 owner,
128 since: Instant::now(),
129 opener: None,
130 is_popup: false,
131 target: target.to_owned(),
132 title: title.to_owned(),
133 url: url.to_owned(),
134 busy: false,
135 },
136 );
137 Ok(number)
138 }
139
140 pub fn create_under(
142 &mut self,
143 opener: u16,
144 number: u16,
145 target: &str,
146 title: &str,
147 url: &str,
148 ) -> Result<u16, String> {
149 let owner = self
150 .owner(opener)
151 .cloned()
152 .ok_or_else(|| format!("the popup's opener tab {opener} is not open"))?;
153 let adopted = self.adopt(number, owner, target, title, url)?;
154 if let Some(tab) = self.tabs.get_mut(&adopted) {
155 tab.opener = Some(opener);
156 tab.is_popup = true;
157 }
158 Ok(adopted)
159 }
160
161 pub fn close(&mut self, tab: u16) -> Option<SessionSlot> {
170 self.unresolved.remove(&tab);
171 self.closed.insert(tab);
174 let removed = self.tabs.remove(&tab)?;
175 for other in self.tabs.values_mut() {
180 if other.opener == Some(tab) {
181 other.opener = removed.opener;
182 }
183 }
184 let opener = removed.opener.filter(|opener| self.tabs.contains_key(opener));
185 match opener {
186 Some(opener) => {
187 for number in self.current.values_mut() {
188 if *number == tab {
189 *number = opener;
190 }
191 }
192 }
193 None => self.current.retain(|_, number| *number != tab),
194 }
195 Some(removed.owner)
196 }
197
198 pub fn mark_unresolved(&mut self, tab: u16) {
202 if self.tabs.contains_key(&tab) {
203 self.unresolved.insert(tab);
204 }
205 }
206
207 pub fn unresolved_tabs(&self) -> Vec<u16> {
209 self.unresolved.iter().copied().collect()
210 }
211
212 pub fn was_closed(&self, tab: u16) -> bool {
215 self.closed.contains(&tab)
216 }
217
218 pub fn close_owned(
219 &mut self,
220 session: &SessionSlot,
221 tab: u16,
222 ) -> Result<Option<SessionSlot>, String> {
223 self.owned(session, tab)?;
224 Ok(self.close(tab))
225 }
226
227 pub fn current(&self, session: &SessionSlot) -> Option<u16> {
228 self.current.get(session).copied().filter(|tab| self.tabs.contains_key(tab))
229 }
230
231 pub fn forget_current(&mut self, session: &SessionSlot) {
234 self.current.remove(session);
235 }
236
237 pub fn select(&mut self, session: &SessionSlot, tab: u16) -> Result<(), String> {
238 self.owned(session, tab)?;
239 self.current.insert(session.clone(), tab);
240 Ok(())
241 }
242
243 pub fn target(&self, tab: u16) -> Option<&str> {
244 self.tabs.get(&tab).map(|tab| tab.target.as_str())
245 }
246
247 pub fn number_of(&self, target: &str) -> Option<u16> {
253 if target.is_empty() {
257 return None;
258 }
259 self.tabs.iter().find(|(_, tab)| tab.target == target).map(|(number, _)| *number)
260 }
261
262 pub fn listing(&self) -> String {
264 if self.tabs.is_empty() {
265 return "no tabs are open".to_owned();
266 }
267 self.tabs
268 .iter()
269 .map(|(number, tab)| format!("{number}: {}", tab.url))
270 .collect::<Vec<_>>()
271 .join(", ")
272 }
273
274 pub fn title_url(&self, tab: u16) -> Option<(&str, &str)> {
276 self.tabs.get(&tab).map(|tab| (tab.title.as_str(), tab.url.as_str()))
277 }
278
279 pub fn owned_target(&self, session: &SessionSlot, target: &str) -> Result<u16, String> {
284 if target.is_empty() {
285 return Err("a tab is addressed by its page id, and this one is empty".to_owned());
286 }
287 let (number, tab) = self
288 .tabs
289 .iter()
290 .find(|(_, tab)| tab.target == target)
291 .map(|(number, tab)| (*number, tab))
292 .ok_or_else(|| format!("no tab has the page id '{target}'"))?;
293 if &tab.owner != session {
294 return Err(format!(
295 "the tab with page id '{target}' belongs to {}",
296 tab.owner.display()
297 ));
298 }
299 Ok(number)
300 }
301
302 pub fn owner(&self, tab: u16) -> Option<&SessionSlot> {
303 self.tabs.get(&tab).map(|tab| &tab.owner)
304 }
305
306 pub fn other_owners(&self, except: &SessionSlot) -> Vec<(SessionSlot, Duration)> {
309 self.other_owners_at(except, Instant::now())
310 }
311
312 pub fn other_owners_at(
315 &self,
316 except: &SessionSlot,
317 now: Instant,
318 ) -> Vec<(SessionSlot, Duration)> {
319 let mut held: Vec<(SessionSlot, Instant)> = Vec::new();
320 for tab in self.tabs.values() {
321 if &tab.owner == except {
322 continue;
323 }
324 match held.iter_mut().find(|(owner, _)| owner == &tab.owner) {
325 Some((_, since)) => *since = (*since).min(tab.since),
326 None => held.push((tab.owner.clone(), tab.since)),
327 }
328 }
329 held.into_iter()
330 .map(|(owner, since)| (owner, now.saturating_duration_since(since)))
331 .collect()
332 }
333
334 pub fn mark_busy(&mut self, tab: u16, busy: bool) {
335 if let Some(entry) = self.tabs.get_mut(&tab) {
336 entry.busy = busy;
337 }
338 }
339
340 pub fn update(&mut self, tab: u16, title: &str, url: &str) -> bool {
342 let Some(entry) = self.tabs.get_mut(&tab) else { return false };
343 let changed = entry.title != title || entry.url != url;
344 title.clone_into(&mut entry.title);
345 url.clone_into(&mut entry.url);
346 changed
347 }
348
349 pub(crate) fn owned(&self, session: &SessionSlot, tab: u16) -> Result<&Tab, String> {
350 let entry = self.tabs.get(&tab).ok_or_else(|| format!("tab {tab} is not open"))?;
351 if &entry.owner != session {
352 return Err(format!("tab {tab} belongs to {}", entry.owner.display()));
353 }
354 Ok(entry)
355 }
356
357 pub fn rows(
362 &self,
363 held: &HashSet<u16>,
364 controlled: &HashSet<u16>,
365 dialogs: &HashMap<u16, BrowserTabDialog>,
366 ) -> Vec<BrowserTabRow> {
367 self.tabs
368 .iter()
369 .map(|(number, tab)| BrowserTabRow {
370 number: *number,
371 title: tab.title.clone(),
372 url: tab.url.clone(),
373 owner: tab.owner.clone(),
374 opener: tab.opener,
375 state: if held.contains(number) {
376 BrowserTabState::Held
377 } else if tab.busy || controlled.contains(number) {
378 BrowserTabState::InUse
379 } else {
380 BrowserTabState::Idle
381 },
382 dialog: dialogs.get(number).cloned(),
383 })
384 .collect()
385 }
386
387 pub fn is_empty(&self) -> bool {
388 self.tabs.is_empty()
389 }
390
391 pub fn numbers(&self) -> Vec<u16> {
392 self.tabs.keys().copied().collect()
393 }
394
395 pub fn popup_age(&self, now: Instant) -> Option<Duration> {
402 self.tabs
403 .values()
404 .filter(|tab| tab.is_popup)
405 .map(|tab| now.saturating_duration_since(tab.since))
406 .min()
407 }
408}
409
410#[cfg(test)]
411mod tests {
412 use super::{DriverTab, TabRegistry, orphan_targets, parse_export, strip_export};
413 use forge_primitives::SessionSlot;
414 use forge_primitives::browser::{BrowserTabDialog, BrowserTabState};
415 use std::collections::{HashMap, HashSet};
416 use std::time::{Duration, Instant};
417
418 #[test]
419 fn the_hosts_number_is_the_tabs_name() {
420 let seat = SessionSlot::lead("O", "p");
421 let mut registry = TabRegistry::default();
422 let one = registry.adopt(1, seat.clone(), "t1", "one", "http://a/").expect("the first tab");
423 let two =
424 registry.adopt(2, seat.clone(), "t2", "two", "http://b/").expect("the second tab");
425 assert_eq!((one, two), (1, 2));
426 assert!(
427 registry.adopt(2, seat.clone(), "t3", "three", "http://c/").is_err(),
428 "a number is never reused",
429 );
430 registry.close(one);
431 assert_eq!(registry.owner(two), Some(&seat), "a page keeps its number");
432 assert!(
433 registry.adopt(1, seat, "t4", "four", "http://d/").is_err(),
434 "a closed number stays retired",
435 );
436 }
437
438 #[test]
439 fn only_an_owner_selects_or_closes_its_tab() {
440 let lead = SessionSlot::lead("O", "p");
441 let worker = SessionSlot::worker("O", "p", "w");
442 let mut registry = TabRegistry::default();
443 let mine = registry.adopt(1, lead.clone(), "t1", "one", "u").expect("the tab");
444 assert_eq!(registry.current(&lead), Some(mine), "a new tab is its owner's current");
445 assert!(registry.select(&worker, mine).is_err(), "a foreign tab is refused");
446 assert!(registry.close_owned(&worker, mine).is_err(), "and not closed");
447 registry.select(&lead, mine).expect("its owner selects it");
448 assert_eq!(registry.current(&lead), Some(mine));
449 registry.close(mine);
450 assert_eq!(registry.current(&lead), None, "closing the current tab clears the pointer");
451 }
452
453 #[test]
458 fn other_owners_names_each_foreign_seat_once_with_its_oldest_tab() {
459 let lead = SessionSlot::lead("O", "p");
460 let worker = SessionSlot::worker("O", "p", "w");
461 let other = SessionSlot::worker("O", "p", "x");
462 let mut registry = TabRegistry::default();
463 registry.adopt(1, lead.clone(), "T1", "", "").expect("the caller's tab");
464 registry.adopt(2, worker.clone(), "T2", "", "").expect("the worker's first");
465 registry.adopt(3, worker.clone(), "T3", "", "").expect("the worker's second");
466 registry.adopt(4, other.clone(), "T4", "", "").expect("another seat's tab");
467 let base = Instant::now();
470 let now = base + Duration::from_secs(5 * 60);
471 registry.tabs.get_mut(&2).expect("the worker's first").since = base;
472 registry.tabs.get_mut(&3).expect("the worker's second").since =
473 base + Duration::from_secs(4 * 60);
474 registry.tabs.get_mut(&4).expect("another seat's tab").since =
475 base + Duration::from_secs(4 * 60);
476
477 let holders = registry.other_owners_at(&lead, now);
478 assert_eq!(
479 holders.len(),
480 2,
481 "one entry per foreign seat, the caller left out: {holders:?}"
482 );
483 assert_eq!(holders[0].0, worker);
484 assert_eq!(
485 holders[0].1,
486 Duration::from_secs(5 * 60),
487 "the oldest tab is the worker's clock, not its newest: {:?}",
488 holders[0].1,
489 );
490 assert_eq!(holders[1].0, other);
491 assert_eq!(
492 holders[1].1,
493 Duration::from_secs(60),
494 "the other seat's clock is its own tab's age: {:?}",
495 holders[1].1,
496 );
497 assert!(
498 registry.other_owners_at(&worker, now).iter().all(|(seat, _)| seat != &worker),
499 "a seat is never its own holder",
500 );
501 }
502
503 #[test]
507 fn the_youngest_popup_sets_the_age() {
508 let seat = SessionSlot::lead("O", "p");
509 let mut registry = TabRegistry::default();
510 registry.adopt(1, seat.clone(), "opener", "", "").expect("the opener's row");
511 registry.create_under(1, 2, "old", "", "").expect("the old popup's row");
512 registry.create_under(1, 3, "fresh", "", "").expect("the fresh popup's row");
513 let now = Instant::now();
514 let old = now.checked_sub(Duration::from_secs(10 * 60)).expect("ten minutes back");
515 let fresh = now.checked_sub(Duration::from_secs(10)).expect("ten seconds back");
516 registry.tabs.get_mut(&2).expect("the old popup").since = old;
517 registry.tabs.get_mut(&3).expect("the fresh popup").since = fresh;
518
519 assert_eq!(
520 registry.popup_age(now),
521 Some(Duration::from_secs(10)),
522 "the freshest popup is the one a keep window is measured from",
523 );
524
525 let mut plain = TabRegistry::default();
526 plain.adopt(1, seat, "t1", "", "").expect("an ordinary tab");
527 assert_eq!(plain.popup_age(now), None, "an ordinary tab has no age");
528 }
529
530 #[test]
533 fn a_page_target_resolves_to_its_tab_and_a_gone_one_names_the_listing() {
534 let lead = SessionSlot::lead("O", "p");
535 let mut registry = TabRegistry::default();
536 registry.adopt(1, lead.clone(), "t1", "one", "http://a/").expect("the tab");
537 registry.adopt(2, lead.clone(), "t2", "two", "http://b/").expect("the second tab");
540 registry.adopt(3, lead, "", "three", "http://c/").expect("a row with no page named");
543 assert_eq!(registry.number_of("t1"), Some(1));
544 assert_eq!(registry.number_of("t2"), Some(2));
545 assert_eq!(registry.number_of("t9"), None);
546 assert_eq!(registry.number_of(""), None, "a page nobody named is not a page");
547 assert_eq!(registry.listing(), "1: http://a/, 2: http://b/, 3: http://c/");
548 }
549
550 #[test]
551 fn rows_say_who_owns_each_tab_and_what_state_it_is_in() {
552 let lead = SessionSlot::lead("O", "p");
553 let worker = SessionSlot::worker("O", "p", "w");
554 let mut registry = TabRegistry::default();
555 let held = registry.adopt(1, lead.clone(), "t1", "one", "http://a/").expect("the held tab");
556 let busy =
557 registry.adopt(2, worker.clone(), "t2", "two", "http://b/").expect("the busy tab");
558 registry.mark_busy(busy, true);
559 let dialogs = HashMap::from([(
560 held,
561 BrowserTabDialog { kind: "confirm".to_owned(), message: "Leave?".to_owned() },
562 )]);
563 let rows = registry.rows(&HashSet::from([held]), &HashSet::new(), &dialogs);
564 assert_eq!(rows[0].state, BrowserTabState::Held);
565 assert_eq!(rows[1].state, BrowserTabState::InUse);
566 assert_eq!(rows[0].owner, lead);
567 assert_eq!(rows[1].owner, worker);
568 assert_eq!(rows[0].dialog.as_ref().map(|dialog| dialog.kind.as_str()), Some("confirm"));
569 assert_eq!(rows[1].dialog, None, "a tab with no dialog carries none");
570
571 let driven =
574 registry.adopt(3, worker.clone(), "t3", "three", "http://c/").expect("the driven tab");
575 let rows = registry.rows(&HashSet::from([held]), &HashSet::from([driven]), &dialogs);
576 assert_eq!(rows[2].state, BrowserTabState::InUse, "the controlled tab is in use");
577
578 registry.close(busy);
580 registry.close(driven);
581 let rows = registry.rows(&HashSet::from([held]), &HashSet::new(), &dialogs);
582 assert_eq!(rows.len(), 1);
583 assert_eq!(rows[0].number, held, "the held tab keeps the number the person was shown");
584 assert_eq!(rows[0].state, BrowserTabState::Held);
585 }
586
587 #[test]
591 fn only_a_driver_made_page_is_an_orphan() {
592 let seat = SessionSlot::lead("O", "p");
593 let mut registry = TabRegistry::default();
594 registry.adopt(1, seat, "T1", "one", "http://a/").expect("the host tab");
595 let listing = [
596 tab_row(0, "T1"),
597 tab_row(1, "T2"),
598 DriverTab {
599 index: 2,
600 target: String::new(),
601 title: String::new(),
602 url: String::new(),
603 current: false,
604 },
605 ];
606 assert_eq!(orphan_targets(®istry, &listing), vec!["T2".to_owned()]);
607 }
608
609 #[test]
613 fn an_unresolved_flag_goes_with_its_row() {
614 let seat = SessionSlot::lead("O", "p");
615 let mut registry = TabRegistry::default();
616 let one = registry.adopt(1, seat, "T1", "one", "u").expect("the tab");
617 registry.mark_unresolved(one);
618 assert_eq!(registry.unresolved_tabs(), vec![one]);
619 registry.close(one);
620 assert!(registry.unresolved_tabs().is_empty(), "a close clears the flag");
621
622 registry.mark_unresolved(9);
623 assert!(registry.unresolved_tabs().is_empty(), "a number with no row is not marked");
624 registry.mark_unresolved(one);
625 assert!(registry.unresolved_tabs().is_empty(), "a retired number is never marked");
626 }
627
628 fn tab_row(index: usize, target: &str) -> DriverTab {
629 DriverTab {
630 index,
631 target: target.to_owned(),
632 title: String::new(),
633 url: String::new(),
634 current: false,
635 }
636 }
637
638 #[test]
643 fn a_tab_is_addressed_by_its_page_id() {
644 let lead = SessionSlot::lead("O", "p");
645 let worker = SessionSlot::worker("O", "p", "w");
646 let mut registry = TabRegistry::default();
647 let mine = registry.adopt(1, lead.clone(), "T1", "one", "http://a/").expect("the tab");
648 registry.adopt(2, worker.clone(), "T2", "two", "http://b/").expect("the worker's tab");
649
650 assert_eq!(registry.owned_target(&lead, "T1"), Ok(mine), "the id finds its own tab");
651 assert_eq!(
652 registry.owned_target(&lead, "T2"),
653 Err(format!("the tab with page id 'T2' belongs to {}", worker.display())),
654 "another session's id is refused by name",
655 );
656 assert_eq!(
657 registry.owned_target(&lead, "gone"),
658 Err("no tab has the page id 'gone'".to_owned()),
659 "an id no row carries is a refusal, never the tab that holds that number now",
660 );
661 assert!(
662 registry.owned_target(&lead, "").is_err(),
663 "an empty id addresses nothing: a row whose page is unnamed is not a wildcard",
664 );
665 }
666
667 #[test]
668 fn a_popup_belongs_to_its_openers_owner() {
669 let worker = SessionSlot::worker("O", "p", "w");
670 let mut registry = TabRegistry::default();
671 let opener = registry.adopt(1, worker.clone(), "t1", "one", "u").expect("the opener");
672 let popup =
673 registry.create_under(opener, 2, "t2", "two", "http://b/").expect("the opener exists");
674 assert_eq!(
675 registry.owner(popup),
676 Some(&worker),
677 "the popup is the opener's, not the caller's"
678 );
679 assert!(
680 registry.create_under(99, 3, "t3", "three", "http://c/").is_err(),
681 "an unknown opener is refused",
682 );
683 }
684
685 #[test]
690 fn a_popup_closing_returns_the_session_to_its_opener() {
691 let worker = SessionSlot::worker("O", "p", "w");
692 let mut registry = TabRegistry::default();
693 let opener = registry.adopt(1, worker.clone(), "t1", "one", "u").expect("the opener");
694 let popup = registry.create_under(opener, 2, "t2", "two", "http://b/").expect("the popup");
695 assert_eq!(registry.current(&worker), Some(popup), "the popup is where the session is");
696 registry.close(popup);
697 assert_eq!(registry.current(&worker), Some(opener), "the session is back on the opener");
698
699 let popup =
700 registry.create_under(opener, 3, "t3", "three", "http://c/").expect("a second popup");
701 registry.close(opener);
702 assert_eq!(registry.current(&worker), Some(popup), "the popup keeps its place");
703 assert_eq!(registry.owner(popup), Some(&worker), "and its owner");
704 assert!(
705 registry.popup_age(Instant::now()).is_some(),
706 "**the popup is still a popup after its opener goes**, or the attach-timeout hold \
707 stops covering a sign-in page that is open",
708 );
709
710 let root = registry.adopt(4, worker.clone(), "t4", "four", "u").expect("a root");
714 let middle = registry.create_under(root, 5, "t5", "five", "http://e/").expect("the middle");
715 let leaf = registry.create_under(middle, 6, "t6", "six", "http://f/").expect("the leaf");
716 assert_eq!(registry.current(&worker), Some(leaf), "the leaf is where the session is");
717 registry.close(middle);
718 assert_eq!(registry.current(&worker), Some(leaf), "the middle closing leaves the leaf");
719 registry.close(leaf);
720 assert_eq!(
721 registry.current(&worker),
722 Some(root),
723 "the leaf hands the session back past the middle",
724 );
725 }
726
727 #[test]
730 fn the_export_names_each_tab_target_and_position() {
731 let text = "FORGE-TABS [{\"i\":0,\"target\":\"T1\",\"title\":\"one\",\
732 \"url\":\"http://a/\",\"current\":true},{\"i\":1,\
733 \"target\":\"T2\",\"title\":\"two\",\"url\":\"http://b/\",\"current\":false}]\n\
734 - 0: (current) [one](http://a/)\n- 1: [two](http://b/)\n";
735 let tabs = parse_export(text).expect("the export parses");
736 assert_eq!(tabs.len(), 2);
737 assert_eq!(tabs[1].index, 1);
738 assert_eq!(tabs[1].target, "T2", "the target is where CDP drives it");
739 assert_eq!(tabs[1].title, "two");
740 assert!(tabs[0].current, "the first tab is the current one");
741 assert!(
742 parse_export("- 0: [one](http://a/)\n").is_err(),
743 "a listing with no export is refused"
744 );
745 }
746
747 #[test]
750 fn the_export_is_stripped_from_the_agent_visible_text() {
751 let text = "FORGE-TABS []\n- 0: (current) [one](http://a/)\n";
752 assert_eq!(strip_export(text), "- 0: (current) [one](http://a/)\n");
753 }
754}