mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-31 21:48:53 -06:00
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())];
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -3,3 +3,4 @@
|
||||
|
||||
mod accessibility;
|
||||
mod docking_workflows;
|
||||
mod local_places;
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user