From 2979b81d38e57d03a766ecfc6293bc4d13433ccb Mon Sep 17 00:00:00 2001 From: pynappo Date: Wed, 21 May 2025 03:24:56 -0700 Subject: [PATCH 1/8] init --- lua/neo-tree/clipboard/init.lua | 6 ++ lua/neo-tree/clipboard/shared.lua | 148 ++++++++++++++++++++++++++++++ 2 files changed, 154 insertions(+) create mode 100644 lua/neo-tree/clipboard/init.lua create mode 100644 lua/neo-tree/clipboard/shared.lua diff --git a/lua/neo-tree/clipboard/init.lua b/lua/neo-tree/clipboard/init.lua new file mode 100644 index 000000000..534cd2e70 --- /dev/null +++ b/lua/neo-tree/clipboard/init.lua @@ -0,0 +1,6 @@ +local M = {} +---@class neotree.Clipboard todo +---@field [integer] NuiTree.Node? + +M.shared = {} +return M diff --git a/lua/neo-tree/clipboard/shared.lua b/lua/neo-tree/clipboard/shared.lua new file mode 100644 index 000000000..5e8d82c67 --- /dev/null +++ b/lua/neo-tree/clipboard/shared.lua @@ -0,0 +1,148 @@ +local fs_watch = require("neo-tree.sources.filesystem.lib.fs_watch") +local manager = require("neo-tree.sources.manager") +local events = require("neo-tree.events") +local renderer = require("neo-tree.ui.renderer") +local log = require("neo-tree.log") +local uv = vim.uv or vim.loop + +---@class neotree.Clipboard.Shared.Opts + +local clipboard_states_dir = vim.fn.stdpath("state") .. "/neo-tree.nvim/clipboards" +local pid = vim.uv.os_getpid() + +---@class neotree.Clipboard.Shared +---@field handle uv.uv_fs_event_t +---@field filename string +---@field source string +---@field pid integer +local SharedClipboard = {} + +---@param filename string +---@param purpose string +local function try_create_file(filename, purpose) + purpose = purpose or "neo-tree internals" + local dir = vim.fn.fnamemodify(filename, ":h") + if not vim.uv.fs_stat(filename) then + local made_dir, err = vim.fn.mkdir(dir, "p") + if not made_dir then + log.error("Could not make directory for ", purpose, ":", err) + return false + end + end + return true +end + +---@param opts neotree.Clipboard.Shared.Opts +---@return neotree.Clipboard.Shared? +function SharedClipboard:new(opts) + local obj = {} -- create object if user does not provide one + setmetatable(obj, self) + self.__index = self + + -- setup the clipboard file + local state_source = "filesystem" -- could be configurable in the future + + local filename = ("%s/%s.json"):format(clipboard_states_dir, state_source) + + if not vim.uv.fs_stat(filename) then + local made_dir, err = vim.fn.mkdir(clipboard_states_dir, "p") + if not made_dir then + log.error("Could not make shared clipboard directory:", err) + return nil + end + end + + events.subscribe({ + event = events.STATE_CREATED, + handler = function(state) + if state.name ~= "filesystem" then + return + end + vim.schedule(function() + SharedClipboard._update_states(M._load()) + end) + end, + }) + + obj.filename = filename + obj.source = state_source + obj.pid = pid + table.insert(require("neo-tree.clipboard").shared, obj) + return obj +end + +---@return boolean started true if working +function SharedClipboard:_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) + if err then + log.error("Could not monitor clipboard file, closing") + event_handle:close() + return + end + self:_update_states(self:_load()) + end) + return start_success == 0 + else + log.info("could not watch shared clipboard on file events, trying polling instead") + -- simulate with uv.new_fs_poll + end +end + +function SharedClipboard:_load() + local file = io.open(self.filename, "r") + if not file then + return nil + end + local content = file:read("*a") + local is_success, clipboard = pcall(vim.json.decode, content) + if not is_success then + local err = clipboard + log.error("Could not read from shared clipboard file @", self.filename, ":", err) + return nil + end + return clipboard +end + +---@param clipboard neotree.Clipboard +function SharedClipboard:save(clipboard) + local file = io.open(self.filename, "w+") + -- We want to erase data in the file if clipboard is nil instead writing null + if not clipboard or not file then + return + end + + local encode_ok, data = pcall(vim.json.encode, clipboard) + if not encode_ok then + local err = data + log.error("Failed to save clipboard. JSON serialization error", err) + return + end + + local _, write_err = file:write(data) + if write_err then + log.error("Saving shared clipboard error", write_err) + end + + file:flush() + local close_err = file:close() + if close_err then + log.error("Could not close shared clipboard file", write_err) + end +end + +function SharedClipboard:_update_states(clipboard) + manager._for_each_state("filesystem", function(state) + state.clipboard = clipboard + vim.schedule(function() + renderer.redraw(state) + end) + end) +end + +return SharedClipboard From 4363ac8a4e83279de357506d8d05cd1d82a98f61 Mon Sep 17 00:00:00 2001 From: pynappo Date: Fri, 23 May 2025 13:53:51 -0700 Subject: [PATCH 2/8] init --- lua/neo-tree/clipboard/shared.lua | 44 +++++++++++++++++++------------ 1 file changed, 27 insertions(+), 17 deletions(-) diff --git a/lua/neo-tree/clipboard/shared.lua b/lua/neo-tree/clipboard/shared.lua index 5e8d82c67..5591724f7 100644 --- a/lua/neo-tree/clipboard/shared.lua +++ b/lua/neo-tree/clipboard/shared.lua @@ -6,6 +6,7 @@ local log = require("neo-tree.log") local uv = vim.uv or vim.loop ---@class neotree.Clipboard.Shared.Opts +---@field source string local clipboard_states_dir = vim.fn.stdpath("state") .. "/neo-tree.nvim/clipboards" local pid = vim.uv.os_getpid() @@ -18,17 +19,28 @@ local pid = vim.uv.os_getpid() local SharedClipboard = {} ---@param filename string ----@param purpose string -local function try_create_file(filename, purpose) - purpose = purpose or "neo-tree internals" +---@return boolean created +local function file_touch(filename) local dir = vim.fn.fnamemodify(filename, ":h") - if not vim.uv.fs_stat(filename) then - local made_dir, err = vim.fn.mkdir(dir, "p") - if not made_dir then - log.error("Could not make directory for ", purpose, ":", err) - return false - end + if vim.uv.fs_stat(filename) then + return true + end + local made_dir, err = pcall(vim.fn.mkdir, dir, "p") + if not made_dir then + return false end + local file, file_err = io.open(dir .. "/" .. filename, "a+") + if not file then + return false + end + + local _, write_err = file:write("") + if write_err then + return false + end + + file:flush() + file:close() return true end @@ -40,16 +52,13 @@ function SharedClipboard:new(opts) self.__index = self -- setup the clipboard file - local state_source = "filesystem" -- could be configurable in the future + local state_source = opts.source or "filesystem" -- could be configurable in the future local filename = ("%s/%s.json"):format(clipboard_states_dir, state_source) - if not vim.uv.fs_stat(filename) then - local made_dir, err = vim.fn.mkdir(clipboard_states_dir, "p") - if not made_dir then - log.error("Could not make shared clipboard directory:", err) - return nil - end + if file_touch(filename) then + log.error("Could not make shared clipboard directory:", err) + return nil end events.subscribe({ @@ -59,7 +68,7 @@ function SharedClipboard:new(opts) return end vim.schedule(function() - SharedClipboard._update_states(M._load()) + SharedClipboard._update_states(self:_load()) end) end, }) @@ -94,6 +103,7 @@ function SharedClipboard:_start() end end +---@return neotree.Clipboard? valid_clipboard_or_nil function SharedClipboard:_load() local file = io.open(self.filename, "r") if not file then From c0bad50c6a3f2abaa3e3624015295d9478ca783c Mon Sep 17 00:00:00 2001 From: pynappo Date: Fri, 23 May 2025 16:43:11 -0700 Subject: [PATCH 3/8] update --- lua/neo-tree/clipboard/shared.lua | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/lua/neo-tree/clipboard/shared.lua b/lua/neo-tree/clipboard/shared.lua index 5591724f7..17ca7ec5a 100644 --- a/lua/neo-tree/clipboard/shared.lua +++ b/lua/neo-tree/clipboard/shared.lua @@ -20,23 +20,24 @@ local SharedClipboard = {} ---@param filename string ---@return boolean created +---@return string? err local function file_touch(filename) local dir = vim.fn.fnamemodify(filename, ":h") if vim.uv.fs_stat(filename) then return true end - local made_dir, err = pcall(vim.fn.mkdir, dir, "p") - if not made_dir then - return false + local code = vim.fn.mkdir(dir, "p") + if code ~= 1 then + return false, "couldn't make dir" .. dir end local file, file_err = io.open(dir .. "/" .. filename, "a+") if not file then - return false + return false, file_err end local _, write_err = file:write("") if write_err then - return false + return false, write_err end file:flush() @@ -56,8 +57,8 @@ function SharedClipboard:new(opts) local filename = ("%s/%s.json"):format(clipboard_states_dir, state_source) - if file_touch(filename) then - log.error("Could not make shared clipboard directory:", err) + if not file_touch(filename) then + log.error("Could not make shared clipboard directory:", clipboard_states_dir) return nil end @@ -68,7 +69,7 @@ function SharedClipboard:new(opts) return end vim.schedule(function() - SharedClipboard._update_states(self:_load()) + SharedClipboard:_update_states(self:_load()) end) end, }) @@ -98,9 +99,10 @@ function SharedClipboard:_start() end) return start_success == 0 else - log.info("could not watch shared clipboard on file events, trying polling instead") + -- log.info("could not watch shared clipboard on file events, trying polling instead") -- simulate with uv.new_fs_poll end + return false end ---@return neotree.Clipboard? valid_clipboard_or_nil @@ -113,9 +115,10 @@ function SharedClipboard:_load() local is_success, clipboard = pcall(vim.json.decode, content) if not is_success then local err = clipboard - log.error("Could not read from shared clipboard file @", self.filename, ":", err) + log.error("Read failed from shared clipboard file @", self.filename, ":", err) return nil end + return clipboard end From 4fcc86021ebeba1ed0162c663318b24c52b68d5d Mon Sep 17 00:00:00 2001 From: pynappo Date: Tue, 27 May 2025 00:29:42 -0700 Subject: [PATCH 4/8] add type and setup, needs testing --- lua/neo-tree/clipboard/backends/base.lua | 25 ++++ .../{shared.lua => backends/file.lua} | 122 +++++++++--------- lua/neo-tree/clipboard/init.lua | 6 - lua/neo-tree/clipboard/sync.lua | 42 ++++++ lua/neo-tree/defaults.lua | 3 + lua/neo-tree/events/init.lua | 1 + lua/neo-tree/setup/init.lua | 2 + lua/neo-tree/sources/common/commands.lua | 3 + lua/neo-tree/types/config.lua | 4 + lua/neo-tree/ui/renderer.lua | 1 + 10 files changed, 141 insertions(+), 68 deletions(-) create mode 100644 lua/neo-tree/clipboard/backends/base.lua rename lua/neo-tree/clipboard/{shared.lua => backends/file.lua} (56%) delete mode 100644 lua/neo-tree/clipboard/init.lua create mode 100644 lua/neo-tree/clipboard/sync.lua diff --git a/lua/neo-tree/clipboard/backends/base.lua b/lua/neo-tree/clipboard/backends/base.lua new file mode 100644 index 000000000..1bfbbd2a4 --- /dev/null +++ b/lua/neo-tree/clipboard/backends/base.lua @@ -0,0 +1,25 @@ +---@class neotree.Clipboard.Backend +local Backend = { balance = 0 } + +---@class neotree.Clipboard.Contents +---@field [string] NuiTree.Node + +---@return neotree.Clipboard.Backend? +function Backend:new() + local o = {} + setmetatable(o, self) + self.__index = self + return o +end + +---Loads the clipboard to the backend +---@return neotree.Clipboard.Contents? valid_clipboard_or_nil +function Backend:load(v) + return nil +end + +---Writes the clipboard to the backend +---@param clipboard neotree.Clipboard.Contents? +function Backend:save(clipboard) end + +return Backend diff --git a/lua/neo-tree/clipboard/shared.lua b/lua/neo-tree/clipboard/backends/file.lua similarity index 56% rename from lua/neo-tree/clipboard/shared.lua rename to lua/neo-tree/clipboard/backends/file.lua index 17ca7ec5a..7257ee63c 100644 --- a/lua/neo-tree/clipboard/shared.lua +++ b/lua/neo-tree/clipboard/backends/file.lua @@ -1,22 +1,22 @@ -local fs_watch = require("neo-tree.sources.filesystem.lib.fs_watch") +local Clipboard = require("neo-tree.clipboard") local manager = require("neo-tree.sources.manager") local events = require("neo-tree.events") local renderer = require("neo-tree.ui.renderer") local log = require("neo-tree.log") local uv = vim.uv or vim.loop ----@class neotree.Clipboard.Shared.Opts +---@class neotree.Clipboard.Backend.File.Opts ---@field source string local clipboard_states_dir = vim.fn.stdpath("state") .. "/neo-tree.nvim/clipboards" local pid = vim.uv.os_getpid() ----@class neotree.Clipboard.Shared +---@class neotree.Clipboard.Backend.File : neotree.Clipboard.Backend ---@field handle uv.uv_fs_event_t ---@field filename string ---@field source string ---@field pid integer -local SharedClipboard = {} +local FileBackend = Clipboard:new() ---@param filename string ---@return boolean created @@ -45,9 +45,9 @@ local function file_touch(filename) return true end ----@param opts neotree.Clipboard.Shared.Opts ----@return neotree.Clipboard.Shared? -function SharedClipboard:new(opts) +---@param opts neotree.Clipboard.Backend.File.Opts +---@return neotree.Clipboard.Backend.File? +function FileBackend:new(opts) local obj = {} -- create object if user does not provide one setmetatable(obj, self) self.__index = self @@ -62,18 +62,6 @@ function SharedClipboard:new(opts) return nil end - events.subscribe({ - event = events.STATE_CREATED, - handler = function(state) - if state.name ~= "filesystem" then - return - end - vim.schedule(function() - SharedClipboard:_update_states(self:_load()) - end) - end, - }) - obj.filename = filename obj.source = state_source obj.pid = pid @@ -82,10 +70,11 @@ function SharedClipboard:new(opts) end ---@return boolean started true if working -function SharedClipboard:_start() +function FileBackend:_start() if self.handle then return true end + -- monitor the file and make sure it doesn't update neo-tree local event_handle = uv.new_fs_event() if event_handle then self.handle = event_handle @@ -95,67 +84,76 @@ function SharedClipboard:_start() event_handle:close() return end - self:_update_states(self:_load()) + self:_sync_to_states(self:load()) end) return start_success == 0 else - -- log.info("could not watch shared clipboard on file events, trying polling instead") - -- simulate with uv.new_fs_poll + log.info("could not watch shared clipboard on file events") end return false end ----@return neotree.Clipboard? valid_clipboard_or_nil -function SharedClipboard:_load() - local file = io.open(self.filename, "r") - if not file then - return nil +function FileBackend:_sync_to_states(clipboard) + manager._for_each_state("filesystem", function(state) + state.clipboard = clipboard + renderer.redraw(state) + end) +end + +---@return neotree.Clipboard.Backend.File? valid_clipboard_or_nil +---@return string? err +function FileBackend:load() + 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") local is_success, clipboard = pcall(vim.json.decode, content) if not is_success then - local err = clipboard - log.error("Read failed from shared clipboard file @", self.filename, ":", err) - return nil + local decode_err = clipboard + local msg = "Read failed from shared clipboard file @" .. self.filename .. ":" .. decode_err + log.error(msg) + return nil, msg end - return clipboard + return clipboard.contents end ----@param clipboard neotree.Clipboard -function SharedClipboard:save(clipboard) - local file = io.open(self.filename, "w+") - -- We want to erase data in the file if clipboard is nil instead writing null - if not clipboard or not file then - return - end - - local encode_ok, data = pcall(vim.json.encode, clipboard) +---@class neotree.Clipboard.FileFormat +---@field pid integer +---@field time integer +---@field contents neotree.Clipboard.Contents + +---@param clipboard neotree.Clipboard.Contents? +---@return boolean success +function FileBackend:save(clipboard) + self.last_save = os.time() + local wrapped = { + pid = pid, + time = os.time(), + contents = clipboard, + } + local encode_ok, str = pcall(vim.json.encode, wrapped) if not encode_ok then - local err = data - log.error("Failed to save clipboard. JSON serialization error", err) - return + log.error("Could not write error") end - - local _, write_err = file:write(data) + if not file_touch(self.filename) then + return false + end + local file, err = io.open(self.filename, "w") + if not file or err then + return false + end + local _, write_err = file:write(str) if write_err then - log.error("Saving shared clipboard error", write_err) + return false end - file:flush() - local close_err = file:close() - if close_err then - log.error("Could not close shared clipboard file", write_err) - end -end - -function SharedClipboard:_update_states(clipboard) - manager._for_each_state("filesystem", function(state) - state.clipboard = clipboard - vim.schedule(function() - renderer.redraw(state) - end) - end) + file:close() + return true end -return SharedClipboard +return FileBackend diff --git a/lua/neo-tree/clipboard/init.lua b/lua/neo-tree/clipboard/init.lua deleted file mode 100644 index 534cd2e70..000000000 --- a/lua/neo-tree/clipboard/init.lua +++ /dev/null @@ -1,6 +0,0 @@ -local M = {} ----@class neotree.Clipboard todo ----@field [integer] NuiTree.Node? - -M.shared = {} -return M diff --git a/lua/neo-tree/clipboard/sync.lua b/lua/neo-tree/clipboard/sync.lua new file mode 100644 index 000000000..99facfc19 --- /dev/null +++ b/lua/neo-tree/clipboard/sync.lua @@ -0,0 +1,42 @@ +local events = require("neo-tree.events") + +local M = {} + +---@enum (key) neotree.Clipboard.BackendName +local backends = { + none = require("neo-tree.clipboard.backends.base"), + file = require("neo-tree.clipboard.backends.file"), +} + +---@type neotree.Clipboard.Backend? +M.current_backend = nil + +---@class neotree.Clipboard.Sync.Opts +---@field backend neotree.Clipboard.BackendName + +---@param opts neotree.Clipboard.Sync.Opts +M.setup = function(opts) + opts = opts or {} + opts.backend = opts.backend or "none" + + M.current_backend = backends[opts.backend] or opts.backend + events.subscribe({ + event = events.STATE_CREATED, + handler = function(state) + if state.name ~= "filesystem" then + return + end + state.clipboard = M.current_backend:load() + end, + }) + + events.subscribe({ + event = events.NEO_TREE_CLIPBOARD_CHANGED, + handler = function(state) + if state.name ~= "filesystem" then + return + end + M.current_backend:save(state.clipboard) + end, + }) +end diff --git a/lua/neo-tree/defaults.lua b/lua/neo-tree/defaults.lua index 079e47804..9ed8fa589 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 = { + backend = "none" + }, 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/setup/init.lua b/lua/neo-tree/setup/init.lua index 27bd642d6..22dc99675 100644 --- a/lua/neo-tree/setup/init.lua +++ b/lua/neo-tree/setup/init.lua @@ -755,6 +755,8 @@ M.merge_config = function(user_config) hijack_cursor.setup() end + require("neo-tree.clipboard.sync").setup(M.config.clipboard.backend) + return M.config end diff --git a/lua/neo-tree/sources/common/commands.lua b/lua/neo-tree/sources/common/commands.lua index 9207dd6c7..7364c68df 100644 --- a/lua/neo-tree/sources/common/commands.lua +++ b/lua/neo-tree/sources/common/commands.lua @@ -225,6 +225,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. @@ -259,6 +260,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. @@ -577,6 +579,7 @@ M.paste_from_clipboard = function(state, callback) table.insert(clipboard_list, item) end state.clipboard = nil + events.fire_event(events.NEO_TREE_CLIPBOARD_CHANGED, state) local handle_next_paste, paste_complete paste_complete = function(source, destination) diff --git a/lua/neo-tree/types/config.lua b/lua/neo-tree/types/config.lua index 7f45f369a..06d559573 100644 --- a/lua/neo-tree/types/config.lua +++ b/lua/neo-tree/types/config.lua @@ -101,11 +101,15 @@ ---@alias neotree.Config.BorderStyle "NC"|"rounded"|"single"|"solid"|"double"|"" +---@class neotree.Config.Clipboard +---@field backend neotree.Clipboard.BackendName + ---@class (exact) neotree.Config.Base ---@field sources string[] ---@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 diff --git a/lua/neo-tree/ui/renderer.lua b/lua/neo-tree/ui/renderer.lua index 53bee1466..5716dc22f 100644 --- a/lua/neo-tree/ui/renderer.lua +++ b/lua/neo-tree/ui/renderer.lua @@ -810,6 +810,7 @@ local get_selected_nodes = function(state) -- I'm not sure if this could actually happen, but just in case start_pos, end_pos = end_pos, start_pos end + vim.print(start_pos) local selected_nodes = {} while start_pos <= end_pos do local node = state.tree:get_node(start_pos) From 2dc244fdd4520c861656916645e2295245039c26 Mon Sep 17 00:00:00 2001 From: pynappo Date: Thu, 29 May 2025 01:53:19 -0700 Subject: [PATCH 5/8] refactor name --- lua/neo-tree/clipboard/init.lua | 51 +++++++++++++++++++ lua/neo-tree/clipboard/sync.lua | 42 --------------- .../clipboard/{backends => sync}/base.lua | 0 .../clipboard/{backends => sync}/file.lua | 4 +- lua/neo-tree/defaults.lua | 2 +- lua/neo-tree/setup/init.lua | 2 +- lua/neo-tree/types/config.lua | 4 +- 7 files changed, 57 insertions(+), 48 deletions(-) create mode 100644 lua/neo-tree/clipboard/init.lua delete mode 100644 lua/neo-tree/clipboard/sync.lua rename lua/neo-tree/clipboard/{backends => sync}/base.lua (100%) rename lua/neo-tree/clipboard/{backends => sync}/file.lua (97%) diff --git a/lua/neo-tree/clipboard/init.lua b/lua/neo-tree/clipboard/init.lua new file mode 100644 index 000000000..973c638b5 --- /dev/null +++ b/lua/neo-tree/clipboard/init.lua @@ -0,0 +1,51 @@ +local events = require("neo-tree.events") + +local M = {} + +---@enum (key) neotree.Clipboard.BackendNames.Builtin +local backends = { + none = require("neo-tree.clipboard.sync.base"), + file = require("neo-tree.clipboard.sync.file"), + -- global = require("neo-tree.clipboard.sync.global"), +} + +---@type neotree.Clipboard.Backend? +M.current_backend = nil + +---@alias neotree.Config.Clipboard.Sync neotree.Clipboard.BackendNames.Builtin|neotree.Clipboard.Backend + +---@param opts neotree.Config.Clipboard +M.setup = function(opts) + opts = opts or {} + opts.sync = opts.sync or "none" + + if type(opts.sync) == "string" then + local selected_backend = backends[opts.sync] + assert(selected_backend, "backend name should be valid") + M.current_backend = selected_backend + else + local sync = opts.sync + ---@cast sync -neotree.Clipboard.BackendNames.Builtin + M.current_backend = sync + end + events.subscribe({ + event = events.STATE_CREATED, + handler = function(state) + if state.name ~= "filesystem" then + return + end + state.clipboard = M.current_backend:load() + end, + }) + + events.subscribe({ + event = events.NEO_TREE_CLIPBOARD_CHANGED, + handler = function(state) + if state.name ~= "filesystem" then + return + end + M.current_backend:save(state.clipboard) + end, + }) +end +return M diff --git a/lua/neo-tree/clipboard/sync.lua b/lua/neo-tree/clipboard/sync.lua deleted file mode 100644 index 99facfc19..000000000 --- a/lua/neo-tree/clipboard/sync.lua +++ /dev/null @@ -1,42 +0,0 @@ -local events = require("neo-tree.events") - -local M = {} - ----@enum (key) neotree.Clipboard.BackendName -local backends = { - none = require("neo-tree.clipboard.backends.base"), - file = require("neo-tree.clipboard.backends.file"), -} - ----@type neotree.Clipboard.Backend? -M.current_backend = nil - ----@class neotree.Clipboard.Sync.Opts ----@field backend neotree.Clipboard.BackendName - ----@param opts neotree.Clipboard.Sync.Opts -M.setup = function(opts) - opts = opts or {} - opts.backend = opts.backend or "none" - - M.current_backend = backends[opts.backend] or opts.backend - events.subscribe({ - event = events.STATE_CREATED, - handler = function(state) - if state.name ~= "filesystem" then - return - end - state.clipboard = M.current_backend:load() - end, - }) - - events.subscribe({ - event = events.NEO_TREE_CLIPBOARD_CHANGED, - handler = function(state) - if state.name ~= "filesystem" then - return - end - M.current_backend:save(state.clipboard) - end, - }) -end diff --git a/lua/neo-tree/clipboard/backends/base.lua b/lua/neo-tree/clipboard/sync/base.lua similarity index 100% rename from lua/neo-tree/clipboard/backends/base.lua rename to lua/neo-tree/clipboard/sync/base.lua diff --git a/lua/neo-tree/clipboard/backends/file.lua b/lua/neo-tree/clipboard/sync/file.lua similarity index 97% rename from lua/neo-tree/clipboard/backends/file.lua rename to lua/neo-tree/clipboard/sync/file.lua index 7257ee63c..dece0b080 100644 --- a/lua/neo-tree/clipboard/backends/file.lua +++ b/lua/neo-tree/clipboard/sync/file.lua @@ -1,4 +1,4 @@ -local Clipboard = require("neo-tree.clipboard") +local BaseBackend = require("neo-tree.clipboard.sync.base") local manager = require("neo-tree.sources.manager") local events = require("neo-tree.events") local renderer = require("neo-tree.ui.renderer") @@ -16,7 +16,7 @@ local pid = vim.uv.os_getpid() ---@field filename string ---@field source string ---@field pid integer -local FileBackend = Clipboard:new() +local FileBackend = BaseBackend:new() ---@param filename string ---@return boolean created diff --git a/lua/neo-tree/defaults.lua b/lua/neo-tree/defaults.lua index 9ed8fa589..36d6e89a0 100644 --- a/lua/neo-tree/defaults.lua +++ b/lua/neo-tree/defaults.lua @@ -13,7 +13,7 @@ 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 = { - backend = "none" + sync = "none", }, 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 diff --git a/lua/neo-tree/setup/init.lua b/lua/neo-tree/setup/init.lua index 22dc99675..532cab3be 100644 --- a/lua/neo-tree/setup/init.lua +++ b/lua/neo-tree/setup/init.lua @@ -755,7 +755,7 @@ M.merge_config = function(user_config) hijack_cursor.setup() end - require("neo-tree.clipboard.sync").setup(M.config.clipboard.backend) + require("neo-tree.clipboard").setup(M.config.clipboard) return M.config end diff --git a/lua/neo-tree/types/config.lua b/lua/neo-tree/types/config.lua index 06d559573..fcd2c95c2 100644 --- a/lua/neo-tree/types/config.lua +++ b/lua/neo-tree/types/config.lua @@ -101,8 +101,8 @@ ---@alias neotree.Config.BorderStyle "NC"|"rounded"|"single"|"solid"|"double"|"" ----@class neotree.Config.Clipboard ----@field backend neotree.Clipboard.BackendName +---@class (exact) neotree.Config.Clipboard +---@field sync neotree.Config.Clipboard.Sync? ---@class (exact) neotree.Config.Base ---@field sources string[] From 1c318039526ac96e9a2775fb7b81c98ca8454106 Mon Sep 17 00:00:00 2001 From: pynappo Date: Mon, 2 Jun 2025 00:15:55 -0700 Subject: [PATCH 6/8] global backend + get global backend working --- lua/neo-tree/clipboard/init.lua | 63 ++++++++++++----- lua/neo-tree/clipboard/sync/base.lua | 27 ++++--- lua/neo-tree/clipboard/sync/file.lua | 40 +++++------ lua/neo-tree/clipboard/sync/global.lua | 17 +++++ lua/neo-tree/health/init.lua | 48 ++++++------- lua/neo-tree/sources/common/commands.lua | 82 ++++++++++------------ lua/neo-tree/sources/common/components.lua | 3 +- lua/neo-tree/sources/manager.lua | 12 ++++ lua/neo-tree/types/config.lua | 3 - 9 files changed, 172 insertions(+), 123 deletions(-) create mode 100644 lua/neo-tree/clipboard/sync/global.lua diff --git a/lua/neo-tree/clipboard/init.lua b/lua/neo-tree/clipboard/init.lua index 973c638b5..d24f1394c 100644 --- a/lua/neo-tree/clipboard/init.lua +++ b/lua/neo-tree/clipboard/init.lua @@ -1,50 +1,77 @@ 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 backends = { +---@enum (key) neotree.clipboard.BackendNames.Builtin +local builtins = { none = require("neo-tree.clipboard.sync.base"), file = require("neo-tree.clipboard.sync.file"), - -- global = require("neo-tree.clipboard.sync.global"), + global = require("neo-tree.clipboard.sync.global"), } +vim.print(builtins) ----@type neotree.Clipboard.Backend? -M.current_backend = nil +---@type table +M.backends = builtins ----@alias neotree.Config.Clipboard.Sync neotree.Clipboard.BackendNames.Builtin|neotree.Clipboard.Backend +---@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 - local selected_backend = backends[opts.sync] - assert(selected_backend, "backend name should be valid") - M.current_backend = selected_backend - else + selected_backend = M.backends[opts.sync] + elseif type(opts.sync) == "table" then local sync = opts.sync - ---@cast sync -neotree.Clipboard.BackendNames.Builtin - M.current_backend = 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, - handler = function(state) - if state.name ~= "filesystem" then + handler = function(new_state) + local clipboard = M.current_backend:load(new_state) or {} + if not clipboard then return end - state.clipboard = M.current_backend:load() + new_state.clipboard = clipboard end, }) events.subscribe({ event = events.NEO_TREE_CLIPBOARD_CHANGED, handler = function(state) - if state.name ~= "filesystem" then - return + local ok, err = M.current_backend:save(state) + if ok == false then + log.error(err) end - M.current_backend:save(state.clipboard) + + -- try loading the changed clipboard into all other states + manager._for_each_state(nil, function(other_state) + if state == other_state then + return + end + local modified_clipboard = M.current_backend:load(other_state) + if not modified_clipboard then + return + end + vim.print("changed clipboard of " .. ("%s%s"):format(other_state.name, other_state.id)) + other_state.clipboard = modified_clipboard + end) end, }) end diff --git a/lua/neo-tree/clipboard/sync/base.lua b/lua/neo-tree/clipboard/sync/base.lua index 1bfbbd2a4..ae240c352 100644 --- a/lua/neo-tree/clipboard/sync/base.lua +++ b/lua/neo-tree/clipboard/sync/base.lua @@ -1,10 +1,10 @@ ----@class neotree.Clipboard.Backend -local Backend = { balance = 0 } +---@class neotree.clipboard.Backend +local Backend = {} ----@class neotree.Clipboard.Contents +---@class neotree.clipboard.Contents ---@field [string] NuiTree.Node ----@return neotree.Clipboard.Backend? +---@return neotree.clipboard.Backend? function Backend:new() local o = {} setmetatable(o, self) @@ -12,14 +12,21 @@ function Backend:new() return o end ----Loads the clipboard to the backend ----@return neotree.Clipboard.Contents? valid_clipboard_or_nil -function Backend:load(v) - return nil +---Loads the clipboard from the backend +---Return a nil clipboard to not make any changes. +---@param state table +---@return neotree.clipboard.Contents? clipboard +---@return string? err +function Backend:load(state) + vim.print("base load") + return nil, nil end ---Writes the clipboard to the backend ----@param clipboard neotree.Clipboard.Contents? -function Backend:save(clipboard) end +---@param state table +function Backend:save(state) + vim.print("base save") + return true +end return Backend diff --git a/lua/neo-tree/clipboard/sync/file.lua b/lua/neo-tree/clipboard/sync/file.lua index dece0b080..defad1708 100644 --- a/lua/neo-tree/clipboard/sync/file.lua +++ b/lua/neo-tree/clipboard/sync/file.lua @@ -5,13 +5,13 @@ local renderer = require("neo-tree.ui.renderer") local log = require("neo-tree.log") local uv = vim.uv or vim.loop ----@class neotree.Clipboard.Backend.File.Opts +---@class neotree.clipboard.FileBackend.Opts ---@field source string local clipboard_states_dir = vim.fn.stdpath("state") .. "/neo-tree.nvim/clipboards" local pid = vim.uv.os_getpid() ----@class neotree.Clipboard.Backend.File : neotree.Clipboard.Backend +---@class neotree.clipboard.FileBackend : neotree.clipboard.Backend ---@field handle uv.uv_fs_event_t ---@field filename string ---@field source string @@ -45,8 +45,8 @@ local function file_touch(filename) return true end ----@param opts neotree.Clipboard.Backend.File.Opts ----@return neotree.Clipboard.Backend.File? +---@param opts neotree.clipboard.FileBackend.Opts +---@return neotree.clipboard.FileBackend? function FileBackend:new(opts) local obj = {} -- create object if user does not provide one setmetatable(obj, self) @@ -57,15 +57,15 @@ function FileBackend:new(opts) local filename = ("%s/%s.json"):format(clipboard_states_dir, state_source) - if not file_touch(filename) then - log.error("Could not make shared clipboard directory:", clipboard_states_dir) + 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 obj.filename = filename obj.source = state_source obj.pid = pid - table.insert(require("neo-tree.clipboard").shared, obj) return obj end @@ -84,7 +84,6 @@ function FileBackend:_start() event_handle:close() return end - self:_sync_to_states(self:load()) end) return start_success == 0 else @@ -93,15 +92,6 @@ function FileBackend:_start() return false end -function FileBackend:_sync_to_states(clipboard) - manager._for_each_state("filesystem", function(state) - state.clipboard = clipboard - renderer.redraw(state) - end) -end - ----@return neotree.Clipboard.Backend.File? valid_clipboard_or_nil ----@return string? err function FileBackend:load() if not file_touch(self.filename) then return nil, self.filename .. " could not be created" @@ -111,6 +101,7 @@ function FileBackend:load() return nil, self.filename .. " could not be opened" end local content = file:read("*a") + ---@type boolean, neotree.clipboard.FileBackend.FileFormat|any local is_success, clipboard = pcall(vim.json.decode, content) if not is_success then local decode_err = clipboard @@ -119,18 +110,21 @@ function FileBackend:load() return nil, msg end + if not clipboard then + return nil, nil + end + return clipboard.contents end ----@class neotree.Clipboard.FileFormat +---@class neotree.clipboard.FileBackend.FileFormat ---@field pid integer ---@field time integer ----@field contents neotree.Clipboard.Contents +---@field contents neotree.clipboard.Contents ----@param clipboard neotree.Clipboard.Contents? ----@return boolean success -function FileBackend:save(clipboard) - self.last_save = os.time() +function FileBackend:save(state) + local clipboard = state.clipboard + ---@type neotree.clipboard.FileBackend.FileFormat local wrapped = { pid = pid, time = os.time(), diff --git a/lua/neo-tree/clipboard/sync/global.lua b/lua/neo-tree/clipboard/sync/global.lua new file mode 100644 index 000000000..c2bc97a3f --- /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/health/init.lua b/lua/neo-tree/health/init.lua index 628845396..d675b8760 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/sources/common/commands.lua b/lua/neo-tree/sources/common/commands.lua index 7364c68df..caa8f97ee 100644 --- a/lua/neo-tree/sources/common/commands.lua +++ b/lua/neo-tree/sources/common/commands.lua @@ -217,7 +217,6 @@ M.toggle_auto_expand_width = function(state) end 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 @@ -252,7 +251,6 @@ M.copy_to_clipboard_visual = function(state, selected_nodes, callback) end 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 @@ -571,54 +569,52 @@ end ---@param state table The state of the source ---@param callback function 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 - 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 - ) + 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 9f87a2bd4..3c4931578 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 7344b099b..3fc5cc229 100644 --- a/lua/neo-tree/sources/manager.lua +++ b/lua/neo-tree/sources/manager.lua @@ -34,16 +34,28 @@ local get_source_data = function(source_name) return sd end +---@type metatable +local state_mt = { + __eq = function(t1, t2) + return t1.id == t2.id and t1.name == t2.name + end, +} + +local id_count = 0 local function create_state(tabid, sd, winid) nt.ensure_config() local default_config = default_configs[sd.name] local state = vim.deepcopy(default_config, compat.noref()) state.tabid = tabid state.id = winid or tabid + state._id = id_count + id_count = id_count + 1 state.dirty = true state.position = {} state.git_base = "HEAD" state.sort = { label = "Name", direction = 1 } + state.clipboard = {} + setmetatable(state, state_mt) 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 fcd2c95c2..646361977 100644 --- a/lua/neo-tree/types/config.lua +++ b/lua/neo-tree/types/config.lua @@ -101,9 +101,6 @@ ---@alias neotree.Config.BorderStyle "NC"|"rounded"|"single"|"solid"|"double"|"" ----@class (exact) neotree.Config.Clipboard ----@field sync neotree.Config.Clipboard.Sync? - ---@class (exact) neotree.Config.Base ---@field sources string[] ---@field add_blank_line_at_top boolean From f2f26cad1d4e4c1f1553c919ae3d3a2243f2c5fe Mon Sep 17 00:00:00 2001 From: pynappo Date: Tue, 10 Jun 2025 02:09:54 -0700 Subject: [PATCH 7/8] bugfixes --- README.md | 2 + doc/neo-tree.txt | 11 ++ lua/neo-tree/clipboard/init.lua | 45 ++++--- lua/neo-tree/clipboard/sync/base.lua | 21 ++-- lua/neo-tree/clipboard/sync/global.lua | 2 +- .../sync/{file.lua => universal.lua} | 113 ++++++++++++------ lua/neo-tree/defaults.lua | 2 +- lua/neo-tree/health/typecheck.lua | 27 +++-- lua/neo-tree/sources/manager.lua | 13 +- lua/neo-tree/ui/renderer.lua | 1 - 10 files changed, 144 insertions(+), 93 deletions(-) rename lua/neo-tree/clipboard/sync/{file.lua => universal.lua} (53%) diff --git a/README.md b/README.md index 708336351..638549de5 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 instances, either globally (within the same Neovim instance) or + universally (across multiple Neovim instances) - 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..115f47bae 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| @@ -2001,4 +2002,14 @@ Currently, this source supports the following commands: ["P"] = "preview", (and related commands) ["s"] = "split", (and related commands) < + +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"`). You can also implement your own backend and pass +it to that option as well - reading the source code at `lua/neo-tree/clipboard` +is a good way to do it. + vim:tw=80:ts=2:et:ft=help: diff --git a/lua/neo-tree/clipboard/init.lua b/lua/neo-tree/clipboard/init.lua index d24f1394c..42898b807 100644 --- a/lua/neo-tree/clipboard/init.lua +++ b/lua/neo-tree/clipboard/init.lua @@ -7,10 +7,9 @@ local M = {} ---@enum (key) neotree.clipboard.BackendNames.Builtin local builtins = { none = require("neo-tree.clipboard.sync.base"), - file = require("neo-tree.clipboard.sync.file"), global = require("neo-tree.clipboard.sync.global"), + universal = require("neo-tree.clipboard.sync.universal"), } -vim.print(builtins) ---@type table M.backends = builtins @@ -43,9 +42,13 @@ M.setup = function(opts) events.subscribe({ event = events.STATE_CREATED, + ---@param new_state neotree.State handler = function(new_state) - local clipboard = M.current_backend:load(new_state) or {} + 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 @@ -54,25 +57,35 @@ M.setup = function(opts) 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 - - -- try loading the changed clipboard into all other states - manager._for_each_state(nil, function(other_state) - if state == other_state then - return - end - local modified_clipboard = M.current_backend:load(other_state) - if not modified_clipboard then - return - end - vim.print("changed clipboard of " .. ("%s%s"):format(other_state.name, other_state.id)) - other_state.clipboard = modified_clipboard - 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 index ae240c352..665a3b474 100644 --- a/lua/neo-tree/clipboard/sync/base.lua +++ b/lua/neo-tree/clipboard/sync/base.lua @@ -1,8 +1,11 @@ ---@class neotree.clipboard.Backend local Backend = {} ----@class neotree.clipboard.Contents ----@field [string] NuiTree.Node +---@class neotree.clipboard.Node +---@field action string +---@field node NuiTree.Node + +---@alias neotree.clipboard.Contents table ---@return neotree.clipboard.Backend? function Backend:new() @@ -14,18 +17,16 @@ end ---Loads the clipboard from the backend ---Return a nil clipboard to not make any changes. ----@param state table ----@return neotree.clipboard.Contents? clipboard +---@param state neotree.State +---@return neotree.clipboard.Contents|false? clipboard ---@return string? err -function Backend:load(state) - vim.print("base load") - return nil, nil -end +function Backend:load(state) end ---Writes the clipboard to the backend ----@param state table +---Returns nil when nothing was saved +---@param state neotree.State +---@return boolean? success_or_noop function Backend:save(state) - vim.print("base save") return true end diff --git a/lua/neo-tree/clipboard/sync/global.lua b/lua/neo-tree/clipboard/sync/global.lua index c2bc97a3f..78d9eb50a 100644 --- a/lua/neo-tree/clipboard/sync/global.lua +++ b/lua/neo-tree/clipboard/sync/global.lua @@ -1,6 +1,6 @@ local Backend = require("neo-tree.clipboard.sync.base") local g = vim.g ----@class neotree.Clipboard.GlobalBackend : neotree.clipboard.Backend +---@class neotree.clipboard.GlobalBackend : neotree.clipboard.Backend local GlobalBackend = Backend:new() ---@type table diff --git a/lua/neo-tree/clipboard/sync/file.lua b/lua/neo-tree/clipboard/sync/universal.lua similarity index 53% rename from lua/neo-tree/clipboard/sync/file.lua rename to lua/neo-tree/clipboard/sync/universal.lua index defad1708..831a1bab0 100644 --- a/lua/neo-tree/clipboard/sync/file.lua +++ b/lua/neo-tree/clipboard/sync/universal.lua @@ -1,36 +1,46 @@ +---A backend for the clipboard that uses a file in stdpath('state')/neo-tree.nvim/clipboards/ to sync the clipboard +---.. self.filename +---between everything local BaseBackend = require("neo-tree.clipboard.sync.base") -local manager = require("neo-tree.sources.manager") -local events = require("neo-tree.events") -local renderer = require("neo-tree.ui.renderer") 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 neotree.clipboard.FileBackend.FileFormat +---@field pid integer +---@field time integer +---@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 neotree.clipboard.Contents +---@field saving boolean local FileBackend = BaseBackend:new() ---@param filename string ---@return boolean created ---@return string? err local function file_touch(filename) - local dir = vim.fn.fnamemodify(filename, ":h") if vim.uv.fs_stat(filename) then return true end + local dir = vim.fn.fnamemodify(filename, ":h") local code = vim.fn.mkdir(dir, "p") if code ~= 1 then return false, "couldn't make dir" .. dir end - local file, file_err = io.open(dir .. "/" .. filename, "a+") + local file, file_err = io.open(filename, "a+") if not file then return false, file_err end @@ -45,17 +55,20 @@ local function file_touch(filename) return true end ----@param opts neotree.clipboard.FileBackend.Opts +---@param opts neotree.clipboard.FileBackend.Opts? ---@return neotree.clipboard.FileBackend? function FileBackend:new(opts) - local obj = {} -- create object if user does not provide one - setmetatable(obj, self) + local backend = {} -- create object if user does not provide one + setmetatable(backend, self) self.__index = self -- setup the clipboard file - local state_source = opts.source or "filesystem" -- could be configurable in the future + opts = opts or {} - local filename = ("%s/%s.json"):format(clipboard_states_dir, state_source) + 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 @@ -63,10 +76,12 @@ function FileBackend:new(opts) return nil end - obj.filename = filename - obj.source = state_source - obj.pid = pid - return obj + ---@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 @@ -74,76 +89,96 @@ function FileBackend:_start() if self.handle then return true end - -- monitor the file and make sure it doesn't update neo-tree 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) if err then - log.error("Could not monitor clipboard file, closing") 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.info("could not watch shared clipboard on file events") + log.warn("could not watch shared clipboard on file events") + --todo: implement polling? end return false end -function FileBackend:load() +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") + 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 = pcall(vim.json.decode, content) + local is_success, clipboard_file = pcall(vim.json.decode, content) if not is_success then - local decode_err = clipboard - local msg = "Read failed from shared clipboard file @" .. self.filename .. ":" .. decode_err - log.error(msg) - return nil, msg + local decode_err = clipboard_file + return nil, "Read failed from shared clipboard file @" .. self.filename .. ":" .. decode_err end - if not clipboard then - return nil, nil + if not validate_clipboard_from_file(clipboard_file) then + return nil, "could not validate clipboard from file" end - return clipboard.contents + return clipboard_file.contents end ----@class neotree.clipboard.FileBackend.FileFormat ----@field pid integer ----@field time integer ----@field contents neotree.clipboard.Contents - function FileBackend:save(state) - local clipboard = state.clipboard + if state.name ~= "filesystem" then + return nil + end + + local c = state.clipboard ---@type neotree.clipboard.FileBackend.FileFormat local wrapped = { pid = pid, time = os.time(), - contents = clipboard, + 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 - log.error("Could not write error") - end - if not file_touch(self.filename) then - return false + return false, "couldn't encode clipboard into json" end local file, err = io.open(self.filename, "w") if not file or err then - return false + return false, "couldn't open " .. self.filename end local _, write_err = file:write(str) if write_err then - return false + return false, "couldn't write to " .. self.filename end file:flush() file:close() diff --git a/lua/neo-tree/defaults.lua b/lua/neo-tree/defaults.lua index e02d3eb4a..c996c34d2 100644 --- a/lua/neo-tree/defaults.lua +++ b/lua/neo-tree/defaults.lua @@ -13,7 +13,7 @@ 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", + 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 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/sources/manager.lua b/lua/neo-tree/sources/manager.lua index 7fb1b06c6..06e4f7976 100644 --- a/lua/neo-tree/sources/manager.lua +++ b/lua/neo-tree/sources/manager.lua @@ -43,14 +43,6 @@ local get_source_data = function(source_name) return sd end ----@type metatable -local state_mt = { - __eq = function(t1, t2) - return t1.id == t2.id and t1.name == t2.name - end, -} - -local id_count = 0 ---@class neotree.State.Window : neotree.Config.Window ---@field win_width integer ---@field last_user_width integer @@ -68,7 +60,7 @@ local id_count = 0 ---@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? @@ -135,14 +127,11 @@ local function create_state(tabid, sd, winid) ---@cast state neotree.State state.tabid = tabid state.id = winid or tabid - state._id = id_count - id_count = id_count + 1 state.dirty = true state.position = {} state.git_base = "HEAD" state.sort = { label = "Name", direction = 1 } state.clipboard = {} - setmetatable(state, state_mt) events.fire_event(events.STATE_CREATED, state) table.insert(all_states, state) return state diff --git a/lua/neo-tree/ui/renderer.lua b/lua/neo-tree/ui/renderer.lua index d85ca6574..132543fd2 100644 --- a/lua/neo-tree/ui/renderer.lua +++ b/lua/neo-tree/ui/renderer.lua @@ -817,7 +817,6 @@ local get_selected_nodes = function(state) -- I'm not sure if this could actually happen, but just in case start_pos, end_pos = end_pos, start_pos end - vim.print(start_pos) local selected_nodes = {} while start_pos <= end_pos do local node = state.tree:get_node(start_pos) From 6c9331131c4be2e5d8a45ad3f9ed8bb6a54f068a Mon Sep 17 00:00:00 2001 From: pynappo Date: Thu, 12 Jun 2025 02:07:09 -0700 Subject: [PATCH 8/8] docs and bugfixes --- README.md | 4 +-- doc/neo-tree.txt | 21 +++++------ lua/neo-tree/clipboard/sync/universal.lua | 43 +++++++++++++++++------ 3 files changed, 46 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 638549de5..b567e0b9d 100644 --- a/README.md +++ b/README.md @@ -45,8 +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 instances, either globally (within the same Neovim instance) or - universally (across multiple Neovim instances) +- 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 115f47bae..f58abc324 100644 --- a/doc/neo-tree.txt +++ b/doc/neo-tree.txt @@ -1901,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 ~ ================================================================================ @@ -2002,14 +2013,4 @@ Currently, this source supports the following commands: ["P"] = "preview", (and related commands) ["s"] = "split", (and related commands) < - -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"`). You can also implement your own backend and pass -it to that option as well - reading the source code at `lua/neo-tree/clipboard` -is a good way to do it. - vim:tw=80:ts=2:et:ft=help: diff --git a/lua/neo-tree/clipboard/sync/universal.lua b/lua/neo-tree/clipboard/sync/universal.lua index 831a1bab0..575746d19 100644 --- a/lua/neo-tree/clipboard/sync/universal.lua +++ b/lua/neo-tree/clipboard/sync/universal.lua @@ -1,6 +1,5 @@ ----A backend for the clipboard that uses a file in stdpath('state')/neo-tree.nvim/clipboards/ to sync the clipboard ----.. self.filename ----between everything +---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 @@ -13,9 +12,10 @@ local uv = vim.uv or vim.loop local clipboard_states_dir = vim.fn.stdpath("state") .. "/neo-tree.nvim/clipboards" local pid = vim.uv.os_getpid() ----@class neotree.clipboard.FileBackend.FileFormat +---@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 @@ -24,7 +24,7 @@ local pid = vim.uv.os_getpid() ---@field source string ---@field pid integer ---@field cached_contents neotree.clipboard.Contents ----@field last_time_saved neotree.clipboard.Contents +---@field last_time_saved integer ---@field saving boolean local FileBackend = BaseBackend:new() @@ -36,9 +36,9 @@ local function file_touch(filename) return true end local dir = vim.fn.fnamemodify(filename, ":h") - local code = vim.fn.mkdir(dir, "p") - if code ~= 1 then - return false, "couldn't make dir" .. dir + 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 @@ -93,6 +93,9 @@ function FileBackend:_start() 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 @@ -118,6 +121,7 @@ local validate_clipboard_from_file = function(wrapped_clipboard) 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 @@ -147,7 +151,23 @@ function FileBackend:load(state) end if not validate_clipboard_from_file(clipboard_file) then - return nil, "could not validate clipboard from file" + 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 @@ -163,6 +183,7 @@ function FileBackend:save(state) local wrapped = { pid = pid, time = os.time(), + state_name = assert(state.name), contents = c, } if not file_touch(self.filename) then @@ -170,7 +191,8 @@ function FileBackend:save(state) end local encode_ok, str = pcall(vim.json.encode, wrapped) if not encode_ok then - return false, "couldn't encode clipboard into json" + 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 @@ -182,6 +204,7 @@ function FileBackend:save(state) end file:flush() file:close() + self.last_time_saved = uv.fs_stat(self.filename).mtime.nsec return true end