Skip to content
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,8 @@ features.
| Support level | OS |
| ----------------------- | -------------------------- |
| ✅ **Supported** | Linux, MacOS, FreeBSD [^2] |
| 🟡 **Not supported yet** | Windows, WSL |
| 🟡 **Experimentally supported** | Windows |
| 🟡 **Not supported yet** | WSL |

### Local machine 💻

Expand Down Expand Up @@ -581,7 +582,7 @@ This plugins provide some additional nice-to have features on top:
- Can copy over your local Neovim configuration to remote
- Allows easy re-connection to past sessions
- Makes it easy to clean up remote machine changes once you are done
- It launches Neovim server on the remote server and connects a UI to it locally.
- It launches Neovim server on the remote server and connects a UI to it locally.

You can read more in [this Neovim discussion](https://github.com/amitds1997/remote-nvim.nvim/discussions/145)

Expand Down
39 changes: 33 additions & 6 deletions lua/remote-nvim/providers/devpod/devpod_provider.lua
Original file line number Diff line number Diff line change
Expand Up @@ -156,12 +156,39 @@ end

function DevpodProvider:_handle_provider_setup()
if self._devpod_provider then
self.local_provider:run_command(
("%s provider list --output json"):format(remote_nvim.config.devpod.binary),
("Checking if the %s provider is present"):format(self._devpod_provider)
)
local stdout = self.local_provider.executor:job_stdout()
local provider_list_output = vim.json.decode(vim.tbl_isempty(stdout) and "{}" or table.concat(stdout, "\n"))
local co = coroutine.running()
---@type string[]
local stdout_lines = {}

if utils.is_windows then
require("plenary.job")
:new({
command = remote_nvim.config.devpod.binary,
args = { "provider", "list", "--output", "json" },
on_exit = function(j, _)
vim.schedule(function()
stdout_lines = j:result()
if co ~= nil then
coroutine.resume(co)
end
end)
end,
})
:start()
else
self.local_provider:run_command(
("%s provider list --output json"):format(remote_nvim.config.devpod.binary),
("Checking if the %s provider is present"):format(self._devpod_provider)
)
stdout_lines = self.local_provider.executor:job_stdout()
end

if co ~= nil then
coroutine.yield()
end

local provider_list_output =
vim.json.decode(vim.tbl_isempty(stdout_lines) and "{}" or table.concat(stdout_lines, "\n"))

-- If the provider does not exist, let's create it
if not vim.tbl_contains(vim.tbl_keys(provider_list_output), self._devpod_provider) then
Expand Down
13 changes: 12 additions & 1 deletion lua/remote-nvim/providers/executor.lua
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
---@field protected _job_stdout string[] Job output (if job is running)
local Executor = require("remote-nvim.middleclass")("Executor")

local utils = require("remote-nvim.utils")

---@class remote-nvim.provider.Executor.JobOpts.CompressionOpts
---@field enabled boolean Apply compression
---@field additional_opts? string[] Additional options to pass to the `tar` command. See `man tar` for possible options
Expand Down Expand Up @@ -76,10 +78,19 @@ function Executor:run_executor_job(command, job_opts)

self:reset() -- Reset job internal state variables
self._job_id = vim.fn.jobstart(command, {
pty = true,
-- Windows ConPTY can corrupt job I/O with its own escape sequences; skip pty there.
-- Job id stays a normal nvim job channel either way, so jobwait/jobstop/nvim_chan_send
-- are unaffected.
pty = not utils.is_windows,
on_stdout = function(_, data_chunk)
self:process_stdout(data_chunk, job_opts.stdout_cb)
end,
-- Without a pty, stdout/stderr are separate streams (pty merges them into on_stdout).
-- SSH sends most diagnostics (prompts, host key checks, auth errors) to stderr, so
-- route it the same way or that output and prompt matching silently break.
on_stderr = function(_, data_chunk)
self:process_stdout(data_chunk, job_opts.stdout_cb)
end,
on_exit = function(_, exit_code)
self:process_job_completion(exit_code)
if job_opts.exit_cb ~= nil then
Expand Down
80 changes: 64 additions & 16 deletions lua/remote-nvim/providers/provider.lua
Original file line number Diff line number Diff line change
Expand Up @@ -587,7 +587,13 @@ function Provider:_setup_remote()
)
end

local default_script_dir = vim.fn.fnamemodify(remote_nvim.default_opts.neovim_install_script_path, ":h:p")
-- `fnamemodify` uses backslashes on Windows but `vim.fs.find`/`:p` always use
-- forward slashes; normalize so the prefix stripping below actually matches.
-- `win` is explicit since `utils.is_windows` also covers win32unix.
local default_script_dir = vim.fs.normalize(
vim.fn.fnamemodify(remote_nvim.default_opts.neovim_install_script_path, ":h:p"),
{ win = utils.is_windows }
)
if not default_script_dir:match("/$") then
default_script_dir = default_script_dir .. "/"
end
Expand All @@ -601,9 +607,9 @@ function Provider:_setup_remote()
})
local paths_to_chmod = {}
for _, path in ipairs(all_scripts) do
local filepath = vim.fn.fnamemodify(path, ":p")
local filepath = vim.fs.normalize(vim.fn.fnamemodify(path, ":p"), { win = utils.is_windows })
local relative_path = filepath:gsub("^" .. vim.pesc(default_script_dir), "")
local remote_script_path = utils.path_join(utils.is_windows, self._remote_scripts_path, relative_path)
local remote_script_path = utils.path_join(self._remote_is_windows, self._remote_scripts_path, relative_path)
table.insert(paths_to_chmod, remote_script_path)
end

