web browser (Servo)
git clone https://git.lucas.co/cce-browser.git
src/session.rs (5K)
1 //! Open-tab persistence: the tab set survives a restart.
2 //!
3 //! `~/.local/state/cce/browser/tabs.tsv` (the same state dir as history and
4 //! bookmarks), one line per tab in strip order: `<1|0>\t<url>`, the flag
5 //! marking the active tab. It is written eagerly on every tab-set change —
6 //! open, close, switch, navigation — rather than on exit, so a crash or a
7 //! compositor-side window close loses nothing; `save` skips the write when
8 //! the serialization is unchanged, which keeps the loading-time signal storm
9 //! from touching the disk more than once. Closing the last tab saves an
10 //! empty set, so a deliberately emptied browser starts fresh on the
11 //! homepage rather than resurrecting what was just closed.
12
13 use std::path::PathBuf;
14
15 use url::Url;
16
17 pub struct Session {
18 path: PathBuf,
19 /// Last serialization written (or loaded), to skip no-op writes.
20 last: Option<String>,
21 }
22
23 impl Session {
24 pub fn new() -> Self {
25 Self::at(crate::pages::state_dir().join("tabs.tsv"))
26 }
27
28 fn at(path: PathBuf) -> Self {
29 Self { path, last: None }
30 }
31
32 /// Tabs saved by the previous run, in strip order, plus the active
33 /// index. Missing file or unparseable lines mean fewer tabs, never an
34 /// error; an empty result is "nothing to restore".
35 pub fn load(&mut self) -> (Vec<Url>, usize) {
36 let text = std::fs::read_to_string(&self.path).unwrap_or_default();
37 let mut tabs = Vec::new();
38 let mut active = 0;
39 for line in text.lines() {
40 let mut parts = line.splitn(2, '\t');
41 if let (Some(flag), Some(url)) = (parts.next(), parts.next()) {
42 if let Ok(u) = Url::parse(url) {
43 if flag == "1" {
44 active = tabs.len();
45 }
46 tabs.push(u);
47 }
48 }
49 }
50 self.last = Some(text);
51 (tabs, active)
52 }
53
54 /// Persist the open tabs; a no-op when nothing changed since the last
55 /// write.
56 pub fn save(&mut self, tabs: &[(String, bool)]) {
57 let mut out = String::new();
58 for (url, active) in tabs {
59 out.push_str(if *active { "1\t" } else { "0\t" });
60 out.push_str(url);
61 out.push('\n');
62 }
63 if self.last.as_deref() == Some(&out) {
64 return;
65 }
66 if let Some(dir) = self.path.parent() {
67 let _ = std::fs::create_dir_all(dir);
68 }
69 if std::fs::write(&self.path, &out).is_ok() {
70 self.last = Some(out);
71 }
72 }
73 }
74
75 #[cfg(test)]
76 mod tests {
77 use super::*;
78
79 /// A session file in a scratch directory of this PROCESS's own.
80 ///
81 /// The name was fixed — `/tmp/cce-browser-session-test` — where the
82 /// sibling helpers in `pages.rs` already scope theirs by pid. /tmp is one
83 /// namespace shared by every user of the machine, so a fixed name belongs
84 /// to whoever ran first and the sticky bit denies it to everyone else;
85 /// nearer to hand, two checkouts running their suites at once shared one
86 /// directory.
87 ///
88 /// The directory is deliberately NOT removed wholesale, here or at the
89 /// end of a test: the three tests run in parallel threads of one process
90 /// and so share this one pid-scoped directory, and a `remove_dir_all`
91 /// would take a sibling's file out from under it. Each test owns a
92 /// distinct file name and clears just that.
93 fn temp_session(name: &str) -> Session {
94 let dir = std::env::temp_dir()
95 .join(format!("cce-browser-session-test-{}", std::process::id()));
96 std::fs::create_dir_all(&dir).unwrap();
97 let path = dir.join(name);
98 let _ = std::fs::remove_file(&path);
99 Session::at(path)
100 }
101
102 #[test]
103 fn round_trips_tabs_and_active_index() {
104 let mut s = temp_session("round-trip.tsv");
105 s.save(&[
106 ("https://example.com/".to_string(), false),
107 ("https://example.org/".to_string(), true),
108 ]);
109
110 let mut fresh = Session::at(s.path.clone());
111 let (tabs, active) = fresh.load();
112 assert_eq!(
113 tabs.iter().map(Url::as_str).collect::<Vec<_>>(),
114 ["https://example.com/", "https://example.org/"]
115 );
116 assert_eq!(active, 1);
117 }
118
119 #[test]
120 fn empty_save_clears_and_loads_as_nothing() {
121 let mut s = temp_session("empty.tsv");
122 s.save(&[("https://example.com/".to_string(), true)]);
123 s.save(&[]);
124
125 let (tabs, active) = Session::at(s.path.clone()).load();
126 assert!(tabs.is_empty());
127 assert_eq!(active, 0);
128 }
129
130 #[test]
131 fn missing_file_and_junk_lines_load_as_fewer_tabs() {
132 let mut s = temp_session("missing.tsv");
133 assert!(s.load().0.is_empty());
134
135 std::fs::write(
136 &s.path,
137 "no-tab-here\n1\tnot a url\n0\thttps://example.com/\n",
138 )
139 .unwrap();
140 let (tabs, active) = Session::at(s.path.clone()).load();
141 assert_eq!(tabs.len(), 1);
142 assert_eq!(active, 0);
143 }
144 }