Skip to main content

twine_core/
folder.rs

1use std::fs;
2use std::io::ErrorKind;
3use std::path::{Component, Path, PathBuf};
4use std::time::{SystemTime, UNIX_EPOCH};
5
6use thiserror::Error;
7use tracing::warn;
8
9use crate::store::{Store, StoreError, StoredFolder};
10
11/// How many recently opened folders Twine remembers.
12const MAX_RECENT_FOLDERS: i64 = 10;
13
14/// The open folder and the recent folders the start page lists.
15#[derive(Clone, Debug, Default, Eq, PartialEq)]
16pub struct FolderState {
17    /// The open folder's current branch, refreshed on demand and never persisted.
18    pub current_branch: Option<String>,
19    /// The folder the window shows, or `None` while it shows the start page.
20    pub open_folder: Option<PathBuf>,
21    /// Recently opened folders, most recent first.
22    pub recent_folders: Vec<RecentFolder>,
23    /// A folder that just failed to open, so the start page can say why it's showing. Cleared when
24    /// a folder opens or closes.
25    pub unavailable_folder: Option<UnavailableFolder>,
26}
27
28#[derive(Clone, Debug, Eq, PartialEq)]
29pub struct RecentFolder {
30    pub path: PathBuf,
31    /// Whether nothing, or something other than a folder, is at `path` now. Missing folders stay
32    /// listed until removed.
33    pub is_missing: bool,
34}
35
36#[derive(Clone, Debug, Eq, PartialEq)]
37pub struct UnavailableFolder {
38    pub path: PathBuf,
39    pub reason: UnavailableReason,
40}
41
42/// Why a folder can't be opened.
43#[derive(Clone, Copy, Debug, Eq, PartialEq)]
44pub enum UnavailableReason {
45    /// Nothing is at the path, or something other than a folder is.
46    Missing,
47    /// The folder can't be read, for example because Twine doesn't have permission.
48    Inaccessible,
49}
50
51/// Folder state, kept in sync with the store.
52#[derive(Debug)]
53pub(crate) struct Folders {
54    store: Store,
55    state: FolderState,
56    window_mode: bool,
57    data_version: i64,
58    /// Durable restore ownership is independent of failed folder-picker attempts.
59    owned_path: Option<PathBuf>,
60}
61
62impl Folders {
63    pub(crate) fn read_store(&self) -> &Store {
64        &self.store
65    }
66
67    pub(crate) fn store(&mut self) -> &mut Store {
68        &mut self.store
69    }
70
71    pub(crate) fn update_git_branch(&mut self, folder: &Path, branch: Option<String>) -> bool {
72        if self.state.open_folder.as_deref() != Some(folder) || self.state.current_branch == branch
73        {
74            return false;
75        }
76        self.state.current_branch = branch;
77        true
78    }
79
80    /// Loads the recent folders and reopens the folder that was open when Twine last quit. If it
81    /// can't be opened, the start page shows and says why. It stays the last open folder, so a
82    /// later launch reopens it once it's back, for example when its drive is reconnected.
83    pub(crate) fn restore(store: Store) -> Result<Self, StoreError> {
84        let mut state = FolderState::default();
85        for StoredFolder { path, is_open } in store.recent_folders()? {
86            let reason = unavailable_reason(&path);
87            if is_open && state.open_folder.is_none() && state.unavailable_folder.is_none() {
88                match reason {
89                    None => state.open_folder = Some(path.clone()),
90                    Some(reason) => {
91                        warn!(?reason, "the last open folder can't be reopened");
92                        state.unavailable_folder = Some(UnavailableFolder {
93                            path: path.clone(),
94                            reason,
95                        });
96                    }
97                }
98            }
99            state.recent_folders.push(RecentFolder {
100                is_missing: reason == Some(UnavailableReason::Missing),
101                path,
102            });
103        }
104        let data_version = store.data_version()?;
105        Ok(Self {
106            store,
107            state,
108            window_mode: false,
109            data_version,
110            owned_path: None,
111        })
112    }
113
114    /// Additional windows start blank; only the first restores a previously open folder.
115    pub(crate) fn for_window(store: Store, restore: bool) -> Result<Self, StoreError> {
116        let restored = if restore {
117            store.open_folder_paths()?.into_iter().next()
118        } else {
119            None
120        };
121        let mut folders = Self::restore(store)?;
122        folders.window_mode = true;
123        folders.state.open_folder = None;
124        folders.state.unavailable_folder = None;
125        folders.owned_path.clone_from(&restored);
126        if let Some(path) = restored {
127            match unavailable_reason(&path) {
128                None => folders.state.open_folder = Some(path),
129                Some(reason) => {
130                    folders.state.unavailable_folder = Some(UnavailableFolder { path, reason });
131                }
132            }
133        }
134        Ok(folders)
135    }
136
137    pub(crate) fn restorable_paths(&self) -> Result<Vec<PathBuf>, StoreError> {
138        self.store.open_folder_paths()
139    }
140
141    /// Refresh shared recents without replacing this window's open folder or processes.
142    pub(crate) fn refresh_shared(&mut self) -> Result<bool, StoreError> {
143        let version = self.store.data_version()?;
144        if !self.window_mode || version == self.data_version {
145            return Ok(false);
146        }
147        self.state.recent_folders = check_recent_folders(self.store.recent_folders()?);
148        self.data_version = version;
149        Ok(true)
150    }
151
152    pub(crate) const fn state(&self) -> &FolderState {
153        &self.state
154    }
155
156    /// Attaches this window to a saved entry even when the folder is currently unavailable.
157    pub(crate) fn restore_path(&mut self, path: &Path) -> Result<(), FolderError> {
158        let path = PathBuf::from(normalize(path)?);
159        if !self.window_mode || !self.store.open_folder_paths()?.contains(&path) {
160            return Err(FolderError::InvalidPath);
161        }
162        let unavailable = unavailable_reason(&path).map(|reason| UnavailableFolder {
163            path: path.clone(),
164            reason,
165        });
166        self.state.open_folder = unavailable.is_none().then(|| path.clone());
167        self.owned_path = Some(path);
168        self.state.unavailable_folder = unavailable;
169        self.state.current_branch = None;
170        Ok(())
171    }
172
173    /// Opens the folder at `path` and records it as the most recent folder.
174    ///
175    /// A folder that can't be opened becomes the unavailable folder, and a missing one is marked as
176    /// missing in the recent folders.
177    pub(crate) fn open(&mut self, path: &Path) -> Result<(), FolderError> {
178        let path = normalize(path)?;
179        if let Some(reason) = unavailable_reason(Path::new(&path)) {
180            let path = PathBuf::from(path);
181            for folder in &mut self.state.recent_folders {
182                if folder.path == path {
183                    folder.is_missing = reason == UnavailableReason::Missing;
184                }
185            }
186            self.state.unavailable_folder = Some(UnavailableFolder { path, reason });
187            return Err(match reason {
188                UnavailableReason::Missing => FolderError::Missing,
189                UnavailableReason::Inaccessible => FolderError::Inaccessible,
190            });
191        }
192
193        let stored = if self.window_mode {
194            self.store.record_window_folder_opened(
195                &path,
196                unix_millis(),
197                MAX_RECENT_FOLDERS,
198                self.owned_path.as_deref(),
199                false,
200            )?
201        } else {
202            self.store
203                .record_folder_opened(&path, unix_millis(), MAX_RECENT_FOLDERS)?
204        };
205        if self.window_mode {
206            self.owned_path = Some(PathBuf::from(&path));
207        }
208        self.state = FolderState {
209            current_branch: None,
210            open_folder: Some(PathBuf::from(path)),
211            recent_folders: check_recent_folders(stored),
212            unavailable_folder: None,
213        };
214        Ok(())
215    }
216
217    /// Closes the open folder, so the window shows the start page now and after the next launch.
218    pub(crate) fn close(&mut self) -> Result<(), FolderError> {
219        let stored = if self.window_mode {
220            self.store
221                .record_window_folder_closed(self.owned_path.as_deref())?
222        } else {
223            self.store.record_folder_closed()?
224        };
225        self.owned_path = None;
226        self.state = FolderState {
227            current_branch: None,
228            open_folder: None,
229            recent_folders: check_recent_folders(stored),
230            unavailable_folder: None,
231        };
232        Ok(())
233    }
234
235    /// Forgets the recent folder at `path`.
236    pub(crate) fn remove_recent(&mut self, path: &Path) -> Result<(), FolderError> {
237        let path = normalize(path)?;
238        let stored = if self.window_mode {
239            let owns_unavailable = self.owned_path.as_deref() == Some(Path::new(&path))
240                && self.state.open_folder.is_none();
241            let stored = self
242                .store
243                .remove_recent_folder_preserving_windows(&path, !owns_unavailable)?;
244            if owns_unavailable {
245                self.owned_path = None;
246            }
247            stored
248        } else {
249            self.store.remove_recent_folder(&path)?
250        };
251        self.state.recent_folders = check_recent_folders(stored);
252        if self
253            .state
254            .unavailable_folder
255            .as_ref()
256            .is_some_and(|folder| folder.path == Path::new(&path))
257        {
258            self.state.unavailable_folder = None;
259        }
260        Ok(())
261    }
262}
263
264/// Marks the stored folders that are missing from the file system.
265fn check_recent_folders(stored: Vec<StoredFolder>) -> Vec<RecentFolder> {
266    stored
267        .into_iter()
268        .map(|folder| RecentFolder {
269            is_missing: unavailable_reason(&folder.path) == Some(UnavailableReason::Missing),
270            path: folder.path,
271        })
272        .collect()
273}
274
275/// Returns why the folder at `path` can't be opened, or `None` if it can. Only a missing path counts
276/// as missing; other errors, such as a denied permission, may hide a folder that still exists.
277fn unavailable_reason(path: &Path) -> Option<UnavailableReason> {
278    match fs::metadata(path) {
279        Ok(metadata) if metadata.is_dir() => None,
280        Ok(_) => Some(UnavailableReason::Missing),
281        Err(error) if matches!(error.kind(), ErrorKind::NotFound | ErrorKind::NotADirectory) => {
282            Some(UnavailableReason::Missing)
283        }
284        Err(error) => {
285            warn!(kind = %error.kind(), "a folder can't be read");
286            Some(UnavailableReason::Inaccessible)
287        }
288    }
289}
290
291/// Returns `path` as UTF-8 text without repeated separators, `.` components, or a trailing
292/// separator, so each folder has one stored form.
293fn normalize(path: &Path) -> Result<String, FolderError> {
294    if !path.is_absolute() || path.components().any(|part| part == Component::ParentDir) {
295        return Err(FolderError::InvalidPath);
296    }
297    path.components()
298        .collect::<PathBuf>()
299        .into_os_string()
300        .into_string()
301        .map_err(|_| FolderError::InvalidPath)
302}
303
304fn unix_millis() -> i64 {
305    SystemTime::now()
306        .duration_since(UNIX_EPOCH)
307        .map_or(0, |elapsed| {
308            i64::try_from(elapsed.as_millis()).unwrap_or(i64::MAX)
309        })
310}
311
312#[derive(Debug, Error)]
313pub(crate) enum FolderError {
314    #[error("a folder path must be absolute UTF-8 without `..` components")]
315    InvalidPath,
316    #[error("the folder can't be read")]
317    Inaccessible,
318    #[error("the folder no longer exists")]
319    Missing,
320    #[error(transparent)]
321    Store(#[from] StoreError),
322}
323
324#[cfg(test)]
325mod tests {
326    use std::os::unix::fs::PermissionsExt;
327
328    use tempfile::TempDir;
329
330    use super::*;
331
332    struct Fixture {
333        data: TempDir,
334        folders: TempDir,
335    }
336
337    impl Fixture {
338        fn new() -> Self {
339            Self {
340                data: tempfile::tempdir().expect("a data directory should be available"),
341                folders: tempfile::tempdir().expect("a folder directory should be available"),
342            }
343        }
344
345        /// Restores folder state from the fixture's database, as a launch does.
346        fn launch(&self) -> Folders {
347            let store =
348                Store::open(&self.data.path().join("twine.db")).expect("the store should open");
349            Folders::restore(store).expect("folders should restore")
350        }
351
352        fn folder(&self, name: &str) -> PathBuf {
353            let path = self.folders.path().join(name);
354            fs::create_dir_all(&path).expect("the folder should be created");
355            path
356        }
357    }
358
359    fn present(path: &Path) -> RecentFolder {
360        RecentFolder {
361            path: path.to_owned(),
362            is_missing: false,
363        }
364    }
365
366    fn missing(path: &Path) -> RecentFolder {
367        RecentFolder {
368            path: path.to_owned(),
369            is_missing: true,
370        }
371    }
372
373    fn unavailable(path: &Path, reason: UnavailableReason) -> UnavailableFolder {
374        UnavailableFolder {
375            path: path.to_owned(),
376            reason,
377        }
378    }
379
380    #[test]
381    fn the_first_launch_has_no_open_or_recent_folders() {
382        let fixture = Fixture::new();
383        assert_eq!(*fixture.launch().state(), FolderState::default());
384    }
385
386    #[test]
387    fn relaunching_reopens_the_last_open_folder() {
388        let fixture = Fixture::new();
389        let first = fixture.folder("first");
390        let second = fixture.folder("second");
391        let mut folders = fixture.launch();
392        folders.open(&first).expect("first should open");
393        folders.open(&second).expect("second should open");
394        drop(folders);
395
396        assert_eq!(
397            *fixture.launch().state(),
398            FolderState {
399                current_branch: None,
400                open_folder: Some(second.clone()),
401                recent_folders: vec![present(&second), present(&first)],
402                unavailable_folder: None,
403            }
404        );
405    }
406
407    #[test]
408    fn closing_the_folder_shows_the_start_page_after_relaunch() {
409        let fixture = Fixture::new();
410        let folder = fixture.folder("project");
411        let mut folders = fixture.launch();
412        folders.open(&folder).expect("the folder should open");
413        folders.close().expect("the folder should close");
414        assert_eq!(folders.state().open_folder, None);
415        drop(folders);
416
417        assert_eq!(
418            *fixture.launch().state(),
419            FolderState {
420                current_branch: None,
421                open_folder: None,
422                recent_folders: vec![present(&folder)],
423                unavailable_folder: None,
424            }
425        );
426    }
427
428    #[test]
429    fn a_deleted_last_folder_falls_back_to_the_start_page_until_it_returns() {
430        let fixture = Fixture::new();
431        let folder = fixture.folder("project");
432        let mut folders = fixture.launch();
433        folders.open(&folder).expect("the folder should open");
434        drop(folders);
435        fs::remove_dir(&folder).expect("the folder should be deleted");
436
437        assert_eq!(
438            *fixture.launch().state(),
439            FolderState {
440                current_branch: None,
441                open_folder: None,
442                recent_folders: vec![missing(&folder)],
443                unavailable_folder: Some(unavailable(&folder, UnavailableReason::Missing)),
444            }
445        );
446
447        fs::create_dir(&folder).expect("the folder should be recreated");
448        assert_eq!(
449            *fixture.launch().state(),
450            FolderState {
451                current_branch: None,
452                open_folder: Some(folder.clone()),
453                recent_folders: vec![present(&folder)],
454                unavailable_folder: None,
455            }
456        );
457    }
458
459    #[test]
460    fn an_unreadable_folder_is_inaccessible_rather_than_missing() {
461        /// Restores the parent's permissions, even if an assertion fails, so the fixture can be
462        /// deleted.
463        struct Unlock<'a>(&'a Path);
464
465        impl Drop for Unlock<'_> {
466            fn drop(&mut self) {
467                let _ = fs::set_permissions(self.0, fs::Permissions::from_mode(0o755));
468            }
469        }
470
471        let fixture = Fixture::new();
472        let folder = fixture.folder("parent/project");
473        let parent = folder.parent().expect("the folder should have a parent");
474        let mut folders = fixture.launch();
475        folders.open(&folder).expect("the folder should open");
476        drop(folders);
477
478        fs::set_permissions(parent, fs::Permissions::from_mode(0o000))
479            .expect("the parent's permissions should change");
480        let _unlock = Unlock(parent);
481        if fs::metadata(&folder).is_ok() {
482            // Running as root, which ignores permissions.
483            return;
484        }
485
486        let mut folders = fixture.launch();
487        assert_eq!(
488            *folders.state(),
489            FolderState {
490                current_branch: None,
491                open_folder: None,
492                recent_folders: vec![present(&folder)],
493                unavailable_folder: Some(unavailable(&folder, UnavailableReason::Inaccessible)),
494            }
495        );
496        assert!(matches!(
497            folders.open(&folder),
498            Err(FolderError::Inaccessible)
499        ));
500        assert_eq!(folders.state().recent_folders, [present(&folder)]);
501    }
502
503    #[test]
504    fn opening_a_missing_recent_folder_marks_it_and_can_remove_it() {
505        let fixture = Fixture::new();
506        let kept = fixture.folder("kept");
507        let deleted = fixture.folder("deleted");
508        let mut folders = fixture.launch();
509        folders.open(&deleted).expect("the folder should open");
510        folders.open(&kept).expect("the folder should open");
511        folders.close().expect("the folder should close");
512        fs::remove_dir(&deleted).expect("the folder should be deleted");
513
514        assert!(matches!(folders.open(&deleted), Err(FolderError::Missing)));
515        assert_eq!(
516            *folders.state(),
517            FolderState {
518                current_branch: None,
519                open_folder: None,
520                recent_folders: vec![present(&kept), missing(&deleted)],
521                unavailable_folder: Some(unavailable(&deleted, UnavailableReason::Missing)),
522            }
523        );
524
525        folders
526            .remove_recent(&deleted)
527            .expect("the missing folder should be removed");
528        assert_eq!(
529            *folders.state(),
530            FolderState {
531                current_branch: None,
532                open_folder: None,
533                recent_folders: vec![present(&kept)],
534                unavailable_folder: None,
535            }
536        );
537        drop(folders);
538        assert_eq!(fixture.launch().state().recent_folders, [present(&kept)]);
539    }
540
541    #[test]
542    fn reopening_moves_a_folder_to_the_front_and_the_list_is_capped() {
543        let fixture = Fixture::new();
544        let paths: Vec<_> = (0..12)
545            .map(|index| fixture.folder(&format!("folder-{index}")))
546            .collect();
547        let mut folders = fixture.launch();
548        for path in &paths {
549            folders.open(path).expect("the folder should open");
550        }
551        folders.open(&paths[5]).expect("the folder should reopen");
552
553        let recent_paths: Vec<_> = folders
554            .state()
555            .recent_folders
556            .iter()
557            .map(|folder| folder.path.clone())
558            .collect();
559        let mut expected = vec![paths[5].clone()];
560        expected.extend(
561            paths[2..12]
562                .iter()
563                .rev()
564                .filter(|path| **path != paths[5])
565                .cloned(),
566        );
567        assert_eq!(recent_paths, expected);
568        assert_eq!(recent_paths.len(), 10);
569    }
570
571    #[test]
572    fn paths_are_normalized_and_invalid_paths_are_rejected() {
573        let fixture = Fixture::new();
574        let folder = fixture.folder("project");
575        let mut folders = fixture.launch();
576        let untidy = format!("{}//./", folder.display());
577        folders
578            .open(Path::new(&untidy))
579            .expect("an untidy path should open");
580        assert_eq!(
581            folders.state().open_folder.as_deref(),
582            Some(folder.as_path())
583        );
584        assert_eq!(folders.state().recent_folders, [present(&folder)]);
585
586        for invalid in ["relative/path", "/tmp/../tmp"] {
587            assert!(matches!(
588                folders.open(Path::new(invalid)),
589                Err(FolderError::InvalidPath)
590            ));
591            assert!(matches!(
592                folders.remove_recent(Path::new(invalid)),
593                Err(FolderError::InvalidPath)
594            ));
595        }
596        assert_eq!(
597            folders.state().open_folder.as_deref(),
598            Some(folder.as_path())
599        );
600    }
601
602    #[test]
603    fn a_file_is_not_a_folder() {
604        let fixture = Fixture::new();
605        let file = fixture.folders.path().join("file.txt");
606        fs::write(&file, "text").expect("the file should be written");
607        let mut folders = fixture.launch();
608
609        assert!(matches!(folders.open(&file), Err(FolderError::Missing)));
610        assert_eq!(
611            folders.state().unavailable_folder,
612            Some(unavailable(&file, UnavailableReason::Missing))
613        );
614        assert_eq!(folders.state().recent_folders, []);
615    }
616}