feat: multi-entry support for plugin system (#3154)

This commit is contained in:
三咲雅 misaki masa 2025-09-08 23:38:10 +08:00 committed by GitHub
parent fa0e7e62c1
commit 3713478753
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 124 additions and 111 deletions

View file

@ -24,9 +24,6 @@ pub(super) enum Command {
/// Manage packages.
#[command(subcommand)]
Pkg(CommandPkg),
#[command(hide = true)]
/// Manage packages.
Pack(CommandPack), // TODO: remove
/// Publish a message to the current instance.
Pub(CommandPub),
/// Publish a message to the specified instance.
@ -83,26 +80,6 @@ pub(super) enum CommandPkg {
},
}
#[derive(clap::Args)]
#[command(arg_required_else_help = true)]
pub(super) struct CommandPack {
/// Add packages.
#[arg(short = 'a', long, num_args = 1..)]
pub(super) add: Option<Vec<String>>,
/// Delete packages.
#[arg(short = 'd', long, num_args = 1..)]
pub(super) delete: Option<Vec<String>>,
/// Install all packages.
#[arg(short = 'i', long)]
pub(super) install: bool,
/// List all packages.
#[arg(short = 'l', long)]
pub(super) list: bool,
/// Upgrade all packages.
#[arg(short = 'u', long)]
pub(super) upgrade: bool,
}
#[derive(clap::Args)]
pub(super) struct CommandPub {
/// Kind of message.

View file

@ -73,24 +73,6 @@ async fn run() -> anyhow::Result<()> {
}
}
Command::Pack(cmd) => {
package::init()?;
outln!(
"WARNING: `ya pack` is deprecated, use the new `ya pkg` instead. See https://github.com/sxyazi/yazi/pull/2770 for more details."
)?;
if cmd.install {
package::Package::load().await?.install().await?;
} else if cmd.list {
package::Package::load().await?.print()?;
} else if cmd.upgrade {
package::Package::load().await?.upgrade_many(&[]).await?;
} else if let Some(uses) = cmd.add {
package::Package::load().await?.add_many(&uses).await?;
} else if let Some(uses) = cmd.delete {
package::Package::load().await?.delete_many(&uses).await?;
}
}
Command::Pub(cmd) => {
yazi_boot::init_default();
yazi_dds::init();

View file

@ -41,15 +41,12 @@ impl Dependency {
pub(super) async fn delete_sources(&self) -> Result<()> {
let dir = self.target();
let files = if self.is_flavor {
&["flavor.toml", "tmtheme.xml", "README.md", "preview.png", "LICENSE", "LICENSE-tmtheme"][..]
} else {
&["main.lua", "README.md", "LICENSE"][..]
};
let files =
if self.is_flavor { Self::flavor_files() } else { Self::plugin_files(&dir).await? };
for p in files.iter().map(|&f| dir.join(f)) {
ok_or_not_found(remove_sealed(&p).await)
.with_context(|| format!("failed to delete `{}`", p.display()))?;
for path in files.iter().map(|s| dir.join(s)) {
ok_or_not_found(remove_sealed(&path).await)
.with_context(|| format!("failed to delete `{}`", path.display()))?;
}
if ok_or_not_found(Local::remove_dir(&dir).await).is_ok() {

View file

@ -1,9 +1,10 @@
use std::{io::BufWriter, path::PathBuf, str::FromStr};
use std::{io::BufWriter, path::{Path, PathBuf}, str::FromStr};
use anyhow::{Result, bail};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use twox_hash::XxHash3_128;
use yazi_fs::Xdg;
use yazi_fs::{Xdg, provider::local::Local};
use yazi_shared::BytesExt;
#[derive(Clone, Default)]
pub(crate) struct Dependency {
@ -59,6 +60,30 @@ impl Dependency {
)?;
Ok(())
}
pub(super) async fn plugin_files(dir: &Path) -> std::io::Result<Vec<String>> {
let mut it = Local::read_dir(dir).await?;
let mut files: Vec<String> =
["LICENSE", "README.md", "main.lua"].into_iter().map(Into::into).collect();
while let Some(entry) = it.next_entry().await? {
if let Ok(name) = entry.file_name().into_string()
&& let Some(stripped) = name.strip_suffix(".lua")
&& stripped != "main"
&& stripped.as_bytes().kebab_cased()
{
files.push(name);
}
}
files.sort_unstable();
Ok(files)
}
pub(super) fn flavor_files() -> Vec<String> {
["LICENSE", "LICENSE-tmtheme", "README.md", "flavor.toml", "preview.png", "tmtheme.xml"]
.into_iter()
.map(Into::into)
.collect()
}
}
impl FromStr for Dependency {
@ -75,7 +100,7 @@ impl FromStr for Dependency {
};
let name = if child.is_empty() { repo } else { child };
if !name.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'z' | b'-')) {
if !name.as_bytes().kebab_cased() {
bail!("Package name `{name}` must be in kebab-case")
}

View file

@ -57,14 +57,9 @@ impl Dependency {
}
async fn deploy_sources(from: &Path, to: &Path, is_flavor: bool) -> Result<()> {
let files = if is_flavor {
&["flavor.toml", "tmtheme.xml", "README.md", "preview.png", "LICENSE", "LICENSE-tmtheme"][..]
} else {
&["main.lua", "README.md", "LICENSE"][..]
};
let files = if is_flavor { Self::flavor_files() } else { Self::plugin_files(from).await? };
for file in files {
let (from, to) = (from.join(file), to.join(file));
let (from, to) = (from.join(&file), to.join(&file));
copy_and_seal(&from, &to)
.await
.with_context(|| format!("failed to copy `{}` to `{}`", from.display(), to.display()))?;

View file

@ -7,22 +7,11 @@ use super::Dependency;
impl Dependency {
pub(crate) async fn hash(&self) -> Result<String> {
let dir = self.target();
let files = if self.is_flavor {
&[
"LICENSE",
"LICENSE-tmtheme",
"README.md",
"filestyle.toml",
"flavor.toml",
"preview.png",
"tmtheme.xml",
][..]
} else {
&["LICENSE", "README.md", "main.lua"][..]
};
let files =
if self.is_flavor { Self::flavor_files() } else { Self::plugin_files(&dir).await? };
let mut h = XxHash3_128::new();
for &file in files {
for file in files {
h.write(file.as_bytes());
h.write(b"VpvFw9Atb7cWGOdqhZCra634CcJJRlsRl72RbZeV0vpG1\0");
h.write(&ok_or_not_found(Local::read(dir.join(file)).await)?);

View file

@ -81,8 +81,8 @@ impl Ember<'static> {
if it.peek() == Some(&b'@') {
it.next(); // Skip `@` as it's a prefix for static messages
}
if !it.all(|b| b.is_ascii_alphanumeric() || b == b'-') {
bail!("Kind must be alphanumeric with dashes");
if !it.all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'z' | b'-')) {
bail!("Kind `{kind}` must be in kebab-case");
}
Ok(())

View file

@ -1,4 +1,4 @@
use std::fmt::Debug;
use std::{borrow::Cow, fmt::Debug};
use anyhow::bail;
use hashbrown::HashMap;
@ -35,7 +35,7 @@ impl TryFrom<CmdCow> for PluginOpt {
};
let mode = c.str("mode").map(Into::into).unwrap_or_default();
Ok(Self { id, args, mode, cb: c.take_any("callback") })
Ok(Self { id: Self::normalize_id(id), args, mode, cb: c.take_any("callback") })
}
}
@ -52,7 +52,22 @@ impl Debug for PluginOpt {
impl PluginOpt {
pub fn new_callback(id: impl Into<SStr>, cb: PluginCallback) -> Self {
Self { id: id.into(), mode: PluginMode::Sync, cb: Some(cb), ..Default::default() }
Self {
id: Self::normalize_id(id.into()),
mode: PluginMode::Sync,
cb: Some(cb),
..Default::default()
}
}
fn normalize_id(s: SStr) -> SStr {
match s {
Cow::Borrowed(s) => s.trim_end_matches(".main").into(),
Cow::Owned(mut s) => {
s.truncate(s.trim_end_matches(".main").len());
s.into()
}
}
}
}

View file

@ -10,7 +10,7 @@ use yazi_proxy::AppProxy;
use yazi_shared::{event::Cmd, pool::Symbol};
use super::slim_lua;
use crate::loader::LOADER;
use crate::loader::{LOADER, Loader};
pub fn peek(
cmd: &'static Cmd,
@ -18,8 +18,10 @@ pub fn peek(
mime: Symbol<str>,
skip: usize,
) -> Option<CancellationToken> {
let (id, ..) = Loader::normalize_id(&cmd.name).ok()?;
let ct = CancellationToken::new();
if let Some(c) = LOADER.read().get(cmd.name.as_ref()) {
if let Some(c) = LOADER.read().get(id) {
if c.sync_peek {
peek_sync(cmd, file, mime, skip);
} else {
@ -32,11 +34,11 @@ pub fn peek(
tokio::spawn(async move {
select! {
_ = ct_.cancelled() => {},
Ok(b) = LOADER.ensure(&cmd.name, |c| c.sync_peek) => {
Ok(b) = LOADER.ensure(id, |c| c.sync_peek) => {
if b {
peek_sync( cmd, file, mime, skip);
peek_sync(cmd, file, mime, skip);
} else {
peek_async( cmd, file, mime, skip, ct_);
peek_async(cmd, file, mime, skip, ct_);
}
},
else => {}

View file

@ -1,13 +1,13 @@
use std::{borrow::Cow, ops::Deref};
use anyhow::{Context, Result, bail};
use anyhow::{Context, Result, bail, ensure};
use hashbrown::HashMap;
use mlua::{ChunkMode, ExternalError, Lua, Table};
use parking_lot::RwLock;
use yazi_boot::BOOT;
use yazi_fs::provider::local::Local;
use yazi_macro::plugin_preset as preset;
use yazi_shared::{LOG_LEVEL, RoCell};
use yazi_shared::{BytesExt, LOG_LEVEL, RoCell};
use super::Chunk;
@ -20,7 +20,6 @@ pub struct Loader {
impl Deref for Loader {
type Target = RwLock<HashMap<String, Chunk>>;
#[inline]
fn deref(&self) -> &Self::Target { &self.cache }
}
@ -52,26 +51,29 @@ impl Default for Loader {
}
impl Loader {
pub async fn ensure<F, T>(&self, name: &str, f: F) -> Result<T>
pub async fn ensure<F, T>(&self, id: &str, f: F) -> Result<T>
where
F: FnOnce(&Chunk) -> T,
{
if let Some(c) = self.cache.read().get(name) {
return Self::compatible_or_error(name, c).map(|_| f(c));
let (id, plugin, entry) = Self::normalize_id(id)?;
if let Some(c) = self.cache.read().get(id) {
return Self::compatible_or_error(id, c).map(|_| f(c));
}
let p = BOOT.plugin_dir.join(format!("{name}.yazi/main.lua"));
let p = BOOT.plugin_dir.join(format!("{plugin}.yazi/{entry}.lua"));
let chunk =
Local::read(&p).await.with_context(|| format!("Failed to load plugin from {p:?}"))?.into();
let result = Self::compatible_or_error(name, &chunk);
let result = Self::compatible_or_error(id, &chunk);
let inspect = f(&chunk);
self.cache.write().insert(name.to_owned(), chunk);
self.cache.write().insert(id.to_owned(), chunk);
result.map(|_| inspect)
}
pub fn load(&self, lua: &Lua, id: &str) -> mlua::Result<Table> {
let (id, ..) = Self::normalize_id(id)?;
let loaded: Table = lua.globals().raw_get::<Table>("package")?.raw_get("loaded")?;
if let Ok(t) = loaded.raw_get(id) {
return Ok(t);
@ -85,18 +87,20 @@ impl Loader {
}
pub fn load_once(&self, lua: &Lua, id: &str) -> mlua::Result<Table> {
let (id, ..) = Self::normalize_id(id)?;
let mut mode = ChunkMode::Text;
let f = match self.read().get(id) {
let f = match self.cache.read().get(id) {
Some(c) => {
mode = c.mode;
lua.load(c).set_name(id).into_function()
}
None => Err(format!("plugin `{id}` not found").into_lua_err()),
None => Err(format!("Plugin `{id}` not found").into_lua_err()),
}?;
if mode != ChunkMode::Binary {
let b = f.dump(LOG_LEVEL.get().is_none());
if let Some(c) = self.write().get_mut(id) {
if let Some(c) = self.cache.write().get_mut(id) {
c.mode = ChunkMode::Binary;
c.bytes = Cow::Owned(b);
}
@ -106,10 +110,13 @@ impl Loader {
}
pub fn try_load(&self, lua: &Lua, id: &str) -> mlua::Result<Table> {
let (id, ..) = Self::normalize_id(id)?;
lua.globals().raw_get::<Table>("package")?.raw_get::<Table>("loaded")?.raw_get(id)
}
pub fn load_with(&self, lua: &Lua, id: &str, chunk: &Chunk) -> mlua::Result<Table> {
let (id, ..) = Self::normalize_id(id)?;
let loaded: Table = lua.globals().raw_get::<Table>("package")?.raw_get("loaded")?;
if let Ok(t) = loaded.raw_get(id) {
return Ok(t);
@ -122,15 +129,24 @@ impl Loader {
Ok(t)
}
pub fn compatible_or_error(name: &str, chunk: &Chunk) -> Result<()> {
pub fn compatible_or_error(id: &str, chunk: &Chunk) -> Result<()> {
if chunk.compatible() {
return Ok(());
}
bail!(
"Plugin `{name}` requires at least Yazi {}, but your current version is Yazi {}.",
"Plugin `{id}` requires at least Yazi {}, but your current version is Yazi {}.",
chunk.since,
yazi_boot::actions::Actions::version()
);
}
pub fn normalize_id(id: &str) -> anyhow::Result<(&str, &str, &str)> {
let id = id.trim_end_matches(".main");
let (plugin, entry) = if let Some((a, b)) = id.split_once(".") { (a, b) } else { (id, "main") };
ensure!(plugin.as_bytes().kebab_cased(), "Plugin name `{plugin}` must be in kebab-case");
ensure!(entry.as_bytes().kebab_cased(), "Entry name `{entry}` must be in kebab-case");
Ok((id, plugin, entry))
}
}

View file

@ -1,7 +1,7 @@
use std::sync::Arc;
use std::{borrow::Cow, sync::Arc};
use mlua::{ExternalResult, Function, IntoLua, Lua, MetaMethod, MultiValue, ObjectLike, Table, Value};
use yazi_binding::runtime_mut;
use yazi_binding::{runtime, runtime_mut};
use super::LOADER;
@ -12,14 +12,15 @@ impl Require {
lua.globals().raw_set(
"require",
lua.create_function(|lua, id: mlua::String| {
let s = &id.to_str()?;
futures::executor::block_on(LOADER.ensure(s, |_| ())).into_lua_err()?;
let id = id.to_str()?;
let id = Self::absolute_id(lua, &id)?;
futures::executor::block_on(LOADER.ensure(&id, |_| ())).into_lua_err()?;
runtime_mut!(lua)?.push(s);
let mod_ = LOADER.load(lua, s);
runtime_mut!(lua)?.push(&id);
let mod_ = LOADER.load(lua, &id);
runtime_mut!(lua)?.pop();
Self::create_mt(lua, s, mod_?, true)
Self::create_mt(lua, id.into_owned(), mod_?, true)
})?,
)
}
@ -28,19 +29,20 @@ impl Require {
lua.globals().raw_set(
"require",
lua.create_async_function(|lua, id: mlua::String| async move {
let s = &id.to_str()?;
LOADER.ensure(s, |_| ()).await.into_lua_err()?;
let id = id.to_str()?;
let id = Self::absolute_id(&lua, &id)?;
LOADER.ensure(&id, |_| ()).await.into_lua_err()?;
runtime_mut!(lua)?.push(s);
let mod_ = LOADER.load(&lua, s);
runtime_mut!(lua)?.push(&id);
let mod_ = LOADER.load(&lua, &id);
runtime_mut!(lua)?.pop();
Self::create_mt(&lua, s, mod_?, false)
Self::create_mt(&lua, id.into_owned(), mod_?, false)
})?,
)
}
fn create_mt(lua: &Lua, id: &str, r#mod: Table, sync: bool) -> mlua::Result<Table> {
fn create_mt(lua: &Lua, id: String, r#mod: Table, sync: bool) -> mlua::Result<Table> {
let id: Arc<str> = Arc::from(id);
let mt = lua.create_table_from([
(
@ -112,4 +114,13 @@ impl Require {
(LOADER.try_load(lua, id)?, args)
})
}
fn absolute_id<'a>(lua: &Lua, id: &'a str) -> mlua::Result<Cow<'a, str>> {
let Some(stripped) = id.strip_prefix('.') else { return Ok(id.into()) };
Ok(if let Some(cur) = runtime!(lua)?.current() {
format!("{}.{stripped}", cur.split('.').next().unwrap_or(cur)).into()
} else {
stripped.into()
})
}
}

View file

@ -19,7 +19,6 @@ pub fn compose(
b"render" => Utils::render(lua)?,
b"emit" => Utils::emit(lua)?,
b"mgr_emit" => Utils::mgr_emit(lua)?,
b"manager_emit" => Utils::mgr_emit(lua)?, // TODO: remove this in the future
// Image
b"image_info" => Utils::image_info(lua)?,

View file

@ -1,8 +1,13 @@
pub trait BytesExt {
fn kebab_cased(&self) -> bool;
fn split_by_seq(&self, sep: &[u8]) -> Option<(&[u8], &[u8])>;
}
impl BytesExt for [u8] {
fn kebab_cased(&self) -> bool {
self.iter().all(|&b| matches!(b, b'0'..=b'9' | b'a'..=b'z' | b'-'))
}
fn split_by_seq(&self, sep: &[u8]) -> Option<(&[u8], &[u8])> {
let idx = memchr::memmem::find(self, sep)?;
let (left, right) = self.split_at(idx);