collective

Browse / github.com/storytold/lightcraft / patch

Hidden entries: folder walks read no extra metadata on macOS; auto-import skips hidden files (follow-up to #3045)

Hidden entries: folder walks read no extra metadata on macOS; auto-import skips hidden files (follow-up to #3045) Two follow-ups to #3045 (thanks @dotCooCoo for the shared hidden-entry helper this builds on). ### 1. Folder walks read no extra metadata per file on macOS `walk::is_hidden_entry` called `DirEntry::metadata()` on every entry to read the hidden attribute. On Windows that costs nothing, because the attributes come with the directory listing. On macOS it is an lstat per entry, which `walk.rs`'s own module doc says a walk avoids. On a NAS that is one more round trip for every file during import, Include Subfolders, Find Missing and the other walks. Now: - **Dot names** are hidden on every platf

Status
open
Score
0.00
Adopters
0 other operators
Author
THEtheChad (imported from pull request #3185, open)
Base
07bc2008d9457603bb77f6b3de33a901db75a8c4
Size
+125 −22 in 3 files
Review
pending
Shared
2026-10-28
Id
ac3f7dc0a5dd5056a31de990

How it went

No other operator has reported using it yet.

Diff

raw · crates/engine/src/cmd/library/import.rs crates/engine/src/tests/import.rs crates/engine/src/walk.rs

diff --git a/crates/engine/src/cmd/library/import.rs b/crates/engine/src/cmd/library/import.rs--- a/crates/engine/src/cmd/library/import.rs+++ b/crates/engine/src/cmd/library/import.rs@@ -7,10 +7,10 @@ pub fn list_auto_import_folder(folder: &str) -> std::result::Result<Vec<(String,     let rd = std::fs::read_dir(folder).map_err(|e| format!("{folder}: {e}"))?;     Ok(rd         .flatten()-        .filter(|e| !e.file_name().to_string_lossy().starts_with('.'))         .filter_map(|e| {-            let m = std::fs::metadata(e.path()).ok()?;-            m.is_file().then(|| (e.path().to_string_lossy().to_string(), m.len()))+            let path = e.path();+            let m = std::fs::metadata(&path).ok()?;+            (m.is_file() && !crate::walk::is_hidden_with(&path, &m)).then(|| (path.to_string_lossy().to_string(), m.len()))         })         .collect()) }diff --git a/crates/engine/src/tests/import.rs b/crates/engine/src/tests/import.rs--- a/crates/engine/src/tests/import.rs+++ b/crates/engine/src/tests/import.rs@@ -492,6 +492,31 @@ fn copy_with_a_folder_template() {     } } +/// Auto import lists the watched folder's visible files: a dot name or (on Windows and macOS) the+/// hidden attribute leaves a file out, as Local and import do (#3045), from the metadata it reads+/// anyway.+#[test]+fn auto_import_lists_no_hidden_files() {+    let dir = temp_dir("watch-hidden");+    for name in ["shown.jpg", ".dot.jpg", "hidden.jpg"] {+        std::fs::write(dir.join(name), b"x").unwrap();+    }+    std::fs::create_dir_all(dir.join("folder")).unwrap();+    #[cfg(windows)]+    assert!(std::process::Command::new("attrib").arg("+h").arg(dir.join("hidden.jpg")).status().unwrap().success());+    #[cfg(target_os = "macos")]+    assert!(std::process::Command::new("chflags").arg("hidden").arg(dir.join("hidden.jpg")).status().unwrap().success());+    let mut listed: Vec<String> = crate::cmd::library::list_auto_import_folder(&dir.to_string_lossy()).unwrap().into_iter().map(|(p, _)| p).collect();+    listed.sort();+    let mut expected = vec![dir.join("shown.jpg").to_string_lossy().to_string()];+    if !cfg!(any(windows, target_os = "macos")) {+        expected.push(dir.join("hidden.jpg").to_string_lossy().to_string());+    }+    expected.sort();+    assert_eq!(listed, expected);+    let _ = std::fs::remove_dir_all(&dir);+}+ /// Auto import: files in the watched folder are added once their size held between two scans, /// into the named album; non-photos are tried once; the selection stays put. #[test]diff --git a/crates/engine/src/walk.rs b/crates/engine/src/walk.rs--- a/crates/engine/src/walk.rs+++ b/crates/engine/src/walk.rs@@ -3,7 +3,8 @@ //! [`files_in`], which stops at a depth and a number of directory entries instead of walking a //! whole drive, follows a symbolic link or junction to a folder at most once (no loops), and //! decides what is a folder from the directory listing itself (no extra open per entry, which-//! is what makes a large walk slow on Windows).+//! is what makes a large walk slow on Windows; nor, on macOS, an lstat per file to see whether it+//! is hidden). //! //! It never chooses where to walk: callers pass a folder the user named. Nothing in the app walks //! at startup or on the UI thread.@@ -43,36 +44,86 @@ pub struct Walked {     pub entries: usize, } -/// Whether the entry has a dot-prefixed name or a Windows or macOS hidden attribute.+/// Whether a listed entry is hidden: a dot-prefixed name, or the Windows or macOS hidden+/// attribute. Windows' comes with the listing; macOS' takes an lstat, so it is read for folders+/// only (a hidden folder like `~/Library` hides a whole tree; a file hidden other than by a dot+/// name is a rare system file). pub fn is_hidden_entry(entry: &std::fs::DirEntry) -> bool {-    hidden_with_metadata(&entry.path(), || entry.metadata())+    hidden_entry(entry, entry.file_type().is_ok_and(|t| t.is_dir()))+}++/// [`is_hidden_entry`] for an entry the caller already knows to be a folder or not.+fn hidden_entry(entry: &std::fs::DirEntry, is_folder: bool) -> bool {+    dot_name(Some(&entry.file_name())) || HIDDEN.is_some_and(|a| a.read_for(is_folder) && entry.metadata().is_ok_and(|m| a.hides(attribute_word(&m)))) }  /// 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))+    dot_name(path.file_name()) || HIDDEN.is_some_and(|a| std::fs::symlink_metadata(path).is_ok_and(|m| a.hides(attribute_word(&m))))+}++/// Whether `path`, whose `metadata` the caller has already read, has a dot-prefixed name or a+/// Windows or macOS hidden attribute (file or folder: the attribute is read already).+pub fn is_hidden_with(path: &Path, metadata: &std::fs::Metadata) -> bool {+    dot_name(path.file_name()) || HIDDEN.is_some_and(|a| a.hides(attribute_word(metadata)))+}++fn dot_name(name: Option<&std::ffi::OsStr>) -> bool {+    name.is_some_and(|n| n.to_string_lossy().starts_with('.')) } -fn hidden_with_metadata(path: &Path, metadata: impl FnOnce() -> std::io::Result<std::fs::Metadata>) -> bool {-    if path.file_name().is_some_and(|n| n.to_string_lossy().starts_with('.')) {-        return true;+/// A platform's hidden attribute: the bit of an entry's attribute word that hides it.+#[derive(Clone, Copy, Debug, PartialEq, Eq)]+struct HiddenAttribute {+    bit: u32,+    /// Whether a folder's listing already holds every entry's attribute word (Windows). Where it+    /// does not (macOS: one lstat per entry, a round trip each on a network share), a listed+    /// entry's attribute is read only for folders: a hidden folder (`~/Library`) hides a whole+    /// tree, while a file hidden other than by a dot name is a rare system file, not a photo.+    listed: bool,+}++impl HiddenAttribute {+    fn hides(self, word: u32) -> bool {+        word & self.bit != 0+    }++    /// Whether a listed entry's attribute word is read: a folder's always, a file's only when+    /// the listing holds it.+    fn read_for(self, is_folder: bool) -> bool {+        is_folder || self.listed     }+}++/// `FILE_ATTRIBUTE_HIDDEN` in the file attributes, which come with the listing.+const WINDOWS_HIDDEN: HiddenAttribute = HiddenAttribute { bit: 0x2, listed: true };+/// `UF_HIDDEN` (Finder's hidden flag) in `st_flags`, which takes an lstat.+const MACOS_HIDDEN: HiddenAttribute = HiddenAttribute { bit: 0x8000, listed: false };+/// This platform's hidden attribute (none elsewhere: only dot names are hidden).+const HIDDEN: Option<HiddenAttribute> = if cfg!(windows) {+    Some(WINDOWS_HIDDEN)+} else if cfg!(target_os = "macos") {+    Some(MACOS_HIDDEN)+} else {+    None+};++/// The word [`HIDDEN`]'s bit is in.+fn attribute_word(m: &std::fs::Metadata) -> u32 {     #[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)+        m.file_attributes()     }     #[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)+        m.st_flags()     }     #[cfg(not(any(windows, target_os = "macos")))]     {-        let _ = metadata;-        false+        let _ = m;+        0     } } @@ -91,7 +142,7 @@ pub enum EntryKind { /// ends the walk there (a scan cancelled; [`Walked::truncated`] is left alone: who stopped it knows). pub type NoteFolder<'a> = &'a mut dyn FnMut(&Path, Vec<(std::ffi::OsString, EntryKind)>, bool) -> bool; -/// The files below folder `root` that `keep` accepts. Hidden entries (names starting with `.`)+/// The files below folder `root` that `keep` accepts. Hidden entries (see [`is_hidden_entry`]) /// are left out, the folder `skip` (e.g. the library's own) is never entered, and the walk stops /// at `limits` (see [`Walked::truncated`]). Unreadable folders are skipped. pub fn files_in(root: &Path, skip: Option<&Path>, limits: Limits, keep: impl FnMut(&Path) -> bool) -> Walked {@@ -147,13 +198,11 @@ fn walk(root: &Path, skip: Option<&Path>, limits: Limits, mut keep: impl FnMut(&             }             out.entries = out.entries.saturating_add(1);             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 = is_hidden_entry(&e);+            let ft = e.file_type();+            let is_hidden = hidden_entry(&e, ft.as_ref().is_ok_and(|t| t.is_dir()));             if is_hidden && listed.is_none() {                 continue;             }-            let ft = e.file_type();             if let Some(listed) = listed.as_mut() {                 let kind = match &ft {                     Ok(t) if t.is_symlink() => EntryKind::Other,@@ -228,13 +277,42 @@ mod tests {         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.push(root.join("Hidden/inside.jpg"));+        }+        // (macOS reads no file's flags in a walk: that would be an lstat per file)+        if !cfg!(windows) {+            expected.push(root.join("hidden.jpg"));         }         expected.sort();         assert_eq!(found, expected);         std::fs::remove_dir_all(root).unwrap();     } +    /// The hidden attributes' bits and when a listed entry's is read, checked on every platform+    /// (the attributes themselves can only be set on Windows and macOS).+    #[test]+    fn hidden_attributes_are_each_platforms_bit_and_macos_reads_only_folders() {+        const FILE_ATTRIBUTE_ARCHIVE: u32 = 0x20;+        const UF_IMMUTABLE: u32 = 0x2;+        assert!(WINDOWS_HIDDEN.hides(0x2) && WINDOWS_HIDDEN.hides(0x2 | FILE_ATTRIBUTE_ARCHIVE));+        assert!(!WINDOWS_HIDDEN.hides(FILE_ATTRIBUTE_ARCHIVE) && !WINDOWS_HIDDEN.hides(0x8000));+        assert!(MACOS_HIDDEN.hides(0x8000) && MACOS_HIDDEN.hides(0x8000 | UF_IMMUTABLE));+        assert!(!MACOS_HIDDEN.hides(UF_IMMUTABLE) && !MACOS_HIDDEN.hides(0));+        // Windows' listing holds every entry's attributes; macOS's would take an lstat per file+        assert!(WINDOWS_HIDDEN.read_for(true) && WINDOWS_HIDDEN.read_for(false));+        assert!(MACOS_HIDDEN.read_for(true) && !MACOS_HIDDEN.read_for(false));+        assert_eq!(+            HIDDEN,+            if cfg!(windows) {+                Some(WINDOWS_HIDDEN)+            } else if cfg!(target_os = "macos") {+                Some(MACOS_HIDDEN)+            } else {+                None+            }+        );+    }+     /// 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