Skip to content

feat(filesystem): Implement shared clipboard #1594

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 4 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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ 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 copy and cut files over many neovim instances (set `filesystem.shared_clipboard=true`)
- 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
1 change: 1 addition & 0 deletions lua/neo-tree/defaults.lua
Original file line number Diff line number Diff line change
Expand Up @@ -453,6 +453,7 @@ local config = {
async_directory_scan = "auto", -- "auto" means refreshes are async, but it's synchronous when called from the Neotree commands.
-- "always" means directory scans are always async.
-- "never" means directory scans are never async.
shared_clipboard = false, -- Enabling this feature allows you to copy and cut files over different instances of neovim
scan_mode = "shallow", -- "shallow": Don't scan into directories to detect possible empty directory a priori
-- "deep": Scan into directories to detect empty or grouped empty directories a priori.
bind_to_cwd = true, -- true creates a 2-way binding between vim's cwd and neo-tree's root
Expand Down
12 changes: 12 additions & 0 deletions lua/neo-tree/sources/filesystem/commands.lua
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ local utils = require("neo-tree.utils")
local filter = require("neo-tree.sources.filesystem.lib.filter")
local renderer = require("neo-tree.ui.renderer")
local log = require("neo-tree.log")
local shared_clipboard = require("neo-tree.sources.filesystem.lib.shared_clipboard")

local M = {}
local refresh = function(state)
Expand All @@ -16,6 +17,12 @@ local redraw = function(state)
renderer.redraw(state)
end

local sync_with_shared_clipboard = function(state)
if state.shared_clipboard then
shared_clipboard.save_clipboard(state.clipboard)
end
end

M.add = function(state)
cc.add(state, utils.wrap(fs.show_new_children, state))
end
Expand All @@ -35,19 +42,23 @@ end
---Marks node as copied, so that it can be pasted somewhere else.
M.copy_to_clipboard = function(state)
cc.copy_to_clipboard(state, utils.wrap(redraw, state))
sync_with_shared_clipboard(state)
end

M.copy_to_clipboard_visual = function(state, selected_nodes)
cc.copy_to_clipboard_visual(state, selected_nodes, utils.wrap(redraw, state))
sync_with_shared_clipboard(state)
end

---Marks node as cut, so that it can be pasted (moved) somewhere else.
M.cut_to_clipboard = function(state)
cc.cut_to_clipboard(state, utils.wrap(redraw, state))
sync_with_shared_clipboard(state)
end

M.cut_to_clipboard_visual = function(state, selected_nodes)
cc.cut_to_clipboard_visual(state, selected_nodes, utils.wrap(redraw, state))
sync_with_shared_clipboard(state)
end

M.move = function(state)
Expand All @@ -57,6 +68,7 @@ end
---Pastes all items from the clipboard to the current directory.
M.paste_from_clipboard = function(state)
cc.paste_from_clipboard(state, utils.wrap(fs.show_new_children, state))
sync_with_shared_clipboard(state)
end

M.delete = function(state)
Expand Down
4 changes: 4 additions & 0 deletions lua/neo-tree/sources/filesystem/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,10 @@ M.setup = function(config, global_config)
end,
})
end

if config.shared_clipboard then
require("neo-tree.sources.filesystem.lib.shared_clipboard").init()
end
end

---Expands or collapses the current node.
Expand Down
79 changes: 79 additions & 0 deletions lua/neo-tree/sources/filesystem/lib/shared_clipboard.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
local fs_watch = require("neo-tree.sources.filesystem.lib.fs_watch")
local manager = require("neo-tree.sources.manager")
local events = require("neo-tree.events")
local renderer = require("neo-tree.ui.renderer")
local log = require("neo-tree.log")

local M = {}

local clipboard_state_dir_path = vim.fn.stdpath("state") .. "/neo-tree/"
local clipboard_file_path = clipboard_state_dir_path .. "filesystem-clipboard.json"
local clipboard_file_change_triggered_by_cur_neovim_instance = false

M.save_clipboard = function(clipboard)
local file = io.open(clipboard_file_path, "w+")
-- We want to erase data in the file if clipboard is nil instead writing null
if not clipboard or not file then
return
end

local is_success, data = pcall(vim.json.encode, clipboard)
if not is_success then
log.error("Failed to save clipboard. JSON serialization error")
return
end
file:write(data)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I actually meant to put the error handling n the file:write / file:flush code. That's where you are going to get frequent errors due to permissions, file locked, disk full, etc.

file:flush()
M._update_all_cilpboards(clipboard)
clipboard_file_change_triggered_by_cur_neovim_instance = true
end

M._load_clipboard = function()
local file = io.open(clipboard_file_path, "r")
if not file then
return nil
end
local content = file:read("*a")
local is_success, clipboard = pcall(vim.json.decode, content)
if not is_success then
return nil
end
return clipboard
end

M._update_all_cilpboards = function(clipboard)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Typo:

Suggested change
M._update_all_cilpboards = function(clipboard)
M._update_all_clipboards = function(clipboard)

manager._for_each_state("filesystem", function(state)
state.clipboard = clipboard
vim.schedule(function()
renderer.redraw(state)
end)
end)
end

M.init = function()
if vim.fn.isdirectory(clipboard_state_dir_path) == 0 then
vim.fn.mkdir(clipboard_state_dir_path)
end

events.subscribe({
event = events.STATE_CREATED,
handler = function(state)
if state.name ~= "filesystem" then
return
end
vim.schedule(function()
M._update_all_cilpboards(M._load_clipboard())
end)
end,
})

-- Using watch_folder because we haven't "watch_file" :)
fs_watch.watch_folder(clipboard_state_dir_path, function()
if not clipboard_file_change_triggered_by_cur_neovim_instance then
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a bad idea. You can't say for sure that a file event came from this instance just because you wrote a file recently. This is fertile ground for hard to recreate bugs.

Between this and the modified time based check I would rather use the first solution where you just process every write, even your own.

I think I have a better idea:

  1. When you convert the clipboard to json, save the json string as last_clipboard_saved
  2. When you read in a new changed file event, compare the contents of the changed file to the last_clipboard_saved
  3. If they are equal, nil out the last_clipboard_saved and ignore the event.

Copy link
Author

@miroshQa miroshQa Nov 3, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually I can't understand why we can't be sure about this. Can you give me an example, please?

  1. The variable clipboard_file_change_triggered_by_cur_neovim_instance is set in the save_clipboard function after writing the clipboard to the file
  2. After that an event is immediately triggered in response to the file change.

Why can't we be sure that the write occurred in the current instance? We are not performing any other actions between these two steps, are we?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We do not have exclusive access to this folder. Another program could write a file in this folder for some reason.

For that matter, neo-tree itself might write a file here. It doesn't do that now, but someone else may do so in the future and they likely won't know that some other section of the code is naively reading every file written without checking the contents or even the file name.

Finally, I don't think you should be so confident about the order of events when you are dealing with asynchronous actions.

M._update_all_cilpboards(M._load_clipboard())
end
clipboard_file_change_triggered_by_cur_neovim_instance = false
end, true)
end

return M