--- @since 25.5.31 local M = {} local SHELL = os.getenv("SHELL") or "" local HOME = os.getenv("HOME") or "" local PLUGIN_NAME = "gvfs" local USER_ID = ya.uid() local USER_NAME = tostring(ya.user_name(USER_ID)) local XDG_RUNTIME_DIR = os.getenv("XDG_RUNTIME_DIR") or ("/run/user/" .. USER_ID) local GVFS_ROOT_MOUNTPOINT = XDG_RUNTIME_DIR and (XDG_RUNTIME_DIR .. "/gvfs") or (HOME .. "/.gvfs") local GVFS_ROOT_MOUNTPOINT_FILE = "/run/media/" .. USER_NAME local SECRET_TOOL = "secret-tool" local GPG_TOOL = "gpg" local PASS_TOOL = "pass" local SECRET_VAULT_VERSION = "1" local path_separator = package.config:sub(1, 1) ---@enum NOTIFY_MSG local NOTIFY_MSG = { CANT_CREATE_SAVE_FOLDER = "Can't create save folder: %s", CANT_SAVE_DEVICES = "Can't write to save file: %s", CMD_NOT_FOUND = 'Command "%s" not found. Make sure it is installed.', MOUNT_SUCCESS = 'Mounted: "%s"', MOUNT_ERROR = "Mount error: %s", CANT_REMOUNT_DEVICE = "This device can't be remounted or already mounted: %s", CANT_AUTOMOUNT = "This device can't be automounted: %s", UNMOUNT_ERROR = "Unmount error: %s", READING_GVFS_MOUNTED_FOLDER_ERROR = "Reading gvfs mounted folder error: %s", UNMOUNT_SUCCESS = 'Unmounted: "%s"', EJECT_SUCCESS = 'Ejected "%s", it can safely be removed', LIST_DEVICES_EMPTY = "No device or URI found.", REMOVED_MOUNT_URI = "Device or URI removed: %s", ADDED_MOUNT_URI = "Device or URI added: %s", UPDATED_MOUNT_URI = "Device or URI updated: %s", DEVICE_IS_DISCONNECTED = "Device or URI is disconnected", CANT_ACCESS_PREV_CWD = "Device or URI is disconnected or Previous directory is removed", URI_CANT_BE_EMPTY = "URI can't be empty", URI_IS_INVALID = "URI is invalid", UNSUPPORTED_SCHEME = "Unsupported scheme %s", UNSUPPORTED_MANUALLY_MOUNT_SCHEME = "%s scheme is mounted automatically via GNOME Online Accounts (GOA)", DISPLAY_NAME_CANT_BE_EMPTY = "Display name can't be empty", MOUNT_ERROR_PASSWORD = 'Failed to mount "%s", please check your password', MOUNT_ERROR_USERNAME = 'Failed to mount "%s", please check your username', HEADLESS_DETECTED = "GVFS.yazi plugin can only run on DBUS session. Check github HEADLESS_WORKAROUND.md to enable DBUS session", LIST_MOUNTS_EMPTY = "List mounts URI is empty", RETRIVE_PASSWORD_SUCCESS = "Retrieved password from secret vault", SAVE_PASSWORD_SUCCESS = "Saved password to secret vault", SAVE_PASSWORD_FAILED = "Save password failed: %s", SECRET_VAULT_LOCKED = "Secret vault is locked%s", PASS_INIT_GPG_ID = 'Please run "pass init " to initialize your GPG key first. \nCheck SECURE_SAVED_PASSWORD.md for the fix', MISSING_PUBLIC_KEY_GPG_KEY = "GPG key is missing public key\nCheck SECURE_SAVED_PASSWORD.md for the fix", AUTOMOUNT_WHEN_CD_STATE = "%s automount when cd for: %s", } ---@enum PASSWORD_VAULT local PASSWORD_VAULT = { KEYRING = "keyring", PASS = "pass", } ---@enum DEVICE_CONNECT_STATUS local DEVICE_CONNECT_STATUS = { MOUNTED = 1, NOT_MOUNTED = 2, } ---@enum SCHEME local SCHEME = { MTP = "mtp", SMB = "smb", SFTP = "sftp", SSH = "ssh", -- Alias for sftp NFS = "nfs", GPHOTO2 = "gphoto2", FTP = "ftp", FTPS = "ftps", FTPIS = "ftpis", GOOGLE_DRIVE = "google-drive", ONE_DRIVE = "onedrive", DNS_SD = "dns-sd", DAV = "dav", DAVS = "davs", DAVSD = "dav+sd", DAVSSD = "davs+sd", AFP = "afp", AFC = "afc", FILE = "file", } ---@enum STATE_KEY local STATE_KEY = { PREV_CWD = "PREV_CWD", WHICH_KEYS = "WHICH_KEYS", CMD_FOUND = "CMD_FOUND", DBUS_SESSION = "DBUS_SESSION", ROOT_MOUNTPOINT = "ROOT_MOUNTPOINT", SAVE_PATH = "SAVE_PATH", SAVE_PATH_AUTOMOUNTS = "SAVE_PATH_AUTOMOUNTS", MOUNTS = "MOUNTS", AUTOMOUNTS = "AUTOMOUNTS", SAVE_PASSWORD_AUTOCONFIRM = "SAVE_PASSWORD_AUTOCONFIRM", PASSWORD_VAULT = "PASSWORD_VAULT", KEY_GRIP = "KEY_GRIP", INPUT_POSITION = "INPUT_POSITION", TASKS_LOAD_GDRIVE_FOLDER = "TASKS_LOAD_GDRIVE_FOLDER", TASKS_LOAD_GDRIVE_FOLDER_RUNNING = "TASKS_LOAD_GDRIVE_FOLDER_RUNNING", BLACKLIST_DEVICES = "BLACKLIST_DEVICES", CACHED_LOCAL_PATH_DEVICE = "CACHED_LOCAL_PATH_DEVICE", } ---@enum ACTION local ACTION = { SELECT_THEN_MOUNT = "select-then-mount", JUMP_TO_DEVICE = "jump-to-device", JUMP_BACK_PREV_CWD = "jump-back-prev-cwd", SELECT_THEN_UNMOUNT = "select-then-unmount", REMOUNT_KEEP_CWD_UNCHANGED = "remount-current-cwd-device", ADD_MOUNT = "add-mount", EDIT_MOUNT = "edit-mount", REMOVE_MOUNT = "remove-mount", LOAD_GDRIVE_FOLDER = "load-gdrive-folder", CACHE_LOCAL_PATH_DEVICE = "cache-local-path-device", AUTOMOUNT_WHEN_CD = "automount-when-cd", MOUNT_THEN_JUMP_SUBFOLDER = "mount-then-jump-subfolder", } ---@class (exact) GdriveMountedFolderAttribute ---@field display_name string ---@field is_symlink "FALSE" | "TRUE" ---@field name string ---@field type string ---@field size number ---@field modified number ---@field created number ---@field access number ---@field can_read boolean ---@field can_write boolean ---@field can_execute boolean ---@field can_delete boolean ---@field can_trash boolean ---@field can_rename boolean ---@class (exact) ChildrenFolderGioInfo ---@field display_name string ---@field uri string ---@field unix_mount string ---@field name string ---@field type string ---@field local_path string ---@field attributes GdriveMountedFolderAttribute ---@class (exact) Device ---@field name string ---@field class string? ---@field mounts Mount[] ---@field scheme SCHEME ---@field bus integer? ---@field device integer? ---@field uuid string? ---@field encrypted_uuid string? ---@field service_domain string? ---@field ["unix-device"] string? ---@field owner string? ---@field activation_root string? ---@field uri string ---@field is_manually_added boolean? ---@field can_mount "1"|"0"|nil ---@field can_unmount "1"|"0" ---@field can_eject "1"|"0" ---@field should_automount "1"|"0" ---@field remote_path string? ---@class (exact) Mount ---@field name string ---@field class string? ---@field uri string ---@field scheme SCHEME ---@field bus integer? ---@field device integer? ---@field uuid string? ---@field ["unix-device"] string? ---@field owner string? ---@field default_location string? ---@field can_unmount "1"|"0"|nil ---@field can_eject "1"|"0"|nil ---@field is_shadowed "1"|"0"|nil -- Encode binary string to hex (e.g., "\xED" => "\\xED") local function hex_encode(s) return (s:gsub(".", function(c) return string.format("\\x%02X", c:byte()) end)) end -- Decode hex-encoded string (e.g., "\\xED" => "\xED") local function hex_decode(s) return (s:gsub("\\x(%x%x)", function(hex) return string.char(tonumber(hex, 16)) end)) end local function hex_encode_table(t) local out = {} for k, v in pairs(t) do local new_k = type(k) == "string" and hex_encode(k) or k local new_v if type(v) == "table" then new_v = hex_encode_table(v) elseif type(v) == "string" then new_v = hex_encode(v) else new_v = v end out[new_k] = new_v end return out end local function hex_decode_table(t) local out = {} for k, v in pairs(t) do local new_k = type(k) == "string" and hex_decode(k) or k local new_v if type(v) == "table" then new_v = hex_decode_table(v) elseif type(v) == "string" then new_v = hex_decode(v) else new_v = v end out[new_k] = new_v end return out end local set_state_table = ya.sync(function(state, table, key, value) if type(table) == "string" and type(key) == "string" then if not state[table] then state[table] = {} end state[table][key] = value end end) local set_state = ya.sync(function(state, key, value) state[key] = value end) local get_state = ya.sync(function(state, key) return state[key] end) local enqueue_task = ya.sync(function(state, task_name, task_data) if not state[task_name] or type(state[task_name]) ~= "table" then state[task_name] = {} end for _, _task_data in ipairs(state[task_name]) do if _task_data.folder_to_load == task_data.folder_to_load then return end end table.insert(state[task_name], task_data) end) local dequeue_task = ya.sync(function(state, task_name) if not state[task_name] or type(state[task_name]) ~= "table" then return {} end return table.remove(state[task_name]) end) ---@param is_password boolean? local function show_input(title, is_password, value) local input_value, input_pw_event = ya.input({ title = title, value = value or "", obscure = is_password or false, pos = get_state(STATE_KEY.INPUT_POSITION), -- TODO: remove this after next yazi released position = get_state(STATE_KEY.INPUT_POSITION), }) if input_pw_event ~= 1 then return nil, nil end return input_value, input_pw_event end local function error(s, ...) ya.notify({ title = PLUGIN_NAME, content = string.format(s, ...), timeout = 3, level = "error" }) end local function info(s, ...) ya.notify({ title = PLUGIN_NAME, content = string.format(s, ...), timeout = 3, level = "info" }) end ---run any command ---@param cmd string ---@param args string[] ---@param _stdin? Stdio|nil ---@return Error|nil, Output|nil local function run_command(cmd, args, _stdin) local stdin = _stdin or Command.INHERIT local child, cmd_err = Command(cmd) :arg(args) :env("XDG_RUNTIME_DIR", XDG_RUNTIME_DIR) :stdin(stdin) :stdout(Command.PIPED) :stderr(Command.PIPED) :spawn() if not child then error("Failed to start `%s` with error: `%s`", cmd, cmd_err) return cmd_err, nil end local output, out_err = child:wait_with_output() if not output then error("Cannot read `%s` output, error: `%s`", cmd, out_err) return out_err, nil else return nil, output end end local function is_in_dbus_session() local dbus_session = get_state(STATE_KEY.DBUS_SESSION) if dbus_session == nil then local cha, _ = fs.cha(Url(XDG_RUNTIME_DIR)) dbus_session = cha and true or false set_state(STATE_KEY.DBUS_SESSION, dbus_session) end return dbus_session end local function is_cmd_exist(cmd) local cmd_found = get_state(STATE_KEY.CMD_FOUND .. cmd) if cmd_found == nil then local _, output = run_command("which", { cmd }) cmd_found = output and output.status and output.status.success set_state(STATE_KEY.CMD_FOUND .. cmd, cmd_found) end return cmd_found end local function pathJoin(...) -- Detect OS path separator ('\' for Windows, '/' for Unix) local separator = package.config:sub(1, 1) local parts = { ... } local filteredParts = {} -- Remove empty strings or nil values for _, part in ipairs(parts) do if part and part ~= "" then table.insert(filteredParts, part) end end -- Join the remaining parts with the separator local path = table.concat(filteredParts, separator) -- Normalize any double separators (e.g., "folder//file" → "folder/file") path = path:gsub(separator .. "+", separator) return path end local function is_folder_exist(path) local err, output = run_command("[", { "-d", path, "]" }) return output and output.status and output.status.success end local function tbl_remove_empty(tbl) local cleaned = {} for _, v in pairs(tbl) do if v ~= nil and v ~= "" then table.insert(cleaned, v) end end return cleaned end local current_dir = ya.sync(function() return tostring(cx.active.current.cwd.path or cx.active.current.cwd) end) ---@enum PUBSUB_KIND local PUBSUB_KIND = { cd = "cd", hover = "hover", mounts_changed = PLUGIN_NAME .. "-" .. "mounts-changed", automounts_changed = PLUGIN_NAME .. "-" .. "automounts-changed", unmounted = PLUGIN_NAME .. "-" .. "unmounted", } --- broadcast through pub sub to other instances ---@param _ table state ---@param pubsub_kind PUBSUB_KIND ---@param data any ---@param to number default = 0 to all instances local broadcast = ya.sync(function(_, pubsub_kind, data, to) ps.pub_to(to or 0, pubsub_kind, data) end) local is_dir = function(dir_path) local cha, err = fs.cha(Url(dir_path)) return not err and cha and cha.is_dir end ---split string by char ---@param s string ---@return string[] local function string_to_array(s) local array = {} for i = 1, #s do table.insert(array, s:sub(i, i)) end return array end local function is_literal_string(str) return str and str:gsub("([%^%$%(%)%%%.%[%]%*%+%-%?])", "%%%1") end local function tbl_deep_clone(original) if type(original) ~= "table" then return original end local copy = {} for key, value in pairs(original) do copy[tbl_deep_clone(key)] = tbl_deep_clone(value) end return copy end local function path_quote(path) if not path or path == "" then return path end local result = "'" .. string.gsub(tostring(path), "'", "'\\''") .. "'" return result end local get_hovered_path = ya.sync(function() local h = cx.active.current.hovered.path or cx.active.current.hovered if h then return tostring(h.url) end end) local function is_secret_vault_available_keyring(unlock_vault_dialog) local res, err = Command(SECRET_TOOL) :arg({ "search", PLUGIN_NAME, SECRET_VAULT_VERSION, unlock_vault_dialog and "--unlock" or nil, }) :env("XDG_RUNTIME_DIR", XDG_RUNTIME_DIR) :stderr(Command.PIPED) :stdout(Command.PIPED) :output() if err or (res and res.stderr and res.stderr:match("^secret%-tool")) then return false end return true end local function build_secret_vault_entry_gpg(protocol, user, domain, prefix, port, service_domain) protocol = protocol and ("/" .. protocol) or "" user = user and ("/" .. user) or "" domain = domain and ("/" .. domain) or "" prefix = prefix and ("/" .. prefix) or "" port = port and ("/" .. port) or "" service_domain = service_domain and ("/" .. service_domain) or "" return PLUGIN_NAME .. "/" .. SECRET_VAULT_VERSION .. protocol .. user .. domain .. port .. prefix .. service_domain end local function is_secret_vault_available_gpg(unlock_vault_dialog, is_second_run) local test_vault_entry = build_secret_vault_entry_gpg("test") local res, err = Command(SHELL) :arg({ "-c", "gpg-connect-agent 'keyinfo " .. get_state(STATE_KEY.KEY_GRIP) .. "' /bye", }) :stderr(Command.PIPED) :stdout(Command.PIPED) :output() if res then -- Case unlocked if res.stdout:match(".* KEYINFO [^ ]+ .+ .+ .+ 1 ") or res.stdout:match(".* KEYINFO [^ ]+ .+ .+ .+ .+ C ") then return true elseif unlock_vault_dialog and res.stdout:match(".* KEYINFO [^ ]+ .+ .+ .+ - ") then -- Display gpg unlock TUI window -- TODO: remove this after next yazi released local permit = (ui.hide or ya.hide)() -- Wrap in shell to capture exit code local full_cmd = string.format("bash -c '%s; echo __EXIT__$?__'", "pass " .. test_vault_entry .. " 2>&1") local handle = io.popen(full_cmd) local output = handle:read("*a") handle:close() -- Extract exit code local exit_code = tonumber(output:match("__EXIT__(%d+)__")) output = output:gsub("__EXIT__%d+__", ""):gsub("%s+$", "") -- clean output permit:drop() if output:match("Error: .* is not in the password store") then res, err = Command(SHELL) :arg({ "-c", ("printf '%s\n%s\n' " .. path_quote("test") .. " " .. path_quote("test") .. " | ") .. PASS_TOOL .. " insert " .. " -f " .. path_quote(test_vault_entry), }) :env("XDG_RUNTIME_DIR", XDG_RUNTIME_DIR) :stderr(Command.PIPED) :stdout(Command.PIPED) :output() if res and res.stderr:match("Error: You must run:") and res.stderr:match("pass init your%-gpg%-id") then error(NOTIFY_MSG.PASS_INIT_GPG_ID) return false end if res and res.stderr:match("encryption failed: No public key") and res.stderr:match("Password encryption aborted") then error(NOTIFY_MSG.MISSING_PUBLIC_KEY_GPG_KEY) return false end if is_second_run or err or (res and res.status and not res.status.success) then return false end return is_secret_vault_available_gpg(unlock_vault_dialog, true) end return exit_code == 0 end end return false end local function is_secret_vault_available(unlock_vault_dialog) if get_state(STATE_KEY.PASSWORD_VAULT) == PASSWORD_VAULT.KEYRING then return is_secret_vault_available_keyring(unlock_vault_dialog) elseif get_state(STATE_KEY.PASSWORD_VAULT) == PASSWORD_VAULT.PASS then return is_secret_vault_available_gpg(unlock_vault_dialog) end return nil end local function save_password_keyring(password, protocol, user, domain, prefix, port, service_domain) if not user or not password or not protocol or not domain then return false end local res, err = Command(SHELL) :arg({ "-c", ("printf %s " .. path_quote(password) .. " | ") .. SECRET_TOOL .. " store " .. " --label " .. path_quote( protocol .. "://" .. user .. "@" .. domain .. (port and (":" .. port) or "") .. (prefix and ("/" .. prefix) or "") .. (service_domain and ("/" .. service_domain) or "") ) .. " " .. PLUGIN_NAME .. " " .. SECRET_VAULT_VERSION .. " protocol " .. protocol .. " user " .. path_quote(user) .. " domain " .. path_quote(domain) .. (port and (" port " .. port) or "") .. (prefix and (" prefix " .. path_quote(prefix)) or "") .. (service_domain and (" service_domain " .. path_quote(service_domain)) or ""), }) :env("XDG_RUNTIME_DIR", XDG_RUNTIME_DIR) :stderr(Command.PIPED) :stdout(Command.PIPED) :output() if res and res.stderr then if res.stderr:match("secret%-tool: Cannot get secret of a locked object") then error(NOTIFY_MSG.SECRET_VAULT_LOCKED) return false elseif res.stderr:match("secret%-tool: The name is not activatable") then error(NOTIFY_MSG.HEADLESS_DETECTED) return false elseif res.stderr:match("secret%-tool: Cannot autolaunch D%-Bus") then error(NOTIFY_MSG.HEADLESS_DETECTED) return false end end if err or (res and not res.status.success and res.stderr) then error(NOTIFY_MSG.SAVE_PASSWORD_FAILED, res and res.stderr or err) return false end info(NOTIFY_MSG.SAVE_PASSWORD_SUCCESS) return true end local function save_password_gpg(password, protocol, user, domain, prefix, port, service_domain) if not user or not password or not protocol or not domain then return false end local res, err = Command(SHELL) :arg({ "-c", ("printf '%s\n%s\n' " .. path_quote(password) .. " " .. path_quote(password) .. " | ") .. PASS_TOOL .. " insert " .. " -f " .. path_quote(build_secret_vault_entry_gpg(protocol, user, domain, prefix, port, service_domain)), }) :stderr(Command.PIPED) :stdout(Command.PIPED) :output() if err or (res and res.status and not res.status.success) then error(NOTIFY_MSG.SAVE_PASSWORD_FAILED, res and res.stderr or err) return false end info(NOTIFY_MSG.SAVE_PASSWORD_SUCCESS) return true end local function save_password(password, protocol, user, domain, prefix, port, service_domain) if get_state(STATE_KEY.PASSWORD_VAULT) == PASSWORD_VAULT.KEYRING then return save_password_keyring(password, protocol, user, domain, prefix, port, service_domain) elseif get_state(STATE_KEY.PASSWORD_VAULT) == PASSWORD_VAULT.PASS then return save_password_gpg(password, protocol, user, domain, prefix, port, service_domain) end return false end local function lookup_password_keyring(protocol, user, domain, prefix, port, service_domain) if not user or not protocol or not domain then return nil end local res, err = Command(SECRET_TOOL) :arg(tbl_remove_empty({ "lookup", PLUGIN_NAME, SECRET_VAULT_VERSION, "protocol", protocol, "user", user, "domain", domain, port and "port" or nil, port and port or nil, prefix and "prefix" or nil, prefix and prefix or nil, service_domain and "service_domain" or nil, service_domain and service_domain or nil, })) :env("XDG_RUNTIME_DIR", XDG_RUNTIME_DIR) :stderr(Command.PIPED) :stdout(Command.PIPED) :output() if not err and res and res.status and res.status.success then return res.stdout end return nil end local function lookup_password_gpg(protocol, user, domain, prefix, port, service_domain) if not user or not protocol or not domain then return nil end local res, err = Command(PASS_TOOL) :arg({ build_secret_vault_entry_gpg(protocol, user, domain, prefix, port, service_domain), }) :env("XDG_RUNTIME_DIR", XDG_RUNTIME_DIR) :stderr(Command.PIPED) :stdout(Command.PIPED) :output() if not err and res and res.status and res.status.success then return res.stdout end return nil end local function lookup_password(protocol, user, domain, prefix, port, service_domain) if get_state(STATE_KEY.PASSWORD_VAULT) == PASSWORD_VAULT.KEYRING then return lookup_password_keyring(protocol, user, domain, prefix, port, service_domain) elseif get_state(STATE_KEY.PASSWORD_VAULT) == PASSWORD_VAULT.PASS then return lookup_password_gpg(protocol, user, domain, prefix, port, service_domain) end return nil end local function clear_password_keyring(protocol, user, domain, prefix, port, service_domain) local res, err = Command(SECRET_TOOL) :arg(tbl_remove_empty({ "clear", PLUGIN_NAME, SECRET_VAULT_VERSION, protocol and "protocol" or nil, protocol and protocol or nil, user and "user" or nil, user and user or nil, domain and "domain" or nil, domain and domain or nil, port and "port" or nil, port and port or nil, prefix and "prefix" or nil, prefix and prefix or nil, service_domain and "service_domain" or nil, service_domain and service_domain or nil, })) :env("XDG_RUNTIME_DIR", XDG_RUNTIME_DIR) :stderr(Command.PIPED) :stdout(Command.PIPED) :output() if res and res.stderr and not res.status.success then if res.stderr:match("secret%-tool: Cannot get secret of a locked object") then error(NOTIFY_MSG.SECRET_VAULT_LOCKED) return false elseif res.stderr:match("secret%-tool: The name is not activatable") then error(NOTIFY_MSG.HEADLESS_DETECTED) return false elseif res.stderr:match("secret%-tool: Cannot autolaunch D%-Bus") then error(NOTIFY_MSG.HEADLESS_DETECTED) return false end end if not err and res and res.status and res.status.success then return true end return false end local function clear_password_gpg(protocol, user, domain, prefix, port, service_domain) local res, err = Command(PASS_TOOL) :arg({ "rm", "-r", "-f", build_secret_vault_entry_gpg(protocol, user, domain, prefix, port, service_domain), }) :env("XDG_RUNTIME_DIR", XDG_RUNTIME_DIR) :stderr(Command.PIPED) :stdout(Command.PIPED) :output() if not err and res and res.status and res.status.success then return true end return false end local function clear_password(protocol, user, domain, prefix, port, service_domain) if get_state(STATE_KEY.PASSWORD_VAULT) == PASSWORD_VAULT.KEYRING then return clear_password_keyring(protocol, user, domain, prefix, port, service_domain) elseif get_state(STATE_KEY.PASSWORD_VAULT) == PASSWORD_VAULT.PASS then return clear_password_gpg(protocol, user, domain, prefix, port, service_domain) end return false end local function extract_info_from_uri(s) local user local domain local port local service_domain -- Attempt 1: Look for user@domain:port first (if it exists) local scheme, temp_user, temp_domain_part = s:match("^([^:]+)://([^@/]+)@([^/]+)") if temp_user and temp_domain_part then -- If user@domain found, the domain might be followed by a comma -- We want the part before the first comma or slash in the domain part user = temp_user -- domain:port domain, port = temp_domain_part:match("^([^:/]+):([^:/]+)") if not port or port == "" then port = nil domain = temp_domain_part:match("^[^/]+") or temp_domain_part end else -- Attempt 2: No user@domain, so try to get domain from the start (before first comma or slash) scheme, temp_domain_part = s:match("^([^:]+)://([^/]+)") if temp_domain_part then domain, port = temp_domain_part:match("^([^:/]+):([^:/]+)") if not port or port == "" then port = nil domain = temp_domain_part:match("^[^/]+") or temp_domain_part end end end local ssl = (s:match("^davs") or s:match("^ftps") or s:match("^ftpis") or s:match("^https")) and true or false local prefix = s:match(".*" .. (is_literal_string(domain) or "") .. (port and ":" .. port or "") .. "/(.+)$") or nil if user then local _service_domain, _user = user:match("^([^;]+);(.+)") user = _service_domain and _user or user service_domain = _service_domain and _service_domain end return scheme, domain, user, ssl, prefix, port, service_domain end local function is_mountpoint_belong_to_volume(mount, volume) return mount.is_shadowed ~= "1" and mount.scheme and mount.scheme == volume.scheme and ( (mount.uri and mount.uri == volume.uri) or (mount.uuid and mount.uuid == volume.uuid) or (mount["unix-device"] and mount["unix-device"] == volume["unix-device"]) or (mount.bus and mount.device and mount.bus == volume.bus and mount.device == volume.device) -- Case fstab with `x-gvfs-show` or (mount.name and mount.name == volume.name and mount.scheme == SCHEME.FILE) ) end --- Parser for gvfs mounted gdrive folder info ---@param data string ---@return ChildrenFolderGioInfo[] local function parse_gdrive_mountpoint_info(data) ---@type table[] local result = {} ---@type table? local current_item = nil -- Iterate over each line of the input data. for line in data:gmatch("([^\r\n]+)") do -- First, try to match an indented attribute line, e.g., " standard::type: 2" local indent, attr_key, attr_value = line:match("^(%s+)[^:]+::([^:]+):%s+(.+)$") if indent and attr_key and attr_value then -- This is an attribute line. Add it to the current item's attribute sub-table. if current_item then -- Ensure the 'attributes' sub-table exists. current_item.attributes = current_item.attributes or {} -- Clean the key (e.g., "standard::type" -> "standard_type") and assign the value. local clean_attr_key = attr_key:match("^%s*(.-)%s*$"):gsub("[%s%-]", "_") if clean_attr_key == "can_read" then current_item.attributes[clean_attr_key] = attr_value == "TRUE" elseif clean_attr_key == "can_write" then current_item.attributes[clean_attr_key] = attr_value == "TRUE" elseif clean_attr_key == "can_execute" then current_item.attributes[clean_attr_key] = attr_value == "TRUE" elseif clean_attr_key == "can_delete" then current_item.attributes[clean_attr_key] = attr_value == "TRUE" elseif clean_attr_key == "can_trash" then current_item.attributes[clean_attr_key] = attr_value == "TRUE" elseif clean_attr_key == "can_rename" then current_item.attributes[clean_attr_key] = attr_value == "TRUE" else current_item.attributes[clean_attr_key] = attr_value end end else -- If it's not an attribute, try to match a top-level key-value pair. -- The value part `(.*)` allows for keys with no value, like "attributes:". local key, value = line:match("^%s*([^:]+):%s*(.*)$") if key then -- Clean the key by trimming whitespace and replacing spaces with underscores. local clean_key = key:match("^%s*(.-)%s*$"):gsub("%s", "_") -- The "display_name" key marks the beginning of a new item block. if clean_key == "display_name" then -- If we were processing a previous item, add it to the result list. if current_item then table.insert(result, current_item) end -- Start a new table for the new item. current_item = {} end -- Add the property to the current item, provided we are inside an item block -- and the key has a value. if current_item and value and value ~= "" then current_item[clean_key] = value end end end end -- After the loop, if there's a pending item, add it to the results. if current_item then table.insert(result, current_item) end return result end local function get_gdrive_children_folder_info(parent_folder) if not parent_folder then return end parent_folder = tostring(parent_folder) if parent_folder:match( "^" .. is_literal_string(get_state(STATE_KEY.ROOT_MOUNTPOINT) .. "/google-drive:host=gmail.com") ) then local output, err = Command(SHELL) :arg({ "-c", "gio info -a standard::display-name,standard::name,time::modified,time::created,time::access,standard::type,standard::is-symlink,standard::size,access::can-read,access::can-write,access::can-execute,access::can-delete,access::can-delete,access::can-rename " .. path_quote(parent_folder) .. "/*", }) :env("XDG_RUNTIME_DIR", XDG_RUNTIME_DIR) :env("LC_ALL", "C") :stderr(Command.PIPED) :stdout(Command.PIPED) :output() if err then error(NOTIFY_MSG.READING_GVFS_MOUNTED_FOLDER_ERROR, tostring(err)) return nil end if output and output.status.code == 0 then return parse_gdrive_mountpoint_info(output.stdout) end end end --- Display real folder name using virtual files/folders ---@param children_folder_info ChildrenFolderGioInfo[] local function display_virtual_children(cwd, children_folder_info) if children_folder_info == nil or #children_folder_info == 0 then return end local id = ya.id("ft") local files = {} ya.emit("update_files", { op = fs.op("part", { id = id, url = Url(cwd), files = {} }) }) for _, gdrive_mountpoint_info in ipairs(children_folder_info) do if id ~= nil and id ~= "" then -- TODO: WORKAROUND: cwd prefix `search://` can't be joined -- local url = Url(cwd):join(gdrive_mountpoint_info.display_name) local url = Url(tostring(cwd.path or cwd) .. path_separator .. tostring(gdrive_mountpoint_info.display_name)) local kind = gdrive_mountpoint_info.type == "directory" and 1 or ( gdrive_mountpoint_info.type == "regular" and 0 or (gdrive_mountpoint_info.type == "shortcut" and 4 or 16) ) -- Fix for Google AI Studio using max unsigned 64-bit integer value if gdrive_mountpoint_info.attributes.access == "18446744073709551615" then gdrive_mountpoint_info.attributes.access = nil end if gdrive_mountpoint_info.attributes.modified == "18446744073709551615" then gdrive_mountpoint_info.attributes.modified = nil end if gdrive_mountpoint_info.attributes.created == "18446744073709551615" then gdrive_mountpoint_info.attributes.created = nil end table.insert( files, File({ url = url, cha = Cha({ kind = kind, mode = tonumber(kind == 1 and "40700" or "100644", 8), len = tonumber(gdrive_mountpoint_info.attributes.size or "0"), atime = gdrive_mountpoint_info.attributes.access, mtime = gdrive_mountpoint_info.attributes.modified, ctime = gdrive_mountpoint_info.attributes.created, btime = gdrive_mountpoint_info.attributes.modified, }), }) ) end end ya.emit("update_files", { op = fs.op("part", { id = id, url = Url(cwd), files = files }) }) ya.emit("update_files", { op = fs.op("done", { id = id, url = Url(cwd), cha = fs.cha(Url(cwd), false) }) }) end local function parse_devices(raw_input) local volumes = {} local mounts = {} local predefined_mounts = tbl_deep_clone(get_state(STATE_KEY.MOUNTS)) or {} local blacklist_devices = get_state(STATE_KEY.BLACKLIST_DEVICES) or {} ---@type Device? local current_volume = nil ---@type Mount? local current_mount = nil for m = #predefined_mounts, 1, -1 do local pm = predefined_mounts[m] if pm.uri then -- replace ssh:// with sftp:// if pm.scheme == SCHEME.SSH then pm.scheme = SCHEME.SFTP pm.uri = pm.uri:gsub("^ssh://", "sftp://") end -- keep remote path, for jumping. Only scheme that doesn't support remote path if pm.scheme == SCHEME.SFTP or pm.scheme == SCHEME.FTP or pm.scheme == SCHEME.FTPS or pm.scheme == SCHEME.FTPIS or pm.scheme == SCHEME.DNS_SD or pm.scheme == SCHEME.AFC then pm.remote_path = pm.uri:match("^[%w+]+://[^/]+/(.+)$") or "" -- Remove remote path pm.uri = pm.uri:gsub("^(%a+://[^/]+).*", "%1") end end end for line in raw_input:gmatch("[^\r\n]+") do local clean_line = line:match("^%s*(.-)%s*$") -- Match volume(0) local volume_name = clean_line:match("^Volume%(%d+%):%s*(.+)$") if line:match("^Drive%(%d+%):") then current_mount = nil current_volume = nil elseif volume_name then current_mount = nil current_volume = { name = volume_name, mounts = {} } table.insert(volumes, current_volume) -- Match mount(0) elseif clean_line:match("^Mount%(%d+%):") then current_mount = nil local mount_indent, mount_name, mount_uri = line:match("^(%s*)Mount%(%d+%):%s*(.-)%s*->%s*(.+)$") if not mount_name then mount_name = clean_line:match("^Mount%(%d+%):%s*(.+)$") end if not mount_indent or #mount_indent == 0 then current_volume = nil end current_mount = { name = mount_name or "", uri = mount_uri or "" } local mount_base_uri = mount_uri:gsub("/+$", "") local mount_uri_port = mount_base_uri:match(":%d+$") for m = #predefined_mounts, 1, -1 do local predefined_mount_base_uri = predefined_mounts[m].uri:gsub("/+$", "") if predefined_mount_base_uri == mount_base_uri or ( not mount_uri_port and predefined_mount_base_uri:gsub(":%d+$", "") == mount_base_uri:gsub(":%d+$", "") ) then current_mount = table.remove(predefined_mounts, m) end end if not current_mount.scheme then for _, value in pairs(SCHEME) do if mount_uri:match("^" .. is_literal_string(value) .. ":") then current_mount.scheme = value end end end -- Case mtp/gphoto2 usb bus dev if mount_uri then local protocol, bus, device = mount_uri:match("^(%w+)://%[usb:(%d+),(%d+)%]/") -- Attach to mount or volume if protocol and (protocol == SCHEME.MTP or protocol == SCHEME.GPHOTO2) and bus and device then current_mount.bus = bus current_mount.device = device end -- file:///run/media/huyhoang/6412-E4B2 local owner, label_or_uuid = mount_uri:match("^file:///run/media/(.+)/(.+)") if owner and label_or_uuid then current_mount.owner = owner current_mount.uuid = current_volume and (current_volume.uuid or current_volume["unix-device"]) or label_or_uuid current_mount["unix-device"] = current_volume and current_volume["unix-device"] end end table.insert(mounts, current_mount) -- Match key=value metadata else local key, value = clean_line:match("^(%S+)%s*=%s*(.+)$") if not key or not value then key, value = clean_line:match("^(%S+)%s*:%s*'(.-)'$") if key == "uuid" and value then current_volume.encrypted_uuid = value end end if key and value then -- Attach to mount or volume local target = current_mount or current_volume if target then if key ~= "name" or not target[key] then target[key] = value end end else local bus, device = line:match(".*:%s*'/dev/bus/usb/(%d+)/(%d+)'") -- Attach to mount or volume if bus and device then local target = current_mount or current_volume if target then target.bus = bus target.device = device end end end end end -- Remove shadowed mounts and attach mount points to volumes for i = #volumes, 1, -1 do local v = volumes[i] if v.activation_root then v.uri = v.activation_root end if not v.uuid and v.class == "device" and v["unix-device"] then v.uuid = v["unix-device"] v.scheme = SCHEME.FILE -- Attach scheme to volume elseif (v.can_mount == "0") or v.uuid and not v.uuid:match("([^:]+)://(.+)") then -- NOTE: can_mount == "0" means that the volume is mounted fstab v.scheme = SCHEME.FILE else for _, value in pairs(SCHEME) do if (v.uri and v.uri:match("^" .. is_literal_string(value) .. ":")) or (v.uuid and v.uuid:match("^" .. is_literal_string(value) .. "://")) then v.scheme = value end end end -- NOTE: Remove volumes without scheme (fstab) if volumes[i] and not v.scheme then table.remove(volumes, i) end -- Attach mount points to volume, then remove it from mounts array for j = #mounts, 1, -1 do if is_mountpoint_belong_to_volume(mounts[j], v) then table.insert(v.mounts, table.remove(mounts, j)) end end end -- Remove shadowed mounts and attach unmapped mounts to itself for _, m in ipairs(mounts) do if m.is_shadowed ~= "1" and m.uri then m.mounts = { tbl_deep_clone(m) } table.insert(volumes, m) end end for _, m in ipairs(predefined_mounts) do m.mounts = { tbl_deep_clone(m) } table.insert(volumes, m) end if #blacklist_devices > 0 then for i = #volumes, 1, -1 do local v = volumes[i] for _, bl_device in pairs(blacklist_devices) do if type(bl_device) == "string" and v.name == bl_device then table.remove(volumes, i) elseif type(bl_device) == "table" then for bl_device_prop, bl_device_value in pairs(bl_device) do if v[bl_device_prop] ~= bl_device_value then goto skip_bl_device end end table.remove(volumes, i) end ::skip_bl_device:: end end end return volumes end ---@param device Device ---@return string|nil local function get_mounted_path(device) if not device then return nil end if device.uri or (#device.mounts > 0 and device.mounts[1].uri) then local res, err = Command(SHELL) :arg({ "-c", "gio info " .. path_quote(device.uri or (#device.mounts > 0 and device.mounts[1].uri)) .. ' | grep -E "^local path: "', }) :env("XDG_RUNTIME_DIR", XDG_RUNTIME_DIR) :env("LC_ALL", "C") :stderr(Command.PIPED) :stdout(Command.PIPED) :output() if err or (res and res.status and not res.status.success) then return nil end return res.stdout:match("^local path: (.+)$"):gsub("\n", "") or nil end return nil end local function can_device_umount(device) if device and device.mounts and #device.mounts > 0 then for _, mount in ipairs(device.mounts) do if mount.can_unmount == "1" or mount.can_eject == "1" then return true end end end end ---@param device Device local function is_mounted(device) if can_device_umount(device) then return true end local mountpath = get_mounted_path(device) return mountpath and is_folder_exist(mountpath) end ---mount device ---@param opts {device: Device, username?:string, password?: string, service_domain?: string, is_pw_saved?: boolean, skipped_secret_vault?: boolean,max_retry?: integer, retries?: integer, anonymous?: boolean} ---@return boolean local function mount_device(opts) local device = opts.device local max_retry = opts.max_retry or 3 local retries = opts.retries or 0 local password = opts.password local is_pw_saved = opts.is_pw_saved local skipped_secret_vault = opts.skipped_secret_vault local username = opts.username local anonymous = opts.anonymous local service_domain = opts.service_domain local error_msg = nil local auths = "" local auth_string_format = "" if password or username then if username then auths = path_quote(username) auth_string_format = auth_string_format .. "%s\n" end if service_domain then auths = auths .. " " .. path_quote(service_domain) auth_string_format = auth_string_format .. "%s\n" end if password then auths = auths .. " " .. path_quote(password) auth_string_format = auth_string_format .. "%s\n" end end local res, err = Command(SHELL) :arg({ "-c", (auth_string_format ~= "" and "printf " .. path_quote(auth_string_format) .. " " .. auths .. " | " or "") .. " gio mount " .. (anonymous and "-a " or "") .. (device.uuid and ("-d " .. device.uuid) or path_quote(device.uri)), }) :env("XDG_RUNTIME_DIR", XDG_RUNTIME_DIR) :env("LC_ALL", "C") :stderr(Command.PIPED) :stdout(Command.PIPED) :output() local mount_success = res and res.status and res.status.success if mount_success then info(NOTIFY_MSG.MOUNT_SUCCESS, device.name) if password and not is_pw_saved and not skipped_secret_vault and is_secret_vault_available() then local confirmed_save_password = get_state(STATE_KEY.SAVE_PASSWORD_AUTOCONFIRM) or ya.confirm({ title = ui.Line("Remember password?"):style(th.confirm.title), body = ui.Text({ ui.Line(""), ui.Line("Press Yes to save password to secret vault."):style(th.confirm.content), ui.Line(""), }) :align(ui.Align.CENTER) :wrap(ui.Wrap.YES), -- TODO: remove this after next yazi released content = ui.Text({ ui.Line(""), ui.Line("Press Yes to save password to secret vault."):style(th.confirm.content), ui.Line(""), }) :align(ui.Align.CENTER) :wrap(ui.Wrap.YES), pos = { "center", w = 70, h = 10 }, }) if confirmed_save_password then if device.uuid then -- case hard drive save_password(password, device.scheme, device.uuid, device.uuid) else local scheme, domain, user, _, prefix, port, _service_domain = extract_info_from_uri(device.uri) save_password( password, scheme, username or user, domain, prefix, port, service_domain or (username or user or ""):match("^([^;]+);") or _service_domain ) end end end return true elseif res and res.status.code == 2 then if res.stderr:match(".*volume doesn’t implement mount.*") then error_msg = string.format(NOTIFY_MSG.HEADLESS_DETECTED) retries = max_retry end if res.stderr:match(".*is already mounted.*") then return true end local stdout = res.stdout if stdout:find("\nUser: \n") or stdout:find("\nUser %[.*%]: \n") then if retries < max_retry then username, _ = show_input( "Enter username " .. (device.name and ("(" .. device.name .. ")") or (device.uri and ("(" .. device.uri .. ")") or "")) .. ":", false, username or stdout:match("User %[(.*)%]:") or "" ) if username == nil then return false elseif username == "" then -- Case using anonymous user anonymous = true end else error_msg = string.format( NOTIFY_MSG.MOUNT_ERROR_USERNAME, (device.name or "NO_NAME") .. " (" .. (device.scheme or "UNKNOWN_SCHEME") .. ")" ) end end if (device.scheme == SCHEME.SMB or device.scheme == SCHEME.DNS_SD or device.scheme == SCHEME.DAVSD) and ( stdout:find("\nDomain: \n") or stdout:find("\nDomain %[.*%]: \n") or stdout:find("\nUser: \n") or stdout:find("\nUser %[.*%]: \n") ) then if retries < max_retry then service_domain, _ = show_input( "Enter Domain " .. (device.name and ("(" .. device.name .. ")") or (device.uri and ("(" .. device.uri .. ")") or "")) .. ":", false, service_domain or stdout:match("Domain %[(.*)%]:") or "WORKGROUP" ) if service_domain == nil then return false end else error_msg = string.format( NOTIFY_MSG.MOUNT_ERROR_USERNAME, (device.name or "NO_NAME") .. " (" .. device.scheme .. ")" ) end end if not anonymous and ( stdout:find("Password: \n") or stdout:find("\nUser: \n") or stdout:find("\nUser %[.*%]: \n") or stdout:find("\nDomain: \n") or stdout:find("\nDomain %[.*%]: \n") ) then if username ~= opts.username or (username == nil and is_pw_saved == nil) then -- Prevent showing gpg passphrase twice if not skipped_secret_vault and not is_secret_vault_available(true) then skipped_secret_vault = true end if not skipped_secret_vault then if device.uuid then -- case hard drive password = lookup_password(device.scheme, device.uuid, device.uuid) else local scheme, domain, user, _, prefix, port, _service_domain = extract_info_from_uri(device.uri) password = lookup_password( scheme, username or user, domain, prefix, port, service_domain or (username or user or ""):match("^([^;]+);") or _service_domain ) end is_pw_saved = password ~= nil if is_pw_saved then info(NOTIFY_MSG.RETRIVE_PASSWORD_SUCCESS) end end end if retries < max_retry then if not is_pw_saved then password, _ = show_input( "Enter password " .. (device.name and ("(" .. device.name .. ")") or (device.uri and ("(" .. device.uri .. ")") or "")) .. ":", true ) if password == nil then return false end end else error_msg = string.format( NOTIFY_MSG.MOUNT_ERROR_PASSWORD, (device.name or "NO_NAME") .. " (" .. device.scheme .. ")" ) end end end -- show notification after get max retry if retries >= max_retry then error( tostring(error_msg or (res and not res.status.success and res.stderr) or err or "Error: Unknown"):gsub( "%%", "%%%%" ) ) return false end -- Increase retries every run retries = retries + 1 return mount_device({ device = device, retries = retries, max_retry = max_retry, password = password, is_pw_saved = is_pw_saved, skipped_secret_vault = skipped_secret_vault, username = username, service_domain = service_domain, anonymous = anonymous, }) end --- Return list of connected devices ---@return Device[] local function list_gvfs_device() ---@type Device[] local devices = {} local _, res = run_command("gio", { "mount", "-li" }) if res and res.status then if res.status.success then devices = parse_devices(res.stdout) end end return devices end ---Return list of mounted devices ---@param status DEVICE_CONNECT_STATUS ---@param filter? function ---@return Device[] local function list_gvfs_device_by_status(status, filter) local devices = list_gvfs_device() local devices_filtered = {} for _, d in ipairs(devices) do if filter and not filter(d) then goto continue end local mounted = is_mounted(d) if status == DEVICE_CONNECT_STATUS.MOUNTED and mounted then table.insert(devices_filtered, d) end if status == DEVICE_CONNECT_STATUS.NOT_MOUNTED and not mounted then table.insert(devices_filtered, d) end ::continue:: end return devices_filtered end --- Unmount a mounted device/uri ---@param device Device ---@param eject boolean? eject = true if user want to safty unplug the device ---@param force boolean? Ignore outstanding file operations when unmounting or ejecting ---@return boolean local function unmount_gvfs(device, eject, force, max_retry, retries) if not device then return true end max_retry = max_retry or 3 retries = retries or 0 local unmount_method = "-u" if eject then unmount_method = "-e" end for _, mount in ipairs(device.mounts ~= nil and device.mounts or { device }) do local cmd_err, res = run_command("gio", tbl_remove_empty({ "mount", unmount_method, force and "-f" or nil, mount.uri })) if cmd_err or (res and not res.status.success) then if eject and res and res.stderr:find("mount doesn.*t implement .*eject.* or .*eject_with_operation.*") then return unmount_gvfs(device, false, force) end if retries >= max_retry then error(NOTIFY_MSG.UNMOUNT_ERROR, tostring(res and (res.stderr or res.stdout))) return false end return unmount_gvfs(device, eject, force, max_retry, retries + 1) end if not cmd_err and res and res.status.success then if eject then info(NOTIFY_MSG.EJECT_SUCCESS, mount.name) else info(NOTIFY_MSG.UNMOUNT_SUCCESS, mount.name) end end return true end end ---show which key to select device from list ---@param devices Device|Mount[] ---@return number|nil local function select_device_which_key(devices) local which_keys = get_state(STATE_KEY.WHICH_KEYS) or "1234567890qwertyuiopasdfghjklzxcvbnm-=[]\\;',./!@#$%^&*()_+{}|:\"<>?" local allow_key_array = string_to_array(which_keys) local cands = {} for idx, d in ipairs(devices) do if idx > #allow_key_array then break end table.insert(cands, { on = tostring(allow_key_array[idx]), desc = (d.name or "NO_NAME") .. " (" .. (d.scheme or "UNKNOWN_SCHEME") .. ")", }) end if #cands == 0 then return end local selected_idx = ya.which({ cands = cands, }) if selected_idx and selected_idx > 0 then return selected_idx end end ---@param path string ---@return string? local function get_gio_uri_from_local_path(path) local root_mountpoint = get_state(STATE_KEY.ROOT_MOUNTPOINT) if not path:match("^" .. is_literal_string(root_mountpoint) .. "(.+)$") and not path:match("^" .. is_literal_string(GVFS_ROOT_MOUNTPOINT_FILE) .. "(.+)$") then return nil end local path_info, err = Command(SHELL) :arg({ "-c", "gio info " .. path_quote(path) .. ' | grep "^uri:"', }) :env("XDG_RUNTIME_DIR", XDG_RUNTIME_DIR) :env("LC_ALL", "C") :stderr(Command.PIPED) :stdout(Command.PIPED) :output() if not path_info or err or (path_info and path_info.status and not path_info.status.success) then return nil end local path_uri = path_info.stdout:match("^uri: (.+)$"):gsub("\n", "") if not path_uri then return nil end return path_uri end ---@param path string ---@param state_key STATE_KEY.CACHED_LOCAL_PATH_DEVICE|STATE_KEY.AUTOMOUNTS|string ---@param devices Device[]? ---@return Device? local function get_device_from_local_path(path, state_key, devices) local local_path = path:match("^" .. is_literal_string(get_state(STATE_KEY.ROOT_MOUNTPOINT)) .. "/[^/]+") or path:match("^" .. is_literal_string(GVFS_ROOT_MOUNTPOINT_FILE) .. "/[^/]+") -- NOTE: Get cached gio uri or get it from command "gio info" ---@type Device? local cached_device = get_state(state_key)[local_path] local device_props_to_compare = { "class", "label", "scheme", "encrypted_uuid", "service_domain", "uri", "remote_path", } local mount_props_to_compare = { "class", "uri", "scheme", "uuid", "remote_path", "name", } if cached_device then if not devices then devices = list_gvfs_device() end local matched_device = nil for _, device in ipairs(devices) do matched_device = device for _, prop in ipairs(device_props_to_compare) do if cached_device[prop] ~= device[prop] then matched_device = nil goto jump_to_compare_mounts end end if matched_device then return matched_device end ::jump_to_compare_mounts:: for _, mount in ipairs(device.mounts) do matched_device = device for _, prop in ipairs(mount_props_to_compare) do if cached_device[prop] ~= mount[prop] then matched_device = nil break end end end end return matched_device else local gio_uri = get_gio_uri_from_local_path(path) if not gio_uri then return nil end if not devices then devices = list_gvfs_device() end for _, device in ipairs(devices) do if device.uri and gio_uri:match("^" .. is_literal_string(device.uri) .. ".*") then return device end for _, mount in ipairs(device.mounts) do if mount.uri and gio_uri:match("^" .. is_literal_string(mount.uri) .. ".*") then return device end end end return nil end return nil end --- Jump to device mountpoint ---@param device Device? local function jump_to_device_mountpoint_action(device, retry, automount) if automount then -- Trigger Automount local automount_script = HOME .. "/.config/yazi/plugins/gvfs.yazi/assets/automount.sh" run_command("chmod", { "+x", automount_script }) run_command(automount_script, {}) end if not device then local list_devices = list_gvfs_device_by_status(DEVICE_CONNECT_STATUS.MOUNTED) device = #list_devices == 1 and list_devices[1] or nil if not device then local selected_device_idx = select_device_which_key(list_devices) if not selected_device_idx then return end device = list_devices[selected_device_idx] end end if not device then info(NOTIFY_MSG.LIST_DEVICES_EMPTY) return end local mnt_path = get_mounted_path(device) if not mnt_path and not retry then -- case hard drive encrypted -> mount uuid changed local matched_devices = list_gvfs_device_by_status(DEVICE_CONNECT_STATUS.MOUNTED, function(d) return (device.uuid and (d.encrypted_uuid == device.uuid or d.uuid == device.uuid)) or (device.uri and d.uri == device.uri) end) if #matched_devices >= 1 then device = matched_devices[1] return jump_to_device_mountpoint_action(device, true, automount) end end if mnt_path then set_state(STATE_KEY.PREV_CWD, current_dir()) if device.remote_path then mnt_path = pathJoin(mnt_path, device.remote_path) end ya.emit("cd", { mnt_path, raw = true }) else error(NOTIFY_MSG.DEVICE_IS_DISCONNECTED) end end --- Jump to previous directory local function jump_to_prev_cwd_action() local prev_cwd = get_state(STATE_KEY.PREV_CWD) if not prev_cwd then return end if is_dir(prev_cwd) then set_state(STATE_KEY.PREV_CWD, current_dir()) ya.emit("cd", { prev_cwd, raw = true }) else error(NOTIFY_MSG.CANT_ACCESS_PREV_CWD) end end --- mount action ---@param opts { jump: boolean?, device: Device? }? local function mount_action(opts) local selected_device -- Let user select a device if device is not specified if not opts or not opts.device then local list_devices = list_gvfs_device_by_status(DEVICE_CONNECT_STATUS.NOT_MOUNTED, function(d) return true end) -- NOTE: Automatically select the first device if there is only one device selected_device = #list_devices == 1 and list_devices[1] or nil if not selected_device then local selected_device_idx = select_device_which_key(list_devices) if not selected_device_idx then return end selected_device = list_devices[selected_device_idx] end if #list_devices == 0 then -- If every devices are mounted, then jump to the first one local root_mountpoint = get_state(STATE_KEY.ROOT_MOUNTPOINT) local list_devices_mounted = list_gvfs_device_by_status(DEVICE_CONNECT_STATUS.MOUNTED, function(d) if d.scheme == SCHEME.FILE then return ( d.mounts and #d.mounts >= 1 and d.mounts[1].uri and ( d.mounts[1].uri:match("^" .. is_literal_string("file://" .. root_mountpoint) .. "(.+)$") or d.mounts[1].uri:match( "^" .. is_literal_string("file://" .. GVFS_ROOT_MOUNTPOINT_FILE) .. "(.+)$" ) ) ) end return true end) selected_device = #list_devices_mounted >= 1 and list_devices_mounted[1] or nil if not selected_device then info(NOTIFY_MSG.LIST_DEVICES_EMPTY) return end --NOTE: Fall-safe x-gvfs-show local status, err = Command(SHELL) :arg({ "-c", "gio info " .. path_quote( selected_device.uri or (#selected_device.mounts > 0 and selected_device.mounts[1].uri) ) .. ' | grep -E "^unix mount:.*x-gvfs-show.*"', }) :env("XDG_RUNTIME_DIR", XDG_RUNTIME_DIR) :env("LC_ALL", "C") :stderr(Command.PIPED) :stdout(Command.PIPED) :status() if err then error(NOTIFY_MSG.UNMOUNT_ERROR, tostring(err)) return end if status and status.code == 0 then error(NOTIFY_MSG.UNMOUNT_ERROR, "Can't unmount device mounted through /etc/fstab") return end jump_to_device_mountpoint_action(selected_device) return true end else selected_device = opts.device end if not selected_device then return end local success = mount_device({ device = selected_device, }) if success and opts and opts.jump then jump_to_device_mountpoint_action(selected_device) end return success end local save_tab_hovered = ya.sync(function() local hovered_item_per_tab = {} for _, tab in ipairs(cx.tabs) do local is_virtual = Url(tab.current.cwd).scheme and Url(tab.current.cwd).scheme.is_virtual table.insert(hovered_item_per_tab, { id = (type(tab.id) == "number" or type(tab.id) == "string") and tab.id or tab.id.value, cwd = tostring((is_virtual and tab.current.cwd or tab.current.cwd.path) or tab.current.cwd), }) end return hovered_item_per_tab end) local redirect_unmounted_tab_to_home = ya.sync(function(_, unmounted_url, notify) if not unmounted_url or unmounted_url == "" then return end -- broadcast to other instances if notify then broadcast(PUBSUB_KIND.unmounted, hex_encode(unmounted_url)) end for _, tab in ipairs(cx.tabs) do local is_virtual = Url(tab.current.cwd).scheme and Url(tab.current.cwd).scheme.is_virtual if ((is_virtual and tab.current.cwd or tab.current.cwd.path) or tab.current.cwd):starts_with(unmounted_url) then ya.emit("cd", { HOME, tab = (type(tab.id) == "number" or type(tab.id) == "string") and tab.id or tab.id.value, raw = true, }) end end end) --- unmount action --- @param device Device? --- @param eject boolean? eject = true if user want to safty unplug the device --- @param force boolean? Ignore outstanding file operations when unmounting or ejecting local function unmount_action(device, eject, force) local selected_device if not device then local root_mountpoint = get_state(STATE_KEY.ROOT_MOUNTPOINT) local list_devices = list_gvfs_device_by_status(DEVICE_CONNECT_STATUS.MOUNTED, function(d) if d.scheme == SCHEME.FILE then return ( d.mounts and #d.mounts >= 1 and d.mounts[1].uri and ( d.mounts[1].uri:match("^" .. is_literal_string("file://" .. root_mountpoint) .. "(.+)$") or d.mounts[1].uri:match( "^" .. is_literal_string("file://" .. GVFS_ROOT_MOUNTPOINT_FILE) .. "(.+)$" ) ) ) end return true end) -- NOTE: Automatically select the first device if there is only one device selected_device = #list_devices == 1 and list_devices[1] or nil if not selected_device then local selected_device_idx = select_device_which_key(list_devices) if not selected_device_idx then return end selected_device = list_devices[selected_device_idx] end if not selected_device and #list_devices == 0 then info(NOTIFY_MSG.LIST_DEVICES_EMPTY) return end --NOTE: Fall-safe x-gvfs-show if selected_device then local status, err = Command(SHELL) :arg({ "-c", "gio info " .. path_quote( selected_device.uri or (#selected_device.mounts > 0 and selected_device.mounts[1].uri) ) .. ' | grep -E "^unix mount:.*x-gvfs-show.*"', }) :env("XDG_RUNTIME_DIR", XDG_RUNTIME_DIR) :env("LC_ALL", "C") :stderr(Command.PIPED) :stdout(Command.PIPED) :status() if err then error(NOTIFY_MSG.UNMOUNT_ERROR, tostring(err)) return end if status and status.code == 0 then error(NOTIFY_MSG.UNMOUNT_ERROR, "Can't unmount device mounted through /etc/fstab") return end end end if device then selected_device = device end if not selected_device then return end local mount_path = get_mounted_path(selected_device) if selected_device.uuid and mount_path then redirect_unmounted_tab_to_home(mount_path, true) end local success = unmount_gvfs(selected_device, eject, force) if success and not selected_device.uuid and mount_path then redirect_unmounted_tab_to_home(mount_path, true) -- cd to home for all tabs within the device, and then restore the tabs location end end ---@param state_key STATE_KEY.CACHED_LOCAL_PATH_DEVICE|STATE_KEY.AUTOMOUNTS|string ---@param jump_location string? ---@param tab_id number? local function remount_keep_cwd_unchanged_action(state_key, jump_location, tab_id, hide_cant_remount_message) local cwd = jump_location or current_dir() local root_mountpoint = get_state(STATE_KEY.ROOT_MOUNTPOINT) if not cwd:match("^" .. is_literal_string(root_mountpoint) .. "(.+)$") and not cwd:match("^" .. is_literal_string(GVFS_ROOT_MOUNTPOINT_FILE) .. "(.+)$") then return nil end local devices = list_gvfs_device() local current_tab_device = get_device_from_local_path(cwd, state_key, devices) if not current_tab_device then info(NOTIFY_MSG.DEVICE_IS_DISCONNECTED) return end if is_mounted(current_tab_device) then if not hide_cant_remount_message then info(NOTIFY_MSG.CANT_REMOUNT_DEVICE, current_tab_device.name) end return current_tab_device end local tabs = save_tab_hovered() local saved_matched_tabs = {} -- cd to home for all tabs within the device, and then restore the tabs location if state_key == STATE_KEY.CACHED_LOCAL_PATH_DEVICE then for _, tab in ipairs(tabs) do local tab_device = get_device_from_local_path(tostring(tab.cwd), state_key, devices) if tab_device and tab_device.name == current_tab_device.name then table.insert(saved_matched_tabs, tab) ya.emit("cd", { root_mountpoint, tab = tab.id, raw = true, }) end end end mount_action({ jump = false, device = current_tab_device }) if state_key == STATE_KEY.CACHED_LOCAL_PATH_DEVICE then for _, tab in ipairs(saved_matched_tabs) do ya.emit("cd", { tostring(tab.cwd), tab = tab.id, raw = true, }) end else if jump_location then local jump_location_cha, _ = fs.cha(Url(jump_location)) ya.emit((jump_location_cha and jump_location_cha.is_dir) and "cd" or "reveal", { tostring(jump_location), no_dummy = true, raw = true, tab = tab_id, }) end end return current_tab_device end local save_automount_devices = function() local automounts = get_state(STATE_KEY.AUTOMOUNTS) local save_path = Url(get_state(STATE_KEY.SAVE_PATH_AUTOMOUNTS)) -- create parent directories local save_path_created, err_create = fs.create("dir_all", save_path.parent) if err_create then error(NOTIFY_MSG.CANT_CREATE_SAVE_FOLDER, tostring(save_path.parent)) end -- save mounts to file if save_path_created then local _, err_write = fs.write(save_path, ya.json_encode(hex_encode_table(automounts))) if err_write then error(NOTIFY_MSG.CANT_SAVE_DEVICES, tostring(save_path)) end end -- trigger update to other instances broadcast(PUBSUB_KIND.automounts_changed, hex_encode_table(automounts)) end local save_mounts = function() local mounts = get_state(STATE_KEY.MOUNTS) local mounts_to_save = {} for idx = #mounts, 1, -1 do if mounts[idx].is_manually_added then -- save name, uri, scheme, is_manually_added table.insert(mounts_to_save, 1, { name = mounts[idx].name, uri = mounts[idx].uri, scheme = mounts[idx].scheme, is_manually_added = mounts[idx].is_manually_added, }) end end local save_path = Url(get_state(STATE_KEY.SAVE_PATH)) -- create parent directories local save_path_created, err_create = fs.create("dir_all", save_path.parent) if err_create then error(NOTIFY_MSG.CANT_CREATE_SAVE_FOLDER, tostring(save_path.parent)) end -- save mounts to file if save_path_created then local _, err_write = fs.write(save_path, ya.json_encode(hex_encode_table(mounts))) if err_write then error(NOTIFY_MSG.CANT_SAVE_DEVICES, tostring(save_path)) end end -- trigger update to other instances broadcast(PUBSUB_KIND.mounts_changed, hex_encode_table(mounts)) end local read_hex_decoded_file_content = function(save_path) local file = io.open(save_path, "r") if file == nil then return {} end local encoded_data = file:read("*all") file:close() return hex_decode_table(ya.json_decode(encoded_data)) end ---@param is_edit boolean? local function add_or_edit_mount_action(is_edit) ---@type any local mount = { is_manually_added = true, } local selected_idx = nil if is_edit then local mounts = get_state(STATE_KEY.MOUNTS) if #mounts == 0 then info(NOTIFY_MSG.LIST_MOUNTS_EMPTY) return end selected_idx = select_device_which_key(mounts) if not selected_idx then return end mount = tbl_deep_clone(mounts[selected_idx]) end mount.uri, _ = show_input("Enter mount URI:", false, mount.uri) if mount.uri == nil then return end mount.uri = mount.uri:gsub("^%s*(.-)%s*$", "%1") if mount.uri == nil then return elseif mount.uri == "" then error(NOTIFY_MSG.URI_CANT_BE_EMPTY) end mount.uri = mount.uri:gsub("/$", "") -- sftp://test@192.168.1.2 -- ftp://huyhoang@192.168.1.2:9999/ local _scheme, uri = string.match(mount.uri, "([^:]+)://(.+)") local scheme if not _scheme or not uri then error(NOTIFY_MSG.URI_IS_INVALID) return end for _, value in pairs(SCHEME) do if _scheme == value and value ~= SCHEME.FILE then scheme = value end end mount.scheme = scheme if not scheme then error(NOTIFY_MSG.UNSUPPORTED_SCHEME, tostring(_scheme)) return end if scheme == SCHEME.GOOGLE_DRIVE or scheme == SCHEME.ONE_DRIVE then error(NOTIFY_MSG.UNSUPPORTED_MANUALLY_MOUNT_SCHEME, tostring(_scheme)) return end if scheme == SCHEME.SMB then mount.service_domain = mount.uri:match("^smb://([^;]+);") if not mount.service_domain then mount.service_domain, _ = show_input("Enter SMB domain:", false, "WORKGROUP") end if not mount.service_domain then return end end mount.name, _ = show_input("Enter display name:", false, mount.name or uri) if mount.name == nil then return end if mount.name == "" or not mount.name then error(NOTIFY_MSG.DISPLAY_NAME_CANT_BE_EMPTY) return end local mounts = get_state(STATE_KEY.MOUNTS) if selected_idx then if is_mounted(mounts[selected_idx]) then unmount_action(mounts[selected_idx], false, true) end if mount.uri ~= mounts[selected_idx].uri then local old_scheme, old_domain, old_user, _, old_prefix, old_port, old_service_domain = extract_info_from_uri(mounts[selected_idx].uri) if old_domain and old_scheme and is_secret_vault_available(true) then clear_password( old_scheme, old_user, old_domain, old_prefix, old_port, old_service_domain or mounts[selected_idx].service_domain ) end end mounts[selected_idx] = mount info(NOTIFY_MSG.UPDATED_MOUNT_URI, mount.name) else table.insert(mounts, mount) info(NOTIFY_MSG.ADDED_MOUNT_URI, mount.name) end set_state(STATE_KEY.MOUNTS, mounts) save_mounts() end local function load_gdrive_folder_action() if get_state(STATE_KEY.TASKS_LOAD_GDRIVE_FOLDER_RUNNING) or not get_state(STATE_KEY.TASKS_LOAD_GDRIVE_FOLDER) or #get_state(STATE_KEY.TASKS_LOAD_GDRIVE_FOLDER) == 0 then return end set_state(STATE_KEY.TASKS_LOAD_GDRIVE_FOLDER_RUNNING, true) local data = dequeue_task(STATE_KEY.TASKS_LOAD_GDRIVE_FOLDER) local folder_to_load_raw = data.folder_to_load local self_load = data.self_load local folder_to_load = Url(folder_to_load_raw) local gdrive_mountpoint_children_info = get_gdrive_children_folder_info(folder_to_load_raw) if gdrive_mountpoint_children_info then display_virtual_children(folder_to_load, gdrive_mountpoint_children_info) end -- TODO: parent if self_load then -- ya.sleep(0.5) -- if folder_to_load.parent and folder_to_load.parent.parent then -- local gdrive_children_info_parents = get_gdrive_children_folder_info(folder_to_load.parent.parent) -- if gdrive_children_info_parents then -- display_virtual_children(folder_to_load.parent.parent, gdrive_children_info_parents) -- end -- end -- TODO: Load preview -- ya.sleep(0.5) -- local hovered_folder_cwd = current_hovered_folder_cwd() -- if hovered_folder_cwd then -- local gdrive_children_info_parents = get_gdrive_children_folder_info(hovered_folder_cwd) -- if gdrive_children_info_parents then -- display_virtual_children(hovered_folder_cwd, gdrive_children_info_parents) -- end -- end end -- ya.sleep(0.5) set_state(STATE_KEY.TASKS_LOAD_GDRIVE_FOLDER_RUNNING, false) load_gdrive_folder_action() end local function cache_local_path_device_action(local_path) local device_matched = get_device_from_local_path(local_path, STATE_KEY.CACHED_LOCAL_PATH_DEVICE) if device_matched then set_state_table(STATE_KEY.CACHED_LOCAL_PATH_DEVICE, local_path, device_matched) end end ---@param enabled boolean? local function toggle_automount_when_cd_action(enabled) local hovered_path = get_hovered_path() local is_virtual = Url(hovered_path).scheme and Url(hovered_path).scheme.is_virtual if is_virtual then return end local local_path = hovered_path:match("^" .. is_literal_string(get_state(STATE_KEY.ROOT_MOUNTPOINT)) .. "/[^/]+") or hovered_path:match("^" .. is_literal_string(GVFS_ROOT_MOUNTPOINT_FILE) .. "/[^/]+") if local_path then if not enabled then set_state_table(STATE_KEY.AUTOMOUNTS, local_path, nil) else local device_matched = get_device_from_local_path(local_path, STATE_KEY.AUTOMOUNTS) if device_matched then if device_matched.mounts and #device_matched.mounts > 0 and not can_device_umount(device_matched) then info(NOTIFY_MSG.CANT_AUTOMOUNT, device_matched.name) return end -- set_state_table(STATE_KEY.AUTOMOUNTS, local_path, device_matched) end end info(NOTIFY_MSG.AUTOMOUNT_WHEN_CD_STATE, enabled and "Enabled" or "Disabled", tostring(local_path)) save_automount_devices() end end local function remove_mount_action() local mounts = get_state(STATE_KEY.MOUNTS) if #mounts == 0 then info(NOTIFY_MSG.LIST_MOUNTS_EMPTY) return end local selected_idx = select_device_which_key(mounts) if not selected_idx then return end local mount = mounts[selected_idx] if not mount then return end if is_mounted(mount) then unmount_action(mount, false, true) end -- run_command("gio", { "mount", "-u", mount.uri }) local old_scheme, old_domain, old_user, _, old_prefix, old_port, old_service_domain = extract_info_from_uri(mounts[selected_idx].uri) if old_domain and old_scheme and is_secret_vault_available(true) then clear_password( old_scheme, old_user, old_domain, old_prefix, old_port, old_service_domain or mount.service_domain ) end local removed_mount = table.remove(mounts, selected_idx) set_state(STATE_KEY.MOUNTS, mounts) info(NOTIFY_MSG.REMOVED_MOUNT_URI, removed_mount.name) save_mounts() end ---setup function in yazi/init.lua ---@param opts {} function M:setup(opts) local st = self if opts and opts.key_grip then st[STATE_KEY.KEY_GRIP] = opts.key_grip end st[STATE_KEY.INPUT_POSITION] = (opts and type(opts.input_position) == "table") and opts.input_position or { "top-center", y = 3, w = 60 } if opts and opts.save_password_autoconfirm == true then st[STATE_KEY.SAVE_PASSWORD_AUTOCONFIRM] = true end if opts and opts.password_vault then st[STATE_KEY.PASSWORD_VAULT] = ( opts and (opts.password_vault == PASSWORD_VAULT.KEYRING or opts.password_vault == PASSWORD_VAULT.PASS) ) and opts.password_vault else -- TODO: REMOVE: backwards compatibility if opts and opts.enabled_keyring == true then st[STATE_KEY.PASSWORD_VAULT] = PASSWORD_VAULT.KEYRING end end if opts and opts.which_keys and type(opts.which_keys) == "string" then st[STATE_KEY.WHICH_KEYS] = opts.which_keys end local save_path = os.getenv("HOME") .. "/.config/yazi/gvfs.private" if type(opts) == "table" then save_path = opts.save_path or save_path end local save_path_automounts = os.getenv("HOME") .. "/.config/yazi/gvfs_automounts.private" if type(opts) == "table" then save_path_automounts = opts.save_path_automounts or save_path_automounts end st[STATE_KEY.SAVE_PATH] = save_path st[STATE_KEY.SAVE_PATH_AUTOMOUNTS] = save_path_automounts -- NOTE: Use pathJoin to avoid double slashes and end with a slash if opts and opts.root_mountpoint and type(opts.root_mountpoint) == "string" then st[STATE_KEY.ROOT_MOUNTPOINT] = pathJoin(opts.root_mountpoint) else st[STATE_KEY.ROOT_MOUNTPOINT] = pathJoin(GVFS_ROOT_MOUNTPOINT) end st[STATE_KEY.BLACKLIST_DEVICES] = (opts and opts.blacklist_devices and type(opts.blacklist_devices) == "table") and opts.blacklist_devices or {} st[STATE_KEY.MOUNTS] = read_hex_decoded_file_content(get_state(STATE_KEY.SAVE_PATH)) st[STATE_KEY.AUTOMOUNTS] = read_hex_decoded_file_content(get_state(STATE_KEY.SAVE_PATH_AUTOMOUNTS)) or {} st[STATE_KEY.CACHED_LOCAL_PATH_DEVICE] = {} ps.sub(PUBSUB_KIND.cd, function(payload) local cwd = cx.active.current.cwd if not cwd then return end local cwd_raw = tostring(cwd) if cwd_raw:match( "^" .. is_literal_string(get_state(STATE_KEY.ROOT_MOUNTPOINT) .. "/google-drive:host=gmail.com") ) then enqueue_task(STATE_KEY.TASKS_LOAD_GDRIVE_FOLDER, { folder_to_load = cwd_raw, self_load = true }) local args = ya.quote(ACTION.LOAD_GDRIVE_FOLDER) ya.emit("plugin", { self._id, args, }) end local local_path = cwd_raw:match("^" .. is_literal_string(st[STATE_KEY.ROOT_MOUNTPOINT]) .. "/[^/]+") or cwd_raw:match("^" .. is_literal_string(GVFS_ROOT_MOUNTPOINT_FILE) .. "/[^/]+") if local_path then if not st[STATE_KEY.CACHED_LOCAL_PATH_DEVICE][local_path] then local args = ya.quote(ACTION.CACHE_LOCAL_PATH_DEVICE) .. " " .. ya.quote(local_path) ya.emit("plugin", { self._id, args, }) end if st[STATE_KEY.AUTOMOUNTS][local_path] and not st[STATE_KEY.AUTOMOUNTS][local_path].locked_automount then st[STATE_KEY.AUTOMOUNTS][local_path].locked_automount = true local args = ya.quote(ACTION.MOUNT_THEN_JUMP_SUBFOLDER) .. " " .. ya.quote(cwd_raw) .. " " .. ya.quote(local_path) .. " " .. ya.quote(payload.tab) ya.emit("plugin", { self._id, args, }) end end end) -- ps.sub(PUBSUB_KIND.hover, function() -- local cwd = current_hovered_folder_cwd() -- if not cwd then -- return -- end -- local cwd_raw = tostring(cwd) -- if -- cwd_raw:match( -- "^" .. is_literal_string(get_state(STATE_KEY.ROOT_MOUNTPOINT) .. "/google-drive:host=gmail.com") -- ) -- then -- enqueue_task(STATE_KEY.TASKS_LOAD_GDRIVE_FOLDER, { folder_to_load = cwd_raw, self_load = false }) -- local args = ya.quote(ACTION.LOAD_GDRIVE_FOLDER) -- ya.emit("plugin", { -- self._id, -- args, -- }) -- end -- end) ps.sub_remote(PUBSUB_KIND.mounts_changed, function(mounts) set_state(STATE_KEY.MOUNTS, hex_decode_table(mounts)) end) ps.sub_remote(PUBSUB_KIND.automounts_changed, function(automounts) set_state(STATE_KEY.AUTOMOUNTS, hex_decode_table(automounts)) end) ps.sub_remote(PUBSUB_KIND.unmounted, function(unmounted_url) redirect_unmounted_tab_to_home(hex_decode(unmounted_url)) end) end ---@param job {args: unknown[], args: {jump: boolean?, eject: boolean?, force: boolean?, automount: boolean?, disabled: boolean?}} function M:entry(job) if not is_cmd_exist("gio") then error(NOTIFY_MSG.CMD_NOT_FOUND, "gio") return end -- Fallback to pass if in headless session if not is_in_dbus_session() then error(NOTIFY_MSG.HEADLESS_DETECTED) return end if get_state(STATE_KEY.PASSWORD_VAULT) == PASSWORD_VAULT.KEYRING then if not is_cmd_exist(SECRET_TOOL) then set_state(STATE_KEY.PASSWORD_VAULT, nil) end end if get_state(STATE_KEY.PASSWORD_VAULT) == PASSWORD_VAULT.PASS then if not is_cmd_exist(GPG_TOOL) or not is_cmd_exist(PASS_TOOL) or get_state(STATE_KEY.KEY_GRIP) == nil then set_state(STATE_KEY.PASSWORD_VAULT, nil) end end local action = job.args[1] -- Select a device then mount if action == ACTION.SELECT_THEN_MOUNT then local jump = job.args.jump or false mount_action({ jump = jump }) -- select a device then unmount elseif action == ACTION.SELECT_THEN_UNMOUNT then local eject = job.args.eject or false local force = job.args.force or false unmount_action(nil, eject, force) -- remount device within current cwd elseif action == ACTION.REMOUNT_KEEP_CWD_UNCHANGED then remount_keep_cwd_unchanged_action(STATE_KEY.CACHED_LOCAL_PATH_DEVICE) -- select a device then go to its mounted point elseif action == ACTION.JUMP_TO_DEVICE then local automount = job.args.automount or false jump_to_device_mountpoint_action(nil, nil, automount) elseif action == ACTION.JUMP_BACK_PREV_CWD then jump_to_prev_cwd_action() elseif action == ACTION.ADD_MOUNT then add_or_edit_mount_action() elseif action == ACTION.EDIT_MOUNT then add_or_edit_mount_action(true) elseif action == ACTION.REMOVE_MOUNT then remove_mount_action() elseif action == ACTION.AUTOMOUNT_WHEN_CD then local enabled = not job.args.disabled toggle_automount_when_cd_action(enabled) -- NOTE: Switch to new async when it's merged to yazi nightly elseif action == ACTION.LOAD_GDRIVE_FOLDER then load_gdrive_folder_action() elseif action == ACTION.CACHE_LOCAL_PATH_DEVICE then local local_path = job.args[2] if fs.cha(Url(local_path)) then cache_local_path_device_action(local_path) end elseif action == ACTION.MOUNT_THEN_JUMP_SUBFOLDER then local subfolder_path = job.args[2] local local_path = job.args[3] local tab_id = job.args[4] local local_path_cha, _ = fs.cha(Url(local_path)) local cached_device = get_state(STATE_KEY.AUTOMOUNTS)[local_path] if local_path_cha and local_path_cha.is_dir then -- NOTE: Skip automount cached_device.locked_automount = false set_state_table(STATE_KEY.AUTOMOUNTS, local_path, cached_device) return end if cached_device then -- Update cached device with new data local new_cached_device = remount_keep_cwd_unchanged_action(STATE_KEY.AUTOMOUNTS, subfolder_path, tab_id, true) if new_cached_device then cached_device = new_cached_device end end cached_device.locked_automount = false set_state_table(STATE_KEY.AUTOMOUNTS, local_path, cached_device) end -- TODO: remove this after next yazi released (ui.render or ya.render)() end return M