Skip to content

feat(clipboard): add global/universal sync option #1800

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 9 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ should you!
plenty of room to display the whole tree.
- Neo-tree does not need to be manually refreshed (set `use_libuv_file_watcher=true`)
- Neo-tree can intelligently follow the current file (set `follow_current_file.enabled=true`)
- Neo-tree can sync its clipboard across multiple Neo-trees, even across multiple Neovim instances! Set `clipboard.sync
= "global"` or `"universal"`
- Neo-tree is thoughtful about maintaining or setting focus on the right node
- Neo-tree windows in different tabs are completely separate
- `respect_gitignore` actually works!
Expand Down
12 changes: 12 additions & 0 deletions doc/neo-tree.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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|
Expand Down Expand Up @@ -1900,6 +1901,17 @@ state to a string. The colors of the popup border are controlled by the
highlight group.


CLIPBOARD *neo-tree-clipboard*

Neo-tree's clipboard can be synced globally (within the same Neovim instance) or
universally (across multiple Neovim instances). The default is to not sync at
all. To change this option, change the `clipboard.sync` option (options are
`"none"|"global"|"universal"`). The universal sync option relies on a file
located under `stdpath("state") .. "/neo-tree.nvim/clipboards"` You can also
implement your own backend and pass it to that option as well - reading the
source code of `require('neo-tree.clipboard')` is a good way to do it.


================================================================================
OTHER SOURCES ~
================================================================================
Expand Down
91 changes: 91 additions & 0 deletions lua/neo-tree/clipboard/init.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
local events = require("neo-tree.events")
local manager = require("neo-tree.sources.manager")
local log = require("neo-tree.log")

local M = {}

---@enum (key) neotree.clipboard.BackendNames.Builtin
local builtins = {
none = require("neo-tree.clipboard.sync.base"),
global = require("neo-tree.clipboard.sync.global"),
universal = require("neo-tree.clipboard.sync.universal"),
}

---@type table<string, neotree.clipboard.Backend?>
M.backends = builtins

---@alias neotree.Config.Clipboard.Sync neotree.clipboard.BackendNames.Builtin|neotree.clipboard.Backend

---@class (exact) neotree.Config.Clipboard
---@field sync neotree.Config.Clipboard.Sync?

---@param opts neotree.Config.Clipboard
M.setup = function(opts)
opts = opts or {}
opts.sync = opts.sync or "none"

---@type neotree.clipboard.Backend?
local selected_backend
if type(opts.sync) == "string" then
selected_backend = M.backends[opts.sync]
elseif type(opts.sync) == "table" then
local sync = opts.sync
---@cast sync -neotree.clipboard.BackendNames.Builtin
selected_backend = sync
end

if not selected_backend then
log.error("invalid clipboard sync method, disabling sync")
selected_backend = builtins.none
end
M.current_backend = assert(selected_backend:new())

events.subscribe({
event = events.STATE_CREATED,
---@param new_state neotree.State
handler = function(new_state)
local clipboard, err = M.current_backend:load(new_state)
if not clipboard then
if err then
log.error(err)
end
return
end
new_state.clipboard = clipboard
end,
})

events.subscribe({
event = events.NEO_TREE_CLIPBOARD_CHANGED,
---@param state neotree.State
handler = function(state)
local ok, err = M.current_backend:save(state)
if ok == false then
log.error(err)
end
M.sync_to_clipboards(state)
end,
})
end

---@param exclude_state neotree.State?
function M.sync_to_clipboards(exclude_state)
-- try loading the changed clipboard into all other states
vim.schedule(function()
manager._for_each_state(nil, function(state)
if exclude_state == state then
return
end
local modified_clipboard, err = M.current_backend:load(state)
if not modified_clipboard then
if err then
log.error(err)
end
return
end
state.clipboard = modified_clipboard
end)
end)
end

return M
33 changes: 33 additions & 0 deletions lua/neo-tree/clipboard/sync/base.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
---@class neotree.clipboard.Backend
local Backend = {}

---@class neotree.clipboard.Node
---@field action string
---@field node NuiTree.Node

---@alias neotree.clipboard.Contents table<string, neotree.clipboard.Node?>

---@return neotree.clipboard.Backend?
function Backend:new()
local o = {}
setmetatable(o, self)
self.__index = self
return o
end

---Loads the clipboard from the backend
---Return a nil clipboard to not make any changes.
---@param state neotree.State
---@return neotree.clipboard.Contents|false? clipboard
---@return string? err
function Backend:load(state) end

---Writes the clipboard to the backend
---Returns nil when nothing was saved
---@param state neotree.State
---@return boolean? success_or_noop
function Backend:save(state)
return true
end

return Backend
17 changes: 17 additions & 0 deletions lua/neo-tree/clipboard/sync/global.lua
Original file line number Diff line number Diff line change
@@ -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<string, neotree.clipboard.Contents?>
local clipboards = {}

function GlobalBackend:load(state)
return clipboards[state.name]
end

function GlobalBackend:save(state)
clipboards[state.name] = state.clipboard
end

return GlobalBackend
211 changes: 211 additions & 0 deletions lua/neo-tree/clipboard/sync/universal.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
---A backend for the clipboard that uses a file in stdpath('state')/neo-tree.nvim/clipboards/ .. self.filename
---to sync the clipboard between everything.
local BaseBackend = require("neo-tree.clipboard.sync.base")
local log = require("neo-tree.log")
local uv = vim.uv or vim.loop

