mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-25 18:47:42 -06:00
Local: browse a folder in the background with progress; keep picked folders in the sidebar
Browse Folder listed, probed and imported the whole folder on the UI thread, so a network share froze the window with no feedback. It now lists and reads on a worker (progress window with Cancel), then adds the photos in small batches reusing the scan's probes. Folders picked with Browse Folder… stay in the Local list after browsing elsewhere. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
e3b61fc883
commit
919e65efaf
@@ -735,7 +735,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Add Photos…",
|
||||
["File"],
|
||||
Some("Cmd+Shift+I"),
|
||||
"{paths: [file or folder (recursive)], mode?: add|copy (add = reference the files in place; copy = into the library's Originals/YYYY/YYYY-MM-DD/), destination?: folder for copies, organize?: date|month|flat, rename?: file-name template for copies ({name} {seq:N} {date:%Y%m%d} {camera} {title}), renameStart?: 1, metadataPreset?: name, dng?: bool (copy raws as DNG), album?: albumId, albumName?: new album, preset?: presetId, keywords?: [..]} → {imported, duplicates, failed, album?}",
|
||||
"{paths: [file or folder (recursive)], mode?: add|copy (add = reference the files in place; copy = into the library's Originals/YYYY/YYYY-MM-DD/), destination?: folder for copies, organize?: date|month|flat, rename?: file-name template for copies ({name} {seq:N} {date:%Y%m%d} {camera} {title}), renameStart?: 1, metadataPreset?: name, dng?: bool (copy raws as DNG), local?: bool (browsing: the photos stay out of the library, like library.browse), album?: albumId, albumName?: new album, preset?: presetId, keywords?: [..]} → {imported, duplicates, failed, album?}",
|
||||
always,
|
||||
|s, p| {
|
||||
let paths = strs(p, "paths");
|
||||
@@ -781,6 +781,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
rename_start: p.get("renameStart").and_then(Value::as_u64).unwrap_or(1) as usize,
|
||||
metadata_preset,
|
||||
convert_dng: super::bool_or(p, "dng", false),
|
||||
local: super::bool_or(p, "local", false),
|
||||
..Default::default()
|
||||
};
|
||||
let undo0 = s.undo.len();
|
||||
|
||||
@@ -528,7 +528,12 @@ pub fn import_with(s: &mut Session, paths: &[String], opts: &ImportOptions) -> c
|
||||
}
|
||||
}
|
||||
|
||||
let probed = probe_all(s.media.file_probe.as_ref(), &todo, &ScanProgress::default());
|
||||
// files a preceding scan already probed are not read again (a network share is slow to read)
|
||||
let cached: Vec<Option<ProbeInfo>> = todo.iter().map(|f| s.import_probes.remove(f)).collect();
|
||||
let missing: Vec<String> = todo.iter().zip(&cached).filter(|(_, c)| c.is_none()).map(|(f, _)| f.clone()).collect();
|
||||
let mut fresh = probe_all(s.media.file_probe.as_ref(), &missing, &ScanProgress::default()).into_iter();
|
||||
let probed: Vec<Result<ProbeInfo, String>> =
|
||||
cached.into_iter().map(|c| c.map(Ok).unwrap_or_else(|| fresh.next().unwrap_or_else(|| Err("not probed".into())))).collect();
|
||||
crate::memory::release();
|
||||
let now = (s.clock)();
|
||||
let mut ops = promote;
|
||||
|
||||
@@ -767,7 +767,12 @@ mod tests {
|
||||
}
|
||||
let library_before = h.app.session.catalog.photos().filter(|p| !p.local).count();
|
||||
let r = h.request("engine.execute", json!({"command": "library.browse", "params": {"path": dir.to_string_lossy()}}), t);
|
||||
assert_eq!(r["result"]["photos"], 1, "{r}");
|
||||
// the folder is read in the background: the view switches at once, the photos follow
|
||||
assert_eq!(r["result"]["scanning"], true, "{r}");
|
||||
assert_eq!(h.app.session.source, lightcraft_engine::LibrarySource::Folder);
|
||||
h.settle(SETTLE);
|
||||
assert!(h.app.scan.is_none() && h.app.import.is_none());
|
||||
assert_eq!(h.app.session.visible_cloned().len(), 1);
|
||||
assert!(h.settle(SETTLE), "a thumbnail that can't load must not keep the renderer busy");
|
||||
// this test session has no file hooks: the thumbnail fails once, is remembered, and isn't retried
|
||||
let id = h.app.session.visible_cloned()[0];
|
||||
|
||||
@@ -78,6 +78,9 @@ pub struct ImportTask {
|
||||
pub failed: usize,
|
||||
undo0: usize,
|
||||
first: Option<u64>,
|
||||
/// Reading a folder for the Local view: the photos stay out of the library, and nothing is
|
||||
/// selected or announced as added.
|
||||
browse: bool,
|
||||
}
|
||||
|
||||
/// A folder scan running on a worker thread (a network share can take minutes to read; the
|
||||
@@ -87,6 +90,8 @@ pub struct ScanTask {
|
||||
rx: std::sync::mpsc::Receiver<ScanOutput>,
|
||||
/// Open the review with "copy into the library" checked (a camera / card).
|
||||
pub copy: bool,
|
||||
/// Browsing a folder (Local): the photos are read in place instead of opening the review.
|
||||
browse: bool,
|
||||
}
|
||||
|
||||
/// Scan `paths` in the background, then open the review dialog (see [`poll_scan`]).
|
||||
@@ -105,10 +110,66 @@ pub fn open(app: &mut LightcraftApp, paths: Vec<String>) -> Result<Value, String
|
||||
std::thread::spawn(job);
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
job();
|
||||
app.scan = Some(ScanTask { progress, rx, copy: false });
|
||||
app.scan = Some(ScanTask { progress, rx, copy: false, browse: false });
|
||||
Ok(json!({"scanning": true}))
|
||||
}
|
||||
|
||||
/// Show a folder's photos in the Local view (`library.browse` from the UI): the view switches at
|
||||
/// once, the folder is listed and read on a worker thread (a network share can take minutes),
|
||||
/// and the photos then join the view in small batches under a progress window. A browse already
|
||||
/// running is replaced; the import review's scan is not.
|
||||
pub fn browse(app: &mut LightcraftApp, path: &str, subfolders: Option<bool>) -> Result<Value, String> {
|
||||
let dir = std::path::absolute(std::path::Path::new(path)).map_err(|e| e.to_string())?;
|
||||
if !dir.is_dir() {
|
||||
return Err(format!("{path}: not a folder"));
|
||||
}
|
||||
if app.scan.as_ref().is_some_and(|t| !t.browse) || app.import.as_ref().is_some_and(|t| !t.browse) {
|
||||
return Err("an import is running".into());
|
||||
}
|
||||
if let Some(t) = app.scan.take() {
|
||||
t.progress.cancel.store(true, Ordering::Relaxed);
|
||||
}
|
||||
app.import = None;
|
||||
let dir_s = dir.to_string_lossy().trim_end_matches(['/', '\\']).to_string();
|
||||
let subfolders = subfolders.unwrap_or_else(|| app.session.browse.as_ref().is_some_and(|b| b.subfolders));
|
||||
app.session.browse = Some(lightcraft_engine::Browse { path: dir_s.clone(), subfolders });
|
||||
app.session.source = lightcraft_engine::LibrarySource::Folder;
|
||||
let (input, _) = ScanInput::new(&mut app.session, std::slice::from_ref(&dir_s));
|
||||
let progress = std::sync::Arc::new(ScanProgress::default());
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let p = progress.clone();
|
||||
let root = dir_s.clone();
|
||||
let job = move || {
|
||||
let files: Vec<String> = if subfolders {
|
||||
lightcraft_engine::import::expand(&[root], None)
|
||||
} else {
|
||||
let mut v: Vec<String> = std::fs::read_dir(&root)
|
||||
.map(|rd| {
|
||||
rd.flatten()
|
||||
.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('.'))
|
||||
})
|
||||
.map(|f| f.to_string_lossy().to_string())
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
v.sort();
|
||||
v
|
||||
};
|
||||
let _ = tx.send(scan_with(input, &files, &p));
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
std::thread::spawn(job);
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
job();
|
||||
app.scan = Some(ScanTask { progress, rx, copy: false, browse: true });
|
||||
app.renderer.forget_imports();
|
||||
Ok(json!({"path": dir_s, "subfolders": subfolders, "scanning": true}))
|
||||
}
|
||||
|
||||
/// Collect a finished scan and open the review (called every frame).
|
||||
pub fn poll_scan(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
let Some(task) = app.scan.as_ref() else { return };
|
||||
@@ -127,6 +188,17 @@ pub fn poll_scan(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
return;
|
||||
}
|
||||
app.session.import_probes = out.probes;
|
||||
if task.browse {
|
||||
// what the library doesn't know yet joins the Local view
|
||||
let queue: Vec<String> =
|
||||
out.candidates.iter().filter(|c| c.duplicate != Some("path".into()) && c.error.is_none()).map(|c| c.path.clone()).collect();
|
||||
if !queue.is_empty() {
|
||||
let undo0 = app.session.undo.len();
|
||||
let total = queue.len();
|
||||
app.import = Some(ImportTask { queue, total, params: json!({"mode": "add", "local": true}), undo0, browse: true, ..Default::default() });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if out.candidates.is_empty() {
|
||||
app.toast(ctx, "No photos found");
|
||||
return;
|
||||
@@ -143,7 +215,11 @@ pub fn scan_progress(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
let t = Tokens::get(ctx);
|
||||
let total = task.progress.total.load(Ordering::Relaxed);
|
||||
let done = task.progress.done.load(Ordering::Relaxed);
|
||||
let text = if total == 0 { "Looking for photos…".to_string() } else { format!("Reading photos… {done} of {total}") };
|
||||
let text = if total == 0 {
|
||||
if task.browse { "Reading folder…" } else { "Looking for photos…" }.to_string()
|
||||
} else {
|
||||
format!("Reading photos… {done} of {total}")
|
||||
};
|
||||
let mut cancel = false;
|
||||
egui::Window::new("Scanning").title_bar(false).resizable(false).anchor(Align2::CENTER_BOTTOM, [0.0, -80.0]).fixed_size([340.0, 80.0]).show(
|
||||
ctx,
|
||||
@@ -249,6 +325,12 @@ pub fn tick(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
let steps = app.session.undo.len().saturating_sub(task.undo0);
|
||||
let label = format!("Add {} Photo{}", task.imported, if task.imported == 1 { "" } else { "s" });
|
||||
app.session.merge_undo(steps, &label);
|
||||
if task.browse {
|
||||
if task.failed > 0 {
|
||||
app.toast(ctx, format!("{} photo{} not readable", task.failed, if task.failed == 1 { "" } else { "s" }));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if let Some(f) = task.first {
|
||||
let _ = app.run("library.select", json!({"ids": [f]}));
|
||||
}
|
||||
@@ -267,7 +349,7 @@ pub fn progress(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
let Some(task) = &app.import else { return };
|
||||
let t = Tokens::get(ctx);
|
||||
let frac = task.done as f32 / task.total.max(1) as f32;
|
||||
let text = format!("Adding photos… {} of {}", task.done, task.total);
|
||||
let text = format!("{} photos… {} of {}", if task.browse { "Reading" } else { "Adding" }, task.done, task.total);
|
||||
egui::Window::new("Importing").title_bar(false).resizable(false).anchor(Align2::CENTER_BOTTOM, [0.0, -80.0]).fixed_size([340.0, 60.0]).show(
|
||||
ctx,
|
||||
|ui| {
|
||||
|
||||
@@ -842,6 +842,11 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
|
||||
app.ui.dialog = Some(Dialog::Shortcuts);
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"library.browse" if !cfg!(target_arch = "wasm32") => {
|
||||
// listed and read in the background (see `import::browse`)
|
||||
let Some(path) = p.get("path").and_then(Value::as_str) else { return None };
|
||||
crate::import::browse(app, path, p.get("subfolders").and_then(Value::as_bool))
|
||||
}
|
||||
"file.addPhotos" => {
|
||||
let paths = match p.get("paths").and_then(Value::as_array) {
|
||||
Some(a) => a.iter().filter_map(Value::as_str).map(str::to_string).collect(),
|
||||
|
||||
@@ -182,11 +182,13 @@ fn local_section(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
}
|
||||
let browsing = app.session.browse.clone().filter(|_| app.session.source == LibrarySource::Folder);
|
||||
if let Some(b) = &browsing
|
||||
&& !places.iter().any(|(_, p)| *p == b.path)
|
||||
{
|
||||
let name = std::path::Path::new(&b.path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| b.path.clone());
|
||||
places.push((name, b.path.clone()));
|
||||
// picked folders stay listed; the folder being browsed is listed even when it wasn't picked
|
||||
let roots = app.ui.local_roots.iter().cloned().chain(browsing.iter().map(|b| b.path.clone()));
|
||||
for path in roots {
|
||||
if !places.iter().any(|(_, p)| *p == path) {
|
||||
let name = std::path::Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.clone());
|
||||
places.push((name, path));
|
||||
}
|
||||
}
|
||||
let current = browsing.as_ref().map(|b| b.path.clone());
|
||||
for (name, path) in places {
|
||||
@@ -194,10 +196,16 @@ fn local_section(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
if app.services.pick_folder.is_some() && row(app, ui, "local:browse", Icon::Plus, "Browse Folder…", None, false, 0.0).clicked() {
|
||||
let picked = app.services.pick_folder.as_mut().and_then(|f| f());
|
||||
if let Some(path) = picked
|
||||
&& let Err(e) = app.run("library.browse", json!({"path": path}))
|
||||
{
|
||||
app.toast(ui.ctx(), e);
|
||||
if let Some(path) = picked {
|
||||
match app.run("library.browse", json!({"path": path})) {
|
||||
Ok(r) => {
|
||||
let dir = r["path"].as_str().unwrap_or(&path).to_string();
|
||||
if !app.ui.local_roots.contains(&dir) {
|
||||
app.ui.local_roots.push(dir);
|
||||
}
|
||||
}
|
||||
Err(e) => app.toast(ui.ctx(), e),
|
||||
}
|
||||
}
|
||||
}
|
||||
ui.add_space(10.0);
|
||||
|
||||
@@ -287,6 +287,8 @@ pub struct UiState {
|
||||
pub spots_threshold: f32,
|
||||
/// The library filter bar above the grid.
|
||||
pub filter_bar: bool,
|
||||
/// Folders picked with Local → Browse Folder…, kept in the sidebar after browsing elsewhere.
|
||||
pub local_roots: Vec<String>,
|
||||
/// Culling: after a rating, flag or colour-label key, move to the next photo.
|
||||
pub auto_advance: bool,
|
||||
/// Full-screen preview (F): the photo alone on black, no chrome.
|
||||
@@ -538,6 +540,7 @@ impl Default for UiState {
|
||||
settings: AppSettings::default(),
|
||||
window_fullscreen: None,
|
||||
filter_bar: false,
|
||||
local_roots: Vec::new(),
|
||||
compare: None,
|
||||
reference: None,
|
||||
toast: None,
|
||||
|
||||
Reference in New Issue
Block a user