diff --git a/Makefile b/Makefile index b9871d162..0dc509ab8 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ .PHONY: test test: - nvim --headless --noplugin -u tests/mininit.lua -c "lua require('plenary.test_harness').test_directory('tests/neo-tree/', {minimal_init='tests/mininit.lua'})" + nvim --headless --noplugin -u tests/mininit.lua -c "lua require('plenary.test_harness').test_directory('tests/neo-tree/', {minimal_init='tests/mininit.lua', sequential=true})" .PHONY: test-docker test-docker: diff --git a/README.md b/README.md index d622cb31d..27421375a 100644 --- a/README.md +++ b/README.md @@ -33,9 +33,8 @@ will be a new branch that you can opt into, when it is a good time for you. See [What is a Breaking Change?](#what-is-a-breaking-change) for details. -See [Changelog -3.0](https://github.com/nvim-neo-tree/neo-tree.nvim/wiki/Changelog#30) for -breaking changes and deprecations in 3.0. +See [Changelog 3.0](https://github.com/nvim-neo-tree/neo-tree.nvim/wiki/Changelog#30) +for breaking changes and deprecations in 3.0. ### User Experience GOOD :slightly_smiling_face: :thumbsup: @@ -47,11 +46,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. -- 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`) + 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 trees, either globally + (within the same Neovim instance) or universally (across all Neovim + instances). Try `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! @@ -72,15 +74,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: @@ -272,6 +278,9 @@ vim.keymap.set("n", "e", "Neotree") require("neo-tree").setup({ close_if_last_window = false, -- Close Neo-tree if it is the last window left in the tab popup_border_style = "NC", -- or "" to use 'winborder' on Neovim v0.11+ + clipboard = { + sync = "none", -- or "global"/"universal" to share a clipboard for each/all Neovim instance(s), respectively + }, enable_git_status = true, enable_diagnostics = true, open_files_do_not_replace_types = { "terminal", "trouble", "qf" }, -- when opening files, do not use windows containing these filetypes or buftypes @@ -432,6 +441,7 @@ require("neo-tree").setup({ ["y"] = "copy_to_clipboard", ["x"] = "cut_to_clipboard", ["p"] = "paste_from_clipboard", + [""] = "clear_clipboard", ["c"] = "copy", -- takes text input for destination, also accepts the optional config.show_path option like "add": -- ["c"] = { -- "copy", diff --git a/doc/neo-tree.txt b/doc/neo-tree.txt index 22e50aa90..638e1636c 100644 --- a/doc/neo-tree.txt +++ b/doc/neo-tree.txt @@ -28,6 +28,8 @@ Configuration ............... |neo-tree-configuration| Components and Renderers .. |neo-tree-renderers| Buffer Variables .......... |neo-tree-buffer-variables| Popups .................... |neo-tree-popups| +Clipboard ................... |neo-tree-clipboard| + Sync ...................... |neo-tree-clipboard-sync| Other Sources ............... |neo-tree-sources| Buffers ................... |neo-tree-buffers| Git Status ................ |neo-tree-git-status-source| @@ -337,6 +339,8 @@ x = cut_to_clipboard: Mark file to be cut (moved). p = paste_from_clipboard: Copy/move each marked file to the selected folder. + = clear_clipboard: Clears the clipboard. + c = copy: Copy the selected file or directory. Also accepts the optional `config.show_path` option like the add file action. @@ -1937,6 +1941,115 @@ state to a string. The colors of the popup border are controlled by the highlight group. +CLIPBOARD *neo-tree-clipboard* + +Neo-tree features a clipboard that you can copy, cut, and paste nodes with (by +default, with y, x, and p respectively). You can also clear a clipboard with + by default. + +CLIPBOARD SYNC *neo-tree-clipboard-sync* + +Neo-tree's clipboard can be synced globally (across all Neo-trees within the +same Neovim instance) or universally (across all Neo-trees on a computer). 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 will also persist your clipboards. + +>lua + require('neo-tree').setup({ + clipboard = { + sync = "global" + } + }) +< + +You can also implement your own backend and pass it to that option as well: +>lua + ---Code derived from require('neo-tree.clipboard.sync.base') + + ---@class your.neotree.clipboard.Backend : neotree.clipboard.Backend + local Backend = {} + + ---A backend saves and loads clipboards to and from states. + ---Returns nil if the backend couldn't be created properly. + ---@return your.neotree.clipboard.Backend? + function Backend:new() + local backend = {} + setmetatable(backend, self) + self.__index = self + -- if not do_setup() then + -- return nil -- will default to no sync/backend + -- end + return backend + end + + -- local function applicable(state) + -- return state.name ~= "document_symbols" + -- end + + ---Saves a state's clipboard to the backend. + ---Automatically called whenever a user changes a state's clipboard. + ---Returns nil when the save is not applicable. + ---@param state neotree.State + ---@return boolean? success_or_noop + function Backend:save(state) + -- if not applicable(state) then + -- return nil -- nothing happens + -- end + + -- local saved, err = save_clipboard_to_somewhere(state) + -- if not saved then + -- return false, err -- will error + -- end + + -- on true, neo-tree will try Backend:load with all other states + -- return true + end + + ---Given a state, determines what clipboard (if any), should be loaded. + ---Automatically called when other states' clipboards saved successfully. + ---Returns nil if the clipboard should not be changed. + ---@param state neotree.State + ---@return neotree.clipboard.Contents? clipboard + ---@return string? err + function Backend:load(state) + -- if not applicable(state) then + -- return nil -- nothing happens + -- end + + -- local clipboard, err = load_clipboard_from_somewhere(state) + -- if err then + -- -- don't modify the clipboard and log an error + -- return nil, err + -- end + + -- change the clipboard to the saved clipboard + -- return clipboard + end + + return Backend + + require("neo-tree").setup({ + clipboard = { + sync = Backend + } + }) +< + +Additionally, this helper method exists on `require('neo-tree.clipboard')`: + +>lua + ---Load saved clipboards into all states (except one, if provided). + ---@param exclude_state neotree.State? + function M.update_states(exclude_state) + -- ... + end +< + +This method may be useful to call if a new clipboard exists somewhere, but an +instance of Neo-tree doesn't know about it yet. For example, the universal backend +calls this method when a different instance of Neovim updates a clipboard file. + ================================================================================ OTHER SOURCES ~ ================================================================================ diff --git a/lua/neo-tree/clipboard/init.lua b/lua/neo-tree/clipboard/init.lua new file mode 100644 index 000000000..0ef7156f9 --- /dev/null +++ b/lua/neo-tree/clipboard/init.lua @@ -0,0 +1,111 @@ +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 = {} + +---@class neotree.clipboard.Node +---@field action string +---@field node NuiTree.Node + +---@alias neotree.clipboard.Contents table + +---@alias neotree.clipboard.BackendNames.Builtin +---|"none" +---|"global" +---|"universal" + +---@type table +local builtins = { + none = function() + return require("neo-tree.clipboard.sync.base") + end, + global = function() + return require("neo-tree.clipboard.sync.global") + end, + universal = function() + return require("neo-tree.clipboard.sync.universal") + end, +} + +M.builtin_backends = builtins + +---@alias neotree.Config.Clipboard.Sync neotree.clipboard.BackendNames.Builtin|neotree.clipboard.Backend + +---@class (exact) neotree.Config.Clipboard +---@field sync neotree.Config.Clipboard.Sync? + +---@param opts neotree.Config.Clipboard +M.setup = function(opts) + opts = opts or {} + opts.sync = opts.sync or "none" + + ---@type neotree.clipboard.Backend? + local selected_backend + if type(opts.sync) == "string" then + local lazy_loaded_backend = M.builtin_backends[opts.sync] + if lazy_loaded_backend then + ---@cast lazy_loaded_backend fun():neotree.clipboard.Backend + selected_backend = lazy_loaded_backend() + end + elseif type(opts.sync) == "table" then + local sync = opts.sync + ---@cast sync -neotree.clipboard.BackendNames.Builtin + selected_backend = sync + end + + if not selected_backend then + log.error("invalid clipboard sync method, disabling sync") + selected_backend = builtins.none() + end + M.current_backend = log.assert(selected_backend:new()) + events.subscribe({ + event = events.STATE_CREATED, + ---@param new_state neotree.State + handler = function(new_state) + local clipboard, err = M.current_backend:load(new_state) + if not clipboard then + log.assert(not err, err) + return + end + new_state.clipboard = clipboard + end, + }) + + events.subscribe({ + event = events.NEO_TREE_CLIPBOARD_CHANGED, + ---@param state neotree.State + handler = function(state) + local ok, err = M.current_backend:save(state) + if ok == false then + log.error(err) + end + if ok then + M.update_states(state) + end + end, + }) +end + +---Load saved clipboards into all states (except one, if provided). +---@param exclude_state neotree.State? +function M.update_states(exclude_state) + -- try loading the changed clipboard into all other states + vim.schedule(function() + manager._for_each_state(nil, function(state) + if state == exclude_state then + return + end + local modified_clipboard, err = M.current_backend:load(state) + if not modified_clipboard then + log.assert(not err, err) + return + end + state.clipboard = modified_clipboard + renderer.redraw(state) + end) + end) +end + +return M diff --git a/lua/neo-tree/clipboard/sync/base.lua b/lua/neo-tree/clipboard/sync/base.lua new file mode 100644 index 000000000..fa8324a90 --- /dev/null +++ b/lua/neo-tree/clipboard/sync/base.lua @@ -0,0 +1,61 @@ +---@class neotree.clipboard.Backend +local Backend = {} + +---A backend saves and loads clipboards to and from states. +---Returns nil if the backend couldn't be created properly. +---@return neotree.clipboard.Backend? +function Backend:new() + local backend = {} + setmetatable(backend, self) + self.__index = self + -- if not do_setup() then + -- return nil -- will default to no sync/backend + -- end + return backend +end + +-- local function applicable(state) +-- return state.name ~= "document_symbols" +-- end + +---Saves a state's clipboard to the backend. +---Automatically called whenever a user changes a state's clipboard. +---Returns nil when the save is not applicable. +---@param state neotree.State +---@return boolean? success_or_noop +function Backend:save(state) + -- if not applicable(state) then + -- return nil -- nothing happens + -- end + + -- local saved, err = save_clipboard_to_somewhere(state) + -- if not saved then + -- return false, err -- will error + -- end + + -- on true, neo-tree will try Backend:load with all other states + -- return true +end + +---Given a state, determines what clipboard (if any), should be loaded. +---Automatically called when other states' clipboards saved successfully. +---Returns nil if the clipboard should not be changed. +---@param state neotree.State +---@return neotree.clipboard.Contents? clipboard +---@return string? err +function Backend:load(state) + -- if not applicable(state) then + -- return nil -- nothing happens + -- end + + -- local clipboard, err = load_clipboard_from_somewhere(state) + -- if err then + -- -- don't modify the clipboard and log an error + -- return nil, err + -- end + + -- change the clipboard to the saved clipboard + -- return clipboard +end + +return Backend diff --git a/lua/neo-tree/clipboard/sync/global.lua b/lua/neo-tree/clipboard/sync/global.lua new file mode 100644 index 000000000..2f7464abd --- /dev/null +++ b/lua/neo-tree/clipboard/sync/global.lua @@ -0,0 +1,27 @@ +local Backend = require("neo-tree.clipboard.sync.base") +---@class neotree.clipboard.GlobalBackend : neotree.clipboard.Backend +local GlobalBackend = Backend:new() + +---@type table + +---@class neotree.clipboard.GlobalBackend +---@field clipboards table +function GlobalBackend:new() + local backend = {} + setmetatable(backend, self) + self.__index = self + + ---@cast backend neotree.clipboard.GlobalBackend + backend.clipboards = {} + return backend +end + +function GlobalBackend:save(state) + self.clipboards[state.name] = state.clipboard +end + +function GlobalBackend:load(state) + return self.clipboards[state.name] +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..e9cfc3584 --- /dev/null +++ b/lua/neo-tree/clipboard/sync/universal.lua @@ -0,0 +1,232 @@ +---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 Backend = require("neo-tree.clipboard.sync.base") +local log = require("neo-tree.log").new("clipboard") +local validate = require("neo-tree.health.typecheck").validate +local uv = vim.uv or vim.loop +local utils = require("neo-tree.utils") + +---@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" + +---@class (exact) neotree.clipboard.FileBackend.FileFormat +---@field state_name string +---@field contents neotree.clipboard.Contents + +---@class neotree.clipboard.FileBackend : neotree.clipboard.Backend +---@field paths table +---@field dir string +---@field handles table +---@field cached_contents neotree.clipboard.Contents +---@field last_stat_seen uv.fs_stat.result? +---@field saving boolean +local UniversalBackend = Backend:new() + +---@param filename string +---@return boolean? stat +---@return string? err +local function file_touch(filename, mkdir) + local dir = vim.fn.fnamemodify(filename, ":h") + local mkdir_ok = vim.fn.mkdir(dir, "p") + if mkdir_ok == 0 then + return nil, "couldn't make dir " .. dir + end + + local file, file_err = io.open(filename, "w") + if not file then + return nil, file_err + end + + file:flush() + file:close() + return true +end + +---@param opts neotree.clipboard.FileBackend.Opts? +---@return neotree.clipboard.FileBackend? +function UniversalBackend:new(opts) + local backend = {} + setmetatable(backend, self) + self.__index = self + + -- setup the clipboard file + opts = opts or {} + self.handles = {} + + backend.dir = opts.dir or clipboard_states_dir + return backend +end + +---@param filename string +---@return uv.uv_fs_event_t? started true if working +function UniversalBackend:_watch_file(filename) + local event_handle = uv.new_fs_event() + if not event_handle then + log.warn("Could not watch shared clipboard on file events") + return nil + end + + local start_success = event_handle:start(filename, {}, function(err) + if err then + log.error("File handle error:", err, ", syncing will be disabled") + event_handle:close() + return + end + + local stat = uv.fs_stat(filename) + if not stat then + log.warn("Clipboard file", filename, "was replaced or deleted") + self.handles[filename] = nil + event_handle:close() + return + end + + local last_write_from_here = self.saving or stat.mtime.nsec == self.last_stat_seen.mtime.nsec + self.last_stat_seen = stat + if last_write_from_here then + return + end + + require("neo-tree.clipboard").update_states() + end) + log.debug("Watching", filename) + local handle = start_success and event_handle + self.handles[filename] = handle + return handle +end + +do + local cache = {} + function UniversalBackend:get_filename(state) + local cached = cache[state.name] + if cached then + return cached + end + + local fname = utils.path_join(self.dir, state.name .. ".json") + cache[state.name] = fname + return fname + end +end + +function UniversalBackend:save(state) + ---@type neotree.clipboard.FileBackend.FileFormat + local wrapped = { + state_name = assert(state.name), + contents = state.clipboard, + } + local filename = self:get_filename(state) + + if not uv.fs_stat(filename) then + local touch_ok, err = file_touch(filename) + if not touch_ok then + return false, "Couldn't write to " .. filename .. ":" .. err + end + end + + local encode_ok, str = pcall(vim.json.encode, wrapped) + if not encode_ok then + local encode_err = str + return false, "Couldn't encode clipboard into json: " .. encode_err + end + + local file, open_err = io.open(filename, "w") + if not file then + return false, "Couldn't open " .. filename .. ": " .. open_err + end + + self.saving = true + local _, write_err = file:write(str) + file:flush() + if write_err then + self.saving = false + return false, "Couldn't write to " .. filename .. ": " .. write_err + end + + self.last_stat_seen = log.assert(uv.fs_stat(filename)) + self.last_clipboard_saved = state.clipboard + self.saving = false + return true +end + +---@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("state_name", c.state_name, "string") + end, false, "Clipboard from file could not be validated", function() end) +end + +function UniversalBackend:load(state) + local filename = self:get_filename(state) + local stat = uv.fs_stat(filename) + if stat and self.last_stat_seen then + if stat.mtime == self.last_stat_seen.mtime then + log.debug("Using cached clipboard from", stat.mtime) + return self.last_clipboard_saved + end + end + self.last_stat_seen = stat + + if not stat then + local file_ok, touch_err = file_touch(filename) + if not file_ok then + return nil, touch_err + end + end + + local handle = self.handles[filename] + if not handle then + self:_watch_file(filename) + elseif not handle:is_active() then + log.debug("Handle for", filename, "is dead") + end + + local file, err = io.open(filename, "r") + if not file then + return nil, filename .. " could not be opened: " .. err + end + + ---@type string + local content = file:read("*a") + file:close() + content = vim.trim(content) + if content == "" then + -- not populated yet, just do nothing + return nil + end + ---@type boolean, neotree.clipboard.FileBackend.FileFormat|any + local is_success, saved_clipboard = pcall(vim.json.decode, content) + if not is_success or not validate_clipboard_from_file(saved_clipboard) then + log.debug("Clipboard file", filename, "looks to be invalid", saved_clipboard) + if + require("neo-tree.ui.inputs").confirm( + "Neo-tree universal clipboard file for " + .. state.name + .. " seems invalid, clear out clipboard?" + ) + then + local success, delete_err = os.remove(filename) + if not success then + log.error(delete_err) + end + + -- clear out current state clipboard + state.clipboard = {} + local ok, save_err = self:save(state) + if ok == false then + log.error(save_err) + end + return {} + end + return nil, "Could not parse a valid clipboard from clipboard file" + end + + return saved_clipboard.contents +end + +return UniversalBackend diff --git a/lua/neo-tree/defaults.lua b/lua/neo-tree/defaults.lua index d7fe0e27b..68ce4d520 100644 --- a/lua/neo-tree/defaults.lua +++ b/lua/neo-tree/defaults.lua @@ -12,6 +12,9 @@ local config = { }, add_blank_line_at_top = false, -- Add a blank line at the top of the tree. auto_clean_after_session_restore = false, -- Automatically clean up broken neo-tree buffers saved in sessions + clipboard = { + sync = "none", -- or "global"/"universal" to share a clipboard for each/all Neovim instance(s), respectively + }, 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, @@ -451,6 +454,7 @@ local config = { ["y"] = "copy_to_clipboard", ["x"] = "cut_to_clipboard", ["p"] = "paste_from_clipboard", + [""] = "clear_clipboard", ["c"] = "copy", -- takes text input for destination, also accepts the config.show_path and config.insert_as options ["m"] = "move", -- takes text input for destination, also accepts the config.show_path and config.insert_as options ["e"] = "toggle_auto_expand_width", 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/health/init.lua b/lua/neo-tree/health/init.lua index 453b27831..6888e05f5 100644 --- a/lua/neo-tree/health/init.lua +++ b/lua/neo-tree/health/init.lua @@ -313,6 +313,19 @@ 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("sync", clip.sync, function(sync) + if type(sync) == "string" then + return vim.tbl_contains({ "global", "none", "universal" }, sync) + elseif type(sync) == "table" then + validate("new", sync.new, "callable") + validate("load", sync.load, "callable") + validate("save", sync.save, "callable") + else + return false + end + end, 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/log.lua b/lua/neo-tree/log.lua index a7ced909c..047a6d635 100644 --- a/lua/neo-tree/log.lua +++ b/lua/neo-tree/log.lua @@ -95,6 +95,9 @@ 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 notify_prefix = vim.tbl_isempty(config.context) and config.plugin_short + or table.concat({ config.plugin_short, prefix }, " ") local title_opts = { title = config.plugin_short } ---@param message string @@ -106,7 +109,7 @@ log_maker.new = function(config) else local level_config = config.level_configs[level] local console_string = ("[%s %s] %s"):format( - config.plugin_short, + notify_prefix, level_config.name:upper(), message ) @@ -116,17 +119,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 +183,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 +205,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 +298,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 +347,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 +381,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 diff --git a/lua/neo-tree/setup/init.lua b/lua/neo-tree/setup/init.lua index c7325b467..0747efac5 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").setup(M.config.clipboard) + return M.config end diff --git a/lua/neo-tree/sources/buffers/commands.lua b/lua/neo-tree/sources/buffers/commands.lua index 7f91a41a2..a9ba130bb 100644 --- a/lua/neo-tree/sources/buffers/commands.lua +++ b/lua/neo-tree/sources/buffers/commands.lua @@ -65,6 +65,11 @@ M.paste_from_clipboard = function(state) cc.paste_from_clipboard(state, refresh) end +M.clear_clipboard = function(state) + cc.clear_clipboard(state) + redraw() +end + M.delete = function(state) cc.delete(state, refresh) end diff --git a/lua/neo-tree/sources/common/commands.lua b/lua/neo-tree/sources/common/commands.lua index c8fedede9..48c951205 100644 --- a/lua/neo-tree/sources/common/commands.lua +++ b/lua/neo-tree/sources/common/commands.lua @@ -227,7 +227,6 @@ end ---@param state neotree.State local copy_node_to_clipboard = function(state, node) - state.clipboard = state.clipboard or {} local existing = state.clipboard[node.id] if existing and existing.action == "copy" then state.clipboard[node.id] = nil @@ -235,6 +234,7 @@ local copy_node_to_clipboard = function(state, node) state.clipboard[node.id] = { action = "copy", node = node } log.info("Copied " .. node.name .. " to clipboard") end + events.fire_event(events.NEO_TREE_CLIPBOARD_CHANGED, state) end ---Marks node as copied, so that it can be pasted somewhere else. @@ -265,7 +265,6 @@ end ---@param state neotree.State ---@param node NuiTree.Node local cut_node_to_clipboard = function(state, node) - state.clipboard = state.clipboard or {} local existing = state.clipboard[node.id] if existing and existing.action == "cut" then state.clipboard[node.id] = nil @@ -273,6 +272,7 @@ local cut_node_to_clipboard = function(state, node) state.clipboard[node.id] = { action = "cut", node = node } log.info("Cut " .. node.name .. " to clipboard") end + events.fire_event(events.NEO_TREE_CLIPBOARD_CHANGED, state) end ---Marks node as cut, so that it can be pasted (moved) somewhere else. @@ -604,53 +604,59 @@ end ---Pastes all items from the clipboard to the current directory. ---@param callback fun(node: NuiTree.Node?, destination: string) The callback to call when the command is done. Called with the parent node as the argument. M.paste_from_clipboard = function(state, callback) - if state.clipboard then - local folder = get_folder_node(state):get_id() - -- Convert to list so to make it easier to pop items from the stack. - local clipboard_list = {} - for _, item in pairs(state.clipboard) do - table.insert(clipboard_list, item) - end - state.clipboard = nil - local handle_next_paste, paste_complete - - paste_complete = function(source, destination) - if callback then - local insert_as = require("neo-tree").config.window.insert_as - -- open the folder so the user can see the new files - local node = insert_as == "sibling" and state.tree:get_node() or state.tree:get_node(folder) - if not node then - log.warn("Could not find node for " .. folder) - end - callback(node, destination) - end - local next_item = table.remove(clipboard_list) - if next_item then - handle_next_paste(next_item) - end - end - - handle_next_paste = function(item) - if item.action == "copy" then - fs_actions.copy_node( - item.node.path, - folder .. utils.path_separator .. item.node.name, - paste_complete - ) - elseif item.action == "cut" then - fs_actions.move_node( - item.node.path, - folder .. utils.path_separator .. item.node.name, - paste_complete - ) + local folder = get_folder_node(state):get_id() + -- Convert to list so to make it easier to pop items from the stack. + local clipboard_list = {} + for _, item in pairs(state.clipboard) do + table.insert(clipboard_list, item) + end + state.clipboard = {} + events.fire_event(events.NEO_TREE_CLIPBOARD_CHANGED, state) + local handle_next_paste, paste_complete + + paste_complete = function(source, destination) + if callback then + local insert_as = require("neo-tree").config.window.insert_as + -- open the folder so the user can see the new files + local node = insert_as == "sibling" and state.tree:get_node() or state.tree:get_node(folder) + if not node then + log.warn("Could not find node for " .. folder) end + callback(node, destination) end - local next_item = table.remove(clipboard_list) if next_item then handle_next_paste(next_item) end end + + handle_next_paste = function(item) + if item.action == "copy" then + fs_actions.copy_node( + item.node.path, + folder .. utils.path_separator .. item.node.name, + paste_complete + ) + elseif item.action == "cut" then + fs_actions.move_node( + item.node.path, + folder .. utils.path_separator .. item.node.name, + paste_complete + ) + end + end + + local next_item = table.remove(clipboard_list) + if next_item then + handle_next_paste(next_item) + end +end + +M.clear_clipboard = function(state) + state.clipboard = {} + log.info("Cleared clipboard") + events.fire_event(events.NEO_TREE_CLIPBOARD_CHANGED, state) + renderer.redraw(state) 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/filesystem/commands.lua b/lua/neo-tree/sources/filesystem/commands.lua index db0eda7fa..6a036bd74 100644 --- a/lua/neo-tree/sources/filesystem/commands.lua +++ b/lua/neo-tree/sources/filesystem/commands.lua @@ -14,9 +14,7 @@ local refresh = function(state) fs._navigate_internal(state, nil, nil, nil, false) end -local redraw = function(state) - renderer.redraw(state) -end +local redraw = renderer.redraw M.add = function(state) cc.add(state, utils.wrap(fs.show_new_children, state)) @@ -63,6 +61,11 @@ M.paste_from_clipboard = function(state) cc.paste_from_clipboard(state, utils.wrap(fs.show_new_children, state)) end +M.clear_clipboard = function(state) + cc.clear_clipboard(state) + redraw(state) +end + M.delete = function(state) cc.delete(state, utils.wrap(refresh, state)) end diff --git a/lua/neo-tree/sources/git_status/commands.lua b/lua/neo-tree/sources/git_status/commands.lua index d7bf5b69d..22719477d 100644 --- a/lua/neo-tree/sources/git_status/commands.lua +++ b/lua/neo-tree/sources/git_status/commands.lua @@ -54,6 +54,11 @@ M.paste_from_clipboard = function(state) cc.paste_from_clipboard(state, refresh) end +M.clear_clipboard = function(state) + cc.clear_clipboard(state) + redraw() +end + M.delete = function(state) cc.delete(state, refresh) end diff --git a/lua/neo-tree/sources/manager.lua b/lua/neo-tree/sources/manager.lua index 5cb35dbf7..c8ea03408 100644 --- a/lua/neo-tree/sources/manager.lua +++ b/lua/neo-tree/sources/manager.lua @@ -51,6 +51,8 @@ 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 @@ -60,7 +62,7 @@ end ---@field position neotree.State.Position ---@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 +72,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? @@ -114,6 +115,7 @@ end ---@class (exact) neotree.StateWithTree : neotree.State ---@field tree NuiTree +---@field _in_pre_render boolean? local a = {} @@ -132,6 +134,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 8bcd6c446..9d7661296 100644 --- a/lua/neo-tree/types/config.lua +++ b/lua/neo-tree/types/config.lua @@ -105,13 +105,12 @@ ---@alias neotree.Config.BorderStyle "NC"|"rounded"|"single"|"solid"|"double"|"" ----@alias neotree.Config.SortFunction fun(a: NuiTree.Node, b: NuiTree.Node):boolean? - ---@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/tests/neo-tree/clipboard/sync_spec.lua b/tests/neo-tree/clipboard/sync_spec.lua new file mode 100644 index 000000000..2f39fbea8 --- /dev/null +++ b/tests/neo-tree/clipboard/sync_spec.lua @@ -0,0 +1,61 @@ +local u = require("tests.utils") +local verify = require("tests.utils.verify") + +describe("Clipboard sync", function() + local test = u.fs.init_test({ + items = { + { + name = "foo", + type = "dir", + items = { + { + name = "bar", + type = "dir", + items = { + { name = "baz1.txt", type = "file" }, + { name = "baz2.txt", type = "file", id = "deepfile2" }, + }, + }, + { name = "foofile1.txt", type = "file" }, + }, + }, + { name = "topfile1.txt", type = "file", id = "topfile1" }, + }, + }) + + test.setup() + + after_each(function() + u.clear_environment() + end) + + describe("Global", function() + it("should work", function() + require("neo-tree").setup({ + clipboard = { + sync = "global", + }, + }) + + vim.cmd("Neotree") + u.wait_for_neo_tree() + local state = verify.get_state() + local wait1 = u.changedtick_waiter() + u.feedkeys("y") + wait1() + assert(next(state.clipboard)) + + vim.cmd("tabnew") + vim.cmd("Neotree") + u.wait_for_neo_tree() + local other_state = verify.get_state() + assert(next(other_state.clipboard)) + local wait2 = u.changedtick_waiter(0, 1) + u.feedkeys("y") + wait2() + assert(not next(other_state.clipboard)) + end) + end) + + test.teardown() +end) diff --git a/tests/utils/init.lua b/tests/utils/init.lua index 229df0ab0..f513bf68d 100644 --- a/tests/utils/init.lua +++ b/tests/utils/init.lua @@ -190,8 +190,10 @@ function mod.wait_for_neo_tree(options) end function mod.changedtick_waiter(bufnr, offset_goal, timeout) - local changedtick = vim.b[bufnr].changedtick + bufnr = bufnr or 0 timeout = timeout or 4000 + offset_goal = offset_goal or 1 + local changedtick = vim.b[bufnr].changedtick return function() assert( vim.wait(timeout, function()