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
+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)];