Skip to main content

twine_core/application/
windows.rs

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
16/// The registry owns the recorder until the last runtime has stopped its processes. Creation and
17/// final shutdown share the lock, so a new window never races the previous recorder's owner lock.
18static 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    /// Creates an independent window runtime sharing durable history with the other windows.
42    /// Only the first runtime recovers interrupted work and restores a previously open folder.
43    ///
44    /// # Errors
45    /// Returns an error if storage, recovery, or runtime initialization fails.
46    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    /// Returns every folder that should reopen after app shutdown, including unavailable folders.
92    ///
93    /// # Errors
94    /// Returns an error if the database or runtime state cannot be read.
95    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}