LRC-LIB-FOLDERS: Local's built-in places offer no Rename or Move

The Home, Pictures, Desktop and Downloads rows carry a translated label. Rename Folder…
started its prompt with that label, so confirming it in German renamed ~/Pictures to
~/Bilder on disk, and Home was offered for rename at all. Local now lists no Rename Folder…
or Move Folder To… on a built-in place; a folder kept with Browse Folder… still renames
from its real name.
This commit is contained in:
Nathaniel Ledford
2026-10-28 17:07:51 -04:00
parent 77c0a6adfa
commit 8fca8eb4f5
6 changed files with 185 additions and 24 deletions
+21 -17
View File
@@ -1,5 +1,5 @@
use super::albums::pointer_over_row;
use super::local::{same_folder, subfolders};
use super::local::{is_builtin_place, same_folder, subfolders};
use super::rows::{INDENT_STEP, LIBFOLDER_REVEALED, Row, out_of_sight, set_tree_open, tree_open, tree_toggle};
use super::{Album, FolderNode, Icon, LibrarySource, LightcraftApp, Tokens, json, register, vec2};
@@ -69,22 +69,26 @@ pub(super) fn folder_tree(
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),
// a built-in place is one of the computer's own folders under a translated label (Home is
// the user's whole home): Local offers no Rename or Move for it (the other items stay)
if !(indent == 0.0 && is_builtin_place(app, path)) {
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),
}
}
}
}
+55 -5
View File
@@ -18,12 +18,10 @@ pub(super) fn local_now(app: &mut LightcraftApp, ui: &egui::Ui) -> Option<LocalN
if !SidebarSection::Local.available() {
return None;
}
let home = std::env::var("HOME").or_else(|_| std::env::var("USERPROFILE")).unwrap_or_default();
let home = home_folder();
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() };
if app.services.local_places.is_none() {
for (name, p) in default_places(&home) {
// (checked off the UI thread: a home folder can be on a network share)
if fs_cached(ui, "is-dir", &p, 5.0, |p| std::path::Path::new(p).is_dir()) == Some(true) {
builtin.push((crate::i18n::tr(name).to_string(), p));
@@ -53,6 +51,40 @@ pub(super) fn local_now(app: &mut LightcraftApp, ui: &egui::Ui) -> Option<LocalN
Some(LocalNow { places, current, hidden })
}
/// The user's home folder; empty when the host has none.
fn home_folder() -> String {
std::env::var("HOME").or_else(|_| std::env::var("USERPROFILE")).unwrap_or_default()
}
/// Local's built-in places under `home` as (English name, path), in order. The names are keys for
/// `tr`, never folder names: the folders on disk keep their own (Pictures, not "Bilder").
fn default_places(home: &str) -> Vec<(&'static str, String)> {
if home.is_empty() {
return Vec::new();
}
[("Pictures", "Pictures"), ("Desktop", "Desktop"), ("Downloads", "Downloads"), ("Home", "")]
.into_iter()
// joined with the platform's separator, like the paths browsing gives back
.map(|(name, sub)| (name, if sub.is_empty() { home.to_string() } else { std::path::Path::new(home).join(sub).to_string_lossy().to_string() }))
.collect()
}
/// Whether `path` is one of Local's built-in places (the home folder, Pictures, Desktop,
/// Downloads, or a place the host listed itself). They are the computer's own folders, shown under
/// a translated label, so Local offers no Rename or Move for them.
pub(super) fn is_builtin_place(app: &LightcraftApp, path: &str) -> bool {
builtin_place_in(app.services.local_places.as_deref(), &home_folder(), path)
}
/// [`is_builtin_place`] for the places the host listed (`injected`, which replace the defaults) or,
/// without them, the ones under `home`.
fn builtin_place_in(injected: Option<&[(String, String)]>, home: &str, path: &str) -> bool {
match injected {
Some(places) => places.iter().any(|(_, p)| same_folder(p, path)),
None => default_places(home).iter().any(|(_, p)| same_folder(p, path)),
}
}
/// Local's rows: each place as a folder tree, Browse Folder…, and the way back for hidden places.
pub(super) fn local_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, now: &LocalNow) {
let (local, current) = (&now.places, now.current.as_deref());
@@ -296,6 +328,24 @@ mod tests {
use super::local_places;
use crate::widgets::elide_head;
#[test]
fn the_standard_places_under_a_home_are_built_in_and_nothing_else_is() {
let home = "/home/example";
for path in [home, "/home/example/", "/home/example/Pictures", "/home/example/Pictures/", "/home/example/Desktop", "/home/example/Downloads"]
{
assert!(super::builtin_place_in(None, home, path), "{path}");
}
for path in ["/home/example/Pictures/Trip", "/home/example/Documents", "/home/other", "/home", "Pictures"] {
assert!(!super::builtin_place_in(None, home, path), "{path}");
}
// no home: no standard places, and no relative ones
assert!(!super::builtin_place_in(None, "", "Pictures") && !super::builtin_place_in(None, "", ""));
// places the host listed replace the standard ones
let injected = vec![("Sandbox".to_string(), "/lightcraft-test/home".to_string())];
assert!(super::builtin_place_in(Some(&injected), home, "/lightcraft-test/home"));
assert!(!super::builtin_place_in(Some(&injected), home, "/home/example/Pictures"));
}
#[test]
fn injected_places_replace_the_machine_home() {
let places = vec![("Sandbox".to_string(), "/lightcraft-test/home".to_string())];
+97
View File
@@ -0,0 +1,97 @@
//! Local's built-in places are the computer's own folders under a translated label: the menu
//! offers no Rename or Move for them, and a folder the user keeps in Local still renames by its
//! real name.
use egui::{PointerButton, vec2};
use egui_kittest::{Harness, kittest::Queryable};
use lightcraft_ui_egui::{LightcraftApp, Services, i18n::Locale, state::Dialog};
use serde_json::json;
use std::path::{Path, PathBuf};
/// A scratch home with Pictures and a folder kept with Browse Folder…; removed on drop.
struct Scratch(PathBuf);
impl Scratch {
fn new(name: &str) -> Self {
let dir = std::env::temp_dir().join(format!("lightcraft-local-places-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
for sub in ["home/Pictures", "kept/Trips"] {
std::fs::create_dir_all(dir.join(sub)).expect("scratch folders");
}
Self(dir)
}
fn path(&self, sub: &str) -> String {
self.0.join(sub).to_string_lossy().to_string()
}
}
impl Drop for Scratch {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
/// The app with Local listing `places` (what the host passes in place of the machine's home) and
/// `kept` folders kept with Browse Folder….
fn harness(places: Vec<(String, String)>, kept: Vec<String>) -> Harness<'static, LightcraftApp> {
let services = Services { local_places: Some(places), pick_folder: Some(Box::new(|| None)), ..Default::default() };
let mut app = LightcraftApp::new(lightcraft_engine::Session::new(), services);
app.ui.left_panel = true;
app.ui.local_roots = kept;
let mut h = Harness::builder().with_size(vec2(1000.0, 800.0)).build_ui_state(
|ui, app: &mut LightcraftApp| {
app.logic(&ui.ctx().clone());
app.ui(ui);
},
app,
);
h.run_steps(8);
h
}
fn open_menu(h: &mut Harness<'_, LightcraftApp>, row: &str) {
h.get_by_label(row).click_button(PointerButton::Secondary);
h.run_steps(4);
}
#[test]
fn a_built_in_place_offers_no_rename_or_move() {
// the label is the translation of "Pictures"; the folder on disk is still called Pictures
let bilder = Locale::De.tr("Pictures");
assert_ne!(bilder, "Pictures", "the German label differs from the folder's name");
let scratch = Scratch::new("builtin");
let pictures = scratch.path("home/Pictures");
let places = vec![(bilder.to_string(), pictures.clone()), ("Home".to_string(), scratch.path("home"))];
let mut h = harness(places, Vec::new());
for label in [bilder, "Home"] {
open_menu(&mut h, label);
assert!(h.query_by_label("Remove from Local").is_some(), "{label}: the row's menu is open");
assert!(
h.query_by_label("Rename Folder…").is_none(),
"{label}: Rename Folder… must not be offered (prompt would start with {:?})",
h.state().ui.dialog
);
assert!(h.query_by_label("Move Folder To…").is_none(), "{label}: Move Folder To… must not be offered");
h.key_press(egui::Key::Escape);
h.run_steps(3);
}
assert!(Path::new(&pictures).is_dir(), "nothing was renamed on disk");
}
#[test]
fn a_kept_folder_renames_from_its_real_name() {
let scratch = Scratch::new("kept");
let kept = scratch.path("kept/Trips");
let mut h = harness(vec![("Home".to_string(), scratch.path("home"))], vec![kept.clone()]);
open_menu(&mut h, "Trips");
h.get_by_label("Rename Folder…").click();
h.run_steps(4);
let Some(Dialog::TextPrompt { value, command, params, key, .. }) = h.state().ui.dialog.clone() else {
panic!("Rename Folder… opens a prompt: {:?}", h.state().ui.dialog)
};
assert_eq!((value.as_str(), command.as_str()), ("Trips", "folder.rename"));
// confirmed as it stands, the prompt renames nothing
let mut p = params;
p[key.as_str()] = json!(value);
assert!(h.state_mut().run(&command, p).is_err(), "the folder already has that name");
assert!(Path::new(&kept).is_dir());
}
+1
View File
@@ -3,3 +3,4 @@
mod accessibility;
mod docking_workflows;
mod local_places;
File diff suppressed because one or more lines are too long
+8
View File
@@ -1,5 +1,13 @@
# What's new in LightCraft
## 2026-10-28
### Local's standard places can't be renamed by mistake
- The Pictures, Desktop, Downloads and Home rows of Local no longer offer Rename Folder… or Move Folder To…. Before,
the Rename prompt began with the row's translated label rather than the folder's name, so in German pressing OK
on “Bilder” renamed your real Pictures folder to “Bilder”, and Home could be renamed the same way. Folders inside
them, and folders you keep in Local with Browse Folder…, rename as before, starting from their real name.
## 2026-10-24
### Relative-date filters and smart albums follow the clock