mirror of
https://github.com/storytold/lightcraft.git
synced 2026-11-01 14:07:34 -06:00
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:
@@ -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,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())];
|
||||
|
||||
@@ -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,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
|
||||
|
||||
Reference in New Issue
Block a user