Browse / github.com/storytold/effectcraft / patch
Scripts: subfolders of the Scripts and ScriptUI Panels folders become submenus
Scripts: subfolders of the Scripts and ScriptUI Panels folders become submenus An extension that ships several ScriptUI panels adds one entry per panel at the bottom of the Window menu. Motion Kit, for example, has five panels (Animate, Rig, Shapes, Comp and a combined one) plus a menu panel, so it adds six entries in a row. This PR lets a subfolder of `Scripts/ScriptUI Panels/` group them: `Scripts/ScriptUI Panels/Motion Kit/Animate.jsx` shows as Window ▸ Motion Kit ▸ Animate.jsx. A subfolder of `Scripts/` works the same way under File ▸ Scripts. Files at the top of either folder show and run exactly as before. ## Names A script in a subfolder is named `Folder/File.j
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- onlykshitij (imported from pull request #2289, open)
- Base
01c904b041064ca8ed03e62bffd07f974dee07f6- Size
- +312 −24 in 9 files
- Review
- pending
- Shared
- 2026-10-28
- Id
18528d149fb7df4cf3300e6c
How it went
No other operator has reported using it yet.
Diff
raw · apps/effectcraft-web/src/store.rs crates/engine/src/cmd/scripts.rs crates/engine/src/menus.rs crates/engine/src/settings/config.rs crates/script/src/tests/panels.rs crates/ui-egui/src/menus/bar.rs crates/ui-egui/src/native_menu.rs docs/plugins.md docs/scripting-parity.md
@@ -366,6 +366,21 @@ impl ConfigStore for WebConfig { v.sort(); v }+ /// The first name part of the keys under `dir/`.+ fn list_dirs(&self, dir: &str) -> Vec<String> {+ let pre = format!("{CONFIG}{}/", dir.trim_end_matches('/'));+ let mut v: Vec<String> = self+ .store+ .lock()+ .list(&pre)+ .into_iter()+ .filter_map(|(k, ..)| k.strip_prefix(&pre)?.split_once('/').map(|(d, _)| d.to_string()))+ .filter(|d| !d.is_empty())+ .collect();+ v.sort();+ v.dedup();+ v+ } fn dir(&self) -> Option<PathBuf> { Some(PathBuf::from("/")) }@@ -533,6 +548,22 @@ mod tests { assert_eq!(s.file("/clip.mp4").as_deref(), Some(&b"abc"[..])); } + /// Scripts in a subfolder of the Scripts folder (`Tools/Hello.jsx`) are listed, as on the desktop.+ #[test]+ fn config_store_lists_folders() {+ let c = WebConfig::new(store());+ for name in ["Scripts/Top.jsx", "Scripts/Tools/Hello.jsx", "Scripts/Tools/More.jsx", "Scripts/ScriptUI Panels/Kit/A.jsx"] {+ c.write(name, "x").unwrap();+ }+ assert_eq!(c.list("Scripts"), ["Top.jsx"]);+ assert_eq!(c.list_dirs("Scripts"), ["ScriptUI Panels", "Tools"]);+ assert_eq!(c.list_dirs("Scripts/ScriptUI Panels"), ["Kit"]);+ let mut s = effectcraft_engine::Session { config: Some(Arc::new(c)), ..Default::default() };+ let list = s.execute("file.scripts.list", serde_json::json!({})).unwrap();+ let installed: Vec<&str> = list.as_array().unwrap().iter().filter(|e| e["source"] == "installed").filter_map(|e| e["name"].as_str()).collect();+ assert_eq!(installed, ["Tools/Hello.jsx", "Tools/More.jsx", "Top.jsx", "Kit/A.jsx"]);+ }+ #[test] fn config_store_and_file_ops() { let s = store();@@ -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`. //! * **ScriptUI windows** (`scriptui.*`): list the open script windows, read their control //! trees, click buttons and set values like a user, close them (see [`crate::scriptui`]). //! * **Script settings** (`script.settings.*`): After Effects' `app.settings`, string values by@@ -22,6 +23,8 @@ use crate::{EngineError, Event, Result, Session, cmd, scriptui}; /// The settings-store folders of installed scripts. pub const SCRIPTS_DIR: &str = "Scripts"; pub const PANELS_DIR: &str = "Scripts/ScriptUI Panels";+/// [`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"; /// 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@@ -111,7 +114,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,@@ -119,16 +122,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 `ScriptUI Panels/…`, the panels' own+/// folder). 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) && !folder.eq_ignore_ascii_case(PANELS_FOLDER),+ None => part(name),+ } } /// One path component on every platform: not empty, `.` or `..`, and without `/`, `\` or NUL.@@ -155,8 +165,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" });+ } } } }@@ -216,12 +231,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] {@@ -312,13 +334,15 @@ fn restore_panels(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 {@@ -334,6 +358,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!(@@ -354,14 +379,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 ),@@ -500,13 +533,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 {@@ -524,6 +559,132 @@ 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", "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");@@ -75,7 +75,8 @@ fn holder_of(s: &Session, keys: &str) -> Option<String> { /// none: `recentProjects`, `recentFootage`, `recentPresets`, `history` (undo steps, newest /// first), `view3dShortcuts`, `workspaceShortcuts`, `savedWorkspaces` (Window ▸ Workspace) and /// `openViewers` (open compositions other than the active one), `scripts` (File ▸ Scripts) and-/// `scriptPanels` (ScriptUI panels).+/// `scriptPanels` (ScriptUI panels; a script in a subfolder is labelled `Folder/File.jsx` here,+/// and [`dynamic_nodes`] puts it in the folder's submenu). pub fn dynamic(s: &Session, name: &str, cx: &DynCtx) -> (Vec<MenuEntry>, Option<&'static str>) { use serde_json::json; let indexed = |list: &[String], cmd: &str, stem: bool| -> Vec<MenuEntry> {@@ -153,6 +154,31 @@ pub fn dynamic(s: &Session, name: &str, cx: &DynCtx) -> (Vec<MenuEntry>, Option< } } +/// [`dynamic`] as menu nodes, which is what frontends draw: scripts and panels in a subfolder+/// (`Motion Kit/Animate.jsx`) go into a submenu named after the folder (`Motion Kit ▸+/// Animate.jsx`), placed where the folder sorts among the other entries.+pub fn dynamic_nodes(s: &Session, name: &str, cx: &DynCtx) -> (Vec<MenuNode>, Option<&'static str>) {+ let (entries, empty) = dynamic(s, name, cx);+ let scripts = matches!(name, "scripts" | "scriptPanels");+ let mut out: Vec<MenuNode> = vec![];+ for mut e in entries {+ let Some((folder, file)) = e.label.split_once('/').filter(|_| scripts).map(|(d, f)| (d.to_string(), f.to_string())) else {+ out.push(MenuNode::Item(e));+ continue;+ };+ e.label = file;+ let open = out.iter_mut().find_map(|n| match n {+ MenuNode::Submenu { label, children } if *label == folder => Some(children),+ _ => None,+ });+ match open {+ Some(children) => children.push(MenuNode::Item(e)),+ None => out.push(MenuNode::Submenu { label: folder, children: vec![MenuNode::Item(e)] }),+ }+ }+ (out, empty)+}+ /// Display label of a submenu (the "Assign Shortcut to …" submenus name the current view or /// workspace, like After Effects). pub fn submenu_label(s: &Session, label: &str, cx: &DynCtx) -> String {@@ -24,7 +24,9 @@ pub trait ConfigStore: Send + Sync { } Ok(text) }+ /// Write entry `name`, replacing any earlier text. fn write(&self, name: &str, data: &str) -> std::io::Result<()>;+ /// Remove entry `name`; a missing entry is no error. fn remove(&self, name: &str) -> std::io::Result<()>; /// Names of the entries directly in `dir` (a `/`-separated name prefix such as `Scripts`), /// sorted. Stores that can't list return nothing.@@ -32,6 +34,11 @@ pub trait ConfigStore: Send + Sync { let _ = dir; vec![] }+ /// Names of the folders directly in `dir`, sorted, like [`ConfigStore::list`].+ fn list_dirs(&self, dir: &str) -> Vec<String> {+ let _ = dir;+ vec![]+ } /// The folder next to the configuration (default Auto-Save folder for untitled projects), /// in the namespace of [`ConfigStore::files`]; `None` when there is none. fn dir(&self) -> Option<PathBuf> {@@ -107,6 +114,15 @@ impl ConfigStore for MemoryConfig { let Ok(m) = self.files.lock() else { return vec![] }; m.keys().filter_map(|k| k.strip_prefix(&pre)).filter(|n| !n.is_empty() && !n.contains('/')).map(str::to_string).collect() }+ /// The first name part of the keys under `dir/`.+ fn list_dirs(&self, dir: &str) -> Vec<String> {+ let pre = format!("{}/", dir.trim_end_matches('/'));+ let Ok(m) = self.files.lock() else { return vec![] };+ let mut v: Vec<String> = m.keys().filter_map(|k| k.strip_prefix(&pre)?.split_once('/')).map(|(d, _)| d.to_string()).filter(|d| !d.is_empty()).collect();+ v.sort();+ v.dedup();+ v+ } } /// Files in one directory, written atomically.@@ -153,6 +169,13 @@ impl ConfigStore for DirConfig { v.sort(); v }+ /// The folders in `dir`, following links like [`ConfigStore::list`] does for files.+ fn list_dirs(&self, dir: &str) -> Vec<String> {+ let Ok(rd) = std::fs::read_dir(self.dir.join(dir)) else { return vec![] };+ let mut v: Vec<String> = rd.flatten().filter(|e| e.path().is_dir()).map(|e| e.file_name().to_string_lossy().to_string()).collect();+ v.sort();+ v+ } fn remove(&self, name: &str) -> std::io::Result<()> { match std::fs::remove_file(self.dir.join(name)) { Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(e),@@ -86,3 +86,27 @@ fn open_panels_open_again_at_the_next_launch() { let mut s = session(&store); assert_eq!(s.execute("window.restoreScriptPanels", json!({})).unwrap(), json!({"opened": [], "failed": []})); }++/// A panel in a subfolder (Window ▸ Motion Kit ▸ Animate.jsx) is `Motion Kit/Animate.jsx`: it opens+/// titled after its file and opens again at the next launch.+#[test]+fn a_panel_in_a_subfolder_opens_and_opens_again_at_the_next_launch() {+ let store = Arc::new(MemoryConfig::default());+ store.write(&format!("{PANELS_DIR}/Motion Kit/Animate.jsx"), PANEL).unwrap();+ let mut s = session(&store);+ 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}");+ let r = s.execute("window.scriptPanel", json!({"name": "Motion Kit/Animate.jsx"})).unwrap();+ let w = s.script_ui.window(r["window"].as_u64().unwrap() as u32).unwrap();+ assert_eq!((w.kind, w.title.as_str()), (WindowKind::Panel, "Animate"));+ assert_eq!(remembered(&store), json!({"open": ["Motion Kit/Animate.jsx"]}));+ // A saved name that leaves the folder, or goes two folders deep, fails and is forgotten.+ store.write(OPEN_PANELS_FILE, &json!({"open": ["../Top.jsx", "Motion Kit/Animate.jsx", "a/b/c.jsx"]}).to_string()).unwrap();+ drop(s);+ let mut s = session(&store);+ let r = s.execute("window.restoreScriptPanels", json!({})).unwrap();+ assert_eq!(r["opened"], json!(["Motion Kit/Animate.jsx"]));+ assert_eq!(r["failed"].as_array().unwrap().iter().map(|f| f["name"].as_str().unwrap()).collect::<Vec<_>>(), ["../Top.jsx", "a/b/c.jsx"]);+ assert!(panel_window(&s, "Motion Kit/Animate.jsx").is_some());+ assert_eq!(remembered(&store), json!({"open": ["Motion Kit/Animate.jsx"]}));+}@@ -149,13 +149,18 @@ fn menu_nodes( // saved workspaces. let ws = app.ui.workspace.clone(); let saved = app.saved_workspace_names();- let (entries, empty) = effectcraft_engine::menus::dynamic(&app.session, name, &dyn_ctx(&ws, &saved));- if entries.is_empty()+ let (nodes, empty) = effectcraft_engine::menus::dynamic_nodes(&app.session, name, &dyn_ctx(&ws, &saved));+ if nodes.is_empty() && let Some(e) = empty { ui.add_enabled(false, egui::Button::new((gutter(false), e))); }- for (i, e) in entries.iter().enumerate() {+ for (i, n) in nodes.iter().enumerate() {+ // A folder of scripts or panels: a submenu.+ let MenuNode::Item(e) = n else {+ menu_nodes(app, ui, std::slice::from_ref(n), clicked, nav, depth);+ continue;+ }; let tip = match name.as_str() { "recentProjects" => app.session.prefs.recent_projects.get(i).cloned(), "recentFootage" => app.session.prefs.recent_footage.get(i).cloned(),@@ -149,6 +149,7 @@ pub fn build_now(app: &EffectcraftApp, ctx: &egui::Context) -> NativeMenu { /// Build the native menu spec for the app's current state. pub fn build(app: &EffectcraftApp) -> NativeMenu {+ /// Append the native nodes of `nodes` to `out`; item ids start with `path`. fn rec(app: &EffectcraftApp, nodes: &[MenuNode], path: &str, out: &mut Vec<NativeNode>) { let mut services_added = false; for (i, n) in nodes.iter().enumerate() {@@ -159,8 +160,8 @@ pub fn build(app: &EffectcraftApp) -> NativeMenu { // Recent projects / footage / presets, undo history, shortcut slots, viewers, // saved workspaces. let saved = app.saved_workspace_names();- let (entries, empty) = effectcraft_engine::menus::dynamic(&app.session, name, &crate::menus::dyn_ctx(&app.ui.workspace, &saved));- if entries.is_empty()+ let (nodes, empty) = effectcraft_engine::menus::dynamic_nodes(&app.session, name, &crate::menus::dyn_ctx(&app.ui.workspace, &saved));+ if nodes.is_empty() && let Some(e) = empty { out.push(NativeNode::Item(NativeItem {@@ -174,7 +175,12 @@ pub fn build(app: &EffectcraftApp) -> NativeMenu { checked: None, })); }- for (k, e) in entries.iter().enumerate() {+ for (k, n) in nodes.iter().enumerate() {+ // A folder of scripts or panels: a submenu.+ let MenuNode::Item(e) = n else {+ rec(app, std::slice::from_ref(n), &format!("{id}.d{k}"), out);+ continue;+ }; out.push(NativeNode::Item(NativeItem { id: format!("{id}.d{k}"), label: e.label.clone(),@@ -12,11 +12,11 @@ Guide): | After Effects | EffectCraft | | |---|---|---| | Effect plug-ins (C/C++ SDK, `.aex` / `.plugin`) | [Effect plug-ins](#effect-plug-ins): WebAssembly modules (plug-in API v1) or Rust; the `Plug-ins` folder next to the settings loads at start-up | Different ABI: After Effects SDK plug-ins can't run (G8) |-| Scripts folder: its scripts are listed in File ▸ Scripts | `Scripts/` in the settings folder (File ▸ Scripts ▸ Install Script File… copies a script there), listed in File ▸ Scripts with the bundled samples | Same |+| Scripts folder: its scripts are listed in File ▸ Scripts | `Scripts/` in the settings folder (File ▸ Scripts ▸ Install Script File… copies a script there), listed in File ▸ Scripts with the bundled samples; a subfolder (one level) is a submenu named after it | Same | | File ▸ Scripts ▸ Run Script File… (`.jsx`, `.jsxbin`) | `.jsx` / `.js` | Gap: `.jsxbin` (an undocumented binary encoding) | | ExtendScript preprocessor directives (`#include`, `#target`, `#targetengine`) | `#include` / `//@include` / `#includepath` inline files relative to the script (once each, depth-limited, file gate applies); `#target`, `#targetengine`, `#strict` are ignored | Same | | `Scripts/Startup` and `Scripts/Shutdown`: run in alphabetical order when the app starts and quits | Not run | Gap |-| `Scripts/ScriptUI Panels`: listed at the bottom of the Window menu, dockable like the app's own panels, `this` is the panel | `Scripts/ScriptUI Panels/` in the settings folder (File ▸ Scripts ▸ Install ScriptUI Panel… copies one there), listed at the bottom of the Window menu, docked like any panel, `this` is the panel; the panels open when the app quits open again at the next launch | Same. Where a panel was docked isn't kept: workspace layouts aren't saved between launches |+| `Scripts/ScriptUI Panels`: listed at the bottom of the Window menu, dockable like the app's own panels, `this` is the panel | `Scripts/ScriptUI Panels/` in the settings folder (File ▸ Scripts ▸ Install ScriptUI Panel… copies one there), listed at the bottom of the Window menu (a subfolder, one level, is a submenu named after it), docked like any panel, `this` is the panel; the panels open when the app quits open again at the next launch | Same. Where a panel was docked isn't kept: workspace layouts aren't saved between launches | | Keyboard shortcuts for scripts (the Keyboard Shortcuts editor lists the scripts of the Scripts folder) | Shortcuts bind to commands and menu entries; the File ▸ Scripts and Window menu entries of scripts aren't offered | Gap | | Running a script from the command line (`afterfx -r script.jsx`) | `effectcraft-cli script`, the control channel's and MCP's `script.run` | Same purpose, other entry points | | `app.settings` (kept in the preferences between sessions) | Kept in `script_settings.json` in the settings folder (`script.settings.get` / `script.settings.save`) | Same |@@ -40,6 +40,11 @@ journaled and the same as the UI's action. The `effectcraft-script` crate's docs folder). It is listed in File ▸ Scripts at once (After Effects asks for a restart). * `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 `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 @@ -51,6 +56,13 @@ other panel. Closing the tab closes the panel (its `onClose` runs). Panels open quits open again at the next launch (`window.restoreScriptPanels`, which the desktop and web apps run at start-up; the list is `scriptui_panels.json` in the settings folder). +To group panels, for example the several panels of one extension, make a subfolder of+`Scripts/ScriptUI Panels/` and put them in it: `Scripts/ScriptUI Panels/My Tools/Animate.jsx` is+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, 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: @@ -22,7 +22,7 @@ our own scripts; no real-world script library has been run against both apps. | `$.evalFile`, `BridgeTalk` | Evaluate another script file; identify the host application and message other Adobe applications | `$.evalFile` evaluates a file relative to the calling file (nested at most 32 deep, under the scripts' file gate) and returns its last value; `BridgeTalk` names the host honestly (`appName` `effectcraft`, its version); messaging other applications throws "not supported" | partial | `tests_evalfile.rs` (#760); no inter-application messaging | | ScriptUI | Dialogs, palettes, dockable panels, resource strings, `onDraw` / ScriptUIGraphics, mouse events | Same, dockable panels reopen at launch | good | `ui_scriptui`, `tests_ui*` | | `app.scheduleTask` / `cancelTask` | One-shot and repeating tasks (polling, panel refresh) until `cancelTask` | Same: repeating tasks run every `delay` ms (at least 50 ms, at most 64 pending per script) until `cancelTask`, until they throw (reported once, then cancelled), or until their owner goes: a script's last window closing, or the project closing for scripts without windows; frontends tick them every frame, agents with `script.runTasks` | good | `tests_tasks.rs` (#773); one-shot tasks of a script file still run right after it, not after their delay |-| Scripts menu, ScriptUI Panels folder | yes | yes | same | [plugins.md](plugins.md) |+| Scripts menu, ScriptUI Panels folder | yes | yes; a subfolder (one level) of either folder is a submenu named after it | same | [plugins.md](plugins.md) | | Startup / Shutdown folders | yes | not run | missing | plugins.md | | `#include`, `#target` directives | yes | `#include` / `//@include` / `#includepath` inline files relative to the including script (once each, nested at most 32 deep, under the scripts' file gate; errors name the included file and line); `#target`, `#targetengine`, `#strict` are ignored | good | `tests_include.rs` (#759); installed scripts and panels have no folder of their own, so they include from the project's folder or absolute paths | | `.jsxbin` | yes | no | out of scope | No public specification |