Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/sxyazi/yazi/llms.txt

Use this file to discover all available pages before exploring further.

Functional plugins extend Yazi’s capabilities by adding new commands, integrating external tools, and automating workflows.

What Are Functional Plugins?

Functional plugins are Lua modules with an entry() function that executes when called. They can:
  • Add interactive commands
  • Integrate with external tools
  • Automate file operations
  • Process selections
  • Show custom dialogs

Basic Structure

local M = {}

function M:entry(job)
  -- job contains:
  -- - job.args: Arguments passed to plugin
  -- - job.file: Current file (if applicable)
  
  -- Plugin logic here
end

return M

Example: Directory Jumping with Zoxide

Let’s examine Yazi’s zoxide integration:
local M = {}

-- State management
local state = ya.sync(function(st)
  return {
    cwd = tostring(cx.active.current.cwd),
    empty = st.empty,
  }
end)

local set_state = ya.sync(function(st, empty)
  st.empty = empty
end)

-- Setup function (called from init.lua)
function M:setup(opts)
  opts = opts or {}
  
  if opts.update_db then
    -- Subscribe to directory changes
    ps.sub("cd", function()
      local cwd = cx.active.current.cwd
      ya.async(function()
        Command("zoxide")
          :arg({ "add", tostring(cwd) })
          :status()
      end)
    end)
  end
end

-- Main entry point
function M:entry()
  local st = state()
  
  -- Check if zoxide has data
  if st.empty == nil then
    st.empty = M.is_empty(st.cwd)
    set_state(st.empty)
  end
  
  if st.empty then
    return ya.notify {
      title = "Zoxide",
      content = "No directory history found",
      timeout = 5,
      level = "error",
    }
  end
  
  -- Hide UI while running interactive command
  local permit = ui.hide()
  local target, err = M.run_with(st.cwd)
  permit:drop()
  
  if not target then
    ya.notify {
      title = "Zoxide",
      content = tostring(err),
      timeout = 5,
      level = "error"
    }
  elseif target ~= "" then
    ya.emit("cd", { target, raw = true })
  end
end

-- Helper: Check if zoxide has entries
function M.is_empty(cwd)
  local child = Command("zoxide")
    :arg({ "query", "-l", "--exclude", cwd })
    :stdout(Command.PIPED)
    :spawn()
  
  if not child then
    return true
  end
  
  local first = child:read_line()
  child:start_kill()
  return not first
end

-- Helper: Run zoxide interactively
function M.run_with(cwd)
  local child, err = Command("zoxide")
    :arg({ "query", "-i", "--exclude", cwd })
    :env("_ZO_FZF_OPTS", "--height=100%")
    :stdin(Command.INHERIT)
    :stdout(Command.PIPED)
    :stderr(Command.PIPED)
    :spawn()
  
  if not child then
    return nil, Err("Failed to start zoxide: %s", err)
  end
  
  local output, err = child:wait_with_output()
  if not output then
    return nil, Err("Cannot read output: %s", err)
  elseif not output.status.success and output.status.code ~= 130 then
    return nil, Err("Exited with code %s", output.status.code)
  end
  
  return output.stdout:gsub("\n$", ""), nil
end

return M
Bind it in keymap.toml:
[[manager.prepend_keymap]]
on = [ "z" ]
run = "plugin zoxide"
desc = "Jump to directory with zoxide"

Example: Fuzzy Finding with FZF

Integrate fzf for file selection:
local M = {}

