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(>k::Image::from_icon_name("image-x-generic-symbolic"));
129 row.add_suffix(>k::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(>k::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(>k::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}