diff --git a/README.md b/README.md index 708336351..b567e0b9d 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,8 @@ should you! plenty of room to display the whole tree. - Neo-tree does not need to be manually refreshed (set `use_libuv_file_watcher=true`) - Neo-tree can intelligently follow the current file (set `follow_current_file.enabled=true`) +- Neo-tree can sync its clipboard across multiple Neo-trees, even across multiple Neovim instances! Set `clipboard.sync += "global"` or `"universal"` - Neo-tree is thoughtful about maintaining or setting focus on the right node - Neo-tree windows in different tabs are completely separate - `respect_gitignore` actually works! diff --git a/doc/neo-tree.txt b/doc/neo-tree.txt index 8d3557873..f58abc324 100644 --- a/doc/neo-tree.txt +++ b/doc/neo-tree.txt @@ -28,6 +28,7 @@ Configuration ............... |neo-tree-configuration| Components and Renderers .. |neo-tree-renderers| Buffer Variables .......... |neo-tree-buffer-variables| Popups .................... |neo-tree-popups| + Clipboard ................. |neo-tree-clipboard| Other Sources ............... |neo-tree-sources| Buffers ................... |neo-tree-buffers| Git Status ................ |neo-tree-git-status-source| @@ -1900,6 +1901,17 @@ state to a string. The colors of the popup border are controlled by the highlight group. +CLIPBOARD *neo-tree-clipboard* + +Neo-tree's clipboard can be synced globally (within the same Neovim instance) or +universally (across multiple Neovim instances). The default is to not sync at +all. To change this option, change the `clipboard.sync` option (options are +`"none"|"global"|"universal"`). The universal sync option relies on a file +located under `stdpath("state") .. "/neo-tree.nvim/clipboards"` You can also +implement your own backend and pass it to that option as well - reading the +source code of `require('neo-tree.clipboard')` is a good way to do it. + + ================================================================================ OTHER SOURCES ~ ================================================================================ diff --git a/lua/neo-tree/clipboard/init.lua b/lua/neo-tree/clipboard/init.lua new file mode 100644 index 000000000..42898b807 --- /dev/null +++ b/lua/neo-tree/clipboard/init.lua @@ -0,0 +1,91 @@ +local events = require("neo-tree.events") +local manager = require("neo-tree.sources.manager") +local log = require("neo-tree.log") + +local M = {} + +---@enum (key) neotree.clipboard.BackendNames.Builtin +local builtins = { + none = require("neo-tree.clipboard.sync.base"), + global = require("neo-tree.clipboard.sync.global"), + universal = require("neo-tree.clipboard.sync.universal"), +} + +---@type table +M.backends = builtins + +---@alias neotree.Config.Clipboard.Sync neotree.clipboard.BackendNames.Builtin|neotree.clipboard.Backend + +---@class (exact) neotree.Config.Clipboard +---@field sync neotree.Config.Clipboard.Sync? + +---@param opts neotree.Config.Clipboard +M.setup = function(opts) + opts = opts or {} + opts.sync = opts.sync or "none" + + ---@type neotree.clipboard.Backend? + local selected_backend + if type(opts.sync) == "string" then + selected_backend = M.backends[opts.sync] + elseif type(opts.sync) == "table" then + local sync = opts.sync + ---@cast sync -neotree.clipboard.BackendNames.Builtin + selected_backend = sync + end + + if not selected_backend then + log.error("invalid clipboard sync method, disabling sync") + selected_backend = builtins.none + end + M.current_backend = assert(selected_backend:new()) + + events.subscribe({ + event = events.STATE_CREATED, + ---@param new_state neotree.State + handler = function(new_state) + local clipboard, err = M.current_backend:load(new_state) + if not clipboard then + if err then + log.error(err) + end + return + end + new_state.clipboard = clipboard + end, + }) + + events.subscribe({ + event = events.NEO_TREE_CLIPBOARD_CHANGED, + ---@param state neotree.State + handler = function(state) + local ok, err = M.current_backend:save(state) + if ok == false then + log.error(err) + end + M.sync_to_clipboards(state) + end, + }) +end + +---@param exclude_state neotree.State? +function M.sync_to_clipboards(exclude_state) + -- try loading the changed clipboard into all other states + vim.schedule(function() + manager._for_each_state(nil, function(state) + if exclude_state == state then + return + end + local modified_clipboard, err = M.current_backend:load(state) + if not modified_clipboard then + if err then + log.error(err) + end + return + end + state.clipboard = modified_clipboard + end) + end) +end + +return M diff --git a/lua/neo-tree/clipboard/sync/base.lua b/lua/neo-tree/clipboard/sync/base.lua new file mode 100644 index 000000000..665a3b474 --- /dev/null +++ b/lua/neo-tree/clipboard/sync/base.lua @@ -0,0 +1,33 @@ +---@class neotree.clipboard.Backend +local Backend = {} + +---@class neotree.clipboard.Node +---@field action string +---@field node NuiTree.Node + +---@alias neotree.clipboard.Contents table + +---@return neotree.clipboard.Backend? +function Backend:new() + local o = {} + setmetatable(o, self) + self.__index = self + return o +end + +---Loads the clipboard from the backend +---Return a nil clipboard to not make any changes. +---@param state neotree.State +---@return neotree.clipboard.Contents|false? clipboard +---@return string? err +function Backend:load(state) end + +---Writes the clipboard to the backend +---Returns nil when nothing was saved +---@param state neotree.State +---@return boolean? success_or_noop +function Backend:save(state) + return true +end + +return Backend diff --git a/lua/neo-tree/clipboard/sync/global.lua b/lua/neo-tree/clipboard/sync/global.lua new file mode 100644 index 000000000..78d9eb50a --- /dev/null +++ b/lua/neo-tree/clipboard/sync/global.lua @@ -0,0 +1,17 @@ +local Backend = require("neo-tree.clipboard.sync.base") +local g = vim.g +---@class neotree.clipboard.GlobalBackend : neotree.clipboard.Backend +local GlobalBackend = Backend:new() + +---@type table +local clipboards = {} + +function GlobalBackend:load(state) + return clipboards[state.name] +end + +function GlobalBackend:save(state) + clipboards[state.name] = state.clipboard +end + +return GlobalBackend diff --git a/lua/neo-tree/clipboard/sync/universal.lua b/lua/neo-tree/clipboard/sync/universal.lua new file mode 100644 index 000000000..575746d19 --- /dev/null +++ b/lua/neo-tree/clipboard/sync/universal.lua @@ -0,0 +1,211 @@ +---A backend for the clipboard that uses a file in stdpath('state')/neo-tree.nvim/clipboards/ .. self.filename +---to sync the clipboard between everything. +local BaseBackend = require("neo-tree.clipboard.sync.base") +local log = require("neo-tree.log") +local uv = vim.uv or vim.loop + +---@class neotree.clipboard.FileBackend.Opts +---@field source string +---@field dir string +---@field filename string + +local clipboard_states_dir = vim.fn.stdpath("state") .. "/neo-tree.nvim/clipboards" +local pid = vim.uv.os_getpid() + +---@class (exact) neotree.clipboard.FileBackend.FileFormat +---@field pid integer +---@field time integer +---@field state_name string +---@field contents neotree.clipboard.Contents + +---@class neotree.clipboard.FileBackend : neotree.clipboard.Backend +---@field handle uv.uv_fs_event_t +---@field filename string +---@field source string +---@field pid integer +---@field cached_contents neotree.clipboard.Contents +---@field last_time_saved integer +---@field saving boolean +local FileBackend = BaseBackend:new() + +---@param filename string +---@return boolean created +---@return string? err +local function file_touch(filename) + if vim.uv.fs_stat(filename) then + return true + end + local dir = vim.fn.fnamemodify(filename, ":h") + local mkdir_ok = vim.fn.mkdir(dir, "p") + if mkdir_ok == 0 then + return false, "couldn't make dir " .. dir + end + local file, file_err = io.open(filename, "a+") + if not file then + return false, file_err + end + + local _, write_err = file:write("") + if write_err then + return false, write_err + end + + file:flush() + file:close() + return true +end + +---@param opts neotree.clipboard.FileBackend.Opts? +---@return neotree.clipboard.FileBackend? +function FileBackend:new(opts) + local backend = {} -- create object if user does not provide one + setmetatable(backend, self) + self.__index = self + + -- setup the clipboard file + opts = opts or {} + + backend.dir = opts.dir or clipboard_states_dir + local state_source = opts.source or "filesystem" + + local filename = ("%s/%s.json"):format(backend.dir, state_source) + + local success, err = file_touch(filename) + if not success then + log.error("Could not make shared clipboard file:", clipboard_states_dir, err) + return nil + end + + ---@cast backend neotree.clipboard.FileBackend + backend.filename = filename + backend.source = state_source + backend.pid = pid + backend:_start() + return backend +end + +---@return boolean started true if working +function FileBackend:_start() + if self.handle then + return true + end + local event_handle = uv.new_fs_event() + if event_handle then + self.handle = event_handle + local start_success = event_handle:start(self.filename, {}, function(err, _, fs_events) + local write_time = uv.fs_stat(self.filename).mtime.nsec + if self.last_time_saved == write_time then + end + if err then + event_handle:close() + return + end + require("neo-tree.clipboard").sync_to_clipboards() + -- we should check whether we just wrote or not + end) + log.info("Watching " .. self.filename) + return start_success == 0 + else + log.warn("could not watch shared clipboard on file events") + --todo: implement polling? + end + return false +end + +local typecheck = require("neo-tree.health.typecheck") +local validate = typecheck.validate + +---@param wrapped_clipboard neotree.clipboard.FileBackend.FileFormat +local validate_clipboard_from_file = function(wrapped_clipboard) + return validate("clipboard_from_file", wrapped_clipboard, function(c) + validate("contents", c.contents, "table") + validate("pid", c.pid, "number") + validate("time", c.time, "number") + validate("state_name", c.state_name, "string") + end, false, "Clipboard from file could not be validated") +end + +function FileBackend:load(state) + if state.name ~= "filesystem" then + return nil, nil + end + if not file_touch(self.filename) then + return nil, self.filename .. " could not be created" + end + + local file, err = io.open(self.filename, "r") + if not file or err then + return nil, self.filename .. " could not be opened" + end + local content = file:read("*a") + file:close() + if vim.trim(content) == "" then + -- not populated yet, just do nothing + return nil, nil + end + ---@type boolean, neotree.clipboard.FileBackend.FileFormat|any + local is_success, clipboard_file = pcall(vim.json.decode, content) + if not is_success then + local decode_err = clipboard_file + return nil, "Read failed from shared clipboard file @" .. self.filename .. ":" .. decode_err + end + + if not validate_clipboard_from_file(clipboard_file) then + if + require("neo-tree.ui.inputs").confirm( + "Neo-tree clipboard file seems invalid, clear out clipboard?" + ) + then + local success, delete_err = os.remove(self.filename) + if not success then + log.error(delete_err) + end + + -- try creating a new file without content + state.clipboard = {} + self:save(state) + -- clear the current clipboard + return {} + end + return nil, "could not parse a valid clipboard from clipboard file" + end + + return clipboard_file.contents +end + +function FileBackend:save(state) + if state.name ~= "filesystem" then + return nil + end + + local c = state.clipboard + ---@type neotree.clipboard.FileBackend.FileFormat + local wrapped = { + pid = pid, + time = os.time(), + state_name = assert(state.name), + contents = c, + } + if not file_touch(self.filename) then + return false, "couldn't write to " .. self.filename .. self.filename + end + local encode_ok, str = pcall(vim.json.encode, wrapped) + if not encode_ok then + local encode_err = str + return false, "couldn't encode clipboard into json: " .. encode_err + end + local file, err = io.open(self.filename, "w") + if not file or err then + return false, "couldn't open " .. self.filename + end + local _, write_err = file:write(str) + if write_err then + return false, "couldn't write to " .. self.filename + end + file:flush() + file:close() + self.last_time_saved = uv.fs_stat(self.filename).mtime.nsec + return true +end + +return FileBackend diff --git a/lua/neo-tree/defaults.lua b/lua/neo-tree/defaults.lua index 0eed5048b..c996c34d2 100644 --- a/lua/neo-tree/defaults.lua +++ b/lua/neo-tree/defaults.lua @@ -12,6 +12,9 @@ local config = { }, add_blank_line_at_top = false, -- Add a blank line at the top of the tree. auto_clean_after_session_restore = false, -- Automatically clean up broken neo-tree buffers saved in sessions + clipboard = { + sync = "none", -- or "global" or "universal" + }, close_if_last_window = false, -- Close Neo-tree if it is the last window left in the tab default_source = "filesystem", -- you can choose a specific source `last` here which indicates the last used source enable_diagnostics = true, diff --git a/lua/neo-tree/events/init.lua b/lua/neo-tree/events/init.lua index fb2f3373d..b4aac8959 100644 --- a/lua/neo-tree/events/init.lua +++ b/lua/neo-tree/events/init.lua @@ -23,6 +23,7 @@ local M = { STATE_CREATED = "state_created", NEO_TREE_BUFFER_ENTER = "neo_tree_buffer_enter", NEO_TREE_BUFFER_LEAVE = "neo_tree_buffer_leave", + NEO_TREE_CLIPBOARD_CHANGED = "neo_tree_clipboard_changed", NEO_TREE_LSP_UPDATE = "neo_tree_lsp_update", NEO_TREE_POPUP_BUFFER_ENTER = "neo_tree_popup_buffer_enter", NEO_TREE_POPUP_BUFFER_LEAVE = "neo_tree_popup_buffer_leave", diff --git a/lua/neo-tree/health/init.lua b/lua/neo-tree/health/init.lua index a86f41a93..e2b6b5a51 100644 --- a/lua/neo-tree/health/init.lua +++ b/lua/neo-tree/health/init.lua @@ -2,29 +2,6 @@ local typecheck = require("neo-tree.health.typecheck") local M = {} local health = vim.health -local function check_dependencies() - local devicons_ok = pcall(require, "nvim-web-devicons") - if devicons_ok then - health.ok("nvim-web-devicons is installed") - else - health.info("nvim-web-devicons not installed") - end - - local plenary_ok = pcall(require, "plenary") - if plenary_ok then - health.ok("plenary.nvim is installed") - else - health.error("plenary.nvim is not installed") - end - - local nui_ok = pcall(require, "nui.tree") - if nui_ok then - health.ok("nui.nvim is installed") - else - health.error("nui.nvim not installed") - end -end - local validate = typecheck.validate ---@module "neo-tree.types.config" @@ -269,6 +246,9 @@ function M.check_config(config) validate("renderers", ds.renderers, schema.Renderers) validate("window", ds.window, schema.Window) end) + validate("clipboard", cfg.clipboard, function(clip) + validate("follow_cursor", clip.sync, { "function", "string" }, true) + end, true) end, false, nil, @@ -301,7 +281,27 @@ end function M.check() health.start("Neo-tree") - check_dependencies() + local devicons_ok = pcall(require, "nvim-web-devicons") + if devicons_ok then + health.ok("nvim-web-devicons is installed") + else + health.info("nvim-web-devicons not installed") + end + + local plenary_ok = pcall(require, "plenary") + if plenary_ok then + health.ok("plenary.nvim is installed") + else + health.error("plenary.nvim is not installed") + end + + local nui_ok = pcall(require, "nui.tree") + if nui_ok then + health.ok("nui.nvim is installed") + else + health.error("nui.nvim not installed") + end + local config = require("neo-tree").ensure_config() M.check_config(config) end diff --git a/lua/neo-tree/health/typecheck.lua b/lua/neo-tree/health/typecheck.lua index 3271cc2b8..17d02859b 100644 --- a/lua/neo-tree/health/typecheck.lua +++ b/lua/neo-tree/health/typecheck.lua @@ -139,14 +139,14 @@ end ---@return boolean valid ---@return string[]? missed function M.validate(name, value, validator, optional, message, on_invalid, track_missed) - local matched, errmsg, errinfo + local valid, errmsg, errinfo M.namestack[#M.namestack + 1] = name if type(validator) == "string" then - matched = M.match(value, validator) + valid = M.match(value, validator) elseif type(validator) == "table" then for _, v in ipairs(validator) do - matched = M.match(value, v) - if matched then + valid = M.match(value, v) + if valid then break end end @@ -158,20 +158,21 @@ function M.validate(name, value, validator, optional, message, on_invalid, track if track_missed and type(value) == "table" then value = M.mock(name, value, true) end - ok, matched, errinfo = pcall(validator, value) + ok, valid, errinfo = pcall(validator, value) if on_invalid then M.errfuncs[#M.errfuncs] = nil end if not ok then - errinfo = matched - matched = false - elseif matched == nil then - matched = true + errinfo = valid + valid = false + elseif valid == nil then + -- for conciseness, assume that it's valid + valid = true end end - matched = matched or (optional and value == nil) or false + valid = valid or (optional and value == nil) or false - if not matched then + if not valid then ---@type string local expected if vim.is_callable(validator) then @@ -205,9 +206,9 @@ function M.validate(name, value, validator, optional, message, on_invalid, track if track_missed then local missed = getmetatable(value).get_missed_paths() - return matched, missed + return valid, missed end - return matched + return valid end return M diff --git a/lua/neo-tree/setup/init.lua b/lua/neo-tree/setup/init.lua index 5152548ff..1225a5b3e 100644 --- a/lua/neo-tree/setup/init.lua +++ b/lua/neo-tree/setup/init.lua @@ -758,6 +758,8 @@ M.merge_config = function(user_config) hijack_cursor.setup() end + require("neo-tree.clipboard").setup(M.config.clipboard) + return M.config end diff --git a/lua/neo-tree/sources/common/commands.lua b/lua/neo-tree/sources/common/commands.lua index 32791b9db..72e560bcd 100644 --- a/lua/neo-tree/sources/common/commands.lua +++ b/lua/neo-tree/sources/common/commands.lua @@ -227,7 +227,6 @@ end ---@param state neotree.State local copy_node_to_clipboard = function(state, node) - state.clipboard = state.clipboard or {} local existing = state.clipboard[node.id] if existing and existing.action == "copy" then state.clipboard[node.id] = nil @@ -235,6 +234,7 @@ local copy_node_to_clipboard = function(state, node) state.clipboard[node.id] = { action = "copy", node = node } log.info("Copied " .. node.name .. " to clipboard") end + events.fire_event(events.NEO_TREE_CLIPBOARD_CHANGED, state) end ---Marks node as copied, so that it can be pasted somewhere else. @@ -265,7 +265,6 @@ end ---@param state neotree.State ---@param node NuiTree.Node local cut_node_to_clipboard = function(state, node) - state.clipboard = state.clipboard or {} local existing = state.clipboard[node.id] if existing and existing.action == "cut" then state.clipboard[node.id] = nil @@ -273,6 +272,7 @@ local cut_node_to_clipboard = function(state, node) state.clipboard[node.id] = { action = "cut", node = node } log.info("Cut " .. node.name .. " to clipboard") end + events.fire_event(events.NEO_TREE_CLIPBOARD_CHANGED, state) end ---Marks node as cut, so that it can be pasted (moved) somewhere else. @@ -604,53 +604,52 @@ end ---Pastes all items from the clipboard to the current directory. ---@param callback fun(node: NuiTree.Node?, destination: string) The callback to call when the command is done. Called with the parent node as the argument. M.paste_from_clipboard = function(state, callback) - if state.clipboard then - local folder = get_folder_node(state):get_id() - -- Convert to list so to make it easier to pop items from the stack. - local clipboard_list = {} - for _, item in pairs(state.clipboard) do - table.insert(clipboard_list, item) - end - state.clipboard = nil - local handle_next_paste, paste_complete - - paste_complete = function(source, destination) - if callback then - local insert_as = require("neo-tree").config.window.insert_as - -- open the folder so the user can see the new files - local node = insert_as == "sibling" and state.tree:get_node() or state.tree:get_node(folder) - if not node then - log.warn("Could not find node for " .. folder) - end - callback(node, destination) - end - local next_item = table.remove(clipboard_list) - if next_item then - handle_next_paste(next_item) - end - end - - handle_next_paste = function(item) - if item.action == "copy" then - fs_actions.copy_node( - item.node.path, - folder .. utils.path_separator .. item.node.name, - paste_complete - ) - elseif item.action == "cut" then - fs_actions.move_node( - item.node.path, - folder .. utils.path_separator .. item.node.name, - paste_complete - ) + local folder = get_folder_node(state):get_id() + -- Convert to list so to make it easier to pop items from the stack. + local clipboard_list = {} + for _, item in pairs(state.clipboard) do + table.insert(clipboard_list, item) + end + state.clipboard = {} + events.fire_event(events.NEO_TREE_CLIPBOARD_CHANGED, state) + local handle_next_paste, paste_complete + + paste_complete = function(source, destination) + if callback then + local insert_as = require("neo-tree").config.window.insert_as + -- open the folder so the user can see the new files + local node = insert_as == "sibling" and state.tree:get_node() or state.tree:get_node(folder) + if not node then + log.warn("Could not find node for " .. folder) end + callback(node, destination) end - local next_item = table.remove(clipboard_list) if next_item then handle_next_paste(next_item) end end + + handle_next_paste = function(item) + if item.action == "copy" then + fs_actions.copy_node( + item.node.path, + folder .. utils.path_separator .. item.node.name, + paste_complete + ) + elseif item.action == "cut" then + fs_actions.move_node( + item.node.path, + folder .. utils.path_separator .. item.node.name, + paste_complete + ) + end + end + + local next_item = table.remove(clipboard_list) + if next_item then + handle_next_paste(next_item) + end end ---Copies a node to a new location, using typed input. diff --git a/lua/neo-tree/sources/common/components.lua b/lua/neo-tree/sources/common/components.lua index f42eaaff6..15d5d95a1 100644 --- a/lua/neo-tree/sources/common/components.lua +++ b/lua/neo-tree/sources/common/components.lua @@ -72,8 +72,7 @@ end ---@param config neotree.Component.Common.Clipboard M.clipboard = function(config, node, state) - local clipboard = state.clipboard or {} - local clipboard_state = clipboard[node:get_id()] + local clipboard_state = state.clipboard[node:get_id()] if not clipboard_state then return {} end diff --git a/lua/neo-tree/sources/manager.lua b/lua/neo-tree/sources/manager.lua index 7378de7a7..06e4f7976 100644 --- a/lua/neo-tree/sources/manager.lua +++ b/lua/neo-tree/sources/manager.lua @@ -60,7 +60,7 @@ end ---@field position table ---@field git_base string ---@field sort table ----@field clipboard table +---@field clipboard neotree.clipboard.Contents ---@field current_position neotree.State.Position? ---@field disposed boolean? ---@field winid integer? @@ -131,6 +131,7 @@ local function create_state(tabid, sd, winid) state.position = {} state.git_base = "HEAD" state.sort = { label = "Name", direction = 1 } + state.clipboard = {} events.fire_event(events.STATE_CREATED, state) table.insert(all_states, state) return state diff --git a/lua/neo-tree/types/config.lua b/lua/neo-tree/types/config.lua index 91e4d0370..0c3bbb9d9 100644 --- a/lua/neo-tree/types/config.lua +++ b/lua/neo-tree/types/config.lua @@ -113,6 +113,7 @@ ---@field add_blank_line_at_top boolean ---@field auto_clean_after_session_restore boolean ---@field close_if_last_window boolean +---@field clipboard neotree.Config.Clipboard ---@field default_source string ---@field enable_diagnostics boolean ---@field enable_git_status boolean