diff --git a/crates/engine/src/commands/scripts.rs b/crates/engine/src/commands/scripts.rs index 996a98cf4..01963042e 100644 --- a/crates/engine/src/commands/scripts.rs +++ b/crates/engine/src/commands/scripts.rs @@ -236,6 +236,11 @@ fn uninstall(s: &mut Session, p: &Value) -> Result { if !is_script(name) || !is_script_name(name) { return Err(bad(c, format!("`{name}` is not a script file name"))); } + // A subfolder can be a link to files outside the settings folder (an extension's own + // folder), so only what Install puts at the top is removed, never a file through a folder. + if name.contains('/') { + return Err(bad(c, format!("`{name}` is in a subfolder: delete it there yourself (only scripts at the top of the Scripts folders are uninstalled)"))); + } let cfg = s.config.clone().ok_or_else(|| bad(c, "no settings folder"))?; let mut removed = false; for dir in [SCRIPTS_DIR, PANELS_DIR] { @@ -369,7 +374,15 @@ pub fn specs() -> Vec { always, |s, p| install(s, p, true) ), - cmd!("file.uninstallScript", "Uninstall Script", [], None, "{name}", always, uninstall), + cmd!( + "file.uninstallScript", + "Uninstall Script", + [], + None, + "{name (a script at the top of a Scripts folder, not `Folder/File.jsx`)}", + always, + uninstall + ), crate::query!("file.scripts.list", "List Scripts", "{} → [{name (`Folder/File.jsx` in a subfolder), panel, source: installed|sample|extension}]", list), cmd!( "window.scriptPanel", @@ -563,7 +576,7 @@ mod tests { } #[test] - fn scripts_in_a_subfolder_are_listed_run_opened_and_uninstalled_as_folder_and_file() { + fn scripts_in_a_subfolder_are_listed_run_and_opened_as_folder_and_file() { let (mut s, root) = session_in_tmp("folders"); put(&root, "Scripts/Tools/Hello.jsx", "hello"); put(&root, "Scripts/ScriptUI Panels/Motion Kit/Animate.jsx", "animate"); @@ -590,10 +603,29 @@ mod tests { } assert!(["Scripts/a/b/c.jsx", "Scripts/ScriptUI Panels/Top.jsx"].iter().all(|p| root.join("settings").join(p).is_file())); assert!(root.join("outside.jsx").is_file()); + // Uninstall removes only scripts at the top; one in a folder is deleted there by hand. for name in ["Tools/Hello.jsx", "Motion Kit/Animate.jsx"] { - s.execute("file.uninstallScript", json!({"name": name})).unwrap(); + assert!(bad_params(&s.execute("file.uninstallScript", json!({"name": name}))), "{name}"); } - assert!(["Scripts/Tools/Hello.jsx", "Scripts/ScriptUI Panels/Motion Kit/Animate.jsx"].iter().all(|p| !root.join("settings").join(p).exists())); + assert!(["Scripts/Tools/Hello.jsx", "Scripts/ScriptUI Panels/Motion Kit/Animate.jsx"].iter().all(|p| root.join("settings").join(p).is_file())); + let _ = std::fs::remove_dir_all(&root); + } + + /// A subfolder can be a link to an extension's own folder, outside the settings folder: its + /// scripts are listed and run from there, but uninstall never deletes through it. + #[cfg(unix)] + #[test] + fn uninstall_never_deletes_through_a_linked_subfolder() { + let (mut s, root) = session_in_tmp("linked"); + std::fs::create_dir_all(root.join("extension")).unwrap(); + std::fs::write(root.join("extension").join("Animate.jsx"), "animate").unwrap(); + std::os::unix::fs::symlink(root.join("extension"), root.join("settings").join(PANELS_DIR).join("Motion Kit")).unwrap(); + let list = s.execute("file.scripts.list", json!({})).unwrap(); + assert!(list.as_array().unwrap().iter().any(|e| e["name"] == "Motion Kit/Animate.jsx" && e["panel"] == true), "{list}"); + assert_eq!(script_code(&s, "Motion Kit/Animate.jsx", Some(true)), Some((true, "animate".to_string()))); + let r = s.execute("file.uninstallScript", json!({"name": "Motion Kit/Animate.jsx"})); + assert!(bad_params(&r), "{r:?}"); + assert!(root.join("extension").join("Animate.jsx").is_file(), "the extension's own file was deleted"); let _ = std::fs::remove_dir_all(&root); } diff --git a/docs/plugins.md b/docs/plugins.md index d4f40f097..8d883777d 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -41,8 +41,10 @@ journaled and the same as the UI's action. The `effectcraft-script` crate's docs * `file.scripts.list` lists installed and bundled scripts and panels; `file.runScript {name}` runs one; `file.uninstallScript {name}` removes an installed one. * A subfolder of `Scripts/` is a submenu of File ▸ Scripts named after the folder, and its scripts - are named `Folder/File.jsx` in these commands. One level of folders is listed; deeper folders - are not. + are named `Folder/File.jsx` in `file.scripts.list` and `file.runScript`. One level of folders is + listed; deeper folders are not. `file.uninstallScript` removes only scripts at the top of the + Scripts folders: delete a script in a subfolder yourself. A subfolder may be a link to another + folder (an extension's own), and uninstall never deletes a file through it. ## ScriptUI panels @@ -59,7 +61,7 @@ To group panels, for example the several panels of one extension, make a subfold listed as Window ▸ My Tools ▸ Animate.jsx, its tab is titled Animate, and commands name it `My Tools/Animate.jsx` (`window.scriptPanel {name}`). One level of folders is listed. Install ScriptUI Panel… copies a panel to the top of the folder; make the subfolder and move panels into it -yourself. +yourself, and delete them from it yourself too. Run from File ▸ Scripts instead, the same script gets no panel, so it should make a floating palette itself. A minimal panel: