From baae1081fc0f8d146cddbcbd5718b353d92c886b Mon Sep 17 00:00:00 2001 From: pynappo Date: Wed, 21 May 2025 03:24:56 -0700 Subject: [PATCH 1/7] init --- lua/neo-tree/clipboard/backends/base.lua | 25 ++++ lua/neo-tree/clipboard/backends/file.lua | 159 +++++++++++++++++++++++ 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 +- 8 files changed, 238 insertions(+), 1 deletion(-) create mode 100644 lua/neo-tree/clipboard/backends/base.lua create mode 100644 lua/neo-tree/clipboard/backends/file.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/backends/file.lua b/lua/neo-tree/clipboard/backends/file.lua new file mode 100644 index 000000000..7257ee63c --- /dev/null +++ b/lua/neo-tree/clipboard/backends/file.lua @@ -0,0 +1,159 @@ +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.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.Backend.File : neotree.Clipboard.Backend +---@field handle uv.uv_fs_event_t +---@field filename string +---@field source string +---@field pid integer +local FileBackend = Clipboard: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 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, 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.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 + + -- setup the clipboard file + 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 file_touch(filename) then + log.error("Could not make shared clipboard directory:", clipboard_states_dir) + return nil + 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 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 + self:_sync_to_states(self:load()) + end) + return start_success == 0 + else + log.info("could not watch shared clipboard on file events") + end + 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" + 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 decode_err = clipboard + local msg = "Read failed from shared clipboard file @" .. self.filename .. ":" .. decode_err + log.error(msg) + return nil, msg + end + + return clipboard.contents +end + +---@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 + log.error("Could not write error") + end + 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 + return false + end + file:flush() + file:close() + return true +end + +return FileBackend 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 d7fe0e27b..94f1f73f3 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 a1514e4a3..44a67a01e 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 c7325b467..a8d36c9b6 100644 --- a/lua/neo-tree/setup/init.lua +++ b/lua/neo-tree/setup/init.lua @@ -704,6 +704,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 c8fedede9..0441bc449 100644 --- a/lua/neo-tree/sources/common/commands.lua +++ b/lua/neo-tree/sources/common/commands.lua @@ -235,6 +235,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. @@ -273,6 +274,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. @@ -612,6 +614,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 8bcd6c446..f61fad7ef 100644 --- a/lua/neo-tree/types/config.lua +++ b/lua/neo-tree/types/config.lua @@ -105,13 +105,15 @@ ---@alias neotree.Config.BorderStyle "NC"|"rounded"|"single"|"solid"|"double"|"" ----@alias neotree.Config.SortFunction fun(a: NuiTree.Node, b: NuiTree.Node):boolean? +---@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 From 37ed746d357046b915e70aea263849a64e5d5bf3 Mon Sep 17 00:00:00 2001 From: pynappo Date: Thu, 29 May 2025 01:53:19 -0700 Subject: [PATCH 2/7] 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 94f1f73f3..179374bc6 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 a8d36c9b6..0747efac5 100644 --- a/lua/neo-tree/setup/init.lua +++ b/lua/neo-tree/setup/init.lua @@ -704,7 +704,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 f61fad7ef..0cea1f635 100644 --- a/lua/neo-tree/types/config.lua +++ b/lua/neo-tree/types/config.lua @@ -105,8 +105,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 a133857d0461c72ae0503b4d59e5f7228f03aebc Mon Sep 17 00:00:00 2001 From: pynappo Date: Mon, 2 Jun 2025 00:15:55 -0700 Subject: [PATCH 3/7] global backend + get global backend working --- README.md | 31 ++-- doc/neo-tree.txt | 11 ++ lua/neo-tree/clipboard/init.lua | 78 ++++++--- lua/neo-tree/clipboard/sync/base.lua | 32 ++-- lua/neo-tree/clipboard/sync/file.lua | 159 ----------------- lua/neo-tree/clipboard/sync/global.lua | 17 ++ lua/neo-tree/clipboard/sync/universal.lua | 188 +++++++++++++++++++++ lua/neo-tree/defaults.lua | 2 +- lua/neo-tree/health/init.lua | 3 + lua/neo-tree/health/typecheck.lua | 27 +-- lua/neo-tree/sources/common/commands.lua | 82 +++++---- lua/neo-tree/sources/common/components.lua | 3 +- lua/neo-tree/sources/manager.lua | 8 +- lua/neo-tree/types/config.lua | 3 - 14 files changed, 377 insertions(+), 267 deletions(-) delete mode 100644 lua/neo-tree/clipboard/sync/file.lua create mode 100644 lua/neo-tree/clipboard/sync/global.lua create mode 100644 lua/neo-tree/clipboard/sync/universal.lua diff --git a/README.md b/README.md index d622cb31d..b6a7c2101 100644 --- a/README.md +++ b/README.md @@ -48,10 +48,15 @@ should you! - Neo-tree won't let other buffers take over its window. - Neo-tree won't leave its window scrolled to the last line when there is 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 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`) + `follow_current_file.enabled = true`) +- Neo-tree won't leave its window scrolled to the last line when there is + plenty of room to display the whole tree. +- 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! @@ -72,15 +77,19 @@ utilities, such as scanning the filesystem. There are also some optional plugins that work with Neo-tree: -- [nvim-tree/nvim-web-devicons](https://github.com/nvim-tree/nvim-web-devicons) for file icons. -- [antosha417/nvim-lsp-file-operations](https://github.com/antosha417/nvim-lsp-file-operations) for LSP-enhanced renames/etc. -- [folke/snacks.nvim](https://github.com/folke/snacks.nvim) for image previews, see Preview Mode section. - - [snacks.rename](https://github.com/folke/snacks.nvim/blob/main/docs/rename.md#neo-treenvim) can also work with - Neo-tree +- [nvim-tree/nvim-web-devicons](https://github.com/nvim-tree/nvim-web-devicons) + for file icons. +- [antosha417/nvim-lsp-file-operations](https://github.com/antosha417/nvim-lsp-file-operations) + for LSP-enhanced renames/etc. +- [folke/snacks.nvim](https://github.com/folke/snacks.nvim) for image previews, + see Preview Mode section. +- [snacks.rename](https://github.com/folke/snacks.nvim/blob/main/docs/rename.md#neo-treenvim) + can also work with Neo-tree - [3rd/image.nvim](https://github.com/3rd/image.nvim) for image previews. - - If both snacks.nvim and image.nvim are installed. Neo-tree currently will - try to preview with snacks.nvim first, then try image.nvim. -- [s1n7ax/nvim-window-picker](https://github.com/s1n7ax/nvim-window-picker) for `_with_window_picker` keymaps. + - If both snacks.nvim and image.nvim are installed. Neo-tree currently will try + to preview with snacks.nvim first, then try image.nvim. +- [s1n7ax/nvim-window-picker](https://github.com/s1n7ax/nvim-window-picker) for + `_with_window_picker` keymaps. ### mini.deps example: diff --git a/doc/neo-tree.txt b/doc/neo-tree.txt index 22e50aa90..bf98a5937 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| @@ -2038,4 +2039,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 973c638b5..42898b807 100644 --- a/lua/neo-tree/clipboard/init.lua +++ b/lua/neo-tree/clipboard/init.lua @@ -1,51 +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 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"), + universal = require("neo-tree.clipboard.sync.universal"), } ----@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 + ---@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 - state.clipboard = M.current_backend:load() + new_state.clipboard = clipboard end, }) events.subscribe({ event = events.NEO_TREE_CLIPBOARD_CHANGED, + ---@param state neotree.State 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) + 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 1bfbbd2a4..665a3b474 100644 --- a/lua/neo-tree/clipboard/sync/base.lua +++ b/lua/neo-tree/clipboard/sync/base.lua @@ -1,10 +1,13 @@ ----@class neotree.Clipboard.Backend -local Backend = { balance = 0 } +---@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 ----@return neotree.Clipboard.Backend? +---@alias neotree.clipboard.Contents table + +---@return neotree.clipboard.Backend? function Backend:new() local o = {} setmetatable(o, self) @@ -12,14 +15,19 @@ 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 -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 ----@param clipboard neotree.Clipboard.Contents? -function Backend:save(clipboard) end +---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/file.lua b/lua/neo-tree/clipboard/sync/file.lua deleted file mode 100644 index dece0b080..000000000 --- a/lua/neo-tree/clipboard/sync/file.lua +++ /dev/null @@ -1,159 +0,0 @@ -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.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.Backend.File : neotree.Clipboard.Backend ----@field handle uv.uv_fs_event_t ----@field filename string ----@field source string ----@field pid integer -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 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, 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.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 - - -- setup the clipboard file - 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 file_touch(filename) then - log.error("Could not make shared clipboard directory:", clipboard_states_dir) - return nil - 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 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 - self:_sync_to_states(self:load()) - end) - return start_success == 0 - else - log.info("could not watch shared clipboard on file events") - end - 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" - 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 decode_err = clipboard - local msg = "Read failed from shared clipboard file @" .. self.filename .. ":" .. decode_err - log.error(msg) - return nil, msg - end - - return clipboard.contents -end - ----@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 - log.error("Could not write error") - end - 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 - return false - end - file:flush() - file:close() - return true -end - -return FileBackend 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..831a1bab0 --- /dev/null +++ b/lua/neo-tree/clipboard/sync/universal.lua @@ -0,0 +1,188 @@ +---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 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) + 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(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) + 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") + 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 + return nil, "could not validate clipboard from 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(), + 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 + 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, "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() + return true +end + +return FileBackend diff --git a/lua/neo-tree/defaults.lua b/lua/neo-tree/defaults.lua index 179374bc6..767f69988 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/init.lua b/lua/neo-tree/health/init.lua index 453b27831..708a987c1 100644 --- a/lua/neo-tree/health/init.lua +++ b/lua/neo-tree/health/init.lua @@ -313,6 +313,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, 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/common/commands.lua b/lua/neo-tree/sources/common/commands.lua index 0441bc449..3d690cef2 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 @@ -266,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 @@ -606,54 +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 - 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 ce9acebae..335484bd5 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 5cb35dbf7..984342c98 100644 --- a/lua/neo-tree/sources/manager.lua +++ b/lua/neo-tree/sources/manager.lua @@ -57,10 +57,10 @@ end ---@field id integer ---@field bufnr integer? ---@field dirty boolean ----@field position neotree.State.Position +---@field position table ---@field git_base string ---@field sort table ----@field clipboard table +---@field clipboard neotree.clipboard.Contents ---@field current_position neotree.State.CurrentPosition? ---@field disposed boolean? ---@field winid integer? @@ -70,7 +70,6 @@ end ---private-ish ---@field orig_tree NuiTree? ---@field _ready boolean? ----@field _in_pre_render boolean? ---@field loading boolean? ---window ---@field window neotree.State.Window? @@ -104,7 +103,7 @@ end ---@field search_pattern string? ---@field use_fzy boolean? ---@field fzy_sort_result_scores table? ----@field fuzzy_finder_mode "directory"|boolean? +---@field fuzzy_finder_mode string? ---@field open_folders_before_search table? ---sort ---@field sort_function_override neotree.Config.SortFunction? @@ -132,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 0cea1f635..9d7661296 100644 --- a/lua/neo-tree/types/config.lua +++ b/lua/neo-tree/types/config.lua @@ -105,9 +105,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 c0d746fe4646227c3bf749587d71ce3a45b1d5fd Mon Sep 17 00:00:00 2001 From: pynappo Date: Thu, 12 Jun 2025 02:07:09 -0700 Subject: [PATCH 4/7] docs and bugfixes --- doc/neo-tree.txt | 21 +++++------ lua/neo-tree/clipboard/sync/universal.lua | 43 +++++++++++++++++------ 2 files changed, 44 insertions(+), 20 deletions(-) diff --git a/doc/neo-tree.txt b/doc/neo-tree.txt index bf98a5937..420ed0838 100644 --- a/doc/neo-tree.txt +++ b/doc/neo-tree.txt @@ -1938,6 +1938,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 ~ ================================================================================ @@ -2039,14 +2050,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 From e7e16f1d950950df44f45301c35b3672fa16833d Mon Sep 17 00:00:00 2001 From: pynappo Date: Thu, 25 Sep 2025 19:07:31 -0700 Subject: [PATCH 5/7] fix uv --- lua/neo-tree/clipboard/sync/universal.lua | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lua/neo-tree/clipboard/sync/universal.lua b/lua/neo-tree/clipboard/sync/universal.lua index 575746d19..ae4c737e4 100644 --- a/lua/neo-tree/clipboard/sync/universal.lua +++ b/lua/neo-tree/clipboard/sync/universal.lua @@ -10,7 +10,7 @@ local uv = vim.uv or vim.loop ---@field filename string local clipboard_states_dir = vim.fn.stdpath("state") .. "/neo-tree.nvim/clipboards" -local pid = vim.uv.os_getpid() +local pid = uv.os_getpid() ---@class (exact) neotree.clipboard.FileBackend.FileFormat ---@field pid integer @@ -32,7 +32,7 @@ local FileBackend = BaseBackend:new() ---@return boolean created ---@return string? err local function file_touch(filename) - if vim.uv.fs_stat(filename) then + if uv.fs_stat(filename) then return true end local dir = vim.fn.fnamemodify(filename, ":h") From 8fefe283e6f64db0cfe17c5dd38da6ce7eb8ddc4 Mon Sep 17 00:00:00 2001 From: pynappo Date: Sat, 27 Sep 2025 00:26:36 -0700 Subject: [PATCH 6/7] fix merge --- README.md | 6 ++---- lua/neo-tree/sources/manager.lua | 6 ++++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index b6a7c2101..6e563147a 100644 --- a/README.md +++ b/README.md @@ -47,16 +47,14 @@ should you! - Neo-tree won't let other buffers take over its window. - Neo-tree won't leave its window scrolled to the last line when there is plenty -of room to display the whole tree. + 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 won't leave its window scrolled to the last line when there is - plenty of room to display the whole tree. - Neo-tree can sync its clipboard across multiple instances, either globally (within the same Neovim instance) or universally (across multiple Neovim - instances) + instances). Set `clipboard.sync = "global" | "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/lua/neo-tree/sources/manager.lua b/lua/neo-tree/sources/manager.lua index 984342c98..f0346faa5 100644 --- a/lua/neo-tree/sources/manager.lua +++ b/lua/neo-tree/sources/manager.lua @@ -51,13 +51,15 @@ end ---@alias neotree.Internal.SortFieldProvider fun(node: NuiTree.Node):any +---@alias neotree.Config.SortFunction fun(a: NuiTree.Node, b: NuiTree.Node):boolean? + ---@class neotree.State : neotree.Config.Source ---@field name string ---@field tabid integer ---@field id integer ---@field bufnr integer? ---@field dirty boolean ----@field position table +---@field position neotree.State.Position ---@field git_base string ---@field sort table ---@field clipboard neotree.clipboard.Contents @@ -103,7 +105,7 @@ end ---@field search_pattern string? ---@field use_fzy boolean? ---@field fzy_sort_result_scores table? ----@field fuzzy_finder_mode string? +---@field fuzzy_finder_mode "directory"|boolean? ---@field open_folders_before_search table? ---sort ---@field sort_function_override neotree.Config.SortFunction? From 98e8faa3575d67617f3d60cc2d1b9ef3c0f3ed18 Mon Sep 17 00:00:00 2001 From: pynappo Date: Tue, 14 Oct 2025 23:22:42 -0700 Subject: [PATCH 7/7] update --- lua/neo-tree/clipboard/init.lua | 10 ++--- lua/neo-tree/clipboard/sync/base.lua | 11 +++--- lua/neo-tree/clipboard/sync/universal.lua | 47 +++++++++++------------ lua/neo-tree/log.lua | 32 ++++++--------- 4 files changed, 44 insertions(+), 56 deletions(-) diff --git a/lua/neo-tree/clipboard/init.lua b/lua/neo-tree/clipboard/init.lua index 42898b807..5a29e7532 100644 --- a/lua/neo-tree/clipboard/init.lua +++ b/lua/neo-tree/clipboard/init.lua @@ -1,6 +1,7 @@ local events = require("neo-tree.events") local manager = require("neo-tree.sources.manager") local log = require("neo-tree.log") +local renderer = require("neo-tree.ui.renderer") local M = {} @@ -38,7 +39,7 @@ M.setup = function(opts) log.error("invalid clipboard sync method, disabling sync") selected_backend = builtins.none end - M.current_backend = assert(selected_backend:new()) + M.current_backend = log.assert(selected_backend:new()) events.subscribe({ event = events.STATE_CREATED, @@ -46,9 +47,7 @@ M.setup = function(opts) handler = function(new_state) local clipboard, err = M.current_backend:load(new_state) if not clipboard then - if err then - log.error(err) - end + log.assert(not err, err) return end new_state.clipboard = clipboard @@ -73,7 +72,7 @@ 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 + if state == exclude_state then return end local modified_clipboard, err = M.current_backend:load(state) @@ -84,6 +83,7 @@ function M.sync_to_clipboards(exclude_state) return end state.clipboard = modified_clipboard + renderer.redraw(state) end) end) end diff --git a/lua/neo-tree/clipboard/sync/base.lua b/lua/neo-tree/clipboard/sync/base.lua index 665a3b474..59db3d19e 100644 --- a/lua/neo-tree/clipboard/sync/base.lua +++ b/lua/neo-tree/clipboard/sync/base.lua @@ -7,6 +7,7 @@ local Backend = {} ---@alias neotree.clipboard.Contents table +---A backend has the responsibility of storing a single instance of a clipboard for other clipboards to save. ---@return neotree.clipboard.Backend? function Backend:new() local o = {} @@ -15,15 +16,15 @@ function Backend:new() return o end ----Loads the clipboard from the backend ----Return a nil clipboard to not make any changes. +---Given a particular state, determines whether the backend should load its saved clipboard into the state. +---Return nil if no clipboard change should be made ---@param state neotree.State ----@return neotree.clipboard.Contents|false? clipboard +---@return neotree.clipboard.Contents? clipboard_or_nil ---@return string? err function Backend:load(state) end ----Writes the clipboard to the backend ----Returns nil when nothing was saved +---Saves a state's clipboard to the backend. +---Returns nil when the save is not applicable. ---@param state neotree.State ---@return boolean? success_or_noop function Backend:save(state) diff --git a/lua/neo-tree/clipboard/sync/universal.lua b/lua/neo-tree/clipboard/sync/universal.lua index ae4c737e4..6da3f1fb5 100644 --- a/lua/neo-tree/clipboard/sync/universal.lua +++ b/lua/neo-tree/clipboard/sync/universal.lua @@ -1,7 +1,7 @@ ---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 log = require("neo-tree.log").new("clipboard.sync.universal") local uv = vim.uv or vim.loop ---@class neotree.clipboard.FileBackend.Opts @@ -14,7 +14,6 @@ local pid = uv.os_getpid() ---@class (exact) neotree.clipboard.FileBackend.FileFormat ---@field pid integer ----@field time integer ---@field state_name string ---@field contents neotree.clipboard.Contents @@ -26,7 +25,7 @@ local pid = uv.os_getpid() ---@field cached_contents neotree.clipboard.Contents ---@field last_time_saved integer ---@field saving boolean -local FileBackend = BaseBackend:new() +local UniversalBackend = BaseBackend:new() ---@param filename string ---@return boolean created @@ -57,7 +56,7 @@ end ---@param opts neotree.clipboard.FileBackend.Opts? ---@return neotree.clipboard.FileBackend? -function FileBackend:new(opts) +function UniversalBackend:new(opts) local backend = {} -- create object if user does not provide one setmetatable(backend, self) self.__index = self @@ -85,28 +84,26 @@ function FileBackend:new(opts) end ---@return boolean started true if working -function FileBackend:_start() +function UniversalBackend:_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 + local start_success = event_handle:start(self.filename, {}, function(err) if err then + log.error("universal clipboard file handle error:", err) 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) + log.info("Watching", self.filename) return start_success == 0 else - log.warn("could not watch shared clipboard on file events") + log.warn("Could not watch shared clipboard on file events") --todo: implement polling? end return false @@ -120,12 +117,11 @@ 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) +function UniversalBackend:load(state) if state.name ~= "filesystem" then return nil, nil end @@ -163,49 +159,50 @@ function FileBackend:load(state) -- try creating a new file without content state.clipboard = {} - self:save(state) + local ok, save_err = self:save(state) + if ok == false then + log.error(save_err) + end -- clear the current clipboard return {} end - return nil, "could not parse a valid clipboard from clipboard file" + return nil, "Could not parse a valid clipboard from clipboard file" end return clipboard_file.contents end -function FileBackend:save(state) +function UniversalBackend: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, + contents = state.clipboard, } if not file_touch(self.filename) then - return false, "couldn't write to " .. self.filename .. self.filename + 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 + 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 + 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 + return false, "Couldn't write to " .. self.filename end file:flush() - file:close() self.last_time_saved = uv.fs_stat(self.filename).mtime.nsec + self.last_clipboard_saved = state.clipboard return true end -return FileBackend +return UniversalBackend diff --git a/lua/neo-tree/log.lua b/lua/neo-tree/log.lua index a7ced909c..803f2823b 100644 --- a/lua/neo-tree/log.lua +++ b/lua/neo-tree/log.lua @@ -95,6 +95,7 @@ log_maker.new = function(config) local log = {} ---@diagnostic disable-next-line: cast-local-type config = vim.tbl_deep_extend("force", default_config, config) + local prefix = table.concat(config.context, ".") local title_opts = { title = config.plugin_short } ---@param message string @@ -116,17 +117,9 @@ log_maker.new = function(config) local initial_filepath = string.format("%s/%s.log", vim.fn.stdpath("data"), config.plugin) - ---@type file*? - log.file = nil - if config.use_file then - log.use_file(initial_filepath) - end - local last_logfile_check_time = 0 - local current_logfile_inode = -1 local logfile_check_interval = 20 -- TODO: probably use filesystem events rather than this local inspect_opts = { depth = 2, newline = " " } - local prefix = table.concat(config.context, ".") ---@param log_type string ---@param msg string local log_to_file = function(log_type, msg) @@ -188,7 +181,6 @@ log_maker.new = function(config) local logfunc = function(log_level, message_maker) local can_log_to_file = log_level >= log.minimum_level.file local can_log_to_console = log_level >= log.minimum_level.console - local log_verbose = vim.env.NEOTREE_TESTING == "true" if not can_log_to_file and not can_log_to_console then return function() end end @@ -211,12 +203,7 @@ log_maker.new = function(config) -- Output to console if config.use_console and can_log_to_console then - local info = debug.getinfo(2, "Sl") vim.schedule(function() - if log_verbose then - local lineinfo = info.short_src .. ":" .. info.currentline - msg = lineinfo .. msg - end notify(msg, log_level) end) end @@ -309,6 +296,7 @@ log_maker.new = function(config) log.set_level(config.level) + local current_logfile_inode = -1 ---@param file string|boolean ---@param quiet boolean? ---@return boolean using_file @@ -357,6 +345,12 @@ log_maker.new = function(config) return config.use_file end + ---@type file*? + log.file = nil + if config.use_file then + log.use_file(initial_filepath, true) + end + ---Quick wrapper around assert that also supports subsequent args being the same as string.format (to reduce work done on happy paths) ---@see string.format ---@generic T @@ -385,13 +379,9 @@ log_maker.new = function(config) ---@param context string log.new = function(context) local new_context = vim.deepcopy(config.context) - return log_maker.new( - vim.tbl_deep_extend( - "force", - config, - { context = vim.list_extend({ new_context }, { context }) } - ) - ) + local new_config = + vim.tbl_deep_extend("force", config, { context = vim.list_extend(new_context, { context }) }) + return log_maker.new(new_config) end return log