mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-28 20:17:18 -06:00
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:
@@ -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,
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(¤t), 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(¤t), 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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user