# Conflicts:
#	crates/ui-egui/src/panels/left.rs
This commit is contained in:
Brandon Thomas committed 2026-10-13 11:52:08 -04:00
commit d71e7d11c1
2 files changed
+79

No files matched your search

+38
View File
@@ -1068,6 +1068,9 @@ fn folder_tree(
app.toast(ui.ctx(), e);
}
resp.context_menu(|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" }))
@@ -1111,6 +1114,9 @@ fn folder_tree(
{
let _ = f(path);
}
if let Some(held) = &held {
synchronize_item(app, ui, &held.0, &held.1);
}
});
if open && indent < 12.0 * 8.0 {
for (n, p) in subfolders(ui, path) {
@@ -1822,6 +1828,38 @@ fn locate_missing_item(app: &mut LightcraftApp, ui: &mut egui::Ui, n: &FolderNod
}
}
/// The node Synchronize Folder… of a Local folder `path` works on, with the name its dialog shows:
/// the Folders row for that very folder, else (a folder above rows) the folder itself, as a folder
/// holding other folders. `None` when no library photo lies in `path` or inside it. Reads the
/// cached Folders tree only; the disk is not touched.
fn library_node_for(tree: &[FolderNode], path: &str) -> Option<(FolderNode, String)> {
use lightcraft_catalog::query::folder_within;
fn exact<'a>(tree: &'a [FolderNode], path: &str) -> Option<&'a FolderNode> {
tree.iter().find_map(|n| if same_folder(&n.path, path) { Some(n) } else { exact(&n.children, path) })
}
fn any_inside(tree: &[FolderNode], path: &str) -> bool {
tree.iter().any(|n| (n.count > 0 && folder_within(&n.path, path)) || any_inside(&n.children, path))
}
if let Some(n) = exact(tree, path).filter(|n| n.count > 0) {
return Some((n.clone(), lightcraft_catalog::folders::folder_label(&n.path)));
}
if !any_inside(tree, path) {
return None;
}
let name = std::path::Path::new(path).file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string());
let node = FolderNode {
name: name.clone(),
path: path.to_string(),
count: 0,
own: 0,
volume: false,
selectable: false,
label: None,
children: Vec::new(),
};
Some((node, name))
}
/// Synchronize Folder… of a Folders row (widget `folderSynchronize`): opens its dialog, which
/// scans the folder in the background.
fn synchronize_item(app: &mut LightcraftApp, ui: &mut egui::Ui, n: &FolderNode, name: &str) {
+41
View File
@@ -5,6 +5,8 @@
//! * Right-click a folder in the sidebar's Folders section ▸ Synchronize Folder…: a dialog opens
//! at once and scans the folder without holding up the app, then says how many photos are new,
//! missing, or have metadata updates.
//! * Right-click a folder in the sidebar's Local section: Synchronize Folder… is there when the
//! folder holds library photos (or has a folder with some inside), and not when it holds none.
//! * Synchronize with the defaults imports the new photos and leaves missing ones alone.
//! * Ticking "Remove missing photos" also moves those to Recently Deleted.
//! * Cancel leaves the library as it was.
@@ -302,3 +304,42 @@ fn the_folders_scan_shows_a_row_and_its_cross_closes_the_dialog() {
h.settle(SETTLE);
assert!(h.app.ui.dialog.is_none(), "a stopped scan opens nothing later");
}
/// Right-click the Local row of `folder`; whether Synchronize Folder… is in its menu.
fn local_menu_offers_synchronize(h: &mut Headless, folder: &str) -> bool {
let row = format!("source:local:{folder}");
assert!(h.step_until(T, |h| h.app.widgets.iter().any(|(w, _)| *w == row)), "no row {row}");
let rect = h.app.widgets.iter().find(|(w, _)| *w == row).map(|(_, r)| *r).unwrap();
let c = rect.center();
let r = h.request("ui.click", json!({"x": c.x, "y": c.y, "button": "right"}), T);
assert_eq!(r["ok"], true, "{r}");
h.step();
h.step();
h.app.widgets.iter().any(|(w, _)| *w == "folderSynchronize")
}
#[test]
fn a_local_folder_with_library_photos_offers_synchronize() {
let dir = Scratch::new("local-menu");
let mut h = changed_folder(&dir);
std::fs::create_dir_all(dir.path("empty")).unwrap();
// Local writes paths the way the OS does
let native = |f: &str| dir.0.join(f).to_string_lossy().to_string();
for f in ["trip", "empty"] {
let r = h.request("engine.execute", json!({"command": "local.addRoot", "params": {"path": native(f)}}), T);
assert_eq!(r["ok"], true, "{r}");
}
h.step();
assert!(!local_menu_offers_synchronize(&mut h, &native("empty")), "no library photos in it: nothing to synchronize");
// close that menu, then open the other folder's
let r = h.request("ui.key", json!({"key": "Escape"}), T);
assert_eq!(r["ok"], true, "{r}");
h.step();
h.step();
assert!(local_menu_offers_synchronize(&mut h, &native("trip")), "the folder holds library photos");
let r = h.request("ui.clickWidget", json!({"id": "folderSynchronize"}), T);
assert_eq!(r["ok"], true, "{r}");
h.step();
assert!(matches!(h.app.ui.dialog, Some(Dialog::SynchronizeFolder { .. })), "the dialog opens at once");
assert!(h.step_until(T, |h| matches!(&h.app.ui.dialog, Some(Dialog::SynchronizeFolder { counts: Some(_), .. }))));
}