summaryrefslogtreecommitdiff
path: root/yazi/plugins/clipboard.yazi/main.lua
diff options
context:
space:
mode:
Diffstat (limited to 'yazi/plugins/clipboard.yazi/main.lua')
-rw-r--r--yazi/plugins/clipboard.yazi/main.lua450
1 files changed, 450 insertions, 0 deletions
diff --git a/yazi/plugins/clipboard.yazi/main.lua b/yazi/plugins/clipboard.yazi/main.lua
new file mode 100644
index 0000000..0849adc
--- /dev/null
+++ b/yazi/plugins/clipboard.yazi/main.lua
@@ -0,0 +1,450 @@
+local get_yanked_paths = ya.sync(function(state)
+ local paths = {}
+ for _, v in pairs(cx.yanked) do
+ if not v.is_regular then
+ goto continue
+ end
+ table.insert(paths, tostring(v))
+ ::continue::
+ end
+ return paths
+end)
+
+local get_cwd = ya.sync(function(state)
+ return tostring(cx.active.current.cwd)
+end)
+
+local M = {
+ notify_unknown_display_server = false,
+}
+
+function M:entry(job)
+ ya.dbg("Clipboard", "args", job.args)
+ self.notify_unknown_display_server = job.args.notify_unknown_display_server or false
+
+ if job.args.action == "copy" then
+ return self:copy()
+ elseif job.args.action == "paste" then
+ return self:paste()
+ else
+ return self:notify_error("Unknown action: " .. tostring(job.args.action))
+ end
+end
+
+function M:copy()
+ local paths = get_yanked_paths()
+ ya.dbg("Clipboard", "files", paths)
+ if #paths == 0 then
+ return self:notify_error("No files to copy")
+ end
+
+ local cmd, err = nil, nil
+ local args = paths
+ if ya.target_os() == "linux" then
+ cmd, err = self:copy_linux_cmd()
+ if cmd then
+ args = {}
+ for _, p in ipairs(paths) do
+ table.insert(args, self:path_to_file_uri(p))
+ end
+ end
+ elseif ya.target_os() == "macos" then
+ cmd, err = self:copy_macos_cmd()
+ else
+ err = "Unsupported OS: " .. ya.target_os()
+ end
+ if not self.notify_unknown_display_server and err == "Unknown display server" then
+ return
+ end
+ if err then
+ return self:notify_error("Copy failed: " .. err)
+ end
+
+ ya.dbg("Clipboard", "cmd", cmd)
+ local cmd = Command("sh"):arg({ "-c", cmd, "--" }):arg(args)
+ local output, err = cmd:output()
+ if err then
+ ya.err("Clipboard", "cmd failed", err)
+ return self:notify_error("Run command failed: " .. tostring(err))
+ end
+ if output then
+ ya.dbg("Clipboard", "cmd output", output.status.code, output.stdout, output.stderr)
+ end
+end
+
+function M:copy_linux_cmd()
+ if self:linux_display_server() == "x11" then
+ return self:copy_x11_cmd()
+ elseif self:linux_display_server() == "wayland" then
+ return self:copy_wayland_cmd()
+ else
+ return nil, "Unknown display server"
+ end
+end
+
+function M:copy_macos_cmd()
+ -- Generated by GPT-5-Codex
+ cmd = [[osascript - "$@" <<END_SCRIPT
+use framework "Foundation"
+use framework "AppKit"
+use scripting additions
+
+on run argv
+ set pasteboard to (current application's NSPasteboard's generalPasteboard())
+ pasteboard's clearContents()
+
+ set urlArray to (current application's NSMutableArray's array())
+ repeat with path in argv
+ set nsPath to (current application's NSString's stringWithString_(path))
+ set nsURL to (current application's |NSURL|'s fileURLWithPath_(nsPath))
+ (urlArray's addObject_(nsURL))
+ end repeat
+ pasteboard's writeObjects_(urlArray)
+
+ set previousDelimiters to AppleScript's text item delimiters
+ set AppleScript's text item delimiters to linefeed
+ set joinedPaths to (argv as text)
+ set AppleScript's text item delimiters to previousDelimiters
+
+ set joinedString to (current application's NSString's stringWithString_(joinedPaths))
+ pasteboard's setString_forType_(joinedString, current application's NSPasteboardTypeString)
+end run
+END_SCRIPT]]
+ return cmd, nil
+end
+
+function M:linux_display_server()
+ local xdg_session_type = os.getenv("XDG_SESSION_TYPE")
+ if xdg_session_type == "wayland" or xdg_session_type == "x11" then
+ return xdg_session_type
+ end
+ if os.getenv("WAYLAND_DISPLAY") then
+ return "wayland"
+ end
+ if os.getenv("DISPLAY") then
+ return "x11"
+ end
+
+ return "unknown"
+end
+
+function M:copy_x11_cmd()
+ local status, err = Command("which"):arg("xclip"):status()
+ if err then
+ ya.err("Clipboard", "which xclip failed", err)
+ return nil, "xclip not found"
+ end
+ if not (status and status.success) then
+ return nil, "xclip not found"
+ end
+ return [[printf '%s\r\n' "$@" | xclip -i -selection clipboard -t text/uri-list]], nil
+end
+
+function M:copy_wayland_cmd()
+ local status, err = Command("which"):arg("wl-copy"):status()
+ if err then
+ ya.err("Clipboard", "which wl-copy failed", err)
+ return nil, "wl-copy not found"
+ end
+ if not (status and status.success) then
+ return nil, "wl-copy not found"
+ end
+ return [[printf '%s\r\n' "$@" | wl-copy -t text/uri-list]], nil
+end
+
+function M:paste()
+ local paths, err = nil, nil
+ if ya.target_os() == "linux" then
+ paths, err = self:paste_linux()
+ elseif ya.target_os() == "macos" then
+ paths, err = self:paste_macos()
+ else
+ err = "Unsupported OS: " .. ya.target_os()
+ end
+ if not self.notify_unknown_display_server and err == "Unknown display server" then
+ return
+ end
+ if err then
+ return self:notify_error("Paste failed: " .. err)
+ end
+ if not paths or #paths == 0 then
+ return self:notify_error("No files in clipboard")
+ end
+
+ ya.dbg("Clipboard", "paste paths", paths)
+
+ local cwd_url = Url(get_cwd())
+ local copied = 0
+ local skipped = 0
+ local errors = {}
+ local apply_all = nil
+ for _, src in ipairs(paths) do
+ local name = src:match("[^/]+$")
+ if name then
+ local dest_url = cwd_url:join(name)
+ local dest = tostring(dest_url)
+
+ local existing = fs.cha(dest_url)
+ if existing then
+ local choice = apply_all
+ if not choice then
+ choice, apply_all = self:ask_conflict(name, #paths > 1)
+ end
+ if choice == "skip" then
+ skipped = skipped + 1
+ goto continue
+ elseif choice == "rename" then
+ dest = self:unique_name(cwd_url, name)
+ end
+ end
+
+ local ok, copy_err = self:copy_path(src, dest)
+ if ok then
+ copied = copied + 1
+ else
+ ya.err("Clipboard", "copy failed", src, tostring(copy_err))
+ table.insert(errors, name .. ": " .. tostring(copy_err))
+ end
+ ::continue::
+ end
+ end
+
+ if copied > 0 or skipped > 0 then
+ local parts = {}
+ if copied > 0 then
+ table.insert(parts, "Pasted " .. copied .. " file(s)")
+ end
+ if skipped > 0 then
+ table.insert(parts, "Skipped " .. skipped .. " file(s)")
+ end
+ ya.notify({
+ title = "Clipboard",
+ content = table.concat(parts, ", "),
+ timeout = 3,
+ level = "info",
+ })
+ end
+ if #errors > 0 then
+ self:notify_error("Failed to paste:\n" .. table.concat(errors, "\n"))
+ end
+end
+
+function M:ask_conflict(name, multiple)
+ local prompt = name .. " exists: (o)verwrite (r)ename (s)kip"
+ if multiple then
+ prompt = prompt .. " (O/R/S=apply to all)"
+ end
+ local value, event = ya.input({
+ title = prompt,
+ pos = { "center", w = #prompt + 4 },
+ })
+ if event ~= 1 or not value or value == "" then
+ return "skip", nil
+ end
+ local c = value:sub(1, 1)
+ local apply_all = nil
+ if multiple and c == c:upper() then
+ c = c:lower()
+ if c == "o" then
+ apply_all = "overwrite"
+ elseif c == "r" then
+ apply_all = "rename"
+ elseif c == "s" then
+ apply_all = "skip"
+ end
+ end
+ if c == "o" then
+ return "overwrite", apply_all
+ elseif c == "r" then
+ return "rename", apply_all
+ else
+ return "skip", apply_all
+ end
+end
+
+function M:unique_name(cwd_url, name)
+ local base, ext = name:match("^(.+)(%.[^.]+)$")
+ if not base then
+ base = name
+ ext = ""
+ end
+ local i = 1
+ while true do
+ local candidate = base .. " (" .. i .. ")" .. ext
+ local candidate_url = cwd_url:join(candidate)
+ if not fs.cha(candidate_url) then
+ return tostring(candidate_url)
+ end
+ i = i + 1
+ end
+end
+
+function M:copy_path(src, dest)
+ local src_url = Url(src)
+ local dest_url = Url(dest)
+ local cha, err = fs.cha(src_url)
+ if not cha then
+ return false, "cannot stat: " .. tostring(err)
+ end
+
+ if cha.is_dir then
+ return self:copy_dir(src_url, dest_url)
+ else
+ local ok, copy_err = fs.copy(src_url, dest_url)
+ if ok then
+ return true, nil
+ else
+ return false, tostring(copy_err)
+ end
+ end
+end
+
+function M:copy_dir(src_url, dest_url)
+ local ok, err = fs.create("dir_all", dest_url)
+ if not ok then
+ return false, "mkdir failed: " .. tostring(err)
+ end
+
+ local entries, read_err = fs.read_dir(src_url, {})
+ if not entries then
+ return false, "read_dir failed: " .. tostring(read_err)
+ end
+
+ for _, entry in ipairs(entries) do
+ local child_dest = dest_url:join(entry.name)
+ if entry.cha.is_dir then
+ local ok, dir_err = self:copy_dir(entry.url, child_dest)
+ if not ok then
+ return false, dir_err
+ end
+ else
+ local ok, copy_err = fs.copy(entry.url, child_dest)
+ if not ok then
+ return false, tostring(copy_err)
+ end
+ end
+ end
+
+ return true, nil
+end
+
+function M:paste_macos()
+ local cmd = Command("osascript"):arg({
+ "-e",
+ [[
+use framework "Foundation"
+use framework "AppKit"
+use scripting additions
+
+set pasteboard to (current application's NSPasteboard's generalPasteboard())
+set urlArray to (pasteboard's readObjectsForClasses_options_({current application's |NSURL|}, missing value))
+
+if urlArray is missing value then return ""
+
+set pathList to {}
+repeat with u in urlArray
+ set end of pathList to (u's |path|() as text)
+end repeat
+
+set AppleScript's text item delimiters to (character id 0)
+return pathList as text
+]],
+ })
+ local output, err = cmd:output()
+ if err then
+ ya.err("Clipboard", "paste macos failed", err)
+ return nil, "osascript failed: " .. tostring(err)
+ end
+ if not output or output.status.code ~= 0 then
+ return nil, "osascript exited with code " .. tostring(output and output.status.code)
+ end
+
+ local stdout = output.stdout
+ if not stdout then
+ return {}, nil
+ end
+ stdout = stdout:gsub("%s+$", "")
+ if stdout == "" then
+ return {}, nil
+ end
+ local paths = {}
+ for path in stdout:gmatch("[^%z]+") do
+ path = path:gsub("%s+$", "")
+ if path ~= "" then
+ table.insert(paths, path)
+ end
+ end
+ return paths, nil
+end
+
+function M:paste_linux()
+ if self:linux_display_server() == "x11" then
+ return self:paste_x11()
+ elseif self:linux_display_server() == "wayland" then
+ return self:paste_wayland()
+ else
+ return nil, "Unknown display server"
+ end
+end
+
+function M:paste_x11()
+ local status, err = Command("which"):arg("xclip"):status()
+ if err or not (status and status.success) then
+ return nil, "xclip not found"
+ end
+ local output, err = Command("xclip"):arg({ "-o", "-selection", "clipboard", "-t", "text/uri-list" }):output()
+ if err then
+ return nil, "xclip failed: " .. tostring(err)
+ end
+ if not output or output.status.code ~= 0 then
+ return {}, nil
+ end
+ return self:parse_uri_list(output.stdout), nil
+end
+
+function M:paste_wayland()
+ local status, err = Command("which"):arg("wl-paste"):status()
+ if err or not (status and status.success) then
+ return nil, "wl-paste not found"
+ end
+ local output, err = Command("wl-paste"):arg({ "-t", "text/uri-list" }):output()
+ if err then
+ return nil, "wl-paste failed: " .. tostring(err)
+ end
+ if not output or output.status.code ~= 0 then
+ return {}, nil
+ end
+ return self:parse_uri_list(output.stdout), nil
+end
+
+function M:parse_uri_list(text)
+ if not text or text == "" then
+ return {}
+ end
+ local paths = {}
+ for line in text:gmatch("[^\r\n]+") do
+ if line:sub(1, 1) ~= "#" then
+ local path = line:match("^file://[^/]*(/.+)")
+ if path then
+ path = path:gsub("%%(%x%x)", function(hex)
+ return string.char(tonumber(hex, 16))
+ end)
+ table.insert(paths, path)
+ end
+ end
+ end
+ return paths
+end
+
+function M:path_to_file_uri(path)
+ local encoded = path:gsub("([^A-Za-z0-9/_.~-])", function(c)
+ return string.format("%%%02X", string.byte(c))
+ end)
+ return "file://" .. encoded
+end
+
+function M:notify_error(msg)
+ ya.notify({ title = "Clipboard", content = msg, timeout = 6.5, level = "error" })
+end
+
+return M