Skip to main content

mimick/library/
folders_view.rs

1use std::cell::{Cell, RefCell};
2use std::collections::{BTreeSet, HashSet};
3use std::rc::Rc;
4
5use libadwaita::prelude::*;
6
7use crate::api_client::{LibraryAsset, SortOrder};
8
9use super::LibraryWindowUi;
10use super::context_menu::show_alert_dialog;
11use super::state::{LibraryPage, LibrarySource, Location};
12use super::tree_page::{
13    TreePage, build_tree_page, leaf_name, parent_path, render_breadcrumbs, show_empty_state,
14};
15
16pub(super) struct FoldersView {
17    pub page: TreePage,
18    asset_folders: RefCell<HashSet<String>>,
19    folders: RefCell<BTreeSet<String>>,
20    path: RefCell<Option<String>>,
21    opened: Cell<bool>,
22}
23
24pub(super) fn build_folders_view() -> FoldersView {
25    FoldersView {
26        page: build_tree_page("folder-symbolic"),
27        asset_folders: RefCell::default(),
28        folders: RefCell::default(),
29        path: RefCell::default(),
30        opened: Cell::new(false),
31    }
32}
33
34pub(super) fn show(ui: &Rc<LibraryWindowUi>, path: Option<String>) {
35    *ui.folders.path.borrow_mut() = path;
36    ui.content_stack.set_visible_child_name("folders");
37    render(ui);
38    reload(ui.clone());
39}
40
41fn reload(ui: Rc<LibraryWindowUi>) {
42    glib::MainContext::default().spawn_local(async move {
43        match ui.ctx.api_client.fetch_folder_paths().await {
44            Ok(paths) => {
45                let view = &ui.folders;
46                *view.folders.borrow_mut() = all_folders(&paths);
47                *view.asset_folders.borrow_mut() = paths.into_iter().collect();
48                if !view.opened.replace(true) && view.path.borrow().is_none() {
49                    let start = start_path(&view.folders.borrow(), &view.asset_folders.borrow());
50                    *view.path.borrow_mut() = start.clone();
51                    let mut state = ui.ctx.library_state.lock();
52                    if state.location == Location::Page(LibraryPage::Folders { path: None }) {
53                        state.location = Location::Page(LibraryPage::Folders { path: start });
54                    }
55                }
56                render(&ui);
57            }
58            Err(err) => show_alert_dialog(
59                &ui,
60                "Could Not Load Folders",
61                &format!("{err}\n\nYour API key needs the 'folder.read' permission."),
62            ),
63        }
64    });
65}
66
67fn open_path(ui: &Rc<LibraryWindowUi>, path: Option<String>) {
68    super::navigation::navigate(ui, Location::Page(LibraryPage::Folders { path }));
69}
70
71fn open_photos(ui: &Rc<LibraryWindowUi>, path: &str) {
72    super::navigation::navigate_source(
73        ui,
74        LibrarySource::Folder {
75            path: path.to_string(),
76        },
77    );
78}
79
80fn render(ui: &Rc<LibraryWindowUi>) {
81    let view = &ui.folders;
82    let folders = view.folders.borrow().clone();
83    let path = view
84        .path
85        .borrow()
86        .clone()
87        .filter(|path| folders.contains(path));
88    *view.path.borrow_mut() = path.clone();
89    let ui_crumb = ui.clone();
90    render_breadcrumbs(
91        &view.page,
92        "Folders",
93        path.as_deref(),
94        Rc::new(move |target| open_path(&ui_crumb, target)),
95    );
96
97    view.page.list.remove_all();
98    if let Some(path) = path.as_deref()
99        && view.asset_folders.borrow().contains(path)
100    {
101        view.page.list.append(&photos_row(ui, path));
102    }
103    let children = subfolders(&folders, path.as_deref());
104    for child in &children {
105        view.page.list.append(&folder_row(
106            ui,
107            child,
108            subfolders(&folders, Some(child)).len(),
109        ));
110    }
111    let empty = (
112        "No Folders",
113        "Folders appear once Immich has photos from them.",
114    );
115    show_empty_state(
116        &view.page,
117        (view.page.list.first_child().is_none()).then_some(empty),
118    );
119}
120
121fn photos_row(ui: &Rc<LibraryWindowUi>, path: &str) -> libadwaita::ActionRow {
122    let row = libadwaita::ActionRow::builder()
123        .title(format!("Photos in {}", leaf_name(path)))
124        .use_markup(false)
125        .title_lines(1)
126        .activatable(true)
127        .build();
128    row.add_prefix(&gtk::Image::from_icon_name("image-x-generic-symbolic"));
129    row.add_suffix(&gtk::Image::from_icon_name("go-next-symbolic"));
130    let (ui, path) = (ui.clone(), path.to_string());
131    row.connect_activated(move |_| open_photos(&ui, &path));
132    row
133}
134
135fn folder_row(ui: &Rc<LibraryWindowUi>, path: &str, children: usize) -> libadwaita::ActionRow {
136    let row = libadwaita::ActionRow::builder()
137        .title(leaf_name(path))
138        .use_markup(false)
139        .title_lines(1)
140        .activatable(true)
141        .build();
142    row.add_prefix(&gtk::Image::from_icon_name("folder-symbolic"));
143    let (ui, path) = (ui.clone(), path.to_string());
144    if children == 0 {
145        row.set_tooltip_text(Some("Show photos"));
146        row.connect_activated(move |_| open_photos(&ui, &path));
147    } else {
148        row.set_subtitle(&match children {
149            1 => "1 folder".to_string(),
150            n => format!("{n} folders"),
151        });
152        row.add_suffix(&gtk::Image::from_icon_name("go-next-symbolic"));
153        row.connect_activated(move |_| open_path(&ui, Some(path.clone())));
154    }
155    row
156}
157
158pub(super) fn sort_by_date(assets: &mut [LibraryAsset], order: Option<SortOrder>) {
159    match order {
160        Some(SortOrder::Asc) => assets.sort_by(|a, b| a.created_at.cmp(&b.created_at)),
161        Some(SortOrder::Desc) => assets.sort_by(|a, b| b.created_at.cmp(&a.created_at)),
162        None => {}
163    }
164}
165
166fn all_folders(paths: &[String]) -> BTreeSet<String> {
167    let mut folders = BTreeSet::new();
168    for path in paths {
169        let mut current = Some(path.as_str());
170        while let Some(folder) = current {
171            if !folders.insert(folder.to_string()) {
172                break;
173            }
174            current = parent_path(folder);
175        }
176    }
177    folders
178}
179
180fn subfolders<'a>(folders: &'a BTreeSet<String>, parent: Option<&str>) -> Vec<&'a str> {
181    folders
182        .iter()
183        .map(String::as_str)
184        .filter(|folder| parent_path(folder) == parent)
185        .collect()
186}
187
188fn start_path(folders: &BTreeSet<String>, asset_folders: &HashSet<String>) -> Option<String> {
189    let mut current: Option<&str> = None;
190    loop {
191        if current.is_some_and(|path| asset_folders.contains(path)) {
192            break;
193        }
194        match subfolders(folders, current).as_slice() {
195            [only] => current = Some(only),
196            _ => break,
197        }
198    }
199    current.map(str::to_string)
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205
206    fn paths(values: &[&str]) -> Vec<String> {
207        values.iter().map(|v| v.to_string()).collect()
208    }
209
210    #[test]
211    fn all_folders_adds_every_ancestor() {
212        let folders = all_folders(&paths(&["/lib/admin/2024/01", "/lib/admin/2023"]));
213        let listed: Vec<&str> = folders.iter().map(String::as_str).collect();
214        assert_eq!(
215            listed,
216            [
217                "/lib",
218                "/lib/admin",
219                "/lib/admin/2023",
220                "/lib/admin/2024",
221                "/lib/admin/2024/01"
222            ]
223        );
224        assert_eq!(subfolders(&folders, None), ["/lib"]);
225        assert_eq!(
226            subfolders(&folders, Some("/lib/admin")),
227            ["/lib/admin/2023", "/lib/admin/2024"]
228        );
229    }
230
231    #[test]
232    fn start_path_skips_single_child_chains() {
233        let listed = paths(&["/lib/admin/2024/01", "/lib/admin/2023"]);
234        let assets: HashSet<String> = listed.iter().cloned().collect();
235        assert_eq!(
236            start_path(&all_folders(&listed), &assets).as_deref(),
237            Some("/lib/admin")
238        );
239    }
240
241    #[test]
242    fn start_path_stops_at_a_folder_with_photos() {
243        let listed = paths(&["/lib", "/lib/admin/2024"]);
244        let assets: HashSet<String> = listed.iter().cloned().collect();
245        assert_eq!(
246            start_path(&all_folders(&listed), &assets).as_deref(),
247            Some("/lib")
248        );
249    }
250
251    #[test]
252    fn sort_by_date_follows_the_order() {
253        let asset = |id: &str, at: &str| -> LibraryAsset {
254            serde_json::from_value(serde_json::json!({
255                "id": id,
256                "originalFileName": "a.jpg",
257                "originalMimeType": "image/jpeg",
258                "fileCreatedAt": at,
259                "type": "IMAGE"
260            }))
261            .unwrap()
262        };
263        let mut assets = vec![
264            asset("old", "2020-01-01T00:00:00.000Z"),
265            asset("new", "2024-01-01T00:00:00.000Z"),
266        ];
267        sort_by_date(&mut assets, Some(SortOrder::Desc));
268        assert_eq!(assets[0].id, "new");
269        sort_by_date(&mut assets, Some(SortOrder::Asc));
270        assert_eq!(assets[0].id, "old");
271    }
272}