mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-30 13:08:07 -06:00
Merge pull request #2289 from onlykshitij/feat/script-panel-submenus
Scripts: subfolders of the Scripts and ScriptUI Panels folders become submenus
This commit is contained in:
@@ -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,11 @@ 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";
|
||||
/// 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
|
||||
@@ -111,7 +117,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 +125,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.
|
||||
@@ -155,8 +168,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 +234,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] {
|
||||
@@ -316,13 +341,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 {
|
||||
@@ -338,6 +365,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!(
|
||||
@@ -358,14 +386,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
|
||||
),
|
||||
@@ -504,13 +540,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 {
|
||||
@@ -528,6 +566,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");
|
||||
|
||||
@@ -82,7 +82,8 @@ fn holder_of(s: &Session, keys: &str) -> Option<String> {
|
||||
/// 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).
|
||||
/// `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> {
|
||||
@@ -167,6 +168,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),
|
||||
|
||||
Reference in New Issue
Block a user