local state = ya.sync(function()
  local selected = {}
  for _, url in pairs(cx.active.selected) do
    selected[#selected + 1] = url
  end
  return cx.active.current.cwd, selected
end)

function M:entry()
  -- Clear visual selection
  ya.emit("escape", { visual = true })
  
  local cwd, selected = state()
  
  if cwd.scheme.is_virtual then
    return ya.notify {
      title = "Fzf",
      content = "Not supported under virtual filesystems",
      timeout = 5,
      level = "warn"
    }
  end
  
  local permit = ui.hide()
  local output, err = M.run_with(cwd, selected)
  permit:drop()
  
  if not output then
    return ya.notify {
      title = "Fzf",
      content = tostring(err),
      timeout = 5,
      level = "error"
    }
  end
  
  local urls = M.split_urls(cwd, output)
  if #urls == 1 then
    local cha = #selected == 0 and fs.cha(urls[1])
    ya.emit(cha and cha.is_dir and "cd" or "reveal", { urls[1], raw = true })
  elseif #urls > 1 then
    urls.state = #selected > 0 and "off" or "on"
    ya.emit("toggle_all", urls)
  end
end

function M.run_with(cwd, selected)
  local child, err = Command("fzf")
    :arg("-m")
    :cwd(tostring(cwd))
    :stdin(#selected > 0 and Command.PIPED or Command.INHERIT)
    :stdout(Command.PIPED)
    :spawn()
  
  if not child then
    return nil, Err("Failed to start fzf: %s", err)
  end
  
  -- Pipe selected files to fzf
  for _, u in ipairs(selected) do
    child:write_all(string.format("%s\n", u))
  end
  if #selected > 0 then
    child:flush()
  end
  
  local output, err = child:wait_with_output()
  if not output then
    return nil, Err("Cannot read fzf output: %s", err)
  elseif not output.status.success and output.status.code ~= 130 then
    return nil, Err("fzf exited with code %s", output.status.code)
  end
  
  return output.stdout, nil
end

function M.split_urls(cwd, output)
  local t = {}
  for line in output:gmatch("[^\r\n]+") do
    local u = Url(line)
    if u.is_absolute then
      t[#t + 1] = u
    else
      t[#t + 1] = cwd:join(u)
    end
  end
  return t
end

return M

Example: Archive Extraction

A plugin that extracts archives:
local M = {}

-- Setup: Register remote command
function M:setup()
  ps.sub_remote("extract", function(args)
    for _, arg in ipairs(args) do
      ya.emit("plugin", { self._id, ya.quote(arg, true) })
    end
  end)
end

-- Entry point
function M:entry(job)
  local from = job.args[1] and Url(job.args[1])
  if not from then
    return ya.err("No URL provided")
  end
  
  local pwd = ""
  while true do
    if not M:try_with(from, pwd) then
      break
    end
    
    -- Ask for password
    local value, event = ya.input {
      pos = { "top-center", y = 2, w = 50 },
      title = string.format('Password for "%s":', from.name),
      obscure = true,
    }
    
    if event == 1 then
      pwd = value
    else
      break
    end
  end
end

-- Try extracting with password
function M:try_with(from, pwd, to)
  to = to or from.parent
  if not to then
    return ya.err("Invalid URL '%s'", from)
  end
  
  -- Create temp directory
  local tmp = fs.unique("dir", to:join(".tmp_extract"))
  if not tmp then
    return ya.err("Failed to create temp directory")
  end
  
  -- Run 7zip
  local archive = require("archive")
  local child, err = archive.spawn_7z {
    "x", "-aou", "-sccUTF-8",
    "-p" .. pwd,
    "-o" .. tostring(tmp),
    tostring(from)
  }
  
  if not child then
    return ya.err("Failed to start 7zip: %s", err)
  end
  
  local output, err = child:wait_with_output()
  
  -- Check if password was wrong
  if output and output.status.code == 2 
      and archive.is_encrypted(output.stderr) then
    fs.remove("dir_all", tmp)
    return true  -- Retry with new password
  end
  
  -- Move extracted files
  self:tidy(from, to, tmp)
  
  if not output then
    return ya.err("7zip failed: %s", err)
  elseif output.status.code ~= 0 then
    return ya.err("7zip error: %s", output.stderr)
  end
  
  ya.notify {
    title = "Extract",
    content = string.format("Extracted %s", from.name),
    timeout = 3
  }
end

function M:tidy(from, to, tmp)
  local outs = fs.read_dir(tmp, { limit = 2 })
  if not outs or #outs == 0 then
    fs.remove("dir", tmp)
    return
  end
  
  local target
  if #outs == 1 then
    target = to:join(outs[1].name)
  else
    target = to:join(from.stem)
  end
  
  target = fs.unique(#outs == 1 and "file" or "dir", target)
  if target then
    if #outs == 1 then
      fs.rename(outs[1].url, target)
    else
      fs.rename(tmp, target)
    end
  end
  fs.remove("dir", tmp)
end

return M
Call from command line:
ya pub extract /path/to/archive.zip

Plugin Job Object

The job parameter contains:
function M:entry(job)
  -- Arguments passed to plugin
  local args = job.args  -- table
  
  -- Current file (if applicable)
  local file = job.file  -- File object or nil
  
  -- Plugin name/ID
  local name = job.name  -- string
end

Interactive UI

User Input

local value, event = ya.input {
  title = "Enter name:",
  pos = { "top-center", y = 2, w = 50 },
  obscure = false,  -- Set true for password
}

if event == 1 then
  -- User confirmed (Enter)
  ya.notify { content = "You entered: " .. value }
else
  -- User cancelled (Esc)
end

Confirmation Dialog

local choice, event = ya.confirm {
  title = "Confirm Action",
  content = "Are you sure?",
  pos = { "center", w = 50, h = 10 },
}

if choice then
  -- User confirmed
end

Hide UI for External Commands

When running interactive external commands:
local permit = ui.hide()

-- Run interactive command
local output = Command("fzf")
  :stdin(Command.INHERIT)
  :stdout(Command.PIPED)
  :spawn()
  :wait_with_output()

permit:drop()  -- Restore UI

State Management

Use ya.sync() for persistent state:
-- Create state accessor
local get_state = ya.sync(function(st)
  st.counter = st.counter or 0
  return st.counter
end)

-- Create state mutator
local set_state = ya.sync(function(st, value)
  st.counter = value
end)

function M:entry()
  local count = get_state()
  count = count + 1
  set_state(count)
  
  ya.notify { content = "Count: " .. count }
end

Event Subscription

Listen to Yazi events:
function M:setup(opts)
  -- Subscribe to directory changes
  ps.sub("cd", function()
    local cwd = cx.active.current.cwd
    ya.notify { content = "Changed to: " .. tostring(cwd) }
  end)
  
  -- Subscribe to selection changes
  ps.sub("select", function()
    local count = #cx.active.selected
    ya.notify { content = count .. " files selected" }
  end)
end

Remote Commands

Register commands callable from CLI:
function M:setup()
  ps.sub_remote("mycommand", function(args)
    -- Handle args from: ya pub mycommand arg1 arg2
    for _, arg in ipairs(args) do
      ya.notify { content = "Got: " .. arg }
    end
  end)
end

File Operations

Processing Selection

function M:entry()
  local selected = {}
  for _, url in pairs(cx.active.selected) do
    selected[#selected + 1] = url
  end
  
  if #selected == 0 then
    local h = cx.active.current.hovered
    if h then
      selected[1] = h.url
    end
  end
  
  for _, url in ipairs(selected) do
    -- Process each file
    ya.notify { content = "Processing: " .. tostring(url) }
  end
end

Batch Operations

function M:entry()
  local files = cx.active.current.files
  
  for _, file in ipairs(files) do
    if file.cha.is_dir then
      -- Process directories
    else
      -- Process files
    end
  end
end

Running Commands

Synchronous

local output, err = Command("ls")
  :arg({ "-la" })
  :stdout(Command.PIPED)
  :output()

if output and output.status.success then
  ya.notify { content = output.stdout }
end

Asynchronous

ya.async(function()
  local output = Command("long-running-cmd")
    :stdout(Command.PIPED)
    :output()
  
  ya.sync(function()
    ya.notify { content = "Done!" }
  end)
end)

Streaming Output

local child = Command("command")
  :stdout(Command.PIPED)
  :spawn()

if child then
  while true do
    local line, event = child:read_line()
    if event ~= 0 then
      break
    end
    -- Process line
  end
  child:start_kill()
end

Best Practices

Notify users of success/failure:
if success then
  ya.notify {
    title = "Success",
    content = "Operation completed",
    timeout = 3
  }
else
  ya.notify {
    title = "Error",
    content = error_message,
    timeout = 5,
    level = "error"
  }
end
Don’t block the UI:
ya.async(function()
  -- Long operation
  local result = Command("slow"):output()
  
  -- Update UI in sync
  ya.sync(function()
    ya.notify { content = "Complete" }
  end)
end)
Check before operating:
local cwd = cx.active.current.cwd
if cwd.scheme.is_virtual then
  return ya.notify {
    content = "Not supported in virtual FS",
    level = "warn"
  }
end
Always kill child processes:
local child = Command("cmd"):spawn()
if child then
  -- Use the child
  child:start_kill()  -- Clean up
end

Next Steps

Previewers

Create custom file previewers

Fetchers

Build metadata fetchers

Build docs developers (and LLMs) love