1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3use std::sync::{Arc, Mutex, OnceLock};
4
5use super::{Application, ApplicationError, DATABASE_FILE_NAME, DEFAULT_EVENT_CAPACITY};
6use crate::config::Config;
7use crate::folder::Folders;
8use crate::store::Store;
9use crate::terminal::TranscriptRecorder;
10
11struct WindowStorage {
12 recorder: Arc<TranscriptRecorder>,
13 windows: usize,
14}
15
16static STORAGE: OnceLock<Mutex<HashMap<PathBuf, WindowStorage>>> = OnceLock::new();
19
20pub(super) struct WindowStorageLease {
21 path: PathBuf,
22}
23
24impl Drop for WindowStorageLease {
25 fn drop(&mut self) {
26 let mut storage = STORAGE
27 .get_or_init(Mutex::default)
28 .lock()
29 .unwrap_or_else(std::sync::PoisonError::into_inner);
30 if let Some(entry) = storage.get_mut(&self.path) {
31 entry.windows -= 1;
32 if entry.windows == 0 {
33 entry.recorder.shutdown();
34 storage.remove(&self.path);
35 }
36 }
37 }
38}
39
40impl Application {
41 pub fn new_window(data_directory: &Path) -> Result<Self, ApplicationError> {
47 std::fs::create_dir_all(data_directory).map_err(|source| {
48 crate::store::StoreError::CreateDirectory {
49 path: data_directory.into(),
50 source,
51 }
52 })?;
53 let path = std::fs::canonicalize(data_directory).map_err(|source| {
54 crate::store::StoreError::ResolveDirectory {
55 path: data_directory.into(),
56 source,
57 }
58 })?;
59 let mut storage = STORAGE
60 .get_or_init(Mutex::default)
61 .lock()
62 .map_err(|_| ApplicationError::Poisoned)?;
63 let restore = !storage.contains_key(&path);
64 let transcripts = match storage.get(&path) {
65 Some(entry) => Arc::clone(&entry.recorder),
66 None => Arc::new(TranscriptRecorder::open(&path.join("transcripts"))?),
67 };
68 let mut store = Store::open(&data_directory.join(DATABASE_FILE_NAME))?;
69 if restore {
70 store.recover_interrupted_work()?;
71 }
72 let folders = Folders::for_window(store, restore)?;
73 let mut application = Self::with_folders(
74 folders,
75 Config::load_user(),
76 DEFAULT_EVENT_CAPACITY,
77 Arc::clone(&transcripts),
78 true,
79 )?;
80 storage
81 .entry(path.clone())
82 .or_insert_with(|| WindowStorage {
83 recorder: transcripts,
84 windows: 0,
85 })
86 .windows += 1;
87 application.window_storage = Some(WindowStorageLease { path });
88 Ok(application)
89 }
90
91 pub fn restorable_folders(&self) -> Result<Vec<PathBuf>, ApplicationError> {
96 Ok(self.lock_inner()?.folders.restorable_paths()?)
97 }
98}
99
100#[cfg(test)]
101mod tests {
102 use super::*;
103 use crate::{
104 Command, CommandDisposition, RequestId, TerminalSize, TerminalStatus, TranscriptRead,
105 WorkflowKind,
106 };
107 use std::thread;
108 use std::time::{Duration, Instant};
109
110 #[test]
111 fn a_new_window_waits_for_final_recording_shutdown() {
112 let data = tempfile::tempdir().unwrap();
113 let folder = tempfile::tempdir().unwrap();
114 let app = Application::new_window(data.path()).unwrap();
115 open(&app, folder.path());
116 let id = terminal(&app, folder.path());
117 let stalled = app.terminal_output.stall_recording_worker(id);
118 let key = std::fs::canonicalize(data.path()).unwrap();
119 let recorder = Arc::clone(
120 &STORAGE
121 .get()
122 .unwrap()
123 .lock()
124 .unwrap()
125 .get(&key)
126 .unwrap()
127 .recorder,
128 );
129 let dropping = thread::spawn(move || drop(app));
130 let deadline = Instant::now() + Duration::from_secs(5);
131 while !recorder.is_shutting_down() {
132 assert!(
133 Instant::now() < deadline,
134 "shutdown never acquired storage ownership"
135 );
136 thread::yield_now();
137 }
138 let path = data.path().to_owned();
139 let (sender, receiver) = std::sync::mpsc::channel();
140 let opening = thread::spawn(move || {
141 let app = Application::new_window(&path);
142 sender
143 .send(
144 app.as_ref()
145 .map(|app| app.snapshot().unwrap().folders.open_folder.clone())
146 .map_err(ToString::to_string),
147 )
148 .unwrap();
149 });
150 assert!(matches!(
151 receiver.recv_timeout(Duration::from_millis(25)),
152 Err(std::sync::mpsc::RecvTimeoutError::Timeout)
153 ));
154 drop(stalled);
155 dropping.join().unwrap();
156 assert_eq!(
157 receiver
158 .recv_timeout(Duration::from_secs(5))
159 .unwrap()
160 .unwrap()
161 .as_deref(),
162 Some(folder.path())
163 );
164 opening.join().unwrap();
165 }
166
167 #[test]
168 fn an_additional_missing_restored_window_owns_and_closes_its_entry() {
169 let data = tempfile::tempdir().unwrap();
170 let missing = tempfile::tempdir().unwrap();
171 let missing_path = missing.path().to_owned();
172 let available = tempfile::tempdir().unwrap();
173 let first = Application::new_window(data.path()).unwrap();
174 open(&first, &missing_path);
175 let second = Application::new_window(data.path()).unwrap();
176 open(&second, available.path());
177 drop(first);
178 drop(second);
179 drop(missing);
180 let primary = Application::new_window(data.path()).unwrap();
181 let restored = Application::new_window(data.path()).unwrap();
182 assert_eq!(
183 restored
184 .handle_command(
185 RequestId(1),
186 Command::RestoreFolder {
187 path: missing_path.clone()
188 }
189 )
190 .unwrap()
191 .disposition,
192 CommandDisposition::Accepted
193 );
194 assert_eq!(
195 restored
196 .snapshot()
197 .unwrap()
198 .folders
199 .unavailable_folder
200 .unwrap()
201 .path,
202 missing_path
203 );
204 restored
205 .handle_command(RequestId(2), Command::CloseFolder)
206 .unwrap();
207 assert_eq!(primary.restorable_folders().unwrap(), [available.path()]);
208 }
209
210 fn open(app: &Application, path: &Path) {
211 assert_eq!(
212 app.handle_command(RequestId(1), Command::OpenFolder { path: path.into() })
213 .unwrap()
214 .disposition,
215 CommandDisposition::Accepted
216 );
217 }
218
219 fn terminal(app: &Application, path: &Path) -> crate::TerminalId {
220 app.handle_command(
221 RequestId(2),
222 Command::CreateWorkflow {
223 folder: path.into(),
224 session_id: None,
225 kind: WorkflowKind::Terminal,
226 roles: vec![],
227 size: TerminalSize {
228 rows: 24,
229 columns: 80,
230 pixel_width: 800,
231 pixel_height: 480,
232 },
233 },
234 )
235 .unwrap();
236 app.snapshot()
237 .unwrap()
238 .workflows
239 .workflows
240 .last()
241 .unwrap()
242 .terminal_ids()[0]
243 }
244
245 #[test]
246 fn windows_share_history_without_recovering_or_stopping_live_work() {
247 let data = tempfile::tempdir().unwrap();
248 let a = tempfile::tempdir().unwrap();
249 let b = tempfile::tempdir().unwrap();
250 let first = Application::new_window(data.path()).unwrap();
251 open(&first, a.path());
252 let first_id = terminal(&first, a.path());
253 let first_workflow = first.snapshot().unwrap().workflows.workflows[0].clone();
254 let second = Application::new_window(data.path()).unwrap();
255 assert!(second.snapshot().unwrap().folders.open_folder.is_none());
256 open(&second, b.path());
257 let second_id = terminal(&second, b.path());
258 assert_ne!(first_id, second_id);
259 assert_eq!(
260 first.snapshot().unwrap().workflows.workflows[0],
261 first_workflow
262 );
263 assert_eq!(first.restorable_folders().unwrap(), [b.path(), a.path()]);
264 assert!(
265 first
266 .snapshot()
267 .unwrap()
268 .terminals
269 .iter()
270 .all(|t| t.status == TerminalStatus::Running)
271 );
272
273 second
274 .handle_command(RequestId(3), Command::CloseFolder)
275 .unwrap();
276 drop(second);
277 assert_eq!(first.restorable_folders().unwrap(), [a.path()]);
278 first
279 .write_terminal_input(first_id, b"printf surviving\\n\n")
280 .unwrap();
281 let deadline = Instant::now() + Duration::from_secs(5);
282 loop {
283 let TranscriptRead::Output(page) =
284 first.read_terminal_transcript(first_id, 0, 4096).unwrap()
285 else {
286 panic!("output expected")
287 };
288 if String::from_utf8_lossy(&page.bytes).contains("surviving") {
289 break;
290 }
291 assert!(
292 Instant::now() < deadline,
293 "the remaining window stopped recording"
294 );
295 thread::sleep(Duration::from_millis(10));
296 }
297 drop(first);
298 let relaunched = Application::new_window(data.path()).unwrap();
299 assert_eq!(
300 relaunched
301 .snapshot()
302 .unwrap()
303 .folders
304 .open_folder
305 .as_deref(),
306 Some(a.path())
307 );
308 assert_eq!(relaunched.restorable_folders().unwrap(), [a.path()]);
309 }
310
311 #[test]
312 fn all_open_folders_restore_and_open_folders_survive_the_recent_limit() {
313 let data = tempfile::tempdir().unwrap();
314 let folders = (0..12)
315 .map(|_| tempfile::tempdir().unwrap())
316 .collect::<Vec<_>>();
317 let mut windows = Vec::new();
318 for folder in &folders {
319 let app = Application::new_window(data.path()).unwrap();
320 open(&app, folder.path());
321 windows.push(app);
322 }
323 assert_eq!(windows[0].restorable_folders().unwrap().len(), 12);
324 assert_eq!(
325 windows[11].snapshot().unwrap().folders.recent_folders.len(),
326 10
327 );
328 drop(windows);
329 let app = Application::new_window(data.path()).unwrap();
330 assert_eq!(app.restorable_folders().unwrap().len(), 12);
331 assert_eq!(
332 app.snapshot().unwrap().folders.open_folder.as_deref(),
333 Some(folders[11].path())
334 );
335 }
336
337 #[test]
338 fn forgetting_another_windows_recent_folder_keeps_its_restore_entry() {
339 let data = tempfile::tempdir().unwrap();
340 let folder = tempfile::tempdir().unwrap();
341 let first = Application::new_window(data.path()).unwrap();
342 open(&first, folder.path());
343 let second = Application::new_window(data.path()).unwrap();
344 std::fs::remove_dir_all(folder.path()).unwrap();
345 second
346 .handle_command(
347 RequestId(1),
348 Command::RemoveRecentFolder {
349 path: folder.path().into(),
350 },
351 )
352 .unwrap();
353 assert_eq!(
354 second.snapshot().unwrap().folders.recent_folders,
355 Vec::new()
356 );
357 assert_eq!(second.restorable_folders().unwrap(), [folder.path()]);
358 std::fs::create_dir_all(folder.path()).unwrap();
359 drop(first);
360 drop(second);
361 let relaunched = Application::new_window(data.path()).unwrap();
362 assert_eq!(
363 relaunched
364 .snapshot()
365 .unwrap()
366 .folders
367 .open_folder
368 .as_deref(),
369 Some(folder.path())
370 );
371 }
372
373 #[test]
374 fn replacing_an_unavailable_restored_folder_clears_its_restore_entry() {
375 let data = tempfile::tempdir().unwrap();
376 let missing = tempfile::tempdir().unwrap();
377 let path = missing.path().to_owned();
378 let replacement = tempfile::tempdir().unwrap();
379 let first = Application::new_window(data.path()).unwrap();
380 open(&first, &path);
381 drop(first);
382 drop(missing);
383 let restored = Application::new_window(data.path()).unwrap();
384 assert_eq!(
385 restored
386 .snapshot()
387 .unwrap()
388 .folders
389 .unavailable_folder
390 .unwrap()
391 .path,
392 path
393 );
394 let rejected_path = data.path().join("also-missing");
395 assert!(matches!(
396 restored
397 .handle_command(
398 RequestId(4),
399 Command::OpenFolder {
400 path: rejected_path
401 }
402 )
403 .unwrap()
404 .disposition,
405 CommandDisposition::Rejected { .. }
406 ));
407 open(&restored, replacement.path());
408 assert_eq!(restored.restorable_folders().unwrap(), [replacement.path()]);
409 }
410}