Merge pull request #3045 from dotCooCoo/fix/local-skips-windows-hidden-folders

Local: skip offscreen folder widgets and show browsed hidden paths
This commit is contained in:
aiongg
2026-10-27 13:47:10 -04:00
committed by GitHub
9 changed files with 258 additions and 83 deletions
+1
View File
@@ -175,6 +175,7 @@ fn services(originals: Originals, backend: Option<Backend>, 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,
+1
View File
@@ -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| {
+2 -1
View File
@@ -35,8 +35,9 @@ fn browse(s: &mut Session, p: &Value) -> Result<Value> {
let mut v: Vec<String> = 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();
+60 -3
View File
@@ -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<std::fs::Metadata>) -> 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
+2 -5
View File
@@ -118,12 +118,9 @@ pub fn browse(app: &mut LightcraftApp, path: &str, subfolders: Option<bool>) ->
let mut v: Vec<String> = 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()
})
+61 -59
View File
@@ -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);
}
}
}
+128 -14
View File
@@ -19,8 +19,8 @@ pub(super) fn local_now(app: &mut LightcraftApp, ui: &egui::Ui) -> Option<LocalN
return None;
}
let home = std::env::var("HOME").or_else(|_| std::env::var("USERPROFILE")).unwrap_or_default();
let mut builtin: Vec<(String, String)> = 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<LocalN
// a hidden built-in place keeps its name (Home, not the user's folder name)
let name_of = |path: &str| builtin.iter().find(|(_, p)| same_folder(p, path)).map_or_else(|| folder_label(path), |(n, _)| n.clone());
let hidden = app.ui.hidden_locations.iter().map(|h| (name_of(h), h.clone())).collect();
let places = local_places(builtin, &app.ui.local_roots, current.as_deref(), app.ui.local_browse_root.as_deref(), &app.ui.hidden_locations);
if current.is_some() {
let unlisted = current.as_deref().and_then(|path| fs_cached(ui, "unlisted-ancestors", path, 2.0, unlisted_ancestors));
let mut places = local_places_with_unlisted(
builtin,
&app.ui.local_roots,
current.as_deref(),
app.ui.local_browse_root.as_deref(),
&app.ui.hidden_locations,
unlisted.as_deref().unwrap_or(&[]),
);
if current.is_some() && unlisted.is_none() {
places.owner = None;
}
if current.is_some() && unlisted.is_some() {
app.ui.local_browse_root = places.browse_root.clone();
}
Some(LocalNow { places, current, hidden })
@@ -131,15 +142,30 @@ fn folder_label(path: &str) -> String {
/// root stays; its siblings stay reachable), never as a root of its own. Each folder is listed
/// once however its path is spelled (the first spelling wins), and hidden ones are left out —
/// they stay reachable through breadcrumbs and Browse Folder….
pub(crate) fn local_places(
#[cfg(test)]
fn local_places(
builtin: Vec<(String, String)>,
saved: &[String],
browsing: Option<&str>,
browse_root: Option<&str>,
hidden: &[String],
) -> LocalPlaces {
local_places_with_unlisted(builtin, saved, browsing, browse_root, hidden, &[])
}
fn local_places_with_unlisted(
builtin: Vec<(String, String)>,
saved: &[String],
browsing: Option<&str>,
browse_root: Option<&str>,
hidden: &[String],
unlisted: &[String],
) -> LocalPlaces {
use lightcraft_catalog::query::{folder_key, folder_within};
let is_hidden = |p: &str| hidden.iter().any(|h| same_folder(h, p));
let listed_below = |current: &str, root: &str| {
folder_within(current, root) && !unlisted.iter().any(|h| !same_folder(h, root) && folder_within(current, h) && folder_within(h, root))
};
let mut places = builtin;
for path in saved {
if !places.iter().any(|(_, p)| same_folder(p, path)) {
@@ -149,9 +175,9 @@ pub(crate) fn local_places(
places.retain(|(_, p)| !is_hidden(p));
let mut out = LocalPlaces::default();
if let Some(c) = browsing
&& !places.iter().any(|(_, p)| folder_within(c, p))
&& !places.iter().any(|(_, p)| listed_below(c, p))
{
let root = browse_root.filter(|r| folder_within(c, r)).unwrap_or(c);
let root = browse_root.filter(|r| listed_below(c, r)).unwrap_or(c);
if !is_hidden(root) {
places.push((folder_label(root), root.to_string()));
out.browse_root = Some(root.to_string());
@@ -164,7 +190,7 @@ pub(crate) fn local_places(
out.owner = places
.iter()
.enumerate()
.filter(|(_, (_, p))| folder_within(c, p) && !through_hidden(p))
.filter(|(_, (_, p))| listed_below(c, p) && !through_hidden(p))
.max_by_key(|(_, (_, p))| folder_key(p).len())
.map(|(i, _)| i);
}
@@ -172,26 +198,30 @@ pub(crate) fn local_places(
out
}
fn unlisted_ancestors(path: &str) -> Vec<String> {
std::path::Path::new(path).ancestors().filter(|p| lightcraft_engine::walk::is_hidden_path(p)).map(|p| p.to_string_lossy().to_string()).collect()
}
/// The subfolders of `path` (not hidden ones), sorted; listed on a worker thread at most every
/// 2 s per folder (empty until the first listing).
pub(super) fn subfolders(ui: &egui::Ui, path: &str) -> Vec<(String, String)> {
pub(super) fn subfolders(ui: &egui::Ui, path: &str) -> std::sync::Arc<Vec<(String, String)>> {
fs_cached(ui, "subfolders", path, 2.0, list_subfolders).unwrap_or_default()
}
fn list_subfolders(path: &str) -> Vec<(String, String)> {
fn list_subfolders(path: &str) -> std::sync::Arc<Vec<(String, String)>> {
let mut v: Vec<(String, String)> = std::fs::read_dir(path)
.map(|rd| {
rd.flatten()
.filter(|e| e.file_type().is_ok_and(|t| t.is_dir()))
.filter_map(|e| {
.filter(|e| e.file_type().is_ok_and(|t| t.is_dir()) && !lightcraft_engine::walk::is_hidden_entry(e))
.map(|e| {
let name = e.file_name().to_string_lossy().to_string();
(!name.starts_with('.')).then(|| (name, e.path().to_string_lossy().to_string()))
(name, e.path().to_string_lossy().to_string())
})
.collect()
})
.unwrap_or_default();
v.sort_by_key(|(n, _)| n.to_lowercase());
v
v.into()
}
/// How many [`fs_cached`] answers for `ctx` are being worked out right now. Rows appear (and
@@ -266,6 +296,90 @@ mod tests {
use super::local_places;
use crate::widgets::elide_head;
#[test]
fn injected_places_replace_the_machine_home() {
let places = vec![("Sandbox".to_string(), "/lightcraft-test/home".to_string())];
let services = crate::Services { local_places: Some(places.clone()), ..Default::default() };
let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::new(), services);
let mut view = crate::headless::HeadlessView::new();
let raw = crate::headless::HeadlessView::raw_input(egui::vec2(300.0, 200.0), 1.0, 0.0, vec![]);
view.run(raw, |ui| {
assert_eq!(super::local_now(&mut app, ui).unwrap().places.places, places);
});
}
#[test]
fn local_folder_rows_outside_the_viewport_skip_interactive_widgets() {
let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::new(), crate::Services::default());
let mut view = crate::headless::HeadlessView::new();
for frame in 0..3 {
let raw = crate::headless::HeadlessView::raw_input(egui::vec2(300.0, 160.0), 1.0, frame as f64 / 60.0, vec![]);
view.run(raw, |ui| {
egui::ScrollArea::vertical().show(ui, |ui| {
for index in 0..500 {
let path = format!("/lightcraft-test/folder-{index:04}");
super::folder_tree(&mut app, ui, &path, &format!("Folder {index}"), &path, 0.0, None, false, false);
}
});
});
let widgets = crate::widgets::take_registry(&view.ctx);
if frame == 2 {
let rows = widgets.iter().filter(|(id, _)| id.starts_with("source:local:")).count();
let toggles = widgets.iter().filter(|(id, _)| id.starts_with("folderToggle:")).count();
assert_eq!(rows, 500, "offscreen rows remain available for scrolling");
assert!((1..=10).contains(&toggles), "only visible rows build interactive widgets: {toggles}");
}
}
}
#[test]
fn browsing_below_an_unlisted_folder_has_a_session_root() {
let home = "/home/example";
let hidden = format!("{home}/.hidden");
let current = format!("{hidden}/Photos");
let builtin = || vec![("Home".into(), home.into())];
let unlisted = vec![hidden];
let first = super::local_places_with_unlisted(builtin(), &[], Some(&current), None, &[], &unlisted);
assert_eq!(first.browse_root.as_deref(), Some(current.as_str()));
assert_eq!(first.owner, Some(1));
let child = format!("{current}/Trip");
let next = super::local_places_with_unlisted(builtin(), &[], Some(&child), first.browse_root.as_deref(), &[], &unlisted);
assert_eq!(next.browse_root, first.browse_root);
assert_eq!(next.owner, Some(1));
let kept = super::local_places_with_unlisted(builtin(), std::slice::from_ref(&current), Some(&child), None, &[], &unlisted);
assert_eq!(kept.browse_root, None);
assert_eq!(kept.owner, Some(1));
let deeper = format!("{current}/.private");
let inside = format!("{deeper}/Trip");
let unlisted = vec![unlisted[0].clone(), deeper];
let moved = super::local_places_with_unlisted(builtin(), &[], Some(&inside), first.browse_root.as_deref(), &[], &unlisted);
assert_eq!(moved.browse_root.as_deref(), Some(inside.as_str()));
assert_eq!(moved.owner, Some(1));
}
#[test]
fn subfolders_leave_out_hidden_folders() {
let dir = std::env::temp_dir().join(format!("lc-ui-hidden-subfolders-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
for name in ["Shown", ".dot", "Hidden"] {
std::fs::create_dir_all(dir.join(name)).unwrap();
}
#[cfg(windows)]
assert!(std::process::Command::new("attrib").arg("+h").arg(dir.join("Hidden")).status().unwrap().success());
#[cfg(target_os = "macos")]
assert!(std::process::Command::new("chflags").arg("hidden").arg(dir.join("Hidden")).status().unwrap().success());
let expected = if cfg!(any(windows, target_os = "macos")) { vec!["Shown"] } else { vec!["Hidden", "Shown"] };
let listed = super::list_subfolders(&dir.to_string_lossy());
assert_eq!(listed.iter().map(|(n, _)| n.as_str()).collect::<Vec<_>>(), expected);
let unlisted = super::unlisted_ancestors(&dir.join(".dot/Photos").to_string_lossy());
assert!(unlisted.contains(&dir.join(".dot").to_string_lossy().to_string()));
if cfg!(any(windows, target_os = "macos")) {
let unlisted = super::unlisted_ancestors(&dir.join("Hidden/Photos").to_string_lossy());
assert!(unlisted.contains(&dir.join("Hidden").to_string_lossy().to_string()));
}
std::fs::remove_dir_all(&dir).unwrap();
}
/// Width = characters, so a limit of 12 is "12 characters".
fn fit(text: &str, max: usize) -> String {
elide_head(text, max as f32, |s| s.chars().count() as f32)
+2
View File
@@ -51,6 +51,8 @@ pub type SystemThemeFn = Box<dyn Fn(&egui::Context) -> Option<egui::Theme>>;
/// Platform services injected by the host app (desktop or web).
#[derive(Default)]
pub struct Services {
/// Local's built-in places as (label, path). None uses the host's home folder.
pub local_places: Option<Vec<(String, String)>>,
/// Native file dialogs for commands, shown off the UI thread and answered later
/// (`pick`; #191). With it, the synchronous pickers below serve only the buttons inside
/// dialogs; without it (the web, tests) they answer the commands too.
+1 -1
View File
@@ -13,7 +13,7 @@ const T: Duration = Duration::from_secs(20);
const SETTLE: Duration = Duration::from_secs(120);
fn demo(size: [f32; 2], ui: serde_json::Value) -> Headless {
let services = Services { png: None, ..Default::default() };
let services = Services { png: None, local_places: Some(Vec::new()), ..Default::default() };
let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services);
let mut h = Headless::new(app, size, 1.0);
let r = h.request("ui.set", ui, T);