diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index 01719115..b911896b 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -794,6 +794,39 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + /// Local: Remove from Local hides a sidebar location (nothing on disk changes) and the + /// "Show hidden locations" row puts it back. + #[test] + fn local_location_can_be_hidden_and_restored() { + let mut h = demo([1300.0, 900.0]); + let t = Duration::from_secs(10); + let dir = std::env::temp_dir().join(format!("lc-ui-hide-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.to_string_lossy().to_string(); + let ids = |h: &mut Headless| -> Vec { + let w = h.request("ui.widgets", json!({"filter": "source:local:"}), t); + w["result"].as_array().unwrap().iter().filter_map(|x| x["id"].as_str().map(String::from)).collect() + }; + h.request("ui.set", json!({"leftPanel": true}), t); + h.request("engine.execute", json!({"command": "library.browse", "params": {"path": path}}), t); + h.settle(SETTLE); + assert!(ids(&mut h).contains(&format!("source:local:{path}"))); + assert!(!ids(&mut h).iter().any(|i| i == "source:local:restoreHidden")); + let r = h.request("engine.execute", json!({"command": "local.hide", "params": {"path": path}}), t); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + let after = ids(&mut h); + assert!(!after.contains(&format!("source:local:{path}")), "{after:?}"); + assert!(after.iter().any(|i| i == "source:local:restoreHidden"), "{after:?}"); + assert!(dir.is_dir(), "the folder itself is untouched"); + assert_eq!(h.request("ui.clickWidget", json!({"id": "source:local:restoreHidden"}), t)["ok"], true); + h.settle(SETTLE); + assert!(h.app.ui.hidden_locations.is_empty()); + assert!(ids(&mut h).contains(&format!("source:local:{path}"))); + let _ = std::fs::remove_dir_all(&dir); + } + /// Versions panel: resting on a version previews it in the loupe without changing the photo; /// a click restores it. #[test] diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index df3acda8..fde1e087 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -53,6 +53,8 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("view.back", "Back to Grid", Some("Escape"), ""), ("tool.done", "Done", Some("Enter"), ""), ("view.filterBar", "Filter Bar", Some("Shift+F"), "View"), + ("local.hide", "Remove from Local", None, ""), + ("local.restoreHidden", "Show Hidden Local Locations", None, ""), ("view.fullScreenPreview", "Full Screen Preview", Some("F"), "View"), ("view.enterFullScreen", "Enter Full Screen", Some("Cmd+Shift+F"), "View"), ("view.infoOverlay", "Cycle Info Overlay", Some("Cmd+I"), "View"), @@ -553,6 +555,23 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option match p.get("path").and_then(Value::as_str) { + Some(path) => { + if !app.ui.hidden_locations.iter().any(|h| h == path) { + app.ui.hidden_locations.push(path.to_string()); + } + Ok(json!({"hidden": app.ui.hidden_locations})) + } + None => Err("local.hide needs a path".into()), + }, + "local.restoreHidden" => { + // one path, or (no path) every hidden location + match p.get("path").and_then(Value::as_str) { + Some(path) => app.ui.hidden_locations.retain(|h| h != path), + None => app.ui.hidden_locations.clear(), + } + Ok(json!({"hidden": app.ui.hidden_locations})) + } "view.filterBar" => { app.ui.filter_bar = !app.ui.filter_bar; if app.ui.filter_bar && !matches!(app.ui.view, ViewMode::PhotoGrid | ViewMode::SquareGrid) { diff --git a/crates/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs index cf89b9da..72516322 100644 --- a/crates/ui-egui/src/panels/left.rs +++ b/crates/ui-egui/src/panels/left.rs @@ -222,12 +222,16 @@ fn local_section(app: &mut LightcraftApp, ui: &mut egui::Ui) { } } let current = browsing.as_ref().map(|b| b.path.clone()); + // hidden locations stay reachable: breadcrumbs, Browse Folder…, and the row below + places.retain(|(_, p)| !app.ui.hidden_locations.contains(p)); for (name, path) in places { folder_tree(app, ui, &name, &path, 0.0, current.as_deref()); } 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 { + // choosing a folder again puts it back in the list + let _ = app.run("local.restoreHidden", json!({"path": path})); match app.run("library.browse", json!({"path": path})) { Ok(r) => { let dir = r["path"].as_str().unwrap_or(&path).to_string(); @@ -239,6 +243,16 @@ fn local_section(app: &mut LightcraftApp, ui: &mut egui::Ui) { } } } + let hidden = app.ui.hidden_locations.len(); + if hidden > 0 { + let label = format!("Show {hidden} hidden location{}", if hidden == 1 { "" } else { "s" }); + if row(app, ui, "local:restoreHidden", Icon::Folder, &label, None, false, 0.0) + .on_hover_text("Put the locations you removed from Local back (no files change)") + .clicked() + { + let _ = app.run("local.restoreHidden", json!({})); + } + } ui.add_space(10.0); } @@ -296,6 +310,12 @@ fn folder_tree(app: &mut LightcraftApp, ui: &mut egui::Ui, name: &str, path: &st app.toast(ui.ctx(), e); } resp.context_menu(|ui| { + if indent == 0.0 + && ui.button("Remove from Local").on_hover_text("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("Rename Folder…").clicked() { app.ui.dialog = Some(crate::state::Dialog::TextPrompt { title: format!("Rename “{name}”"), diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index d618bafa..a9749f89 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -230,6 +230,8 @@ pub struct UiState { pub grid_info: String, /// Photo counts next to sources and albums in the left panel. pub show_counts: bool, + /// Local sidebar locations hidden with “Remove from Local” (folders on disk are untouched). + pub hidden_locations: Vec, /// Copies opened in an external editor this session (reloaded when the window is focused /// again), and whether the window had focus last frame. #[serde(skip)] @@ -502,6 +504,7 @@ impl Default for UiState { show_filenames: true, grid_info: "filename".into(), show_counts: true, + hidden_locations: Vec::new(), dragging_control: None, external_edits: Vec::new(), was_focused: true, diff --git a/docs/parity.md b/docs/parity.md index fb3410c5..a33caa46 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -108,7 +108,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-IMP-RAWDEFAULT | Raw defaults | P1 | ✅ | `cmd:library.preferences`, `crates/engine/src/import.rs`, `crates/ui-egui/src/panels/settings.rs` | LightCraft default / a preset / per camera (make + model); non-raw default too; the preset look counts as unedited and Reset returns to it | | LR-IMP-MIGRATE | Migrate other catalogs | OOS | 🚫 | | | | LR-IMP-PROFILES | Import profiles & presets | P1 | ✅ | `cmd:file.importPresets`, `cmd:preset.import`, `cmd:profile.import`, `crates/engine/src/preset_import.rs`, `crates/engine/src/preset_luminar.rs` | presets: .lcpreset, XMP, classic .lrtemplate, photos carrying edits ("DNG presets"), Luminar looks (.lmp, .mplumpack collections; sliders with a counterpart), .zip bundles, folders (folder → group), drag & drop; masks carried over; unmapped settings reported; profiles: .cube 3D LUTs; Adobe profile formats deliberately unsupported | -| LR-IMP-LOCAL | Work on files in place | P0 | ✅ | `cmd:library.browse`, `cmd:photo.addToLibrary`, `cmd:library.import` (mode add), `crates/engine/src/cmd/browse.rs`, `crates/ui-egui/src/panels/left.rs` (`local_section`), `crates/ui-egui/src/panels/grid.rs` (`folder_header`) | Local: browse Pictures / Desktop / Downloads / Home or any folder without adding it (breadcrumb, Include subfolders, Add N to My Photos); browsed photos stay out of All Photos, albums and counts; edits go to XMP sidecars; importing promotes them | +| LR-IMP-LOCAL | Work on files in place | P0 | ✅ | `cmd:library.browse`, `cmd:photo.addToLibrary`, `cmd:library.import` (mode add), `cmd:local.hide`, `cmd:local.restoreHidden`, `crates/engine/src/cmd/browse.rs`, `crates/ui-egui/src/panels/left.rs` (`local_section`), `crates/ui-egui/src/panels/grid.rs` (`folder_header`) | Local: browse Pictures / Desktop / Downloads / Home or any folder without adding it (breadcrumb, Include subfolders, Add N to My Photos); browsed photos stay out of All Photos, albums and counts; edits go to XMP sidecars; importing promotes them; right-click a top-level location → Remove from Local hides the shortcut only (nothing on disk or in the catalog changes), saved with the UI state, and “Show N hidden locations” (or Browse Folder… on it) brings it back | | LR-IMP-SIDECAR-SPLIT | Separate XMP sidecar variants | P2 | ⬜ | `cmd:library.xmpPreferences` | sidecar naming option exists (stem/full), no split sidecars | | LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs` | JPEG, PNG, TIFF, WebP, JXL, PSD, GIF, BMP; DNG (incl. lossy / Smart Preview DNG), CR2, ARW, NEF, RAF, RW2, PEF, ORF. Missing: CR3, compressed NEF/RAF/ORF, RW2 v4 (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes), HEIC/AVIF decode | | LR-IMP-CULL-AT-IMPORT | Culling analysis at import | P2 | 🟡 | `cmd:photo.analyze` | run Assisted Culling on the imported photos (they're selected after an import); not automatic |