From 00b36157dfea135d4ebd37a51bf3b26bd743b492 Mon Sep 17 00:00:00 2001 From: ROBOT Date: Sat, 4 Jul 2026 16:39:16 +0900 Subject: [PATCH 1/8] fix(devpod): handle provider setup for Windows compatibility --- .../providers/devpod/devpod_provider.lua | 37 ++++++++++++++++--- 1 file changed, 31 insertions(+), 6 deletions(-) diff --git a/lua/remote-nvim/providers/devpod/devpod_provider.lua b/lua/remote-nvim/providers/devpod/devpod_provider.lua index 08b04d68..5b1466f5 100644 --- a/lua/remote-nvim/providers/devpod/devpod_provider.lua +++ b/lua/remote-nvim/providers/devpod/devpod_provider.lua @@ -156,12 +156,37 @@ 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 is_windows = vim.fn.has("win32"); + local co = coroutine.running() + ---@type string[] + local stdout_lines = {} + + if is_windows then + require("plenary.job") + :new({ + command = remote_nvim.config.devpod.binary, + args = { "provider", "list", "--output", "json" }, + on_exit = function(j, _) + stdout_lines = j:result() + if co ~= nil then + coroutine.resume(co) + 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 From ac2ae1b87149183b525f3c335db19f2fc19031dc Mon Sep 17 00:00:00 2001 From: ROBOT Date: Sat, 4 Jul 2026 16:49:25 +0900 Subject: [PATCH 2/8] fix(devpod): ensure proper scheduling of provider setup callback --- lua/remote-nvim/providers/devpod/devpod_provider.lua | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/lua/remote-nvim/providers/devpod/devpod_provider.lua b/lua/remote-nvim/providers/devpod/devpod_provider.lua index 5b1466f5..77b93ea9 100644 --- a/lua/remote-nvim/providers/devpod/devpod_provider.lua +++ b/lua/remote-nvim/providers/devpod/devpod_provider.lua @@ -167,10 +167,12 @@ function DevpodProvider:_handle_provider_setup() command = remote_nvim.config.devpod.binary, args = { "provider", "list", "--output", "json" }, on_exit = function(j, _) - stdout_lines = j:result() - if co ~= nil then - coroutine.resume(co) - end + vim.schedule(function () + stdout_lines = j:result() + if co ~= nil then + coroutine.resume(co) + end + end) end, }) :start() From 412f594499c77ea4b37a384bbb29e7d9eac21f39 Mon Sep 17 00:00:00 2001 From: ROBOT Date: Sat, 4 Jul 2026 17:08:19 +0900 Subject: [PATCH 3/8] fix(devpod): use utility function to check for Windows in provider setup --- lua/remote-nvim/providers/devpod/devpod_provider.lua | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lua/remote-nvim/providers/devpod/devpod_provider.lua b/lua/remote-nvim/providers/devpod/devpod_provider.lua index 77b93ea9..15348364 100644 --- a/lua/remote-nvim/providers/devpod/devpod_provider.lua +++ b/lua/remote-nvim/providers/devpod/devpod_provider.lua @@ -156,12 +156,11 @@ end function DevpodProvider:_handle_provider_setup() if self._devpod_provider then - local is_windows = vim.fn.has("win32"); local co = coroutine.running() ---@type string[] local stdout_lines = {} - if is_windows then + if utils.is_windows then require("plenary.job") :new({ command = remote_nvim.config.devpod.binary, From b193079e366d92b4060340294412abeb8f8753ff Mon Sep 17 00:00:00 2001 From: ROBOT Date: Sat, 4 Jul 2026 17:15:09 +0900 Subject: [PATCH 4/8] fix(progressview): ensure valid buffer for progress view on Windows --- lua/remote-nvim/ui/progressview.lua | 38 +++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/lua/remote-nvim/ui/progressview.lua b/lua/remote-nvim/ui/progressview.lua index 9db38b84..359e1785 100644 --- a/lua/remote-nvim/ui/progressview.lua +++ b/lua/remote-nvim/ui/progressview.lua @@ -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" @@ -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. @@ -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", @@ -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() From f87c865c3d01caf7875173028aaf2116d1c1cd72 Mon Sep 17 00:00:00 2001 From: ROBOT Date: Sun, 5 Jul 2026 15:13:59 +0900 Subject: [PATCH 5/8] fix(devpod): improve formatting in provider setup function --- lua/remote-nvim/providers/devpod/devpod_provider.lua | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lua/remote-nvim/providers/devpod/devpod_provider.lua b/lua/remote-nvim/providers/devpod/devpod_provider.lua index 15348364..5906d051 100644 --- a/lua/remote-nvim/providers/devpod/devpod_provider.lua +++ b/lua/remote-nvim/providers/devpod/devpod_provider.lua @@ -166,7 +166,7 @@ function DevpodProvider:_handle_provider_setup() command = remote_nvim.config.devpod.binary, args = { "provider", "list", "--output", "json" }, on_exit = function(j, _) - vim.schedule(function () + vim.schedule(function() stdout_lines = j:result() if co ~= nil then coroutine.resume(co) @@ -187,7 +187,8 @@ function DevpodProvider:_handle_provider_setup() coroutine.yield() end - local provider_list_output = vim.json.decode(vim.tbl_isempty(stdout_lines) and "{}" or table.concat(stdout_lines, "\n")) + 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 From 7448e29ec84865321263d44b63d9662e0b072eb6 Mon Sep 17 00:00:00 2001 From: ROBOT Date: Sun, 5 Jul 2026 15:20:15 +0900 Subject: [PATCH 6/8] fix(executor): handle Windows compatibility for job execution without pty --- lua/remote-nvim/providers/executor.lua | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/lua/remote-nvim/providers/executor.lua b/lua/remote-nvim/providers/executor.lua index d73336eb..a0bab5ef 100644 --- a/lua/remote-nvim/providers/executor.lua +++ b/lua/remote-nvim/providers/executor.lua @@ -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 @@ -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 From 5a4592fc92712d6d6f46c7bba83d4f710015ab4f Mon Sep 17 00:00:00 2001 From: ROBOT Date: Sun, 5 Jul 2026 15:24:34 +0900 Subject: [PATCH 7/8] fix(provider): improve Windows compatibility for script path normalization and server readiness check --- lua/remote-nvim/providers/provider.lua | 80 ++++++++++++++++++++------ 1 file changed, 64 insertions(+), 16 deletions(-) diff --git a/lua/remote-nvim/providers/provider.lua b/lua/remote-nvim/providers/provider.lua index d55dc026..b8fd0f74 100644 --- a/lua/remote-nvim/providers/provider.lua +++ b/lua/remote-nvim/providers/provider.lua @@ -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 @@ -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 @@ -796,9 +802,17 @@ 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'"):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", + } local timeout = 20000 -- Wait for max 20 seconds for server to get ready local timer = utils.uv.new_timer() @@ -806,19 +820,53 @@ function Provider:_wait_for_server_to_be_ready() 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'"):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 From 60167ada73cb7886d19f822c687c001dcd1b88e4 Mon Sep 17 00:00:00 2001 From: ROBOT Date: Sun, 5 Jul 2026 18:31:35 +0900 Subject: [PATCH 8/8] fix(readme): update Windows support status to 'experimentally supported' --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index fb2552c5..a0231023 100644 --- a/README.md +++ b/README.md @@ -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 💻 @@ -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)