Merge pull request #2291 from dotCooCoo/feat/recent-script-files

Scripts: Recent Script Files lists the last ten scripts and Cmd+Alt+Shift+D reruns the newest
This commit is contained in:
BFlat
2026-10-29 10:10:03 +07:00
committed by GitHub
17 changed files with 337 additions and 16 deletions
+4
View File
@@ -151,6 +151,10 @@ impl Services for WebServices {
fn is_dir(&self, _path: &str) -> bool {
false
}
/// The table's keys are the paths themselves.
fn full_path(&self, path: &str) -> Option<String> {
Some(path.to_string())
}
}
/// Media import from the table (the bytes are also handed to the media pool, which decodes them).
+33 -2
View File
@@ -430,6 +430,20 @@ fn collect_files(s: &mut Session, p: &Value) -> Result<Value> {
/// array or JSON lines of `{"command": id, "params": {…}}` (also accepts the control channel's
/// `{"method": "engine.execute", "params": {"id", "params"}}`).
fn run_script(s: &mut Session, p: &Value) -> Result<Value> {
// File ▸ Scripts ▸ Recent Script Files ▸ <script> (0 is the most recent).
if let Some(i) = p.get("recent") {
let i = i.as_u64().ok_or_else(|| bad("file.runScript", format!("`recent` must be an index, not {i}")))?;
let script = match s.prefs.recent_scripts.get(usize::try_from(i).unwrap_or(usize::MAX)) {
Some(x) => x.clone(),
None if s.prefs.recent_scripts.is_empty() => return Err(bad("file.runScript", "no recent script files")),
None => return Err(bad("file.runScript", format!("no recent script file #{i}"))),
};
let q = match script {
crate::prefs::RecentScript::Path(path) => json!({"path": path}),
crate::prefs::RecentScript::Name(name) => json!({"name": name}),
};
return run_script(s, &q);
}
// File ▸ Scripts ▸ <installed or sample script>.
if let Some(name) = str_p(p, "name")
&& p.get("path").is_none()
@@ -446,15 +460,22 @@ fn run_script(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad("file.runScript", JSXBIN));
}
let bytes = s.services.read_file(path).map_err(|e| EngineError::Other(format!("cannot read {path}: {e}")))?;
let full = s.services.full_path(path);
let code = String::from_utf8_lossy(&bytes).to_string();
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string());
let out = run_js(s, &code, path, false)?;
return report_js(s, &name, out);
let result = report_js(s, &name, out)?;
if let Some(full) = full {
remember_script(s, crate::prefs::RecentScript::Path(full));
}
return Ok(result);
}
let mut script_path = None;
let steps: Vec<Value> = match (p.get("steps"), str_p(p, "path")) {
(Some(Value::Array(a)), _) => a.clone(),
(_, Some(path)) => {
let bytes = s.services.read_file(path).map_err(|e| EngineError::Other(format!("cannot read {path}: {e}")))?;
script_path = s.services.full_path(path);
let text = String::from_utf8_lossy(&bytes).to_string();
match serde_json::from_str::<Value>(&text) {
Ok(Value::Array(a)) => a,
@@ -487,9 +508,19 @@ fn run_script(s: &mut Session, p: &Value) -> Result<Value> {
let r = r.map_err(|e| EngineError::Other(format!("step {} ({id}): {e}", i + 1)))?;
results.push(r);
}
if let Some(path) = script_path {
remember_script(s, crate::prefs::RecentScript::Path(path));
}
Ok(json!({"steps": results.len(), "results": results}))
}
/// Remember a script that runs for File ▸ Scripts ▸ Recent Script Files.
pub(super) fn remember_script(s: &mut Session, script: crate::prefs::RecentScript) {
s.prefs.push_recent_script(script);
s.save_prefs();
s.prefs_revision += 1;
}
/// `.jsx` / `.js` (and `.jsxbin`, rejected) are JavaScript; `.json` / `.jsonl` / other files are
/// command scripts.
fn is_js_script(path: &str) -> bool {
@@ -1017,7 +1048,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Run Script File...",
["File", "Scripts"],
None,
"{path (.jsx/.js JavaScript, or a .jsonl/.json command script) | name (an installed or sample script, see file.scripts.list) | steps: [{command, params}]}",
"{path (.jsx/.js JavaScript, or a .jsonl/.json command script) | name (an installed or sample script, see file.scripts.list) | recent: index (File ▸ Scripts ▸ Recent Script Files, 0 = the most recent) | steps: [{command, params}]}",
always,
run_script
),
+176 -3
View File
@@ -235,13 +235,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.
@@ -553,6 +557,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).
+25 -9
View File
@@ -61,6 +61,12 @@ fn file_name(p: &str) -> String {
std::path::Path::new(p).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| p.to_string())
}
/// The labels of a recent-files list: `names[i]`, or the whole of `full[i]` when another entry
/// has the same name, so entries for two files named `Tool.jsx` tell them apart.
fn distinct_labels(names: Vec<String>, full: &[String]) -> Vec<String> {
names.iter().zip(full).map(|(n, f)| if names.iter().filter(|m| *m == n).count() > 1 { f.clone() } else { n.clone() }).collect()
}
fn dyn_entry(label: String, command: &str, params: Value) -> MenuEntry {
MenuEntry { label, command: command.into(), params, shortcut: None, alt_shortcuts: vec![] }
}
@@ -72,26 +78,34 @@ fn holder_of(s: &Session, keys: &str) -> Option<String> {
}
/// The entries of a `@dynamic:<name>` node, and the disabled placeholder shown when there are
/// none: `recentProjects`, `recentFootage`, `recentPresets`, `history` (undo steps, newest
/// none: `recentProjects`, `recentFootage`, `recentPresets`, `recentScripts` (File ▸ Scripts ▸
/// Recent Script Files), `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).
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> {
list.iter()
.enumerate()
.map(|(i, p)| {
let n =
if stem { std::path::Path::new(p).file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| p.clone()) } else { file_name(p) };
dyn_entry(n, cmd, json!({"index": i}))
})
.collect()
let names = list
.iter()
.map(
|p| if stem { std::path::Path::new(p).file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| p.clone()) } else { file_name(p) },
)
.collect();
distinct_labels(names, list).into_iter().enumerate().map(|(i, n)| dyn_entry(n, cmd, json!({"index": i}))).collect()
};
match name {
"recentProjects" => (indexed(&s.prefs.recent_projects, "file.openRecent", false), Some("No Recent Projects")),
"recentFootage" => (indexed(&s.prefs.recent_footage, "file.importRecent", false), Some("No Recent Footage")),
"recentPresets" => (indexed(&s.prefs.recent_presets, "anim.applyRecentPreset", true), Some("No Recent Presets")),
"recentScripts" => {
let full: Vec<String> = s.prefs.recent_scripts.iter().map(|r| r.text().to_string()).collect();
let names = full.iter().map(|p| file_name(p)).collect();
(
distinct_labels(names, &full).into_iter().enumerate().map(|(i, n)| dyn_entry(n, "file.runScript", json!({"recent": i}))).collect(),
Some("No Recent Script Files"),
)
}
"history" => (
s.history.undo.iter().rev().enumerate().map(|(i, (l, _))| dyn_entry(format!("Undo {l}"), "edit.history", json!({"steps": i + 1}))).collect(),
Some("No History"),
@@ -576,6 +590,8 @@ File
Install Script File... | file.installScript
Install ScriptUI Panel... | file.installScriptUIPanel
Run Script File... | file.runScript
Recent Script Files
@dynamic:recentScripts
---
Create Proxy
Still... | file.createProxy {"kind":"still"}
+7
View File
@@ -37,6 +37,13 @@ pub trait Services: Send + Sync {
fn is_dir(&self, path: &str) -> bool {
std::path::Path::new(path).is_dir()
}
/// `path` as a list of recent files keeps it (File ▸ Scripts ▸ Recent Script Files): a
/// relative path is made absolute against the working directory, so it opens the same file
/// after the working directory changes. `None` when the path can't be made absolute (the
/// working directory can't be read); the script is then not added to the list.
fn full_path(&self, path: &str) -> Option<String> {
std::path::absolute(path).ok().map(|p| p.to_string_lossy().into_owned())
}
}
/// Native filesystem.
+4
View File
@@ -28,6 +28,7 @@ pub use choices::{APPEARANCE_MODES, DARK_THEMES, LANGUAGES, LIGHT_THEMES, THEMES
pub use dialog::{Item, Kind, Page, pages, pages_json, todo_keys};
pub use document::{migrate, page_id, section_key};
pub use queries::{CacheBudgets, WEB_LAYER_CACHE, WEB_MEDIA_CACHE, mask_color};
pub use recent::{MAX_RECENT_SCRIPTS, RecentScript};
pub use sections::{
Appearance, Audio, AutoSave, CompPreset, CompositionPrefs, Disk, Export, FacePrefs, General, Grids, Import, LabelPref, Memory, Previews, ProjectPrefs,
RotoPrefs, Scripting, Startup, ThreeD, TypePrefs, Video,
@@ -74,6 +75,8 @@ pub struct Prefs {
/// Animation ▸ Recent Animation Presets (`.ecpreset` paths or built-in preset ids), newest
/// first.
pub recent_presets: Vec<String>,
/// File ▸ Scripts ▸ Recent Script Files, newest first, at most [`MAX_RECENT_SCRIPTS`].
pub recent_scripts: Vec<RecentScript>,
/// Character panel: recently used font families, newest first.
pub recent_fonts: Vec<String>,
/// Effects & Presets: the starred effects' ids, in the order starred.
@@ -117,6 +120,7 @@ impl Default for Prefs {
recent_projects: vec![],
recent_footage: vec![],
recent_presets: vec![],
recent_scripts: vec![],
recent_fonts: vec![],
effects_favorites: vec![],
preview: crate::preview::PreviewSettings::default(),
+3 -1
View File
@@ -1,5 +1,5 @@
use super::choices::{APPEARANCE_MODES, DARK_THEMES, LANGUAGES, LIGHT_THEMES, THEMES, WINDOW_GRAPHICS, choices, is_choice, theme_family};
use super::recent::MAX_EFFECTS_FAVORITES;
use super::recent::{MAX_EFFECTS_FAVORITES, MAX_RECENT_SCRIPTS};
use super::sections::{CompPreset, default_labels, parse_hex};
use super::{Appearance, General, Map, PREFS_VERSION, Prefs, Startup, Value, json};
@@ -146,6 +146,7 @@ impl Prefs {
self.grids.grid_spacing = self.grids.grid_spacing.clamp(1.0, 10_000.0);
let n = self.general.recent_items as usize;
self.recent_projects.truncate(n);
self.recent_scripts.truncate(MAX_RECENT_SCRIPTS);
}
/// The value at a dotted key (`general.undoLevels`, `labels.3.color`); `""` = everything.
@@ -211,6 +212,7 @@ impl Prefs {
recent_projects: std::mem::take(&mut self.recent_projects),
recent_footage: std::mem::take(&mut self.recent_footage),
recent_presets: std::mem::take(&mut self.recent_presets),
recent_scripts: std::mem::take(&mut self.recent_scripts),
recent_fonts: std::mem::take(&mut self.recent_fonts),
effects_favorites: std::mem::take(&mut self.effects_favorites),
preview: std::mem::take(&mut self.preview),
@@ -1,3 +1,5 @@
use serde::{Deserialize, Serialize};
use super::Prefs;
impl Prefs {
@@ -45,6 +47,34 @@ impl Prefs {
pub fn push_recent_preset(&mut self, path: &str) {
push_mru(&mut self.recent_presets, path, self.general.recent_items as usize);
}
/// Remember a script that ran for File ▸ Scripts ▸ Recent Script Files.
pub fn push_recent_script(&mut self, script: RecentScript) {
self.recent_scripts.retain(|x| *x != script);
self.recent_scripts.insert(0, script);
self.recent_scripts.truncate(MAX_RECENT_SCRIPTS);
}
}
/// Most scripts File ▸ Scripts ▸ Recent Script Files lists.
pub const MAX_RECENT_SCRIPTS: usize = 10;
/// A File ▸ Scripts ▸ Recent Script Files entry, stored as the `file.runScript` parameter that
/// ran it: `{"path": …}` for a script file, `{"name": …}` for an installed or bundled script.
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum RecentScript {
Path(String),
Name(String),
}
impl RecentScript {
/// The file's path or the script's name.
pub fn text(&self) -> &str {
match self {
RecentScript::Path(s) | RecentScript::Name(s) => s,
}
}
}
/// Most effects Effects & Presets keeps starred (a hand-edited settings file can hold more).
+15
View File
@@ -105,6 +105,21 @@ pub fn bindables(ui: &[UiCommand]) -> Vec<Bindable> {
&mut out,
);
}
// File ▸ Scripts ▸ Recent Script Files lists its scripts at run time; the first one, the most
// recent, has a key.
let recent = json!({"recent": 0});
push(
Bindable {
key: binding_key("file.runScript", &recent),
command: "file.runScript".into(),
params: recent,
label: "Run Most Recent Script File".into(),
path: vec!["File".into(), "Scripts".into(), "Recent Script Files".into()],
scope: APP_SCOPE.into(),
defaults: normalize("Cmd+Alt+Shift+D").into_iter().collect(),
},
&mut out,
);
for c in crate::command_specs() {
if !c.journal || entries.iter().any(|(_, e)| e.command == c.id) {
continue;
+5
View File
@@ -708,6 +708,11 @@ fn dynamic_menus_and_window_viewer_labels() {
s.prefs.push_recent("/p/One.ecproj");
let (e, _) = crate::menus::dynamic(&s, "recentProjects", &Default::default());
assert_eq!((e[0].label.as_str(), e[0].command.as_str()), ("One.ecproj", "file.openRecent"));
// Two projects with the same file name show their paths.
s.prefs.push_recent("/q/One.ecproj");
s.prefs.push_recent("/p/Two.ecproj");
let (e, _) = crate::menus::dynamic(&s, "recentProjects", &Default::default());
assert_eq!(e.iter().map(|e| e.label.as_str()).collect::<Vec<_>>(), ["Two.ecproj", "/q/One.ecproj", "/p/One.ecproj"]);
assert_eq!(crate::menus::dynamic(&s, "history", &Default::default()).0.len(), s.history.undo.len());
let a = solid(&mut s, "#ff0000");
s.state.selected_layers = vec![LayerId(a)];
+11
View File
@@ -469,6 +469,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str, &str)] = &[
("file.installScript", "Install Script File...", "安装脚本文件..."),
("file.installScriptUIPanel", "Install ScriptUI Panel...", "安装 ScriptUI 面板..."),
("file.runScript", "Run Script File...", "运行脚本文件..."),
("", "Recent Script Files", "最近的脚本文件"),
("", "Create Proxy", "创建代理"),
("file.createProxy", "Still...", "静止图像..."),
("file.createProxy", "Movie...", "影片..."),
@@ -1104,6 +1105,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str, &str)] = &[
("file.installScript", "Install Script File...", "安裝指令碼檔案..."),
("file.installScriptUIPanel", "Install ScriptUI Panel...", "安裝 ScriptUI 面板..."),
("file.runScript", "Run Script File...", "執行指令碼檔案..."),
("", "Recent Script Files", "最近使用的指令碼檔案"),
("", "Create Proxy", "建立代理"),
("file.createProxy", "Still...", "靜態影像..."),
("file.createProxy", "Movie...", "影片..."),
@@ -1736,6 +1738,7 @@ const JAPANESE: &[(&str, &str, &str)] = &[
("file.installScript", "Install Script File...", "スクリプトファイルをインストール..."),
("file.installScriptUIPanel", "Install ScriptUI Panel...", "ScriptUIパネルをインストール..."),
("file.runScript", "Run Script File...", "スクリプトファイルを実行..."),
("", "Recent Script Files", "最近使用したスクリプトファイル"),
("", "Create Proxy", "プロキシを作成"),
("file.createProxy", "Still...", "静止画..."),
("file.createProxy", "Movie...", "ムービー..."),
@@ -2371,6 +2374,7 @@ const UKRAINIAN: &[(&str, &str, &str)] = &[
("file.installScript", "Install Script File...", "Встановити файл скрипту..."),
("file.installScriptUIPanel", "Install ScriptUI Panel...", "Встановити панель ScriptUI..."),
("file.runScript", "Run Script File...", "Виконати файл скрипту..."),
("", "Recent Script Files", "Нещодавні файли скриптів"),
("", "Create Proxy", "Створити проксі"),
("file.createProxy", "Still...", "Зображення..."),
("file.createProxy", "Movie...", "Відео..."),
@@ -3007,6 +3011,7 @@ const SPANISH: &[(&str, &str, &str)] = &[
("file.installScript", "Install Script File...", "Instalar archivo de script..."),
("file.installScriptUIPanel", "Install ScriptUI Panel...", "Instalar panel de ScriptUI..."),
("file.runScript", "Run Script File...", "Ejecutar archivo de script..."),
("", "Recent Script Files", "Archivos de script recientes"),
("", "Create Proxy", "Crear proxy"),
("file.createProxy", "Still...", "Imagen fija..."),
("file.createProxy", "Movie...", "Película..."),
@@ -3641,6 +3646,7 @@ const PORTUGUESE: &[(&str, &str, &str)] = &[
("file.installScript", "Install Script File...", "Instalar arquivo de script..."),
("file.installScriptUIPanel", "Install ScriptUI Panel...", "Instalar painel ScriptUI..."),
("file.runScript", "Run Script File...", "Executar arquivo de script..."),
("", "Recent Script Files", "Arquivos de script recentes"),
("", "Create Proxy", "Criar proxy"),
("file.createProxy", "Still...", "Imagem fixa..."),
("file.createProxy", "Movie...", "Filme..."),
@@ -4283,6 +4289,7 @@ const RUSSIAN: &[(&str, &str, &str)] = &[
("file.installScript", "Install Script File...", "Установить файл скрипта..."),
("file.installScriptUIPanel", "Install ScriptUI Panel...", "Установить панель ScriptUI..."),
("file.runScript", "Run Script File...", "Выполнить файл скрипта..."),
("", "Recent Script Files", "Недавние файлы скриптов"),
("", "Create Proxy", "Создать суррогат"),
("file.createProxy", "Still...", "Стоп-кадр..."),
("file.createProxy", "Movie...", "Видео..."),
@@ -4917,6 +4924,7 @@ const FRENCH: &[(&str, &str, &str)] = &[
("file.installScript", "Install Script File...", "Installer un fichier de script..."),
("file.installScriptUIPanel", "Install ScriptUI Panel...", "Installer un panneau ScriptUI..."),
("file.runScript", "Run Script File...", "Exécuter un fichier de script..."),
("", "Recent Script Files", "Fichiers de script récents"),
("", "Create Proxy", "Créer un proxy"),
("file.createProxy", "Still...", "Image fixe..."),
("file.createProxy", "Movie...", "Vidéo..."),
@@ -5551,6 +5559,7 @@ const GERMAN: &[(&str, &str, &str)] = &[
("file.installScript", "Install Script File...", "Skriptdatei installieren..."),
("file.installScriptUIPanel", "Install ScriptUI Panel...", "ScriptUI-Bedienfeld installieren..."),
("file.runScript", "Run Script File...", "Skriptdatei ausführen..."),
("", "Recent Script Files", "Zuletzt verwendete Skriptdateien"),
("", "Create Proxy", "Proxy erstellen"),
("file.createProxy", "Still...", "Standbild..."),
("file.createProxy", "Movie...", "Film..."),
@@ -6185,6 +6194,7 @@ const CZECH: &[(&str, &str, &str)] = &[
("file.installScript", "Install Script File...", "Nainstalovat soubor skriptu..."),
("file.installScriptUIPanel", "Install ScriptUI Panel...", "Nainstalovat panel ScriptUI..."),
("file.runScript", "Run Script File...", "Spustit soubor skriptu..."),
("", "Recent Script Files", "Nedávné soubory skriptů"),
("", "Create Proxy", "Vytvořit zástupný soubor"),
("file.createProxy", "Still...", "Statický snímek..."),
("file.createProxy", "Movie...", "Video..."),
@@ -6816,6 +6826,7 @@ const GEORGIAN: &[(&str, &str, &str)] = &[
("file.installScript", "Install Script File...", "სკრიპტის ფაილის დაყენება..."),
("file.installScriptUIPanel", "Install ScriptUI Panel...", "ScriptUI პანელის დაყენება..."),
("file.runScript", "Run Script File...", "სკრიპტის ფაილის გაშვება..."),
("", "Recent Script Files", "ბოლო სკრიპტის ფაილები"),
("", "Create Proxy", "პროქსის შექმნა"),
("file.createProxy", "Still...", "სტატიკური კადრი..."),
("file.createProxy", "Movie...", "ვიდეო..."),
+1
View File
@@ -160,6 +160,7 @@ fn menu_nodes(
"recentProjects" => app.session.prefs.recent_projects.get(i).cloned(),
"recentFootage" => app.session.prefs.recent_footage.get(i).cloned(),
"recentPresets" => app.session.prefs.recent_presets.get(i).cloned(),
"recentScripts" => app.session.prefs.recent_scripts.get(i).map(|r| r.text().to_string()),
_ => None,
};
let r = ui.add_enabled(entry_enabled(app, e), egui::Button::new((gutter(entry_checked(app, e) == Some(true)), e.label.as_str())));
+1 -1
View File
@@ -548,7 +548,7 @@ pub(super) fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) ->
"file.importProject" => ("path", Ask::Open(&["ecproj", "ecprojx"])),
"file.importTimeline" => ("path", Ask::Open(&["xml", "fcpxml", "otio", "edl", "aaf", "omf"])),
"render.saveCurrentPreview" => ("path", Ask::Save("Preview.mp4")),
"file.runScript" if params.get("name").is_none() => ("path", Ask::Open(&["jsx", "js", "jsonl", "json", "txt"])),
"file.runScript" if params.get("name").is_none() && params.get("recent").is_none() => ("path", Ask::Open(&["jsx", "js", "jsonl", "json", "txt"])),
"file.installScript" | "file.installScriptUIPanel" => ("path", Ask::Open(&["jsx", "js"])),
"effect.plugins.load" if params.get("folder").is_none() => ("path", Ask::Open(&["wasm", "wat"])),
"file.replaceFootage" => ("path", Ask::Import),
+19
View File
@@ -299,6 +299,25 @@ fn save_dialogs_offer_the_file_extension() {
assert_eq!(*asked.borrow(), ["project Frame.png"]);
}
/// File ▸ Scripts ▸ Recent Script Files and its key run a script again without a file dialog.
#[test]
fn recent_script_files_run_without_a_dialog() {
let mut app = EffectcraftApp::new(effectcraft_engine::Session::default());
let ctx = egui::Context::default();
let dir = std::env::temp_dir().join(format!("ec-recent-script-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let script = dir.join("New Comp.jsonl");
std::fs::write(&script, r#"{"command": "comp.new", "params": {"name": "From Script", "width": 64, "height": 36, "frameRate": 30, "duration": 1}}"#)
.unwrap();
app.session.execute("file.runScript", json!({"path": script.to_string_lossy()})).unwrap();
let (m, k, id, params) =
bindings(&app.session).into_iter().find(|b| b.2 == "file.runScript" && b.3 == json!({"recent": 0})).expect("the most recent script has a key");
assert_eq!((m, k), parse_shortcut("Cmd+Alt+Shift+D").unwrap());
assert_eq!(invoke(&mut app, &ctx, &id, params).unwrap()["steps"], 1);
assert_eq!(app.session.project.items.values().filter(|i| i.name.starts_with("From Script")).count(), 2);
let _ = std::fs::remove_dir_all(&dir);
}
/// #511: with a host's non-blocking dialogs, a command's file dialog opens without running
/// a nested event loop inside the frame (a drag over the window then aborted the app on
/// macOS); the command runs on a later frame with the answer, and a cancelled one not at all.
+1
View File
@@ -340,6 +340,7 @@ pub fn state_key(app: &EffectcraftApp) -> u64 {
s.journal.last().map(|j| &j.0).hash(&mut h);
s.prefs.recent_projects.hash(&mut h);
(&s.prefs.recent_footage, &s.prefs.recent_presets).hash(&mut h);
s.prefs.recent_scripts.hash(&mut h);
s.history.undo.iter().for_each(|(l, _)| l.hash(&mut h));
s.state.open_comps.hash(&mut h);
s.active_comp_id().and_then(|c| s.state.views3d.get(&c)).map(|v| format!("{:?}", v.current)).hash(&mut h);
+1
View File
@@ -14,6 +14,7 @@ Guide):
| 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 |
| File ▸ Scripts ▸ Run Script File… (`.jsx`, `.jsxbin`) | `.jsx` / `.js` | Gap: `.jsxbin` (an undocumented binary encoding) |
| File ▸ Scripts ▸ Recent Script Files: the 10 most recently run scripts; Cmd+Option+Shift+D (Ctrl+Alt+Shift+D on Windows) reruns one | File ▸ Scripts ▸ Recent Script Files lists the 10 scripts run last (script files and File ▸ Scripts entries, newest first, kept in the settings); Cmd+Alt+Shift+D runs the most recent one again (`file.runScript {"recent": 0}`, a key that can be changed in Edit ▸ Keyboard Shortcuts) | Same |
| 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 |
+1
View File
@@ -27,6 +27,7 @@ our own scripts; no real-world script library has been run against both apps.
| `#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 |
| Keyboard shortcuts for scripts | yes | no | missing | plugins.md |
| Recent Script Files | File ▸ Scripts ▸ Recent Script Files (10 scripts), a key to rerun | Same list, kept in the settings; Cmd+Alt+Shift+D reruns the most recent script | same | `recent_script_files_run_again` ([plugins.md](plugins.md)) |
| Command-line rendering `aerender` | yes | `effectcraft-cli render`, `script`, `exec` | same purpose | `apps/effectcraft-cli` |
| Essential Graphics scripting hooks | `addToMotionGraphicsTemplate…` | yes (M13.7) | good | — |
| Agent control (MCP, control channel, every command) | none | yes | beyond | [agents.md](agents.md), [control-protocol.md](control-protocol.md); `list_effects` omits parameter types and ranges (#441) |