Merge main into #2279; keep main's Scripts subfolders (RESERVED_FOLDERS) next to the Startup/Shutdown constants, merge docs rows

This commit is contained in:
bflatastic
2026-10-30 05:34:26 +07:00
564 changed files with 34296 additions and 7590 deletions
+363 -17
View File
@@ -5,7 +5,8 @@
//! settings on the desktop, so dropping `.jsx` files in those folders works too. File ▸ Scripts
//! lists them with the bundled sample scripts and extensions ([`BUNDLED`]) and runs them by name; ScriptUI
//! panels appear at the bottom of the Window menu and open as dockable panels
//! (`window.scriptPanel`), running with `this` = the panel as in After Effects.
//! (`window.scriptPanel`), running with `this` = the panel as in After Effects. A subfolder of
//! either folder (one level) is a submenu named after it; its scripts are named `Folder/File.jsx`.
//! * **Startup and Shutdown scripts** in `Scripts/Startup/` and `Scripts/Shutdown/` run by
//! themselves, in alphabetical order: at launch (`file.runStartupScripts`) and as EffectCraft
//! quits, after the project is closed (`file.runShutdownScripts`).
@@ -37,6 +38,11 @@ pub const RUNS_SCRIPTS: &[&str] =
const MAX_SCRIPT_BYTES: usize = 16 << 20;
/// Modal dialogs a Shutdown script may open one after another; each closes as if canceled.
const MAX_SHUTDOWN_DIALOGS: usize = 64;
/// [`PANELS_DIR`]'s name in [`SCRIPTS_DIR`]: it holds the panels, so it is not a folder of scripts.
const PANELS_FOLDER: &str = "ScriptUI Panels";
/// Subfolders of Scripts that hold scripts the app runs itself (panels; launch and quit, as in
/// After Effects): never listed as File ▸ Scripts submenus.
const RESERVED_FOLDERS: [&str; 3] = [PANELS_FOLDER, "Startup", "Shutdown"];
/// The ScriptUI panels open when EffectCraft last quit (settings store), reopened at launch.
pub const OPEN_PANELS_FILE: &str = "scriptui_panels.json";
/// Scripts' `app.settings` in the settings store: `{section: {key: value}}`, as After Effects
@@ -126,7 +132,7 @@ pub const BUNDLED: &[(&str, bool, &str, &str)] = &[
/// A script File ▸ Scripts (or the Window menu, for panels) offers.
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct ScriptEntry {
/// File name (`Rename Layers.jsx`): what the menus show and commands take.
/// File name (`Rename Layers.jsx`), or `Folder/File.jsx` in a subfolder: what commands take.
pub name: String,
/// In the ScriptUI Panels folder: opens as a dockable panel.
pub panel: bool,
@@ -134,16 +140,23 @@ pub struct ScriptEntry {
pub source: &'static str,
}
/// Whether `name` ends in `.jsx` or `.js` (any letter case).
fn is_script(name: &str) -> bool {
let l = name.to_ascii_lowercase();
l.ends_with(".jsx") || l.ends_with(".js")
}
/// Whether `name` can name an installed script: a bare file name, never a path (names are joined
/// onto the Scripts folders of the settings store, which takes them as relative paths), and on
/// Windows none of the names [`windows_unsafe_name`] lists.
/// Whether `name` can name an installed script: a file name, or `Folder/File` for one in a
/// subfolder of a Scripts folder (one level, and not one of [`RESERVED_FOLDERS`]: `ScriptUI
/// Panels/…`, `Startup/…`, `Shutdown/…`). Each part is a bare name, never a path (names are joined onto the Scripts folders of
/// the settings store, which takes them as relative paths), and on Windows none of the names
/// [`windows_unsafe_name`] lists.
fn is_script_name(name: &str) -> bool {
is_bare_name(name) && !(cfg!(windows) && windows_unsafe_name(name))
let part = |n: &str| is_bare_name(n) && !(cfg!(windows) && windows_unsafe_name(n));
match name.split_once('/') {
Some((folder, file)) => part(folder) && part(file) && !RESERVED_FOLDERS.iter().any(|r| folder.eq_ignore_ascii_case(r)),
None => part(name),
}
}
/// One path component on every platform: not empty, `.` or `..`, and without `/`, `\` or NUL.
@@ -170,8 +183,13 @@ pub fn scripts(s: &Session) -> Vec<ScriptEntry> {
let mut v: Vec<ScriptEntry> = vec![];
if let Some(cfg) = &s.config {
for (dir, panel) in [(SCRIPTS_DIR, false), (PANELS_DIR, true)] {
for n in cfg.list(dir).into_iter().filter(|n| is_script(n) && is_script_name(n)) {
v.push(ScriptEntry { name: n, panel, source: "installed" });
// The folder itself, then each subfolder, whose scripts are named `Folder/File.jsx`.
let subfolders = cfg.list_dirs(dir).into_iter().map(|f| (format!("{dir}/{f}"), format!("{f}/")));
for (folder, prefix) in std::iter::once((dir.to_string(), String::new())).chain(subfolders) {
let names = cfg.list(&folder).into_iter().map(|n| format!("{prefix}{n}"));
for n in names.filter(|n| is_script(n) && is_script_name(n)) {
v.push(ScriptEntry { name: n, panel, source: "installed" });
}
}
}
}
@@ -231,12 +249,19 @@ fn list(s: &mut Session, _: &Value) -> Result<Value> {
Ok(json!(scripts(s)))
}
/// `file.uninstallScript {name}`: delete an installed script or panel at the top of the Scripts
/// folders.
fn uninstall(s: &mut Session, p: &Value) -> Result<Value> {
let c = "file.uninstallScript";
let name = str_p(p, "name").ok_or_else(|| bad(c, "missing `name`"))?;
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] {
@@ -250,13 +275,17 @@ fn uninstall(s: &mut Session, p: &Value) -> Result<Value> {
}
/// Run an installed or bundled script by name (File ▸ Scripts ▸ `<name>`). A ScriptUI panel run
/// this way gets `this` = a floating palette-like panel window.
/// this way gets `this` = a floating palette-like panel window. A script listed under File ▸
/// Scripts runs before a panel of the same name.
pub(crate) fn run_named(s: &mut Session, name: &str) -> Result<Value> {
let (panel, code) = script_code(s, name, None).ok_or_else(|| bad("file.runScript", format!("no installed or bundled script `{name}`")))?;
let found = script_code(s, name, Some(false)).or_else(|| script_code(s, name, None));
let (panel, code) = found.ok_or_else(|| bad("file.runScript", format!("no installed or bundled script `{name}`")))?;
if panel {
return open_panel(s, &json!({"name": name}));
}
super::file_more::run_js_named(s, &code, name)
let result = super::file_more::run_js_named(s, &code, name)?;
super::file_more::remember_script(s, crate::prefs::RecentScript::Name(name.to_string()));
Ok(result)
}
/// The code a ScriptUI panel runs with: `this` is a dockable Panel titled after the script.
@@ -406,13 +435,15 @@ fn run_shutdown(s: &mut Session, _: &Value) -> Result<Value> {
fn open_panel(s: &mut Session, p: &Value) -> Result<Value> {
let c = "window.scriptPanel";
let name = str_p(p, "name").ok_or_else(|| bad(c, "missing `name` (a script in the ScriptUI Panels folder)"))?.to_string();
let title = name.trim_end_matches(".jsx").trim_end_matches(".js").to_string();
let file = name.rsplit_once('/').map_or(name.as_str(), |(_, f)| f);
let title = file.trim_end_matches(".jsx").trim_end_matches(".js").to_string();
if let Some(w) = s.script_ui.windows.iter().find(|w| w.kind == scriptui::WindowKind::Panel && w.script == name) {
let id = w.id;
s.events.push(Event::Frontend { command: c.into(), params: json!({"window": id}) });
return Ok(json!({"window": id, "open": true}));
}
let (_, code) = script_code(s, &name, Some(true)).or_else(|| script_code(s, &name, None)).ok_or_else(|| bad(c, format!("no ScriptUI panel `{name}`")))?;
// Only panels: a script opened as one would be remembered, then forgotten at the next launch.
let (_, code) = script_code(s, &name, Some(true)).ok_or_else(|| bad(c, format!("no ScriptUI panel `{name}`")))?;
let out = super::file_more::run_js_named(s, &panel_wrapper(&title, &code), &name)?;
let id = s.script_ui.windows.iter().rev().find(|w| w.kind == scriptui::WindowKind::Panel && w.script == name).map(|w| w.id);
if let Some(id) = id {
@@ -428,6 +459,7 @@ fn open_panel(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"window": id, "result": out}))
}
/// The commands of File ▸ Scripts, ScriptUI panels and windows, and script settings.
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
@@ -448,14 +480,22 @@ pub fn specs() -> Vec<CommandSpec> {
always,
|s, p| install(s, p, true)
),
cmd!("file.uninstallScript", "Uninstall Script", [], None, "{name}", always, uninstall),
crate::query!("file.scripts.list", "List Scripts", "{} → [{name, panel, source: installed|sample|extension}]", list),
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",
"ScriptUI Panel",
[],
None,
"{name (a script in the ScriptUI Panels folder, e.g. `Layer Tools.jsx`)} → opens it as a dockable panel",
"{name (a script in the ScriptUI Panels folder, e.g. `Layer Tools.jsx`, or `Folder/Panel.jsx` in a subfolder)} → opens it as a dockable panel",
always,
open_panel
),
@@ -612,13 +652,15 @@ mod tests {
}
}
/// Bundled and ordinary script file names are accepted; paths are not.
#[test]
fn script_names_are_bare_file_names() {
let more = ["..jsx", "a..b.js", "Äpfel ✓.jsx", "console.jsx", "Concert.jsx", "NULL.jsx", "COM10.jsx", "LPT.jsx"];
for name in BUNDLED.iter().map(|b| b.0).chain(more) {
assert!(is_script(name) && is_script_name(name), "{name}");
}
for name in PATHS {
// `a/b.jsx` is a script in a folder (see below).
for name in PATHS.into_iter().filter(|n| *n != "a/b.jsx") {
assert!(!is_script_name(name), "{name:?}");
}
for name in WINDOWS_ONLY {
@@ -636,6 +678,141 @@ mod tests {
}
}
/// `Folder/File.jsx` is accepted; deeper, upward, absolute and `\` names are not.
#[test]
fn script_names_may_have_one_folder() {
for name in ["Motion Kit/Animate.jsx", "a/b.jsx", "Äpfel/✓.js", "..jsx/x.jsx"] {
assert!(is_script_name(name), "{name:?}");
}
// Deeper, up, absolute, an empty part, a `\` path, and the panels' own folder in `Scripts`.
let refused = ["a/b/c.jsx", "../x.jsx", "a/../x.jsx", "./x.jsx", "a/..", "/x.jsx", "a//x.jsx", "a/", "a\\b.jsx", "a/b\\c.jsx", "a\0/x.jsx"];
for name in refused.into_iter().chain(["ScriptUI Panels/x.jsx", "scriptui panels/x.jsx"]) {
assert!(!is_script_name(name), "{name:?}");
}
for name in ["In:Out/x.jsx", "con/x.jsx", "x/COM1.jsx", "x./y.jsx"] {
assert_eq!(is_script_name(name), !cfg!(windows), "{name:?}");
}
}
/// Writes `code` to `rel` in the settings folder of [`session_in_tmp`]'s `root`.
fn put(root: &std::path::Path, rel: &str, code: &str) {
let p = root.join("settings").join(rel);
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
std::fs::write(p, code).unwrap();
}
/// Every script command takes `Folder/File.jsx` names and refuses names outside one folder level.
#[test]
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");
put(&root, "Scripts/ScriptUI Panels/Top.jsx", "top");
// Two folders deep: neither listed nor run.
put(&root, "Scripts/a/b/c.jsx", "too deep");
put(&root, "Scripts/ScriptUI Panels/Motion Kit/sub/Deep.jsx", "too deep");
let list = s.execute("file.scripts.list", json!({})).unwrap();
let installed: Vec<(&str, bool)> =
list.as_array().unwrap().iter().filter(|e| e["source"] == "installed").map(|e| (e["name"].as_str().unwrap(), e["panel"] == true)).collect();
assert_eq!(installed, [("Tools/Hello.jsx", false), ("Motion Kit/Animate.jsx", true), ("Top.jsx", true)]);
assert_eq!(s.execute("file.runScript", json!({"name": "Tools/Hello.jsx"})).unwrap()["ran"], "hello");
// A panel in a folder is titled after its file.
let r = s.execute("window.scriptPanel", json!({"name": "Motion Kit/Animate.jsx"})).unwrap();
let ran = r["result"]["ran"].as_str().unwrap_or_default();
assert!(ran.contains("animate") && ran.contains(r#"__uiDockPanel("Animate")"#), "{r}");
assert_eq!(script_code(&s, "Motion Kit/Animate.jsx", Some(true)), Some((true, "animate".to_string())));
// A script that is not a panel doesn't open as one.
for name in ["Tools/Hello.jsx", "Rename Layers.jsx"] {
assert!(bad_params(&s.execute("window.scriptPanel", json!({"name": name}))), "{name}");
}
for name in [
"../x.jsx",
"a/b/c.jsx",
"a\\b.jsx",
"Motion Kit/sub/Deep.jsx",
"ScriptUI Panels/Top.jsx",
"Startup/Run.jsx",
"shutdown/Run.jsx",
"Tools/../../../outside.jsx",
] {
assert_eq!(script_code(&s, name, None), None, "{name:?}");
for c in ["file.runScript", "window.scriptPanel", "file.uninstallScript"] {
let r = s.execute(c, json!({"name": name}));
assert!(bad_params(&r), "{c} `{name}`: {r:?}");
}
}
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"] {
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).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);
}
/// Window ▸ Motion Kit ▸ Animate.jsx: a subfolder is a submenu named after it, where the folder
/// sorts; the entries outside folders stay as they were.
#[test]
fn a_subfolder_is_a_submenu() {
use crate::menus::{MenuNode, dynamic, dynamic_nodes};
let (s, root) = session_in_tmp("submenus");
put(&root, "Scripts/ScriptUI Panels/Motion Kit/Animate.jsx", "");
put(&root, "Scripts/ScriptUI Panels/Motion Kit/Rig.jsx", "");
put(&root, "Scripts/ScriptUI Panels/Top.jsx", "");
put(&root, "Scripts/Tools/Hello.jsx", "");
let cx = Default::default();
let item =
|label: &str, command: &str, name: &str| json!({"type": "item", "label": label, "command": command, "params": {"name": name}, "shortcut": null});
let menus = [
(
"scriptPanels",
json!({"type": "submenu", "label": "Motion Kit", "children": [
item("Animate.jsx", "window.scriptPanel", "Motion Kit/Animate.jsx"),
item("Rig.jsx", "window.scriptPanel", "Motion Kit/Rig.jsx"),
]}),
),
("scripts", json!({"type": "submenu", "label": "Tools", "children": [item("Hello.jsx", "file.runScript", "Tools/Hello.jsx")]})),
];
for (menu, folder) in menus {
let (nodes, _) = dynamic_nodes(&s, menu, &cx);
let (entries, _) = dynamic(&s, menu, &cx);
let items: Vec<_> = nodes.iter().filter_map(|n| if let MenuNode::Item(e) = n { Some(e) } else { None }).collect();
assert_eq!(items, entries.iter().filter(|e| !e.label.contains('/')).collect::<Vec<_>>(), "{menu}");
let folders: Vec<Value> = nodes.iter().filter(|n| matches!(n, MenuNode::Submenu { .. })).map(|n| serde_json::to_value(n).unwrap()).collect();
assert_eq!(folders, [folder], "{menu}");
}
let (panels, _) = dynamic_nodes(&s, "scriptPanels", &cx);
let labels: Vec<&str> = panels
.iter()
.filter_map(|n| match n {
MenuNode::Item(e) => Some(e.label.as_str()),
MenuNode::Submenu { label, .. } => Some(label),
_ => None,
})
.collect();
assert_eq!(labels, ["Ease Presets.jsx", "Layer Tools.jsx", "Motion Kit", "Top.jsx"]);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn uninstall_takes_a_script_name_not_a_path() {
let (mut s, root) = session_in_tmp("uninstall");
@@ -665,6 +842,175 @@ mod tests {
let _ = std::fs::remove_dir_all(&root);
}
/// File ▸ Scripts ▸ Recent Script Files: the ten scripts run last, newest first, kept in the
/// settings; Cmd+Alt+Shift+D runs the first one again.
#[test]
fn recent_script_files_run_again() {
use crate::prefs::{MAX_RECENT_SCRIPTS, PREFS_FILE, Prefs, RecentScript};
let (mut s, root) = session_in_tmp("recent");
assert_eq!(crate::menus::dynamic(&s, "recentScripts", &Default::default()), (vec![], Some("No Recent Script Files")));
assert!(bad_params(&s.execute("file.runScript", json!({"recent": 0}))));
assert_eq!(s.shortcuts().shortcut_of("file.runScript", &json!({"recent": 0})), Some("Cmd+Alt+Shift+D"));
let tool = root.join("Tool.jsx");
std::fs::write(&tool, "tool").unwrap();
let tool = tool.to_string_lossy().into_owned();
s.execute("file.runScript", json!({"path": tool})).unwrap();
s.execute("file.runScript", json!({"name": "Rename Layers.jsx"})).unwrap();
// A ScriptUI panel opened from File ▸ Scripts is not a recent script file.
std::fs::write(root.join("settings").join(PANELS_DIR).join("Panel.jsx"), "panel").unwrap();
s.execute("file.runScript", json!({"name": "Panel.jsx"})).unwrap();
let (path, name) = (RecentScript::Path(tool.clone()), RecentScript::Name("Rename Layers.jsx".into()));
assert_eq!(s.prefs.recent_scripts, [name.clone(), path.clone()]);
let (e, _) = crate::menus::dynamic(&s, "recentScripts", &Default::default());
assert_eq!(e.iter().map(|e| e.label.as_str()).collect::<Vec<_>>(), ["Rename Layers.jsx", "Tool.jsx"]);
assert_eq!((e[1].command.as_str(), &e[1].params), ("file.runScript", &json!({"recent": 1})));
// Running one again runs that file and moves it to the top.
assert_eq!(s.execute("file.runScript", json!({"recent": 1})).unwrap()["ran"], "tool");
assert_eq!(s.prefs.recent_scripts, [path.clone(), name.clone()]);
let sample = BUNDLED.iter().find(|b| b.0 == "Rename Layers.jsx").unwrap().3;
assert_eq!(s.execute("file.runScript", json!({"recent": 1})).unwrap()["ran"], sample);
assert!(bad_params(&s.execute("file.runScript", json!({"recent": 2}))));
assert!(bad_params(&s.execute("file.runScript", json!({"recent": "Tool.jsx"}))));
// A file named like a bundled script runs again as that file.
let local = root.join("Rename Layers.jsx");
std::fs::write(&local, "local").unwrap();
s.execute("file.runScript", json!({"path": local.to_string_lossy()})).unwrap();
assert_eq!(s.execute("file.runScript", json!({"recent": 0})).unwrap()["ran"], "local");
assert_eq!(s.execute("file.runScript", json!({"recent": 1})).unwrap()["ran"], sample);
// Ten at most, saved with the settings.
let mut last = String::new();
for i in 0..MAX_RECENT_SCRIPTS + 2 {
let f = root.join(format!("Step {i}.jsonl"));
std::fs::write(&f, "").unwrap();
last = f.to_string_lossy().into_owned();
s.execute("file.runScript", json!({"path": last})).unwrap();
}
assert_eq!(s.prefs.recent_scripts.len(), MAX_RECENT_SCRIPTS);
assert_eq!(s.prefs.recent_scripts[0], RecentScript::Path(last));
let saved = std::fs::read_to_string(root.join("settings").join(PREFS_FILE)).unwrap();
assert_eq!(Prefs::from_json(&saved).recent_scripts, s.prefs.recent_scripts);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn recent_script_history_uses_the_selected_script() {
let (mut s, root) = session_in_tmp("recent-panel-collision");
let name = "Rename Layers.jsx";
std::fs::write(root.join("settings").join(PANELS_DIR).join(name), "panel").unwrap();
assert!(super::scripts(&s).iter().any(|entry| entry.name == name && !entry.panel));
assert!(script_code(&s, name, None).unwrap().0);
let sample = BUNDLED.iter().find(|b| b.0 == name).unwrap().3;
assert_eq!(s.execute("file.runScript", json!({"name": name})).unwrap()["ran"], sample);
assert!(super::open_panel_names(&s).is_empty(), "the listed script runs instead of the panel");
assert_eq!(s.prefs.recent_scripts, [crate::prefs::RecentScript::Name(name.to_string())]);
let _ = std::fs::remove_dir_all(root);
}
#[test]
fn recent_script_history_keeps_successful_entries_when_scripts_fail() {
fn fail_or_echo(_: &mut Session, request: &ScriptRequest) -> Value {
if request.code == "fail" { json!({"error": {"message": "script failed"}}) } else { json!({"ran": request.code}) }
}
let (mut s, root) = session_in_tmp("recent-failed");
s.script = Some(fail_or_echo);
let good = root.join("good.jsonl");
std::fs::write(&good, "[]").unwrap();
s.execute("file.runScript", json!({"path": good})).unwrap();
let remembered = s.prefs.recent_scripts.clone();
for (name, code) in [("parse.jsonl", "{"), ("step.jsonl", r#"{"command":"does.not.exist"}"#), ("missing-command.jsonl", "{}"), ("runtime.jsx", "fail")]
{
let path = root.join(name);
std::fs::write(&path, code).unwrap();
assert!(s.execute("file.runScript", json!({"path": path})).is_err(), "{name}");
assert_eq!(s.prefs.recent_scripts, remembered, "{name}");
}
std::fs::write(root.join("settings").join(SCRIPTS_DIR).join("Failed.jsx"), "fail").unwrap();
assert!(s.execute("file.runScript", json!({"name": "Failed.jsx"})).is_err());
assert_eq!(s.prefs.recent_scripts, remembered);
let _ = std::fs::remove_dir_all(root);
}
/// Reads relative paths from a working directory that a test can change.
struct InFolder(std::sync::Mutex<PathBuf>);
impl InFolder {
fn at(&self, path: &str) -> PathBuf {
self.0.lock().unwrap().join(path)
}
}
impl crate::Services for InFolder {
fn read_file(&self, path: &str) -> std::io::Result<Vec<u8>> {
std::fs::read(self.at(path))
}
fn write_file(&self, path: &str, data: &[u8]) -> std::io::Result<()> {
std::fs::write(self.at(path), data)
}
fn full_path(&self, path: &str) -> Option<String> {
Some(self.at(path).to_string_lossy().into_owned())
}
}
/// Reads from a folder but can't make paths absolute.
struct NoFullPath(PathBuf);
impl crate::Services for NoFullPath {
fn read_file(&self, path: &str) -> std::io::Result<Vec<u8>> {
std::fs::read(self.0.join(path))
}
fn write_file(&self, path: &str, data: &[u8]) -> std::io::Result<()> {
std::fs::write(self.0.join(path), data)
}
fn full_path(&self, _path: &str) -> Option<String> {
None
}
}
/// A script whose path can't be made absolute still runs, and Recent Script Files does not
/// keep its relative path.
#[test]
fn unresolved_script_paths_are_not_remembered() {
let (mut s, root) = session_in_tmp("recent-unresolved");
std::fs::write(root.join("Tool.jsx"), "a").unwrap();
std::fs::write(root.join("steps.jsonl"), "[]").unwrap();
s.services = Arc::new(NoFullPath(root.clone()));
assert_eq!(s.execute("file.runScript", json!({"path": "Tool.jsx"})).unwrap()["ran"], "a");
s.execute("file.runScript", json!({"path": "steps.jsonl"})).unwrap();
assert!(s.prefs.recent_scripts.is_empty(), "{:?}", s.prefs.recent_scripts);
let _ = std::fs::remove_dir_all(&root);
}
/// A script run from a relative path runs the same file again from Recent Script Files after
/// the working directory changes, and two scripts with the same file name show their paths.
#[test]
fn recent_script_files_keep_the_file_that_ran() {
let (mut s, root) = session_in_tmp("recent-relative");
let (a, b) = (root.join("a"), root.join("b"));
for (dir, code) in [(&a, "a"), (&b, "b")] {
std::fs::create_dir_all(dir).unwrap();
std::fs::write(dir.join("Tool.jsx"), code).unwrap();
}
let services = Arc::new(InFolder(std::sync::Mutex::new(a.clone())));
s.services = services.clone();
assert_eq!(s.execute("file.runScript", json!({"path": "Tool.jsx"})).unwrap()["ran"], "a");
*services.0.lock().unwrap() = b.clone();
assert_eq!(s.execute("file.runScript", json!({"recent": 0})).unwrap()["ran"], "a");
let (e, _) = crate::menus::dynamic(&s, "recentScripts", &Default::default());
assert_eq!(e.iter().map(|e| e.label.as_str()).collect::<Vec<_>>(), ["Tool.jsx"]);
assert_eq!(s.execute("file.runScript", json!({"path": "Tool.jsx"})).unwrap()["ran"], "b");
let (e, _) = crate::menus::dynamic(&s, "recentScripts", &Default::default());
let (a, b) = (a.join("Tool.jsx").to_string_lossy().into_owned(), b.join("Tool.jsx").to_string_lossy().into_owned());
assert_eq!(e.iter().map(|e| e.label.as_str()).collect::<Vec<_>>(), [b.as_str(), a.as_str()]);
let _ = std::fs::remove_dir_all(&root);
}
/// Native script paths are kept absolute.
#[test]
fn native_full_paths_are_absolute() {
use crate::Services;
assert!(crate::FsServices.full_path("Tool.jsx").is_some_and(|p| std::path::Path::new(&p).is_absolute()));
}
#[test]
fn only_scripts_are_read_or_removed() {
// A settings file in the Scripts folder that is not a script (.jsx / .js).