diff --git a/lua/neogit/lib/input.lua b/lua/neogit/lib/input.lua index bc60f4c62..677e79a88 100644 --- a/lua/neogit/lib/input.lua +++ b/lua/neogit/lib/input.lua @@ -1,5 +1,48 @@ local a = require("neogit.lib.async") -local input = a.wrap(vim.ui.input, 2) + +-- `vim.ui.input`, hardened against implementations that close their prompt +-- without ever invoking the callback. +-- +-- The built-in cmdline input always calls back, but several floating-window +-- replacements (e.g. snacks.nvim) only call `on_confirm` via their own +-- confirm/cancel actions and skip it entirely when the window is dismissed +-- some other way (`:q`, `c`, a focus steal, a programmatic close). A +-- dropped callback parks the awaiting coroutine forever -- and because popup +-- actions run under a single shared permit lock, one parked action silently +-- turns *every* subsequent popup action into a no-op until Neovim restarts. +-- +-- We guarantee the callback fires exactly once: the real value if the user +-- responds, or `nil` (a normal cancel) if the prompt window closes first. +local input = a.wrap(function(opts, callback) + local done = false + local function finish(value) + if done then + return + end + done = true + callback(value) + end + + vim.ui.input(opts, finish) + + -- If the implementation synchronously entered a floating prompt window, + -- treat that window closing without a result as a cancel. Deferred so a + -- real callback dispatched during the same close wins the race: both the + -- value and this fallback get scheduled, and implementations call + -- `on_confirm` before closing, so the real value is enqueued first. + local win = vim.api.nvim_get_current_win() + if not done and vim.api.nvim_win_is_valid(win) and vim.api.nvim_win_get_config(win).relative ~= "" then + vim.api.nvim_create_autocmd("WinClosed", { + pattern = tostring(win), + once = true, + callback = function() + vim.schedule(function() + finish(nil) + end) + end, + }) + end +end, 2) local M = {} diff --git a/tests/specs/neogit/lib/input_spec.lua b/tests/specs/neogit/lib/input_spec.lua new file mode 100644 index 000000000..5e8fa8748 --- /dev/null +++ b/tests/specs/neogit/lib/input_spec.lua @@ -0,0 +1,100 @@ +local input = require("neogit.lib.input") +local async = require("neogit.lib.async") + +--- Drives the event loop until `predicate()` returns true (or `timeout` ms elapse). +local function wait_for(predicate, timeout) + return vim.wait(timeout or 1000, predicate, 5) +end + +--- Open and enter a floating window, mimicking a `vim.ui.input` replacement +--- (e.g. snacks.nvim) that synchronously enters its prompt window. +local function open_float() + local buf = vim.api.nvim_create_buf(false, true) + return vim.api.nvim_open_win(buf, true, { + relative = "editor", + row = 1, + col = 1, + width = 20, + height = 1, + }) +end + +--- Run get_user_input inside an async task under the given `vim.ui.input` +--- stub, returning { value = ... } once it resolves (or nil if it hangs). +local function drive(ui_input_impl) + local saved = vim.ui.input + vim.ui.input = ui_input_impl + + local outcome = nil + async.run(function() + outcome = { value = input.get_user_input("Prompt") } + end) + + local resolved = wait_for(function() + return outcome ~= nil + end) + + vim.ui.input = saved + return resolved and outcome or nil +end + +describe("lib.input", function() + describe("get_user_input", function() + -- Regression: some vim.ui.input replacements only invoke on_confirm via + -- their own confirm/cancel actions and skip it when the prompt window is + -- closed another way. A dropped callback used to park the awaiting + -- coroutine forever, wedging the shared popup action lock until restart. + it("resumes as a cancel when the prompt window closes without a callback", function() + local outcome = drive(function(_, _on_confirm) + local win = open_float() + vim.defer_fn(function() + if vim.api.nvim_win_is_valid(win) then + vim.api.nvim_win_close(win, true) -- closed, no callback + end + end, 30) + end) + + assert.is_truthy(outcome) -- did not hang + assert.is_nil(outcome.value) + end) + + it("delivers the real value even if the window closes right after", function() + local outcome = drive(function(_, on_confirm) + local win = open_float() + vim.defer_fn(function() + on_confirm("hello") -- implementations call on_confirm before closing + if vim.api.nvim_win_is_valid(win) then + vim.api.nvim_win_close(win, true) + end + end, 30) + end) + + assert.is_truthy(outcome) + assert.are.equal("hello", outcome.value) + end) + + it("returns nil on an explicit cancel without double-resuming", function() + local outcome = drive(function(_, on_confirm) + local win = open_float() + vim.defer_fn(function() + on_confirm(nil) + if vim.api.nvim_win_is_valid(win) then + vim.api.nvim_win_close(win, true) + end + end, 30) + end) + + assert.is_truthy(outcome) + assert.is_nil(outcome.value) + end) + + it("works with a synchronous cmdline-style implementation", function() + local outcome = drive(function(_, on_confirm) + on_confirm("typed") -- no window at all + end) + + assert.is_truthy(outcome) + assert.are.equal("typed", outcome.value) + end) + end) +end)