Expand Down Expand Up @@ -796,29 +802,71 @@ end
---@private
---Wait until the server is ready
function Provider:_wait_for_server_to_be_ready()
local cmd = ("nvim --server localhost:%s --remote-send ':lua vim.g.remote_neovim_host=true<CR>'"):format(
self._local_free_port
)
-- `--headless` is required: on connect failure, `nvim --server`/`--remote-send`
-- falls through to starting a normal UI instead of just erroring out, which as a
-- background probe job has nowhere sane to attach (fights the host console for
-- input on Windows).
local remote_send_args = {
"--headless",
"--server",
("localhost:%s"):format(self._local_free_port),
"--remote-send",
":lua vim.g.remote_neovim_host=true<CR>",
}
local timeout = 20000 -- Wait for max 20 seconds for server to get ready

local timer = utils.uv.new_timer()
assert(timer ~= nil, "Timer object should not be nil")

local co = coroutine.running()
local function probe_server_readiness()
-- This is synchronous but that's fine because the command we are running should immediately return
local res = vim.fn.system(cmd)
if res == "" then
timer:stop()
timer:close()
if co ~= nil and coroutine.status(co) == "suspended" then
coroutine.resume(co)
end
else
vim.defer_fn(probe_server_readiness, 2000)
if utils.is_windows then
-- `vim.fn.system` runs strings through cmd.exe on Windows, which doesn't group
-- single-quoted args, splitting `--remote-send`'s argument on whitespace. Use
-- plenary.job to invoke `nvim` directly instead (no shell), like the Windows
-- branch in DevpodProvider:_handle_provider_setup.
require("plenary.job")
:new({
command = "nvim",
args = remote_send_args,
on_exit = function(j, _)
vim.schedule(function()
local res = table.concat(j:result(), "\n") .. table.concat(j:stderr_result(), "\n")
if res == "" then
timer:stop()
timer:close()
if co ~= nil and coroutine.status(co) == "suspended" then
coroutine.resume(co)
end
else
vim.defer_fn(probe_server_readiness, 2000)
end
end)
end,
})
:start()
if co ~= nil and coroutine.status(co) == "running" then
coroutine.yield(co)
end
else
-- This is synchronous but that's fine because the command we are running should immediately return
local res = vim.fn.system(
("nvim --headless --server localhost:%s --remote-send ':lua vim.g.remote_neovim_host=true<CR>'"):format(
self._local_free_port
)
)
if res == "" then
timer:stop()
timer:close()
if co ~= nil and coroutine.status(co) == "suspended" then
coroutine.resume(co)
end
else
vim.defer_fn(probe_server_readiness, 2000)
if co ~= nil and coroutine.status(co) == "running" then
coroutine.yield(co)
end
end
end
end

Expand Down
38 changes: 38 additions & 0 deletions lua/remote-nvim/ui/progressview.lua
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ local Split = require("nui.split")
local hl_groups = require("remote-nvim.colors").hl_groups
---@type remote-nvim.RemoteNeovim
local remote_nvim = require("remote-nvim")
local utils = require("remote-nvim.utils")

---@alias progress_view_node_type "run_node"|"section_node"|"command_node"|"stdout_node"
---@alias session_node_type "local_node"|"remote_node"|"config_node"|"root_node"|"info_node"
Expand Down Expand Up @@ -184,6 +185,8 @@ end

---Show the progress viewer
function ProgressView:show()
self:_ensure_progress_view_buffer()

-- Update layout because progressview internally holds the window ID relative to which
-- it should create the split/popup in case of rel="win". If it no longer exists, it
-- will throw an error. So, we update the layout to get the latest window ID.
Expand Down Expand Up @@ -274,6 +277,8 @@ end
---@param title string Title for the run
---@return NuiTree.Node run_node Created run node
function ProgressView:start_run(title)
self:_ensure_progress_view_buffer()

local run_node = self:add_progress_node({
text = title,
type = "run_node",
Expand All @@ -283,6 +288,39 @@ function ProgressView:start_run(title)
return run_node
end

---@private
---On Windows, something outside the plugin (another buffer-cleanup plugin/autocmd,
---or retrying a launch after a previous run failed) can delete the progress view's
---scratch buffer without going through nui's `unmount` lifecycle, leaving
---`self.progress_view` holding a stale bufnr that crashes the next tree render with "Invalid buffer id".
---This has only been observed on Windows, so the recovery is scoped to it.
function ProgressView:_ensure_progress_view_buffer()
local is_windows = utils.is_windows
if not is_windows then
return
end

if self.progress_view.bufnr == nil or not vim.api.nvim_buf_is_valid(self.progress_view.bufnr) then
self.progress_view:unmount()
-- `unmount` only clears `bufnr` if it thought it was mounted; force it so the
-- upcoming `mount` call always creates a fresh buffer instead of reusing the
-- stale (now invalid) id.
if self.progress_view.bufnr ~= nil and not vim.api.nvim_buf_is_valid(self.progress_view.bufnr) then
self.progress_view.bufnr = nil
end
self.progress_view:mount()

-- Rebind the tree to the freshly (re)created buffer and restore its top line/keymaps.
self.progress_view_pane_tree.bufnr = self.progress_view.bufnr
self:_set_top_line(self.progress_view.bufnr)
self.progress_view_tree_render_linenr = vim.api.nvim_buf_line_count(self.progress_view.bufnr) + 1
local keymaps = self:_get_progressview_keymaps()
local tree_keymaps = self:_get_tree_keymaps(self.progress_view_pane_tree, self.progress_view_tree_render_linenr)
keymaps = vim.list_extend(keymaps, tree_keymaps)
self:_set_buffer_keymaps(self.progress_view.bufnr, keymaps)
end
end

---@private
---Set up progress view pane
function ProgressView:_setup_progress_view_pane()
Expand Down