This commit is contained in:
Cal 2026-07-13 20:41:53 +00:00 committed by GitHub
commit 22f1ba16a6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
37 changed files with 718 additions and 54 deletions

1
.gitignore vendored
View file

@ -10,5 +10,6 @@ result-*
.idea/
.vscode/
.zed/
*.snap

View file

@ -26,6 +26,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/):
- Show file icons in trash/delete/overwrite confirmations ([#4096])
- Dynamic keymap Lua API ([#4031])
- New `ui.Input` element ([#4040])
- Copying and pasting files with the system clipboard ([#4035])
- Image preview with Überzug++ on Niri ([#3990])
- New gait for input `backward` and `forward` actions ([#4012])
@ -1768,6 +1769,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/):
[#4012]: https://github.com/sxyazi/yazi/pull/4012
[#4022]: https://github.com/sxyazi/yazi/pull/4022
[#4031]: https://github.com/sxyazi/yazi/pull/4031
[#4035]: https://github.com/sxyazi/yazi/pull/4035
[#4040]: https://github.com/sxyazi/yazi/pull/4040
[#4064]: https://github.com/sxyazi/yazi/pull/4064
[#4065]: https://github.com/sxyazi/yazi/pull/4065

2
Cargo.lock generated
View file

@ -4744,7 +4744,9 @@ dependencies = [
"inventory",
"libc",
"mlua",
"parking_lot",
"paste",
"percent-encoding",
"ratatui-core",
"scopeguard",
"tokio",

View file

@ -40,19 +40,21 @@ yazi-watcher = { path = "../yazi-watcher", version = "26.5.6" }
yazi-widgets = { path = "../yazi-widgets", version = "26.5.6" }
# External dependencies
anyhow = { workspace = true }
either = { workspace = true }
futures = { workspace = true }
hashbrown = { workspace = true }
indexmap = { workspace = true }
inventory = { workspace = true }
mlua = { workspace = true }
paste = { workspace = true }
ratatui-core = { workspace = true }
scopeguard = { workspace = true }
tokio = { workspace = true }
tokio-stream = { workspace = true }
tracing = { workspace = true }
anyhow = { workspace = true }
either = { workspace = true }
futures = { workspace = true }
hashbrown = { workspace = true }
indexmap = { workspace = true }
inventory = { workspace = true }
mlua = { workspace = true }
parking_lot = { workspace = true }
paste = { workspace = true }
ratatui-core = { workspace = true }
scopeguard = { workspace = true }
tokio = { workspace = true }
tokio-stream = { workspace = true }
tracing = { workspace = true }
percent-encoding = { workspace = true }
[target."cfg(unix)".dependencies]
libc = { workspace = true }

View file

@ -0,0 +1,39 @@
use anyhow::Result;
use mlua::{ObjectLike, Table};
use tracing::error;
use yazi_actor::lives::Lives;
use yazi_binding::runtime_scope;
use yazi_macro::succ;
use yazi_parser::app::ClipboardForm;
use yazi_plugin::LUA;
use yazi_shared::data::Data;
use crate::{Actor, Ctx};
pub struct Clipboard;
impl Actor for Clipboard {
type Form = ClipboardForm;
const NAME: &str = "clipboard";
fn act(cx: &mut Ctx, form: Self::Form) -> Result<Data> {
let Some(size) = cx.term.as_ref().and_then(|t| t.size().ok()) else { succ!() };
let area = yazi_binding::elements::Rect::from(size);
let result = Lives::scope(cx.core, move |_| {
runtime_scope!(LUA, "root", {
let root = LUA.globals().raw_get::<Table>("Root")?.call_method::<Table>("new", area)?;
root.call_method::<()>("clipboard", form.event)?;
Ok(())
})
});
if let Err(ref e) = result {
error!("{e}");
}
succ!(result?);
}
}

View file

@ -1,6 +1,7 @@
yazi_macro::mod_flat!(
accept_payload
bootstrap
clipboard
deprecate
dnd
focus

View file

@ -2,7 +2,8 @@ use anyhow::{Result, bail};
use yazi_macro::{act, succ};
use yazi_parser::mgr::CopyForm;
use yazi_shared::{data::Data, strand::ToStrand, url::UrlLike};
use yazi_widgets::CLIPBOARD;
use yazi_shim::RFC_3986;
use yazi_widgets::{CLIPBOARD, ClipboardData};
use crate::{Actor, Ctx};
@ -41,6 +42,16 @@ impl Actor for Copy {
"name_without_ext" => {
s.extend_from_slice(&form.separator.transform(&u.stem().unwrap_or_default()));
}
"uri_list" => {
// Per the spec this should be CRLF line endings but everything i've tested on
// linux works with just LF
s.extend_from_slice(b"file://");
s.extend_from_slice(
percent_encoding::percent_encode(&form.separator.transform(&u.to_strand()), RFC_3986)
.to_string()
.as_bytes(),
);
}
_ => bail!("Unknown copy type: {}", form.r#type),
};
if it.peek().is_some() {
@ -53,7 +64,25 @@ impl Actor for Copy {
s.extend_from_slice(&form.separator.transform(&cx.cwd().to_strand()));
}
futures::executor::block_on(CLIPBOARD.set(s));
if yazi_emulator::EMULATOR.osc_5522 {
let mut data = Vec::<ClipboardData>::new();
match form.r#type.as_ref() {
"uri_list" => {
data.push(ClipboardData {
mime: b"text/uri-list".to_vec(),
payload: s,
alias: b"text/plain".to_vec(),
});
}
_ => {
data.push(ClipboardData { mime: b"text/plain".to_vec(), payload: s, alias: vec![] });
}
}
futures::executor::block_on(CLIPBOARD.write(data));
} else {
futures::executor::block_on(CLIPBOARD.set(s));
}
succ!();
}
}

View file

@ -23,7 +23,7 @@ impl Actor for Paste {
mgr.tabs.iter_mut().for_each(|t| _ = t.selected.remove_many(mgr.yanked.urls()));
act!(mgr:unyank, cx)
} else {
succ!(cx.core.tasks.file_copy(&mgr.yanked, dest, form.force, form.follow));
succ!(cx.core.tasks.file_copy(&mgr.yanked, dest, form.force));
}
}
}

View file

@ -16,6 +16,7 @@ impl Actor for Spawn {
fn act(cx: &mut Ctx, form: Self::Form) -> Result<Data> {
succ!(match form.opt {
TaskOpt::Cut(r#in) => cx.tasks.scheduler.file_cut(r#in),
TaskOpt::Copy(r#in) => cx.tasks.scheduler.file_copy(r#in),
TaskOpt::Plugin(r#in) => cx.tasks.scheduler.plugin_entry(r#in),
})

View file

@ -101,6 +101,7 @@ keymap = [
{ on = [ "c", "d" ], run = "copy dirname", desc = "Copy directory URL" },
{ on = [ "c", "f" ], run = "copy filename", desc = "Copy filename" },
{ on = [ "c", "n" ], run = "copy name_without_ext", desc = "Copy filename without extension" },
{ on = [ "c", "l" ], run = "copy uri_list", desc = "Copy URI list" },
# Filter
{ on = "f", run = "filter --smart", desc = "Filter files" },

View file

@ -1,5 +1,5 @@
use tracing::debug;
use yazi_scheduler::file::FileInCut;
use yazi_scheduler::file::{FileInCopy, FileInCut};
use yazi_shared::url::{UrlBuf, UrlLike};
use super::Tasks;
@ -22,7 +22,7 @@ impl Tasks {
}
}
pub fn file_copy(&self, src: &Yanked, dest: &UrlBuf, force: bool, follow: bool) {
pub fn file_copy(&self, src: &Yanked, dest: &UrlBuf, force: bool) {
self.scheduler.behavior.reset();
for u in src.urls() {
@ -33,7 +33,7 @@ impl Tasks {
if force && u == to {
debug!("file_copy: same file, skip {to:?}");
} else {
self.scheduler.file_copy(u.clone(), to, force, follow);
self.scheduler.file_copy(FileInCopy::new(u.clone(), to, force));
}
}
}

View file

@ -1,13 +1,14 @@
use std::borrow::Cow;
use yazi_macro::impl_data_any;
use yazi_scheduler::{TaskIn, file::FileInCut, plugin::PluginInEntry};
use yazi_scheduler::{TaskIn, file::{FileInCopy, FileInCut}, plugin::PluginInEntry};
use yazi_shared::id::Id;
use yazi_shim::SStr;
#[derive(Clone, Debug)]
pub enum TaskOpt {
Cut(FileInCut),
Copy(FileInCopy),
Plugin(PluginInEntry),
}
@ -20,6 +21,7 @@ impl TaskIn for TaskOpt {
fn id(&self) -> Id {
match self {
Self::Cut(r#in) => r#in.id(),
Self::Copy(r#in) => r#in.id(),
Self::Plugin(r#in) => r#in.id(),
}
@ -28,6 +30,7 @@ impl TaskIn for TaskOpt {
fn set_id(&mut self, id: Id) -> &mut Self {
match self {
Self::Cut(r#in) => _ = r#in.set_id(id),
Self::Copy(r#in) => _ = r#in.set_id(id),
Self::Plugin(r#in) => _ = r#in.set_id(id),
}
@ -37,6 +40,7 @@ impl TaskIn for TaskOpt {
fn title(&self) -> Cow<'_, str> {
match self {
Self::Cut(r#in) => r#in.title(),
Self::Copy(r#in) => r#in.title(),
Self::Plugin(r#in) => r#in.title(),
}
@ -45,6 +49,7 @@ impl TaskIn for TaskOpt {
fn set_title(&mut self, title: impl Into<SStr>) -> &mut Self {
match self {
Self::Cut(r#in) => _ = r#in.set_title(title),
Self::Copy(r#in) => _ = r#in.set_title(title),
Self::Plugin(r#in) => _ = r#in.set_title(title),
}

View file

@ -9,7 +9,7 @@ use tracing::{debug, error, warn};
use yazi_macro::writef;
use yazi_shim::cell::RoCell;
use yazi_term::TERM;
use yazi_tty::{Handle, TTY, sequence::{HideCursor, If, KittyGraphicsQuery, MoveTo, RequestBgColor, RequestCellPixelSize, RequestDA1, RequestXtVersion, RestoreCursorPos, SaveCursorPos, SetFg, SetSgr, ShowCursor}};
use yazi_tty::{Handle, TTY, sequence::{HideCursor, If, KittyGraphicsQuery, MoveTo, QueryOSC5522, RequestBgColor, RequestCellPixelSize, RequestDA1, RequestXtVersion, RestoreCursorPos, SaveCursorPos, SetFg, SetSgr, ShowCursor}};
use crate::{Brand, Mux, TMUX, Unknown};
@ -22,6 +22,7 @@ pub struct Emulator {
pub light: bool,
pub csi_16t: (u16, u16),
pub force_16t: bool,
pub osc_5522: bool,
}
impl Default for Emulator {
@ -32,6 +33,7 @@ impl Default for Emulator {
light: false,
csi_16t: (0, 0),
force_16t: false,
osc_5522: false,
}
}
}
@ -44,11 +46,12 @@ impl Emulator {
let resort = Brand::from_env();
writef!(
TTY.writer(),
"{SaveCursorPos}{}{}{}{}{}{RestoreCursorPos}",
"{SaveCursorPos}{}{}{}{}{}{}{RestoreCursorPos}",
If(resort.is_none(), Mux::wrap(KittyGraphicsQuery)),
Mux::wrap(RequestXtVersion),
RequestCellPixelSize,
RequestBgColor,
QueryOSC5522,
Mux::wrap(RequestDA1),
)?;
@ -65,12 +68,14 @@ impl Emulator {
};
let csi_16t = Self::csi_16t(&resp).unwrap_or_default();
Ok(Self {
kind,
version: Self::csi_gt_q(&resp).unwrap_or_default(),
light: Self::light_bg(&resp).unwrap_or_default(),
csi_16t,
force_16t: Self::force_16t(csi_16t),
osc_5522: Self::osc_5522(&resp),
})
}
@ -186,6 +191,10 @@ impl Emulator {
}
}
fn osc_5522(resp: &str) -> bool {
["\x1b[?5522;1$y", "\x1b[?5522;2$y", "\x1b[?5522;3$y"].iter().any(|s| resp.contains(s))
}
fn force_16t((w, h): (u16, u16)) -> bool {
if w == 0 || h == 0 {
return false;

View file

@ -5,7 +5,7 @@ use tracing::warn;
use yazi_actor::Ctx;
use yazi_macro::{act, emit};
use yazi_shared::event::{ActionCow, Event, NEED_RENDER};
use yazi_term::event::{DndEvent, Event as TermEvent, KeyEvent, MouseEvent};
use yazi_term::event::{ClipboardEvent, DndEvent, Event as TermEvent, KeyEvent, MouseEvent};
use yazi_widgets::input::InputMode;
use crate::{Executor, Router, app::App};
@ -29,6 +29,7 @@ impl<'a> Dispatcher<'a> {
Event::Term(TermEvent::FocusOut) => Ok(()),
Event::Term(TermEvent::Paste(str)) => self.dispatch_paste(str),
Event::Term(TermEvent::Dnd(dnd)) => self.dispatch_dnd(dnd),
Event::Term(TermEvent::Clipboard(clip)) => self.dispatch_clipboard(clip),
};
if let Err(e) = &result {
@ -101,4 +102,15 @@ impl<'a> Dispatcher<'a> {
let cx = &mut Ctx::active(&mut self.app.core, &mut self.app.term);
act!(app:dnd, cx, dnd).map(|_| ())
}
fn dispatch_clipboard(&mut self, clip: ClipboardEvent) -> Result<()> {
if self.app.core.input.focus() {
if let Some(text) = clip.text() {
self.dispatch_paste(text)?;
return Ok(());
}
}
let cx = &mut Ctx::active(&mut self.app.core, &mut self.app.term);
act!(app:clipboard, cx, clip).map(|_| ())
}
}

View file

@ -0,0 +1,19 @@
use mlua::{ExternalError, FromLua, IntoLua, Lua, Value};
use yazi_term::event::ClipboardEvent;
#[derive(Debug)]
pub struct ClipboardForm {
pub event: ClipboardEvent,
}
impl From<ClipboardEvent> for ClipboardForm {
fn from(event: ClipboardEvent) -> Self { Self { event } }
}
impl FromLua for ClipboardForm {
fn from_lua(_: Value, _: &Lua) -> mlua::Result<Self> { Err("unsupported".into_lua_err()) }
}
impl IntoLua for ClipboardForm {
fn into_lua(self, _: &Lua) -> mlua::Result<Value> { Err("unsupported".into_lua_err()) }
}

View file

@ -1 +1 @@
yazi_macro::mod_flat!(deprecate dnd lua mouse plugin quit reflow title update_progress);
yazi_macro::mod_flat!(clipboard deprecate dnd lua mouse plugin quit reflow title update_progress);

View file

@ -10,6 +10,7 @@ pub enum Spark<'a> {
// App
AppAcceptPayload(yazi_dds::Payload<'a>),
AppBootstrap(crate::VoidForm),
AppClipboard(crate::app::ClipboardForm),
AppDeprecate(crate::app::DeprecateForm),
AppDnd(crate::app::DndForm),
AppFocus(crate::VoidForm),
@ -205,6 +206,7 @@ impl<'a> IntoLua for Spark<'a> {
// App
Self::AppAcceptPayload(b) => b.into_lua(lua),
Self::AppBootstrap(b) => b.into_lua(lua),
Self::AppClipboard(b) => b.into_lua(lua),
Self::AppDeprecate(b) => b.into_lua(lua),
Self::AppDnd(b) => b.into_lua(lua),
Self::AppFocus(b) => b.into_lua(lua),
@ -386,6 +388,7 @@ try_from_spark!(
// App
try_from_spark!(crate::ArrowForm, mgr:arrow, mgr:tab_swap);
try_from_spark!(crate::app::ClipboardForm, app:clipboard);
try_from_spark!(crate::app::DeprecateForm, app:deprecate);
try_from_spark!(crate::app::DndForm, app:dnd);
try_from_spark!(crate::app::LuaForm, app:lua);

View file

@ -99,3 +99,16 @@ function Root:drop(event)
return c and c.drop and c:drop(event)
end
end
function Root:clipboard(event)
if event and event.type == "mimetypes" and event.pw then
-- No harm in asking for unavailable types
local mimetypes = "text/plain text/uri-list"
rt.tty:queue("ReadClipboard", { mimes = mimetypes, pw = event.pw, name = "Paste Event", primary = event.primary })
rt.tty:flush()
elseif event and event.type == "data" then
if event.data["text/uri-list"] ~= nil then
require("clipboard").copy_uri_list(event.data["text/uri-list"])
end
end
end

View file

@ -0,0 +1,32 @@
local M = {}
function M.copy_uri_list(list)
cx.tasks.behavior:reset()
for line in list:gmatch("[^\r\n]+") do
if line:sub(1, 7) ~= "file://" then
goto continue
end
local from = Url(ya.percent_decode(line:sub(8)))
if from.name then
local to = cx.active.current.cwd:join(from.name)
ya.async(function() ya.task("copy", { from = from, to = to }):spawn() end)
end
::continue::
end
end
function M.paste_image(mime, data)
local type = mime:match("image/([^;]+)")
local dir = cx.active.current.cwd
local url = Url(dir .. "/pasted_image." .. type)
ya.async(function()
local file = fs.unique("file", url)
if file then
fs.write(file, data)
end
end)
end
return M

View file

@ -9,6 +9,7 @@ impl Utils {
lua.create_function(|lua, (kind, value): (LuaString, Value)| {
Ok(TaskOpt(match &*kind.as_bytes() {
b"cut" => tasks::TaskOpt::Cut(<_>::from_lua(value, lua)?),
b"copy" => tasks::TaskOpt::Copy(<_>::from_lua(value, lua)?),
b"plugin" => tasks::TaskOpt::Plugin(<_>::from_lua(value, lua)?),

View file

@ -28,6 +28,7 @@ impl Default for Loader {
let cache = HashMap::from_iter([
// Plugins
("archive".to_owned(), preset!("plugins/archive").into()),
("clipboard".to_owned(), preset!("plugins/clipboard").into()),
("code".to_owned(), preset!("plugins/code").into()),
("dds".to_owned(), preset!("plugins/dds").into()),
("dnd".to_owned(), preset!("plugins/dnd").into()),

View file

@ -131,7 +131,7 @@ impl FileIn {
// --- Copy
#[derive(Clone, Debug)]
pub(crate) struct FileInCopy {
pub struct FileInCopy {
pub(crate) id: Id,
pub(crate) from: UrlBuf,
pub(crate) to: UrlBuf,
@ -157,6 +157,18 @@ impl TaskIn for FileInCopy {
}
impl FileInCopy {
pub fn new(from: UrlBuf, to: UrlBuf, force: bool) -> Self {
Self {
follow: !from.auth().covariant(to.auth()),
id: Id::ZERO,
from,
to,
force,
cha: None,
retry: 0,
}
}
pub(super) fn into_link(self) -> FileInLink {
FileInLink {
id: self.id,
@ -171,6 +183,15 @@ impl FileInCopy {
}
}
impl FromLua for FileInCopy {
fn from_lua(value: Value, _: &Lua) -> mlua::Result<Self> {
let Value::Table(t) = value else {
return Err("constructing FileInCopy from non-table value".into_lua_err());
};
Ok(Self::new(t.raw_get("from")?, t.raw_get("to")?, t.raw_get("force")?))
}
}
// --- Cut
#[derive(Clone, Debug)]
pub struct FileInCut {

View file

@ -77,16 +77,16 @@ impl Scheduler {
id
}
pub fn file_copy(&self, from: UrlBuf, to: UrlBuf, force: bool, follow: bool) {
let follow = follow || !from.auth().covariant(to.auth());
let mut r#in = FileInCopy { id: Id::ZERO, from, to, force, cha: None, follow, retry: 0 };
pub fn file_copy(&self, mut r#in: FileInCopy) -> Id {
let id = self.add(&mut r#in, |t| t.id);
self.add(&mut r#in, |_| ());
if r#in.to.try_starts_with(&r#in.from).unwrap_or(false) && !r#in.to.covariant(&r#in.from) {
self.ops.out(r#in.id, FileOutCopy::Fail("Cannot copy directory into itself".to_owned()));
} else {
self.file.submit(r#in, LOW);
}
id
}
pub fn file_link(&self, from: UrlBuf, to: UrlBuf, relative: bool, force: bool) {

View file

@ -6,3 +6,10 @@ pub const BASE64_SANE: GeneralPurpose = GeneralPurpose::new(
.with_encode_padding(false)
.with_decode_padding_mode(DecodePaddingMode::Indifferent),
);
pub const BASE64_PAD: GeneralPurpose = GeneralPurpose::new(
&STANDARD,
GeneralPurposeConfig::new()
.with_encode_padding(true)
.with_decode_padding_mode(DecodePaddingMode::Indifferent),
);

View file

@ -0,0 +1,144 @@
use strum::{FromRepr, IntoStaticStr};
use crate::{event::mime::MimeList, parser::{Osc5522Status, StateOsc5522}};
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum ClipboardEvent {
ReadMimetypes(ClipboardPaste),
ReadData(ClipboardRead),
ReadError(ClipboardError),
WriteSuccess,
WriteError(ClipboardError),
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ClipboardPaste {
pub mimes: MimeList,
pub primary: bool,
pub pw: Vec<u8>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ClipboardData {
pub mime: Vec<u8>,
pub data: Vec<u8>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ClipboardRead {
pub mimes: MimeList,
pub primary: bool,
pub data: Vec<ClipboardData>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ClipboardError {
pub name: String,
}
impl ClipboardEvent {
pub fn r#type(&self) -> &'static str {
match self {
Self::ReadMimetypes(_) => "mimetypes",
Self::ReadData(_) => "data",
Self::ReadError(_) => "error",
Self::WriteSuccess => "success",
Self::WriteError(_) => "error",
}
}
pub fn mimes(&self) -> Option<&MimeList> {
match self {
Self::ReadMimetypes(e) => Some(&e.mimes),
Self::ReadData(e) => Some(&e.mimes),
_ => None,
}
}
pub fn primary(&self) -> Option<bool> {
match self {
Self::ReadMimetypes(e) => Some(e.primary),
_ => None,
}
}
pub fn pw(&self) -> Option<String> {
match self {
Self::ReadMimetypes(e) => Some(String::from_utf8_lossy(&e.pw).into_owned()),
_ => None,
}
}
pub fn text(&self) -> Option<String> {
match self {
Self::ReadData(e) if let Some(t) = e.data.iter().find(|e| e.mime == b"text/plain") => {
Some(String::from_utf8_lossy(&t.data).into_owned())
}
_ => None,
}
}
pub fn is_read(&self) -> bool {
match self {
Self::ReadMimetypes(_) | Self::ReadError(_) | Self::ReadData(_) => true,
_ => false,
}
}
pub(crate) fn from_state(s: StateOsc5522) -> Option<Self> {
Some(match s {
StateOsc5522 { read: true, status: Some(Osc5522Status::DONE), idx: 0, mime, .. }
if mime.first()? == b"." =>
{
ClipboardEvent::ReadMimetypes(ClipboardPaste {
mimes: MimeList::new(s.payload.first()?.to_owned())?,
primary: s.primary,
pw: s.pw,
})
}
StateOsc5522 { read: true, status: Some(Osc5522Status::DONE), .. } => {
let mut mimes = Vec::new();
let mut data = Vec::new();
for (mime, payload) in s.mime.iter().zip(s.payload.iter()) {
data.push(ClipboardData { mime: mime.to_owned(), data: payload.to_owned() });
mimes.extend(mime);
mimes.push(b' ');
}
ClipboardEvent::ReadData(ClipboardRead {
mimes: MimeList::new(mimes)?,
primary: s.primary,
data,
})
}
StateOsc5522 { read: true, .. } => {
Self::ReadError(ClipboardError { name: parse_error(s.status)? })
}
StateOsc5522 { read: false, status: Some(Osc5522Status::DONE), .. } => {
ClipboardEvent::WriteSuccess
}
StateOsc5522 { read: false, .. } => {
Self::WriteError(ClipboardError { name: parse_error(s.status)? })
}
})
}
}
// --- Operation
#[derive(Clone, Copy, Debug, Eq, FromRepr, IntoStaticStr, PartialEq)]
#[repr(u8)]
pub enum ClipboardType {
Read = 1,
Write = 2,
}
// --- Error payload parsing
fn parse_error(status: Option<Osc5522Status>) -> Option<String> {
match status {
Some(Osc5522Status::ENOSYS) => Some("ENOSYS".to_string()),
Some(Osc5522Status::EPERM) => Some("EPERM".to_string()),
Some(Osc5522Status::EBUSY) => Some("EBUSY".to_string()),
Some(Osc5522Status::EIO) => Some("EIO".to_string()),
Some(Osc5522Status::EINVAL) => Some("EINVAL".to_string()),
_ => None,
}
}

View file

@ -1,10 +1,8 @@
use std::str::SplitWhitespace;
use base64::Engine;
use strum::{FromRepr, IntoStaticStr};
use yazi_shim::BASE64_SANE;
use crate::parser::StateOsc72;
use crate::{event::mime::MimeList, parser::StateOsc72};
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum DndEvent {
@ -63,7 +61,7 @@ pub struct DndDropEnter {
pub x: u32,
pub y: u32,
pub op: DndOp,
pub mimes: DndMimeList,
pub mimes: MimeList,
}
#[derive(Clone, Debug, Eq, PartialEq)]
@ -71,7 +69,7 @@ pub struct DndDropReady {
pub x: u32,
pub y: u32,
pub op: DndOp,
pub mimes: DndMimeList,
pub mimes: MimeList,
}
#[derive(Clone, Debug, Eq, PartialEq)]
@ -144,7 +142,7 @@ impl DndEvent {
}
}
pub fn mimes(&self) -> Option<&DndMimeList> {
pub fn mimes(&self) -> Option<&MimeList> {
match self {
Self::DropEnter(e) => Some(&e.mimes),
Self::DropReady(e) => Some(&e.mimes),
@ -185,13 +183,13 @@ impl DndEvent {
x: s.x?.try_into().ok()?,
y: s.y?.try_into().ok()?,
op: DndOp::from_repr(s.op?)?,
mimes: DndMimeList::new(s.payload)?,
mimes: MimeList::new(s.payload)?,
}),
b'M' => Self::DropReady(DndDropReady {
x: s.x?.try_into().ok()?,
y: s.y?.try_into().ok()?,
op: DndOp::from_repr(s.op?)?,
mimes: DndMimeList::new(s.payload)?,
mimes: MimeList::new(s.payload)?,
}),
b'r' => Self::DropArrive(DndDropArrive {
idx: s.x?.try_into().ok()?,
@ -217,14 +215,6 @@ pub enum DndOp {
}
// --- MIME list
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct DndMimeList(String);
impl DndMimeList {
pub fn new(b: Vec<u8>) -> Option<Self> { Some(Self(String::from_utf8(b).ok()?)) }
pub fn iter(&self) -> SplitWhitespace<'_> { self.0.split_whitespace() }
}
// --- Error payload parsing
fn parse_error(payload: Vec<u8>) -> Option<(String, String)> {

View file

@ -1,4 +1,4 @@
use crate::{Dimension, event::{DndEvent, KeyEvent, MouseEvent}};
use crate::{Dimension, event::{ClipboardEvent, DndEvent, KeyEvent, MouseEvent}};
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum Event {
@ -9,4 +9,5 @@ pub enum Event {
FocusOut,
Paste(String),
Dnd(DndEvent),
Clipboard(ClipboardEvent),
}

View file

@ -3,7 +3,7 @@ use std::mem;
use mlua::{IntoLua, Lua, LuaSerdeExt, UserData, UserDataFields, Value};
use yazi_shim::{mlua::{SER_OPT, UserDataFieldsExt}, strum::IntoStr};
use crate::event::{DndDropArrive, DndEvent, KeyEvent, MouseButton, MouseEvent, MouseEventKind};
use crate::event::{ClipboardEvent, ClipboardRead, DndDropArrive, DndEvent, KeyEvent, MouseButton, MouseEvent, MouseEventKind};
// --- DndEvent
impl UserData for DndEvent {
@ -35,6 +35,37 @@ impl UserData for DndEvent {
}
}
// --- ClipboardEvent
impl UserData for ClipboardEvent {
fn add_fields<F: UserDataFields<Self>>(fields: &mut F) {
fields.add_field_method_get("type", |_, me| Ok(me.r#type()));
fields.add_field_method_get("pw", |_, me| Ok(me.pw()));
fields.add_field_method_get("primary", |_, me| Ok(me.primary()));
fields.add_cached_field("mimes", |lua, me| {
if let Some(mimes) = me.mimes() {
lua.create_sequence_from(mimes.iter())?.into_lua(lua)
} else {
Ok(Value::Nil)
}
});
fields.add_cached_field_mut("data", |lua, me| match me {
Self::ReadData(ClipboardRead { data, .. }) => lua
.create_table_from(
data
.iter()
.map(|d| Ok((lua.create_string(&d.mime)?, lua.create_external_string(&*d.data)?)))
.collect::<Result<Vec<_>, mlua::Error>>()?,
)?
.into_lua(lua),
_ => Ok(Value::Nil),
});
}
}
// --- MouseEvent
impl UserData for MouseEvent {
fn add_fields<F: UserDataFields<Self>>(fields: &mut F) {

View file

@ -0,0 +1,10 @@
use std::str::SplitWhitespace;
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MimeList(String);
impl MimeList {
pub fn new(b: Vec<u8>) -> Option<Self> { Some(Self(String::from_utf8(b).ok()?)) }
pub fn iter(&self) -> SplitWhitespace<'_> { self.0.split_whitespace() }
}

View file

@ -1 +1 @@
yazi_macro::mod_flat!(dnd event keyboard lua modifiers mouse);
yazi_macro::mod_flat!(clipboard dnd event keyboard lua mime modifiers mouse);

View file

@ -1,4 +1,7 @@
use crate::{ParseError, Result, parser::{Parser, State}};
use base64::Engine;
use yazi_shim::BASE64_PAD;
use crate::{ParseError, Result, parser::{Osc5522Status, Parser, State}};
impl Parser {
pub(super) fn parse_osc72(&mut self) -> Result<()> {
@ -37,4 +40,68 @@ impl Parser {
state.payload.extend(payload);
Ok(())
}
pub(super) fn parse_osc5522(&mut self) -> Result<()> {
debug_assert!(self.seq.starts_with(b"\x1b]5522;"));
debug_assert!(self.seq.ends_with(b"\x1b\\"));
let mut it = self.seq[7..self.seq.len() - 2].splitn(2, |&b| b == b';');
let meta = str::from_utf8(it.next().ok_or(ParseError::Invalid)?)?;
let payload = it.next().unwrap_or(&[]);
let State::Osc5522(state) = &mut self.state else { unreachable!() };
state.has_more = false;
for part in meta.split(':') {
match part.split_once('=').ok_or(ParseError::Invalid)? {
("status", v) => match v {
"OK" => {
state.status = Some(Osc5522Status::OK);
state.has_more = true;
}
"DATA" => {
state.status = Some(Osc5522Status::DATA);
state.has_more = true;
}
"DONE" => state.status = Some(Osc5522Status::DONE),
"ENOSYS" => state.status = Some(Osc5522Status::ENOSYS),
"EPERM" => state.status = Some(Osc5522Status::EPERM),
"EBUSY" => state.status = Some(Osc5522Status::EBUSY),
"EIO" => state.status = Some(Osc5522Status::EIO),
"EINVAL" => state.status = Some(Osc5522Status::EINVAL),
_ => return Err(ParseError::Invalid),
},
("type", v) => state.read = v == "read",
("loc", v) => state.primary = v == "primary",
("mime", v) => {
let bytes = BASE64_PAD.decode(v.as_bytes()).or(Err(ParseError::Invalid))?;
if state.mime.len() == 0 {
state.mime.push(bytes);
} else if state.mime[state.idx] != bytes {
state.mime.push(bytes);
state.idx += 1;
}
}
("pw", v) => state.pw = BASE64_PAD.decode(v.as_bytes()).unwrap_or_default(),
_ => {}
}
}
// decode now since each payload may have its own padding
let payload = BASE64_PAD.decode(&payload).or(Err(ParseError::Invalid))?;
if state.idx >= state.payload.len() {
state.payload.push(payload.to_vec());
} else {
state.payload[state.idx].extend(payload);
}
// Limit payload size to 1MiB per mime type to prevent potential DoS
// TODO A larger size would be required for directly pasting images/large files
if state.payload[state.idx].len() > 1 << 20 {
return Err(ParseError::Invalid);
}
Ok(())
}
}

View file

@ -3,7 +3,7 @@ use std::{collections::VecDeque, mem, num::NonZeroU8, str};
use yazi_shim::utf8_char_width;
use super::state::State;
use crate::event::{DndEvent, Event, KeyCode, KeyEvent, Modifiers};
use crate::event::{ClipboardEvent, DndEvent, Event, KeyCode, KeyEvent, Modifiers};
#[derive(Debug)]
pub struct Parser {
@ -39,6 +39,7 @@ impl Parser {
State::BracketedPaste => self.on_bracketed_paste(b),
State::Osc | State::OscSt => self.on_osc(b),
State::Osc72(_) => self.on_osc72(b),
State::Osc5522(_) => self.on_osc5522(b),
State::Dcs | State::DcsSt => self.on_dcs(b),
State::Utf8(n) => self.on_utf8(b, *n),
State::AltUtf8(n) => self.on_alt_utf8(b, *n),
@ -55,6 +56,7 @@ impl Parser {
match &self.state {
State::Esc => self.emit_key(KeyCode::Escape),
State::Osc72(s) if s.has_more => return,
State::Osc5522(s) if s.has_more => return,
_ => {}
}
@ -202,6 +204,9 @@ impl Parser {
(State::Osc, _) if self.seq.starts_with(b"\x1b]72;") => {
self.state = State::Osc72(Default::default());
}
(State::Osc, _) if self.seq.starts_with(b"\x1b]5522;") => {
self.state = State::Osc5522(Default::default());
}
(State::Osc, b'\x1B') => self.state = State::OscSt,
(State::Osc, _) => {} // keep accumulating
(State::OscSt, b'\\') => self.reset(), // ST (`\x1B\\`) — OSC complete (discard)
@ -235,6 +240,30 @@ impl Parser {
}
}
fn on_osc5522(&mut self, b: u8) {
self.seq.push(b);
if !self.seq.ends_with(b"\x1b\\") {
return;
} else if self.parse_osc5522().is_err() {
return self.reset();
}
match mem::take(&mut self.state) {
State::Osc5522(s) if s.has_more => {
self.seq.clear();
self.state = State::Osc5522(s);
}
State::Osc5522(s) => {
if let Some(e) = ClipboardEvent::from_state(s) {
self.emit(Event::Clipboard(e));
}
self.reset();
}
_ => unreachable!(),
}
}
fn on_dcs(&mut self, b: u8) {
self.seq.push(b);

View file

@ -19,6 +19,8 @@ pub(crate) enum State {
Osc,
/// Inside an OSC 72 (DnD) sequence (`\x1B]72;` … ST).
Osc72(StateOsc72),
/// Inside an OSC 5522 (Clipboard) sequence (`\x1B]5522;` … ST).
Osc5522(StateOsc5522),
/// Inside OSC, just saw `\x1B` (potential start of ST = `\x1B\\`).
OscSt,
/// Inside a DCS sequence (`\x1BP` … ST).
@ -40,3 +42,28 @@ pub(crate) struct StateOsc72 {
pub(crate) payload: Vec<u8>,
pub(crate) has_more: bool,
}
#[derive(Debug, Default, PartialEq)]
pub(crate) struct StateOsc5522 {
pub(crate) status: Option<Osc5522Status>,
pub(crate) read: bool,
pub(crate) primary: bool,
pub(crate) mime: Vec<Vec<u8>>,
pub(crate) payload: Vec<Vec<u8>>,
pub(crate) pw: Vec<u8>,
pub(crate) idx: usize,
pub(crate) has_more: bool,
}
#[derive(Debug, Default, PartialEq)]
pub(crate) enum Osc5522Status {
#[default]
OK,
DATA,
DONE,
ENOSYS,
EPERM,
EBUSY,
EIO,
EINVAL,
}

View file

@ -4,7 +4,7 @@ use mlua::{BorrowedBytes, ExternalError, IntoLuaMulti, Lua, MultiValue, Table, U
use yazi_binding::Error;
use yazi_shim::mlua::{ByteString, LuaTableExt};
use crate::{Tty, sequence::{AgreeDrag, AgreeDrop, FinishDrop, PresentDrag, PresentDragIcon, StartDrag, StartDrop}};
use crate::{Tty, sequence::{AgreeDrag, AgreeDrop, FinishDrop, PresentDrag, PresentDragIcon, ReadClipboard, StartDrag, StartDrop, WriteClipboard, WriteClipboardData}};
impl Tty {
fn queue(&self, lua: &Lua, kind: &[u8], t: &Table) -> mlua::Result<MultiValue> {
@ -54,6 +54,32 @@ impl Tty {
_ => return Err("invalid FinishDrop type".into_lua_err()),
},
// Clipboard
b"ReadClipboard" => {
write!(w, "{}", ReadClipboard {
mime: &t.raw_get::<BorrowedBytes>("mimes")?,
pw: &t.raw_get::<BorrowedBytes>("pw")?,
name: &t.raw_get::<BorrowedBytes>("name")?,
primary: t.raw_get("primary")?,
})
}
b"WriteClipboard" => {
let mut data = Vec::new();
for v in &t.sequence_values::<Table>().collect::<Result<Vec<_>, mlua::Error>>()? {
data.push((
v.raw_get::<BorrowedBytes>("mime")?,
v.raw_get::<BorrowedBytes>("data")?,
v.raw_get::<BorrowedBytes>("alias")?,
));
}
write!(w, "{}", WriteClipboard {
data: data
.iter()
.map(|(m, p, a)| { WriteClipboardData { mime: &m, payload: &p, alias: &a } })
.collect(),
})
}
_ => return Err("invalid sequence kind".into_lua_err()),
};

View file

@ -1,6 +1,7 @@
use std::fmt::{self, Display};
use base64::{Engine, engine::general_purpose};
use yazi_shim::BASE64_PAD;
/// Set clipboard content via OSC 52
pub struct SetClipboard {
@ -18,3 +19,94 @@ impl Display for SetClipboard {
write!(f, "\x1b]52;c;{}\x1b\\", self.content)
}
}
/// Query OSC 5522 via DECRQM
pub struct QueryOSC5522;
impl Display for QueryOSC5522 {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "\x1b[?5522$p") }
}
/// Enable receiving unsolicited paste events via OSC 5522: `CSI ? 5522 h`
pub struct EnablePasteEvents;
impl Display for EnablePasteEvents {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "\x1b[?5522h") }
}
/// Disable receiving unsolicited paste events via OSC 5522: `CSI ? 5522 l`
pub struct DisablePasteEvents;
impl Display for DisablePasteEvents {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "\x1b[?5522l") }
}
/// Read data from clipboard:
/// `OSC 5522 ; type=read : <metadata> ; <base64 MIME list> ST`
pub struct ReadClipboard<'a> {
pub mime: &'a [u8],
pub pw: &'a [u8],
pub name: &'a [u8],
pub primary: bool,
}
impl Display for ReadClipboard<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let b64_mime = BASE64_PAD.encode(self.mime);
let mut metadata = String::new();
if self.pw.len() > 0 {
let b64_pw = BASE64_PAD.encode(self.pw);
let b64_name = BASE64_PAD.encode(self.name);
metadata.push_str(&format!(":pw={}:name={}", b64_pw, b64_name));
}
if self.primary {
metadata.push_str(":loc=primary");
}
write!(f, "\x1b]5522;type=read{};{}\x1b\\", metadata, b64_mime)
}
}
/// Read available MIME types from clipboard:
/// `OSC 5522 ; type=read ; <base64 [.]> ST`
pub struct ReadClipboardMimes;
impl Display for ReadClipboardMimes {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "\x1b]5522;type=read;{}\x1b\\", BASE64_PAD.encode(b"."))
}
}
/// Write data to clipboard:
/// `OSC 5522 ; type=write ST`
/// `OSC 5522 ; type=wdata : mime=<base64 MIME type> ; <base64 data chunk> ST`
/// `OSC 5522 ; type=wdata ST`
pub struct WriteClipboard<'a> {
pub data: Vec<WriteClipboardData<'a>>,
}
impl Display for WriteClipboard<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "\x1b]5522;type=write\x1b\\")?;
for item in &self.data {
let b64_mime = BASE64_PAD.encode(item.mime);
let data = item.payload;
for (_, chunk) in data.chunks(4096).enumerate() {
let b64_chunk = BASE64_PAD.encode(chunk);
write!(f, "\x1b]5522;type=wdata:mime={};{}\x1b\\", b64_mime, b64_chunk)?;
}
if item.alias.len() > 0 {
let b64_alias = BASE64_PAD.encode(item.alias);
write!(f, "\x1b]5522;type=walias:mime={};{}\x1b\\", b64_mime, b64_alias)?;
}
}
write!(f, "\x1b]5522;type=wdata\x1b\\")
}
}
pub struct WriteClipboardData<'a> {
pub mime: &'a [u8],
pub payload: &'a [u8],
pub alias: &'a [u8],
}

View file

@ -8,7 +8,7 @@ use yazi_macro::writef;
use yazi_proxy::AppProxy;
use yazi_shim::cell::SyncCell;
use yazi_term::{TERM, event::{Event, KeyEventKind}, stream::EventStream};
use yazi_tty::{TTY, TtyWriter, sequence::{DisableBracketedPaste, DisableDrag, DisableDrop, DisableFocusChange, DisableMouseCapture, EnableBracketedPaste, EnableDrag, EnableDrop, EnableFocusChange, EnableMouseCapture, EnterAlternateScreen, If, LeaveAlternateScreen, PopKeyboardFlags, PushKeyboardFlags, RequestCursorBlink, RequestCursorStyle, RequestDA1, RestoreCursorStyle, SetTitle, ShowCursor}};
use yazi_tty::{TTY, TtyWriter, sequence::{DisableBracketedPaste, DisableDrag, DisableDrop, DisableFocusChange, DisableMouseCapture, DisablePasteEvents, EnableBracketedPaste, EnableDrag, EnableDrop, EnableFocusChange, EnableMouseCapture, EnablePasteEvents, EnterAlternateScreen, If, LeaveAlternateScreen, PopKeyboardFlags, PushKeyboardFlags, RequestCursorBlink, RequestCursorStyle, RequestDA1, RestoreCursorStyle, SetTitle, ShowCursor}};
use crate::{RatermBackend, RatermOption, RatermState};
@ -43,7 +43,7 @@ impl Raterm {
let opt = RatermOption::default();
writef!(
TTY.writer(),
"{}{RequestCursorStyle}{RequestCursorBlink}{RequestDA1}{}{EnableBracketedPaste}{EnableFocusChange}{}{}{}{}",
"{}{RequestCursorStyle}{RequestCursorBlink}{RequestDA1}{}{EnableBracketedPaste}{EnableFocusChange}{}{}{}{}{EnablePasteEvents}",
If(!TMUX.get(), EnterAlternateScreen),
If(TMUX.get(), EnterAlternateScreen),
PushKeyboardFlags::DISAMBIGUATE_ESCAPE_CODES
@ -77,7 +77,7 @@ impl Raterm {
_ = writef!(
TTY.writer(),
"{}{PopKeyboardFlags}{DisableDrop}{DisableDrag}{}{}{DisableFocusChange}{DisableBracketedPaste}{LeaveAlternateScreen}{ShowCursor}",
"{}{PopKeyboardFlags}{DisableDrop}{DisablePasteEvents}{DisableDrag}{}{}{DisableFocusChange}{DisableBracketedPaste}{LeaveAlternateScreen}{ShowCursor}",
If(state.mouse, DisableMouseCapture),
RestoreCursorStyle { shape: state.cursor_shape, blink: state.cursor_blink },
If(state.title, SetTitle("")),

View file

@ -8,6 +8,12 @@ pub struct Clipboard {
content: Mutex<Vec<u8>>,
}
pub struct ClipboardData {
pub mime: Vec<u8>,
pub payload: Vec<u8>,
pub alias: Vec<u8>,
}
impl Clipboard {
#[cfg(unix)]
pub async fn get(&self) -> Vec<u8> {
@ -102,4 +108,44 @@ impl Clipboard {
.await
.ok();
}
/// OSC 5522 Query MIME types
pub async fn query_mime_types(&self) {
use yazi_macro::writef;
use yazi_tty::{TTY, sequence::ReadClipboardMimes};
writef!(TTY.writer(), "{}", ReadClipboardMimes {}).ok();
}
/// OSC 5522 Clipboard read
pub async fn read(&self, mime: impl AsRef<[u8]>, pw: impl AsRef<[u8]>) {
use yazi_macro::writef;
use yazi_tty::{TTY, sequence::ReadClipboard};
writef!(TTY.writer(), "{}", ReadClipboard {
mime: mime.as_ref(),
pw: pw.as_ref(),
name: b"yazi",
primary: false,
})
.ok();
}
/// OSC 5522 Clipboard write
pub async fn write(&self, data: impl AsRef<[ClipboardData]>) {
use yazi_macro::writef;
use yazi_tty::{TTY, sequence::{WriteClipboard, WriteClipboardData}};
let items = data
.as_ref()
.iter()
.map(|d| WriteClipboardData {
mime: d.mime.as_ref(),
payload: d.payload.as_ref(),
alias: d.alias.as_ref(),
})
.collect::<Vec<_>>();
writef!(TTY.writer(), "{}", WriteClipboard { data: items }).ok();
}
}