Merge PR #3276: LRC-LIB-FOLDERS: the home folder and Local's built-in places can't be renamed or moved

docs/parity-checklist.md: header keeps main's newer date plus the PR's item; the PR's edit to the LRC-LIB-FOLDERS row applied to main's row; revision rows union, newest first.
docs/whats-new.md: the PR's 2026-10-28 section added above 2026-10-27.
This commit is contained in:
aiongg
2026-10-31 02:11:01 +00:00
8 changed files with 413 additions and 26 deletions
+194 -2
View File
@@ -256,10 +256,11 @@ pub fn specs() -> Vec<CommandSpec> {
"Rename Folder",
[],
None,
"{path, name} — rename a folder on disk (its files and sidecars go along) and relink the photos in it; one undo step (undo renames it back, refused if the old name is taken) → {path, relinked}",
"{path, name} — rename a folder on disk (its files and sidecars go along) and relink the photos in it; one undo step (undo renames it back, refused if the old name is taken); refused for the home folder and its Pictures, Desktop and Downloads → {path, relinked}",
always,
|s, p| {
let from = str_param(p, "path").ok_or_else(|| bad("folder.rename", "missing path"))?.trim_end_matches(['/', '\\']).to_string();
refuse_protected_place("folder.rename", &from)?;
let name = str_param(p, "name")
.map(str::trim)
.filter(|n| !n.is_empty() && !n.contains(['/', '\\']) && *n != "." && *n != "..")
@@ -274,10 +275,11 @@ pub fn specs() -> Vec<CommandSpec> {
"Move Folder",
[],
None,
"{path, into: destination folder} — move a folder on disk into another and relink the photos in it; one undo step (undo moves it back, refused if the old place is taken) → {path, relinked}",
"{path, into: destination folder} — move a folder on disk into another and relink the photos in it; one undo step (undo moves it back, refused if the old place is taken); refused for the home folder and its Pictures, Desktop and Downloads → {path, relinked}",
always,
|s, p| {
let from = str_param(p, "path").ok_or_else(|| bad("folder.move", "missing path"))?.trim_end_matches(['/', '\\']).to_string();
refuse_protected_place("folder.move", &from)?;
let into = str_param(p, "into").ok_or_else(|| bad("folder.move", "missing into"))?;
let name = Path::new(&from).file_name().ok_or_else(|| bad("folder.move", "bad path"))?;
let to = Path::new(into).join(name).to_string_lossy().to_string();
@@ -550,3 +552,193 @@ pub(crate) fn move_folder(s: &mut Session, from: &str, to: &str) -> std::result:
follow_folder(s, from, to);
Ok(n)
}
/// Whether `path` is the user's home folder or one of its standard places (Pictures, Desktop,
/// Downloads): the computer's own folders, which renaming or moving would break for every other
/// program. `home` is passed in so the check can be tested; the commands read the real one.
pub(crate) fn is_protected_place(path: &Path, home: &Path) -> bool {
use lightcraft_catalog::query::folder_key;
let home = home.to_string_lossy();
if home.is_empty() {
return false;
}
// compared without case where the usual volumes ignore it (macOS, Windows): there `pictures` is `Pictures`
let fold = cfg!(any(target_os = "macos", windows));
let key = |p: &str| if fold { folder_key(p).to_lowercase() } else { folder_key(p) };
let path = key(&path.to_string_lossy());
std::iter::once(home.to_string())
.chain(["Pictures", "Desktop", "Downloads"].map(|sub| Path::new(&*home).join(sub).to_string_lossy().to_string()))
.any(|p| key(&p) == path)
}
/// Refuses `from` when renaming or moving it would take the home folder or a standard place of it.
fn refuse_protected_place(command: &str, from: &str) -> Result<()> {
refuse_protected_place_in(command, from, crate::library::home_folder().as_deref())
}
/// What `from` names once the file system has read it: made absolute against the working folder
/// (the commands act on it that way) and with its parent's links followed, so a spelling through a
/// linked folder is the same place. The last name stays as written (a link that merely points at
/// Pictures is its own folder: renaming it renames the link), except that a name which is not a
/// link is also read in full, which turns a Windows short name (`DOWNLO~1`) into the long one.
fn named_places(from: &str) -> Vec<std::path::PathBuf> {
let spelled = std::path::absolute(from).unwrap_or_else(|_| from.into());
let followed = spelled.parent().and_then(|p| p.canonicalize().ok()).zip(spelled.file_name()).map(|(p, name)| p.join(name));
let is_link = std::fs::symlink_metadata(&spelled).is_ok_and(|m| m.file_type().is_symlink());
let read = if is_link { None } else { spelled.canonicalize().ok() };
std::iter::once(spelled).chain(followed).chain(read).collect()
}
/// The home folder and its standard places.
fn standard_places(home: &Path) -> Vec<std::path::PathBuf> {
std::iter::once(home.to_path_buf()).chain(["Pictures", "Desktop", "Downloads"].map(|sub| home.join(sub))).collect()
}
/// Whether `from` is, on disk, one of `places`, however it is spelled (a firmlink or bind mount
/// reaches the same folder under another path). A link to a place is not the place, but a place
/// that is itself a link stands for the folder it points to (the places are read through their
/// links), since renaming that folder would break the place.
#[cfg(unix)]
fn is_one_of(from: &str, places: &[std::path::PathBuf]) -> bool {
use std::os::unix::fs::MetadataExt;
let Ok(mine) = std::fs::symlink_metadata(std::path::absolute(from).unwrap_or_else(|_| from.into())) else { return false };
mine.is_dir() && places.iter().filter_map(|p| std::fs::metadata(p).ok()).any(|m| (m.dev(), m.ino()) == (mine.dev(), mine.ino()))
}
#[cfg(not(unix))]
fn is_one_of(_from: &str, _places: &[std::path::PathBuf]) -> bool {
false
}
fn refuse_protected_place_in(command: &str, from: &str, home: Option<&Path>) -> Result<()> {
let Some(home) = home.filter(|h| !h.as_os_str().is_empty()) else { return Ok(()) };
let homes: Vec<std::path::PathBuf> = std::iter::once(home.to_path_buf()).chain(home.canonicalize().ok()).collect();
let spelled = named_places(from).iter().any(|place| homes.iter().any(|h| is_protected_place(place, h)));
if spelled || is_one_of(from, &homes.iter().flat_map(|h| standard_places(h)).collect::<Vec<_>>()) {
return Err(bad(
command,
format!(
"{from} is the home folder or one of its standard places (Pictures, Desktop, Downloads); rename or move it in the system's file manager instead"
),
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::{is_protected_place, refuse_protected_place_in};
use std::path::Path;
#[test]
fn the_home_folder_and_its_standard_places_are_protected() {
let home = Path::new("/home/example");
for path in [
"/home/example",
"/home/example/",
"/home/example/Pictures",
"/home/example/Pictures/",
"/home/example/Desktop",
"/home/example/Downloads",
] {
assert!(is_protected_place(Path::new(path), home), "{path}");
}
for path in ["/home/example/Pictures/Trip", "/home/example/Documents", "/home/other", "/home", "/home/example2", "Pictures", ""] {
assert!(!is_protected_place(Path::new(path), home), "{path}");
}
// no home known: nothing is protected, and no relative path is mistaken for a place
assert!(!is_protected_place(Path::new("Pictures"), Path::new("")));
assert!(!is_protected_place(Path::new(""), Path::new("")));
// case counts only where the volume ignores it
assert_eq!(is_protected_place(Path::new("/home/example/pictures"), home), cfg!(any(target_os = "macos", windows)));
}
fn scratch(name: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("lightcraft-protected-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("home/Pictures")).expect("scratch home");
dir
}
#[test]
fn a_protected_place_is_refused_by_name_with_the_reason() {
let dir = scratch("named");
let home = dir.join("home");
for from in [home.clone(), home.join("Pictures"), home.join("Pictures").join(".")] {
let e = refuse_protected_place_in("folder.rename", &from.to_string_lossy(), Some(&home)).expect_err("refused").to_string();
assert!(e.contains("home folder") && e.contains("file manager"), "{e}");
}
assert!(refuse_protected_place_in("folder.move", &home.join("Pictures").join("Trip").to_string_lossy(), Some(&home)).is_ok());
assert!(refuse_protected_place_in("folder.rename", &home.to_string_lossy(), None).is_ok(), "no home known");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_relative_path_to_a_protected_place_is_refused() {
let dir = scratch("relative");
// the home folder is spelled through a link, so only the spelling as given can match it
#[cfg(unix)]
let home = {
std::os::unix::fs::symlink(dir.join("home"), dir.join("alias")).expect("link");
dir.join("alias")
};
#[cfg(not(unix))]
let home = dir.join("home");
// the commands act on the process's working folder, so a relative path names the real place
let cwd = std::env::current_dir().expect("working folder");
let relative = pathdiff(&home.join("Pictures"), &cwd);
if !Path::new(&relative).is_absolute() {
// what the commands will see: the working folder's spelling of the same place
assert!(super::named_places(&relative).iter().any(|p| is_protected_place(p, &home)), "{relative}");
assert!(refuse_protected_place_in("folder.rename", &relative, Some(&home)).is_err(), "{relative}");
}
let _ = std::fs::remove_dir_all(&dir);
}
/// `target` as a path relative to `from` (both absolute), with `..` steps.
fn pathdiff(target: &Path, from: &Path) -> String {
let (t, f): (Vec<_>, Vec<_>) = (target.components().collect(), from.components().collect());
let common = t.iter().zip(&f).take_while(|(a, b)| a == b).count();
let mut out = std::path::PathBuf::new();
f[common..].iter().for_each(|_| out.push(".."));
t[common..].iter().for_each(|c| out.push(c));
out.to_string_lossy().to_string()
}
#[cfg(target_os = "macos")]
#[test]
fn a_protected_place_spelled_through_a_firmlink_is_refused() {
let dir = scratch("firmlink");
let home = dir.join("home");
// the data volume is also reachable under /System/Volumes/Data: the same folders, spelled otherwise
let real = home.canonicalize().expect("home");
let spelled = Path::new("/System/Volumes/Data").join(real.strip_prefix("/").expect("absolute"));
if spelled.is_dir() {
for from in [spelled.clone(), spelled.join("Pictures")] {
assert!(refuse_protected_place_in("folder.rename", &from.to_string_lossy(), Some(&home)).is_err(), "{}", from.display());
}
}
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(unix)]
#[test]
fn a_protected_place_reached_through_a_linked_parent_is_refused() {
let dir = scratch("linked");
let home = dir.join("home");
let alias = dir.join("alias");
std::os::unix::fs::symlink(&home, &alias).expect("link");
// the home folder is spelled one way, the path another, both naming the same folder
let through_the_link = alias.join("Pictures");
assert!(refuse_protected_place_in("folder.rename", &through_the_link.to_string_lossy(), Some(&home)).is_err());
assert!(
refuse_protected_place_in("folder.rename", &alias.join("Pictures").to_string_lossy(), Some(&alias)).is_err(),
"home spelled through the link"
);
// a link that merely points at Pictures is its own folder: renaming it renames the link
let shortcut = dir.join("shortcut");
std::os::unix::fs::symlink(home.join("Pictures"), &shortcut).expect("link");
assert!(refuse_protected_place_in("folder.rename", &shortcut.to_string_lossy(), Some(&home)).is_ok());
let _ = std::fs::remove_dir_all(&dir);
}
}
+31
View File
@@ -68,6 +68,37 @@ pub fn default_dir() -> Option<PathBuf> {
Some(PathBuf::from(home).join("Pictures").join(DEFAULT_NAME))
}
/// The user's home folder (`%USERPROFILE%` on Windows, `$HOME` elsewhere); `None` when the host has
/// none or it is empty. The one place the home path is read for Local's built-in places, in the
/// engine's rename/move guard and in the sidebar.
pub fn home_folder() -> Option<PathBuf> {
home_folder_from(cfg!(windows), |name| std::env::var_os(name))
}
fn home_folder_from(windows: bool, var: impl Fn(&str) -> Option<std::ffi::OsString>) -> Option<PathBuf> {
var(if windows { "USERPROFILE" } else { "HOME" }).filter(|h| !h.is_empty()).map(PathBuf::from)
}
#[cfg(test)]
mod home_tests {
use super::home_folder_from;
use std::ffi::OsString;
#[test]
fn windows_reads_userprofile_and_the_rest_read_home() {
let env = |name: &str| match name {
"HOME" => Some(OsString::from("/home/example")),
"USERPROFILE" => Some(OsString::from("C:\\Users\\example")),
_ => None,
};
assert_eq!(home_folder_from(true, env).as_deref(), Some(std::path::Path::new("C:\\Users\\example")));
assert_eq!(home_folder_from(false, env).as_deref(), Some(std::path::Path::new("/home/example")));
// only the other platform's variable is set: no home, never a guess from it
assert_eq!(home_folder_from(true, |n| (n == "HOME").then(|| OsString::from("/home/example"))), None);
assert_eq!(home_folder_from(false, |_| Some(OsString::new())), None);
}
}
pub struct Library {
/// The library directory, or a descriptive pseudo-path when the library isn't on disk.
pub dir: PathBuf,
+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),
}
}
}
}
+56 -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,41 @@ pub(super) fn local_now(app: &mut LightcraftApp, ui: &egui::Ui) -> Option<LocalN
Some(LocalNow { places, current, hidden })
}
/// The user's home folder, the engine's own reading of it (so the menu and the rename/move guard
/// agree); empty when the host has none.
fn home_folder() -> String {
lightcraft_engine::library::home_folder().map(|h| h.to_string_lossy().into_owned()).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 +329,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
+10
View File
@@ -1,5 +1,15 @@
# 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. The
`folder.rename` and `folder.move` commands now refuse the home folder and its Pictures, Desktop and Downloads too,
so a script or an agent can't do it either.
## 2026-10-27
### The Edit menu groups Select Only Active Photo and Paste Settings from Previous with their kin