├── LICENSE ├── README.md ├── doc └── nvim-unception.txt ├── lua ├── client │ └── client.lua ├── common │ └── common_functions.lua ├── server │ ├── server.lua │ └── server_functions.lua └── unception.lua └── plugin └── main.lua /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Samuel Williams 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # nvim-unception 2 | 3 | A plugin that leverages Neovim's built-in `RPC` functionality to simplify 4 | opening files from within Neovim's terminal emulator without nesting sessions. 5 | 6 | Terminal buffers will no longer enter a state of "inception" in which an 7 | instance of Neovim is open within an instance of Neovim. Instead, the desired 8 | files and directories will be opened by the "host" Neovim session, which 9 | leverages `:argadd` to update its own arguments. 10 | 11 | https://user-images.githubusercontent.com/25990267/170632310-8bbee2fa-672b-4385-9dea-7ed4501a0558.mp4 12 | 13 | ## Working with Git 14 | 15 | The best way to work with git from within a terminal buffer is to make git 16 | defer editing to the host session, and block until the host quits the buffer 17 | being edited. This can be done by setting your git `core.editor` to pass the 18 | `g:unception_block_while_host_edits=1` argument 19 | (like 20 | [this](https://github.com/samjwill/dotfiles/blob/ba56af2ff49cd23ac19fcffe7840a78c58a89c9b/.gitconfig#L5)). 21 | Note that the terminal will be blocked and its buffer will be hidden until Neovim's `QuitPre` event is triggered for the commit buffer, after which, the terminal buffer will be restored to its original location. 22 | 23 | Here's an example workflow with this flag set: 24 | 25 | https://user-images.githubusercontent.com/25990267/208282262-594b5693-8166-414b-9695-63fc02d3c25f.mp4 26 | 27 | Alternatively, if you would like to be able to edit using Neovim directly 28 | inside of a nested session, you can disable unception altogether by setting 29 | your git `core.editor` to pass the `g:unception_disable=1` argument (like 30 | [this](https://github.com/samjwill/dotfiles/blob/c59477c47867fb8f5560ba01d17722443428bc7e/.gitconfig#L5)). 31 | 32 | Lastly, setting your `core.editor` to another file editor, such as GNU nano would also work. 33 | 34 | ## Requirements 35 | 36 | Neovim 0.7 or later. 37 | 38 | ## Installation 39 | 40 | #### Using [lazy.nvim](https://github.com/folke/lazy.nvim): 41 | return { 42 | "samjwill/nvim-unception", 43 | init = function() 44 | -- Optional settings go here! 45 | -- e.g.) vim.g.unception_open_buffer_in_new_tab = true 46 | end 47 | } 48 | 49 | #### Using [vim-plug](https://github.com/junegunn/vim-plug): 50 | 51 | Plug 'samjwill/nvim-unception' 52 | 53 | ## Settings 54 | 55 | For usage details and additional options (such as opening the file buffers in 56 | new tabs rather than the current window), see 57 | [doc/nvim-unception.txt](https://github.com/samjwill/nvim-unception/blob/main/doc/nvim-unception.txt), 58 | or, after installation, run `:help nvim-unception`. 59 | 60 | ## Can this work with terminal-toggling plugins? 61 | 62 | Yep! See the [wiki](https://github.com/samjwill/nvim-unception/wiki) for setup info. 63 | 64 | ## How does it work? 65 | 66 | The plugin tells Neovim to automatically start a local server listening to a 67 | named pipe at launch. Upon launching a new Neovim session within a terminal 68 | emulator buffer, the arguments are forwarded to the aforementioned Neovim 69 | server session via the pipe, and the server session replaces the buffer under 70 | the cursor (the terminal buffer) with the first file/directory argument 71 | specified. 72 | 73 | ## Limitations 74 | 75 | This plugin works well enough for me but your mileage may vary. If you 76 | find an issue, feel free to create one detailing the problem on the 77 | GitHub repo, and I'll try to fix it if I'm able. If you run into a 78 | problem, Unception can be temporarily disabled when launching Neovim 79 | like so: 80 | `nvim --cmd "let g:unception_disable=1"` 81 | 82 | Other Neovim command-line arguments that do not involve editing a file or 83 | directory may not work as expected from *within* the terminal emulator (e.g. 84 | passing `-b` to edit in binary mode when inside of a terminal buffer will not 85 | propagate binary mode to the file when it's unnested, and opening a file as 86 | read-only when the server session is not set to read-only mode will not result 87 | in a read-only buffer). See `:help vim-arguments` for how these are typically 88 | used. Note that any arguments that might not work when launched from within a 89 | Neovim terminal buffer should work just fine when launching Neovim normally. 90 | They should also behave as as they do by default if you pass the disable flag 91 | described above, even if launched from within a terminal buffer. 92 | -------------------------------------------------------------------------------- /doc/nvim-unception.txt: -------------------------------------------------------------------------------- 1 | nvim-unception.txt 2 | *nvim-unception* 3 | INTRODUCTION *unception* 4 | 5 | A plugin that leverages Neovim's built-in |RPC| functionality to 6 | simplify opening files from within Neovim's |terminal| emulator without 7 | unintentionally nesting sessions. 8 | 9 | Terminal buffers will no longer enter a state of "inception" in which 10 | an instance of Neovim is open within an instance of Neovim. Instead, the 11 | desired files and directories will be opened by the "host" Neovim session, 12 | which leverages |:argadd| to update its own arguments. 13 | 14 | SETTINGS *nvim-unception-settings* 15 | 16 | IMPORTANT: The nested Neovim session (i.e. the Neovim session that is 17 | launched from within a terminal buffer) is the session that dictates this 18 | plugin's behavior. These settings must be read into the nested session. 19 | What this means is that you cannot set these options in the Neovim 20 | server/host session alone after Neovim is already running. These values 21 | must be set sometime during initialization, before the nested session 22 | loads the plugin, and likely cannot be lazy loaded. 23 | 24 | To put it simply, these settings can be placed in your initialization 25 | files (e.g. |init.vim|), but will not work if directly calling |:let| in 26 | the server session after you've already launched Neovim. 27 | 28 | *g:unception_delete_replaced_buffer* bool (default=false) 29 | 30 | Must be set during initialization. Cannot be set after Neovim is 31 | already running. See |nvim-unception-settings| for more info. 32 | 33 | When true, causes the Neovim session running the local server to 34 | delete the buffer under the cursor when a command is received from a 35 | Neovim client session (i.e. a session launched from within a terminal 36 | buffer) if that buffer is not visible in any other windows. 37 | 38 | Has no effect when |g:unception_open_buffer_in_new_tab| is true. 39 | 40 | Note: The deleted buffer should always be the terminal buffer that was 41 | used to launch the new Neovim session, but there are no checks in place 42 | that mandate that this will always be the case. If the cursor was somehow 43 | moved quickly enough to another terminal buffer between the time the client 44 | sends the command and the server receives it, this could technically cause 45 | deletion of the wrong buffer. 46 | 47 | 48 | *g:unception_open_buffer_in_new_tab* bool (default=false) 49 | 50 | Must be set during initialization. Cannot be set after Neovim is 51 | already running. See |nvim-unception-settings| for more info. 52 | 53 | When true, causes the server Neovim session to open files/directories 54 | in a new tab rather than placing them in the window that has focus. 55 | 56 | 57 | *g:unception_enable_flavor_text* bool (default=true) 58 | 59 | Must be set during initialization. Cannot be set after Neovim is 60 | already running. See |nvim-unception-settings| for more info. 61 | 62 | When true, causes unception to echo a message whenever it is 63 | triggered. 64 | 65 | 66 | *g:unception_block_while_host_edits* bool (default=false) 67 | 68 | Must be set during initialization. Cannot be set after Neovim is 69 | already running. See |nvim-unception-settings| for more info. 70 | 71 | When true, if unception detects that the session of Neovim has been 72 | launched from within a terminal buffer, the file passed as an argument 73 | will be opened in the host session, and the terminal will be blocked until 74 | |QuitPre| is triggered on the buffer. Note that only a single, filepath 75 | argument may be passed when this is enabled. 76 | 77 | If |g:unception_open_buffer_in_new_tab| is true, the new buffer will 78 | be opened in a new tab rather than in the current window. If false, the 79 | new buffer will be opened in the current window, making the buffer under 80 | the cursor hidden, and restoring its visibility to the current window when 81 | |QuitPre| is triggered. 82 | 83 | This can be useful for command-line tools that are expected to launch 84 | an editor, such as git. By setting the core editor to: 85 | `nvim --cmd 'let g:unception_block_while_host_edits=1'` 86 | one can edit their commit messages launched from a terminal buffer 87 | directly in the host session. 88 | 89 | 90 | *g:unception_disable* bool (default=false) 91 | 92 | When true, disables nvim-unception. 93 | 94 | 95 | EVENTS *nvim-unception-events* 96 | 97 | *UnceptionEditRequestReceived* 98 | 99 | A |User| event that is triggered when a request to edit a 100 | file/directory is received from a nested Neovim session, launched from a 101 | terminal buffer. 102 | 103 | Below is an example of Lua code that creates an |autocmd| that would 104 | print "Hello world!" whenever this event is triggered: 105 | > 106 | vim.api.nvim_create_autocmd( 107 | "User", 108 | { 109 | pattern = "UnceptionEditRequestReceived", 110 | callback = function() 111 | print("Hello world!") 112 | end 113 | } 114 | ) 115 | < 116 | 117 | vim:ft=help:norl: 118 | -------------------------------------------------------------------------------- /lua/client/client.lua: -------------------------------------------------------------------------------- 1 | require("common.common_functions") 2 | 3 | -- We don't want to overwrite :h shada 4 | vim.o.sdf = "NONE" 5 | 6 | -- We don't want to start. Send the args to the server instance instead. 7 | local args = vim.call("argv") 8 | 9 | local arg_str = "" 10 | for index, iter in pairs(args) do 11 | local absolute_filepath = unception_get_absolute_filepath(iter) 12 | 13 | if (string.len(arg_str) == 0) then 14 | arg_str = unception_escape_special_chars(absolute_filepath) 15 | else 16 | arg_str = arg_str.." "..unception_escape_special_chars(absolute_filepath) 17 | end 18 | end 19 | 20 | -- Send messages to host on existing pipe. 21 | local sock = vim.fn.sockconnect("pipe", os.getenv(unception_pipe_path_host_env_var), {rpc = true}) 22 | local edit_files_call = "unception_edit_files(" 23 | .."\""..arg_str.."\", " 24 | ..#args..", " 25 | ..vim.inspect(vim.g.unception_open_buffer_in_new_tab)..", " 26 | ..vim.inspect(vim.g.unception_delete_replaced_buffer)..", " 27 | ..vim.inspect(vim.g.unception_enable_flavor_text)..")" 28 | vim.fn.rpcrequest(sock, "nvim_exec_lua", edit_files_call, {}) 29 | 30 | if (not vim.g.unception_block_while_host_edits) then 31 | -- Our work here is done. Kill the nvim session that would have started otherwise. 32 | vim.fn.chanclose(sock) 33 | 34 | if (not vim.g.unception_delete_replaced_buffer) then 35 | -- TODO: Try removing this conditional when Neovim core gets updated. 36 | -- "qall!" should always be called here, regardless of whether 37 | -- unception_delete_replaced_buffer is true. 38 | -- 39 | -- See issue #60 in GitHub. Looks like there might be a bug in Neovim 40 | -- core that can ocassionally cause a segfault when deleting a terminal 41 | -- buffer? In any case, not exiting here appears to rectify the 42 | -- behavior, but it is a band-aid. 43 | vim.cmd("qall!") 44 | end 45 | 46 | return 47 | end 48 | 49 | -- Start up a pipe so that the client can listen for a response from the host session. 50 | local nested_pipe_path = vim.call("serverstart") 51 | 52 | -- Send the pipe path and edited filepath to the host so that it knows what file to look for and who to respond to. 53 | local notify_when_done_call = "unception_notify_when_done_editing(" 54 | ..vim.inspect(nested_pipe_path).."," 55 | ..vim.inspect(arg_str)..")" 56 | vim.fn.rpcnotify(sock, "nvim_exec_lua", notify_when_done_call, {}) 57 | 58 | -- Sleep forever. The host session will kill this when it's done editing. 59 | while (true) 60 | do 61 | vim.cmd("sleep 10") 62 | end 63 | 64 | -------------------------------------------------------------------------------- /lua/common/common_functions.lua: -------------------------------------------------------------------------------- 1 | function _G.unception_get_absolute_filepath(relative_path) 2 | local absolute_path = vim.loop.fs_realpath(relative_path) 3 | 4 | -- File doesn't exist (yet) 5 | if(absolute_path == nil) then 6 | 7 | -- User did specify a filepath 8 | if (string.len(relative_path) > 0) then 9 | local pos_of_last_file_separator = 0 10 | for i = 1, string.len(relative_path) do 11 | local char = string.sub(relative_path, i, i) 12 | if (char == "/") then 13 | pos_of_last_file_separator = i 14 | end 15 | end 16 | 17 | local dir_path = string.sub(relative_path, 0, pos_of_last_file_separator) 18 | if (string.len(dir_path) == 0) then 19 | dir_path = "." 20 | end 21 | dir_path = vim.loop.fs_realpath(dir_path) 22 | 23 | if (dir_path == nil) then 24 | -- Don't try to resolve it. Just leave it be. It could be a path like "term://". 25 | absolute_path = relative_path 26 | else 27 | local filename = string.sub(relative_path, pos_of_last_file_separator + 1, string.len(relative_path)) 28 | absolute_path = dir_path.."/"..filename 29 | end 30 | end 31 | end 32 | 33 | return absolute_path 34 | end 35 | 36 | function _G.unception_escape_special_chars(str) 37 | if (str ~= nil) then 38 | -- Need to escape backslashes and quotes in case they are part of the 39 | -- filepaths. Lua needs \\ to define a \, so to escape special chars, 40 | -- there are twice as many backslashes as you would think that there 41 | -- should be. 42 | str = string.gsub(str, "\\", "\\\\\\\\") 43 | str = string.gsub(str, "\"", "\\\\\\\"") 44 | str = string.gsub(str, " ", "\\\\ ") 45 | return str 46 | else 47 | return "" 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /lua/server/server.lua: -------------------------------------------------------------------------------- 1 | require("server.server_functions") 2 | 3 | local new_server_pipe_path = vim.call("serverstart") 4 | vim.call("setenv", unception_pipe_path_host_env_var, new_server_pipe_path) 5 | 6 | -------------------------------------------------------------------------------- /lua/server/server_functions.lua: -------------------------------------------------------------------------------- 1 | require("common.common_functions") 2 | 3 | local response_sock = nil 4 | local unception_quitpre_autocmd_id = nil 5 | local unception_bufunload_autocmd_id = nil 6 | local filepath_to_check = nil 7 | local blocked_terminal_buffer_id = nil 8 | local last_replaced_buffer_id = nil 9 | 10 | local function unblock_client_and_reset_state() 11 | -- Remove the autocmds we made. 12 | vim.api.nvim_del_autocmd(unception_quitpre_autocmd_id) 13 | vim.api.nvim_del_autocmd(unception_bufunload_autocmd_id) 14 | 15 | -- Unblock client by killing its editor session. 16 | vim.fn.rpcnotify(response_sock, "nvim_exec_lua", "vim.cmd('quit')", {}) 17 | vim.fn.chanclose(response_sock) 18 | 19 | -- Reset state-sensitive variables. 20 | response_sock = nil 21 | unception_quitpre_autocmd_id = nil 22 | unception_bufunload_autocmd_id = nil 23 | filepath_to_check = nil 24 | blocked_terminal_buffer_id = nil 25 | last_replaced_buffer_id = nil 26 | end 27 | 28 | function _G.unception_handle_bufunload(unloaded_buffer_filepath) 29 | unloaded_buffer_filepath = unception_get_absolute_filepath(unloaded_buffer_filepath) 30 | unloaded_buffer_filepath = unception_escape_special_chars(unloaded_buffer_filepath) 31 | 32 | if (unloaded_buffer_filepath == filepath_to_check) then 33 | unblock_client_and_reset_state() 34 | end 35 | end 36 | 37 | function _G.unception_handle_quitpre(quitpre_buffer_filepath) 38 | quitpre_buffer_filepath = unception_get_absolute_filepath(quitpre_buffer_filepath) 39 | quitpre_buffer_filepath = unception_escape_special_chars(quitpre_buffer_filepath) 40 | 41 | if (quitpre_buffer_filepath == filepath_to_check) then 42 | -- If this buffer replaced the blocked terminal buffer, we should restore it to the same window. 43 | if (blocked_terminal_buffer_id ~= nil and vim.fn.bufexists(blocked_terminal_buffer_id) == 1) then 44 | vim.cmd("split") -- Open a new window and switch focus to it. 45 | vim.cmd("buffer " .. blocked_terminal_buffer_id) -- Set the buffer for that window to the buffer that was replaced. 46 | vim.cmd("wincmd x") -- Navigate to previous (initial) window, and proceed with quitting. 47 | end 48 | 49 | unblock_client_and_reset_state() 50 | end 51 | end 52 | 53 | function _G.unception_notify_when_done_editing(pipe_to_respond_on, filepath) 54 | filepath_to_check = filepath 55 | blocked_terminal_buffer_id = last_replaced_buffer_id 56 | response_sock = vim.fn.sockconnect("pipe", pipe_to_respond_on, {rpc = true}) 57 | unception_quitpre_autocmd_id = vim.api.nvim_create_autocmd("QuitPre",{ command = "lua unception_handle_quitpre(vim.fn.expand(':p'))"}) 58 | 59 | -- Create an autocmd for BufUnload as a failsafe should QuitPre not get triggered on the target buffer (e.g. if a user runs :bdelete). 60 | unception_bufunload_autocmd_id = vim.api.nvim_create_autocmd("BufUnload",{ command = "lua unception_handle_bufunload(vim.fn.expand(':p'))"}) 61 | end 62 | 63 | function _G.unception_edit_files(file_args, num_files_in_list, open_in_new_tab, delete_replaced_buffer, enable_flavor_text) 64 | vim.api.nvim_exec_autocmds("User", {pattern = "UnceptionEditRequestReceived"}) 65 | 66 | -- log buffer number so that we can delete it later. We don't want a ton of 67 | -- running terminal buffers in the background when we switch to a new nvim buffer. 68 | local tmp_buf_number = vim.fn.bufnr() 69 | 70 | -- If there aren't arguments, we just want a new, empty buffer, but if 71 | -- there are, append them to the host Neovim session's arguments list. 72 | if (num_files_in_list > 0) then 73 | -- Had some issues when using argedit. Explicitly calling these 74 | -- separately appears to work though. 75 | vim.cmd("0argadd "..file_args) 76 | 77 | if (open_in_new_tab) then 78 | last_replaced_buffer_id = nil 79 | vim.cmd("tab argument 1") 80 | else 81 | last_replaced_buffer_id = vim.fn.bufnr() 82 | vim.cmd("argument 1") 83 | end 84 | 85 | -- This is kind of stupid, but basically, it appears that Neovim may 86 | -- not always properly handle opening buffers using the method 87 | -- above(?), notably if it's opening directly to a directory using 88 | -- netrw. Calling "edit" here appears to give it another chance to 89 | -- properly handle opening the buffer; otherwise it can occasionally 90 | -- segfault. 91 | vim.cmd("edit") 92 | else 93 | if (open_in_new_tab) then 94 | last_replaced_buffer_id = nil 95 | vim.cmd("tabnew") 96 | else 97 | last_replaced_buffer_id = vim.fn.bufnr() 98 | vim.cmd("enew") 99 | end 100 | end 101 | 102 | -- We don't want to delete the replaced buffer if there wasn't a replaced buffer. 103 | if (delete_replaced_buffer and last_replaced_buffer_id ~= nil) then 104 | if (vim.fn.len(vim.fn.win_findbuf(tmp_buf_number)) == 0 and string.sub(vim.api.nvim_buf_get_name(tmp_buf_number), 1, 7) == "term://") then 105 | vim.cmd("bdelete! "..tmp_buf_number) 106 | end 107 | end 108 | 109 | if (enable_flavor_text) then 110 | print("Unception prevented inception!") 111 | end 112 | end 113 | 114 | -------------------------------------------------------------------------------- /lua/unception.lua: -------------------------------------------------------------------------------- 1 | unception_pipe_path_host_env_var = "NVIM_UNCEPTION_PIPE_PATH_HOST" 2 | local in_terminal_buffer = (os.getenv(unception_pipe_path_host_env_var) ~= nil) 3 | 4 | if in_terminal_buffer then 5 | require("client.client") 6 | else 7 | require("server.server") 8 | end 9 | 10 | -------------------------------------------------------------------------------- /plugin/main.lua: -------------------------------------------------------------------------------- 1 | ------------------------------------------------------------------------------- 2 | -- Initialize all expected variables 3 | ------------------------------------------------------------------------------- 4 | if(vim.g.unception_delete_replaced_buffer == nil) then 5 | vim.g.unception_delete_replaced_buffer = false 6 | end 7 | 8 | if(vim.g.unception_open_buffer_in_new_tab == nil) then 9 | vim.g.unception_open_buffer_in_new_tab = false 10 | end 11 | 12 | if (vim.g.unception_enable_flavor_text == nil) then 13 | vim.g.unception_enable_flavor_text = true 14 | end 15 | 16 | if (vim.g.unception_block_while_host_edits == nil) then 17 | vim.g.unception_block_while_host_edits = false 18 | end 19 | 20 | -- Can't allow buffer holding terminal to be deleted. 21 | if (vim.g.unception_block_while_host_edits) then 22 | vim.g.unception_delete_replaced_buffer = false 23 | end 24 | 25 | if (vim.g.unception_disable == nil) then 26 | vim.g.unception_disable = false 27 | end 28 | 29 | -- If in ":h --headless" or ":h --embed" mode, a UI is not desired, so don't 30 | -- run the plugin. 31 | if (0 == #vim.api.nvim_list_uis()) then 32 | vim.g.unception_disable = true 33 | end 34 | 35 | -- Version check to ensure that necessary features are available. 36 | if (1 ~= vim.fn.has("nvim-0.7.0")) then 37 | vim.api.nvim_err_writeln("Unception requires Neovim 0.7 or later.") 38 | vim.g.unception_disable = true 39 | end 40 | 41 | -- It's invalid to try to block as a client if there is more than one argument. 42 | if (vim.g.unception_block_while_host_edits and (os.getenv("NVIM") ~= nil) and (#(vim.call("argv")) ~= 1)) then 43 | vim.api.nvim_err_writeln("Must have exactly 1 argument when g:unception_block_while_host_edits is enabled!") 44 | vim.g.unception_disable = true 45 | end 46 | 47 | ------------------------------------------------------------------------------- 48 | -- Handle early exit if necessary 49 | ------------------------------------------------------------------------------- 50 | if (vim.g.unception_disable) then 51 | return 52 | end 53 | 54 | ------------------------------------------------------------------------------- 55 | -- We're good to go. Start the main plugin logic. 56 | ------------------------------------------------------------------------------- 57 | require("unception") 58 | 59 | --------------------------------------------------------------------------------