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
11const MAX_RECENT_FOLDERS: i64 = 10;
13
14#[derive(Clone, Debug, Default, Eq, PartialEq)]
16pub struct FolderState {
17 pub current_branch: Option<String>,
19 pub open_folder: Option<PathBuf>,
21 pub recent_folders: Vec<RecentFolder>,
23 pub unavailable_folder: Option<UnavailableFolder>,
26}
27
28#[derive(Clone, Debug, Eq, PartialEq)]
29pub struct RecentFolder {
30 pub path: PathBuf,
31 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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
44pub enum UnavailableReason {
45 Missing,
47 Inaccessible,
49}
50
51#[derive(Debug)]
53pub(crate) struct Folders {
54 store: Store,
55 state: FolderState,
56 window_mode: bool,
57 data_version: i64,
58 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 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 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 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 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 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 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 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
264fn 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
275fn 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
291fn 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 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 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 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}