---@class neotree.clipboard.FileBackend.Opts
---@field source string
---@field dir string
---@field filename string

local clipboard_states_dir = vim.fn.stdpath("state") .. "/neo-tree.nvim/clipboards"
local pid = vim.uv.os_getpid()

---@class (exact) neotree.clipboard.FileBackend.FileFormat
---@field pid integer
---@field time integer
---@field state_name string
---@field contents neotree.clipboard.Contents

---@class neotree.clipboard.FileBackend : neotree.clipboard.Backend
---@field handle uv.uv_fs_event_t
---@field filename string
---@field source string
---@field pid integer
---@field cached_contents neotree.clipboard.Contents
---@field last_time_saved integer
---@field saving boolean
local FileBackend = BaseBackend:new()

---@param filename string
---@return boolean created
---@return string? err
local function file_touch(filename)
if vim.uv.fs_stat(filename) then
return true
end
local dir = vim.fn.fnamemodify(filename, ":h")
local mkdir_ok = vim.fn.mkdir(dir, "p")
if mkdir_ok == 0 then
return false, "couldn't make dir " .. dir
end
local file, file_err = io.open(filename, "a+")
if not file then
return false, file_err
end

local _, write_err = file:write("")
if write_err then
return false, write_err
end

file:flush()
file:close()
return true
end

---@param opts neotree.clipboard.FileBackend.Opts?
---@return neotree.clipboard.FileBackend?
function FileBackend:new(opts)
local backend = {} -- create object if user does not provide one
setmetatable(backend, self)
self.__index = self

-- setup the clipboard file
opts = opts or {}

backend.dir = opts.dir or clipboard_states_dir
local state_source = opts.source or "filesystem"

local filename = ("%s/%s.json"):format(backend.dir, state_source)

local success, err = file_touch(filename)
if not success then
log.error("Could not make shared clipboard file:", clipboard_states_dir, err)
return nil
end

---@cast backend neotree.clipboard.FileBackend
backend.filename = filename
backend.source = state_source
backend.pid = pid
backend:_start()
return backend
end

---@return boolean started true if working
function FileBackend:_start()
if self.handle then
return true
end
local event_handle = uv.new_fs_event()
if event_handle then
self.handle = event_handle
local start_success = event_handle:start(self.filename, {}, function(err, _, fs_events)
local write_time = uv.fs_stat(self.filename).mtime.nsec
if self.last_time_saved == write_time then
end
if err then
event_handle:close()
return
end
require("neo-tree.clipboard").sync_to_clipboards()
-- we should check whether we just wrote or not
end)
log.info("Watching " .. self.filename)
return start_success == 0
else
log.warn("could not watch shared clipboard on file events")
--todo: implement polling?
end
return false
end

local typecheck = require("neo-tree.health.typecheck")
local validate = typecheck.validate

---@param wrapped_clipboard neotree.clipboard.FileBackend.FileFormat
local validate_clipboard_from_file = function(wrapped_clipboard)
return validate("clipboard_from_file", wrapped_clipboard, function(c)
validate("contents", c.contents, "table")
validate("pid", c.pid, "number")
validate("time", c.time, "number")
validate("state_name", c.state_name, "string")
end, false, "Clipboard from file could not be validated")
end

function FileBackend:load(state)
if state.name ~= "filesystem" then
return nil, nil
end
if not file_touch(self.filename) then
return nil, self.filename .. " could not be created"
end

local file, err = io.open(self.filename, "r")
if not file or err then
return nil, self.filename .. " could not be opened"
end
local content = file:read("*a")
file:close()
if vim.trim(content) == "" then
-- not populated yet, just do nothing
return nil, nil
end
---@type boolean, neotree.clipboard.FileBackend.FileFormat|any
local is_success, clipboard_file = pcall(vim.json.decode, content)
if not is_success then
local decode_err = clipboard_file
return nil, "Read failed from shared clipboard file @" .. self.filename .. ":" .. decode_err
end

if not validate_clipboard_from_file(clipboard_file) then
if
require("neo-tree.ui.inputs").confirm(
"Neo-tree clipboard file seems invalid, clear out clipboard?"
)
then
local success, delete_err = os.remove(self.filename)
if not success then
log.error(delete_err)
end

-- try creating a new file without content
state.clipboard = {}
self:save(state)
-- clear the current clipboard
return {}
end
return nil, "could not parse a valid clipboard from clipboard file"
end

return clipboard_file.contents
end

function FileBackend:save(state)
if state.name ~= "filesystem" then
return nil
end

local c = state.clipboard
---@type neotree.clipboard.FileBackend.FileFormat
local wrapped = {
pid = pid,
time = os.time(),
state_name = assert(state.name),
contents = c,
}
if not file_touch(self.filename) then
return false, "couldn't write to " .. self.filename .. self.filename
end
local encode_ok, str = pcall(vim.json.encode, wrapped)
if not encode_ok then
local encode_err = str
return false, "couldn't encode clipboard into json: " .. encode_err
end
local file, err = io.open(self.filename, "w")
if not file or err then
return false, "couldn't open " .. self.filename
end
local _, write_err = file:write(str)
if write_err then
return false, "couldn't write to " .. self.filename
end
file:flush()
file:close()
self.last_time_saved = uv.fs_stat(self.filename).mtime.nsec
return true
end

return FileBackend
Loading
Loading