diff --git a/apps/lightcraft-web/src/web.rs b/apps/lightcraft-web/src/web.rs index 6e9e85596..bd5215e4d 100644 --- a/apps/lightcraft-web/src/web.rs +++ b/apps/lightcraft-web/src/web.rs @@ -175,6 +175,7 @@ fn services(originals: Originals, backend: Option, files: Files, frozen let (backup_backend, backup_files) = (backend.clone(), files); let restore_backend = backend.clone(); Services { + local_places: None, picker: None, pick_display_profile: None, screen_profile: None, diff --git a/apps/lightcraft/src/session.rs b/apps/lightcraft/src/session.rs index ce3ab95b7..b886b472f 100644 --- a/apps/lightcraft/src/session.rs +++ b/apps/lightcraft/src/session.rs @@ -11,6 +11,7 @@ pub(crate) fn services(cc: &eframe::CreationContext<'_>, log_file: Option<&std:: #[cfg(any(target_os = "linux", target_os = "freebsd"))] let _ = DIALOG_PARENT.set(rfd::FileDialog::new().set_parent(cc)); Services { + local_places: None, // Commands' file dialogs run on their own thread (#191): a dialog on the UI thread // stopped the window answering the compositor, which then reported the app as hung. picker: Some(Box::new(move |req: lightcraft_ui_egui::pick::PickRequest| { diff --git a/crates/engine/src/cmd/browse.rs b/crates/engine/src/cmd/browse.rs index 6f3f88963..b24246592 100644 --- a/crates/engine/src/cmd/browse.rs +++ b/crates/engine/src/cmd/browse.rs @@ -35,8 +35,9 @@ fn browse(s: &mut Session, p: &Value) -> Result { let mut v: Vec = std::fs::read_dir(&dir) .map_err(|e| bad(C, format!("{path}: {e}")))? .flatten() + .filter(|e| !crate::walk::is_hidden_entry(e)) .map(|e| e.path()) - .filter(|f| f.is_file() && is_supported(f) && !f.file_name().is_some_and(|n| n.to_string_lossy().starts_with('.'))) + .filter(|f| f.is_file() && is_supported(f)) .map(|f| f.to_string_lossy().to_string()) .collect(); v.sort(); diff --git a/crates/engine/src/walk.rs b/crates/engine/src/walk.rs index 12dbadbd6..98290130e 100644 --- a/crates/engine/src/walk.rs +++ b/crates/engine/src/walk.rs @@ -43,8 +43,37 @@ pub struct Walked { pub entries: usize, } -fn hidden(p: &Path) -> bool { - p.file_name().is_some_and(|n| n.to_string_lossy().starts_with('.')) +/// Whether the entry has a dot-prefixed name or a Windows or macOS hidden attribute. +pub fn is_hidden_entry(entry: &std::fs::DirEntry) -> bool { + hidden_with_metadata(&entry.path(), || entry.metadata()) +} + +/// Whether the path has a dot-prefixed name or a Windows or macOS hidden attribute. +pub fn is_hidden_path(path: &Path) -> bool { + hidden_with_metadata(path, || std::fs::symlink_metadata(path)) +} + +fn hidden_with_metadata(path: &Path, metadata: impl FnOnce() -> std::io::Result) -> bool { + if path.file_name().is_some_and(|n| n.to_string_lossy().starts_with('.')) { + return true; + } + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + const FILE_ATTRIBUTE_HIDDEN: u32 = 0x2; + metadata().is_ok_and(|m| m.file_attributes() & FILE_ATTRIBUTE_HIDDEN != 0) + } + #[cfg(target_os = "macos")] + { + use std::os::darwin::fs::MetadataExt; + const UF_HIDDEN: u32 = 0x0000_8000; + metadata().is_ok_and(|m| m.st_flags() & UF_HIDDEN != 0) + } + #[cfg(not(any(windows, target_os = "macos")))] + { + let _ = metadata; + false + } } /// What a folder's listing says an entry is. @@ -120,7 +149,7 @@ fn walk(root: &Path, skip: Option<&Path>, limits: Limits, mut keep: impl FnMut(& let p = e.path(); // (a hidden entry's kind is only asked for when the listing is wanted; every other // entry's is asked once) - let is_hidden = hidden(&p); + let is_hidden = is_hidden_entry(&e); if is_hidden && listed.is_none() { continue; } @@ -178,6 +207,34 @@ mod tests { d } + #[test] + fn hidden_names_and_platform_attributes_are_excluded_from_walks() { + let root = tmp("hidden-attributes"); + for folder in [".dot", "Hidden"] { + std::fs::create_dir_all(root.join(folder)).unwrap(); + std::fs::write(root.join(folder).join("inside.jpg"), b"x").unwrap(); + } + for file in ["Shown.jpg", ".dot.jpg", "hidden.jpg"] { + std::fs::write(root.join(file), b"x").unwrap(); + } + for name in ["Hidden", "hidden.jpg"] { + #[cfg(windows)] + assert!(std::process::Command::new("attrib").arg("+h").arg(root.join(name)).status().unwrap().success()); + #[cfg(target_os = "macos")] + assert!(std::process::Command::new("chflags").arg("hidden").arg(root.join(name)).status().unwrap().success()); + assert_eq!(is_hidden_path(&root.join(name)), cfg!(any(windows, target_os = "macos"))); + } + let mut found = files_in(&root, None, Limits::default(), |_| true).files; + found.sort(); + let mut expected = vec![root.join("Shown.jpg")]; + if !cfg!(any(windows, target_os = "macos")) { + expected.extend([root.join("Hidden/inside.jpg"), root.join("hidden.jpg")]); + } + expected.sort(); + assert_eq!(found, expected); + std::fs::remove_dir_all(root).unwrap(); + } + /// Given a folder with files, a folder inside it, a hidden file and a link, when it is walked /// with its listings noted, then the walk finds what it finds without them, and each folder's /// listing holds every entry by name with its kind (hidden ones too, a link as neither file nor diff --git a/crates/ui-egui/src/dialogs/import/scan.rs b/crates/ui-egui/src/dialogs/import/scan.rs index ee237fd5a..a930af00c 100644 --- a/crates/ui-egui/src/dialogs/import/scan.rs +++ b/crates/ui-egui/src/dialogs/import/scan.rs @@ -118,12 +118,9 @@ pub fn browse(app: &mut LightcraftApp, path: &str, subfolders: Option) -> let mut v: Vec = std::fs::read_dir(&root) .map(|rd| { rd.flatten() + .filter(|e| !lightcraft_engine::walk::is_hidden_entry(e)) .map(|e| e.path()) - .filter(|f| { - f.is_file() - && lightcraft_engine::import::is_supported(f) - && !f.file_name().is_some_and(|n| n.to_string_lossy().starts_with('.')) - }) + .filter(|f| f.is_file() && lightcraft_engine::import::is_supported(f)) .map(|f| f.to_string_lossy().to_string()) .collect() }) diff --git a/crates/ui-egui/src/panels/left/folders.rs b/crates/ui-egui/src/panels/left/folders.rs index b9aa33037..95915b740 100644 --- a/crates/ui-egui/src/panels/left/folders.rs +++ b/crates/ui-egui/src/panels/left/folders.rs @@ -36,70 +36,72 @@ pub(super) fn folder_tree( ui.data_mut(|d| d.insert_temp(revealed_id, target)); } } - // at the tree's own indent, as in Albums and Folders: the triangle sits in the gutter and the - // icon lines up with the rows above - let resp = row(app, ui, &format!("local:{path}"), Icon::Folder, name, None, sel, indent).on_hover_text(path); - let tr = tree_toggle(ui, &resp, indent, open_id, &mut open, format!("folderToggle:{path}")); - if !tr.clicked() - && resp.clicked() - && let Err(e) = app.run("library.browse", json!({"path": path})) - { - app.toast(ui.ctx(), e); - } - crate::widgets::context_menu(&resp, |ui| { - // the library actions of the Folders tree, when this folder (or one inside it) holds library photos - let library = app.caches.folder_tree(&app.session.catalog); - let held = library_node_for(&library, path); - if (transient || indent > 0.0) - && ui - .button(crate::i18n::tr(if transient { "Keep in Local" } else { "Add to Local" })) - .on_hover_text(crate::i18n::tr("List this folder in Local from now on")) - .clicked() + if !out_of_sight(app, ui, &format!("local:{path}"), name, None, indent, false) { + // at the tree's own indent, as in Albums and Folders: the triangle sits in the gutter and the + // icon lines up with the rows above + let resp = row(app, ui, &format!("local:{path}"), Icon::Folder, name, None, sel, indent).on_hover_text(path); + let tr = tree_toggle(ui, &resp, indent, open_id, &mut open, format!("folderToggle:{path}")); + if !tr.clicked() + && resp.clicked() + && let Err(e) = app.run("library.browse", json!({"path": path})) { - let _ = app.run("local.addRoot", json!({"path": path})); - ui.close(); + app.toast(ui.ctx(), e); } - if indent == 0.0 - && ui - .button(crate::i18n::tr("Remove from Local")) - .on_hover_text(crate::i18n::tr("Hides this shortcut only; the folder and its photos stay as they are")) - .clicked() - { - let _ = app.run("local.hide", json!({"path": path})); - ui.close(); - } - if ui.button(crate::i18n::tr("Rename Folder…")).clicked() { - app.ui.dialog = Some(crate::state::Dialog::TextPrompt { - title: crate::i18n::tr_format!("Rename “{name}”", name = name), - hint: "Folder name (renamed on disk; its photos follow)".into(), - value: name.to_string(), - command: "folder.rename".into(), - params: json!({"path": path}), - key: "name".into(), - }); - } - if app.services.pick_folder.is_some() && ui.button(crate::i18n::tr("Move Folder To…")).clicked() { - let into = app.services.pick_folder.as_mut().and_then(|f| f()); - if let Some(into) = into { - match app.run("folder.move", json!({"path": path, "into": into})) { - Ok(r) => app.toast(ui.ctx(), crate::i18n::tr_format!("Moved; {} photo(s) relinked", r["relinked"])), - Err(e) => app.toast(ui.ctx(), e), + crate::widgets::context_menu(&resp, |ui| { + // the library actions of the Folders tree, when this folder (or one inside it) holds library photos + let library = app.caches.folder_tree(&app.session.catalog); + let held = library_node_for(&library, path); + if (transient || indent > 0.0) + && ui + .button(crate::i18n::tr(if transient { "Keep in Local" } else { "Add to Local" })) + .on_hover_text(crate::i18n::tr("List this folder in Local from now on")) + .clicked() + { + let _ = app.run("local.addRoot", json!({"path": path})); + ui.close(); + } + if indent == 0.0 + && ui + .button(crate::i18n::tr("Remove from Local")) + .on_hover_text(crate::i18n::tr("Hides this shortcut only; the folder and its photos stay as they are")) + .clicked() + { + let _ = app.run("local.hide", json!({"path": path})); + ui.close(); + } + if ui.button(crate::i18n::tr("Rename Folder…")).clicked() { + app.ui.dialog = Some(crate::state::Dialog::TextPrompt { + title: crate::i18n::tr_format!("Rename “{name}”", name = name), + hint: "Folder name (renamed on disk; its photos follow)".into(), + value: name.to_string(), + command: "folder.rename".into(), + params: json!({"path": path}), + key: "name".into(), + }); + } + if app.services.pick_folder.is_some() && ui.button(crate::i18n::tr("Move Folder To…")).clicked() { + let into = app.services.pick_folder.as_mut().and_then(|f| f()); + if let Some(into) = into { + match app.run("folder.move", json!({"path": path, "into": into})) { + Ok(r) => app.toast(ui.ctx(), crate::i18n::tr_format!("Moved; {} photo(s) relinked", r["relinked"])), + Err(e) => app.toast(ui.ctx(), e), + } } } - } - if app.services.reveal.is_some() - && ui.button(crate::i18n::tr(crate::menus::reveal_label())).clicked() - && let Some(f) = app.services.reveal.as_mut() - { - let _ = f(path); - } - if let Some(held) = &held { - synchronize_item(app, ui, &held.0, &held.1); - } - }); + if app.services.reveal.is_some() + && ui.button(crate::i18n::tr(crate::menus::reveal_label())).clicked() + && let Some(f) = app.services.reveal.as_mut() + { + let _ = f(path); + } + if let Some(held) = &held { + synchronize_item(app, ui, &held.0, &held.1); + } + }); + } if open && indent < INDENT_STEP * 8.0 { - for (n, p) in subfolders(ui, path) { - folder_tree(app, ui, place, &n, &p, indent + INDENT_STEP, current, reveal, false); + for (n, p) in subfolders(ui, path).iter() { + folder_tree(app, ui, place, n, p, indent + INDENT_STEP, current, reveal, false); } } } diff --git a/crates/ui-egui/src/panels/left/local.rs b/crates/ui-egui/src/panels/left/local.rs index 0fedcb2b5..e1a62d8ea 100644 --- a/crates/ui-egui/src/panels/left/local.rs +++ b/crates/ui-egui/src/panels/left/local.rs @@ -19,8 +19,8 @@ pub(super) fn local_now(app: &mut LightcraftApp, ui: &egui::Ui) -> Option = Vec::new(); - if !home.is_empty() { + let mut builtin = app.services.local_places.clone().unwrap_or_default(); + if app.services.local_places.is_none() && !home.is_empty() { for (name, sub) in [("Pictures", "Pictures"), ("Desktop", "Desktop"), ("Downloads", "Downloads"), ("Home", "")] { // joined with the platform's separator, like the paths browsing gives back let p = if sub.is_empty() { home.clone() } else { std::path::Path::new(&home).join(sub).to_string_lossy().to_string() }; @@ -35,8 +35,19 @@ pub(super) fn local_now(app: &mut LightcraftApp, ui: &egui::Ui) -> Option